feat(report): 알람 디바운스 + 카드 민감단 트렌드 스파크라인

- 디바운스: 알람 조건이 AlertDebounceSec(기본 60s) 연속 유지돼야 발화(인메모리 후보 추적).
  flapping(임계 빈번교차) 억제. 조건 해제 시 후보 리셋.
- 스파크라인: 카드마다 민감단 온도(TC) 최근 60분 미니 SVG 트렌드.
  GET /api/report/sparks (history_1s 다운샘플, 순수 SQL — Timescale 함수 미사용으로
  search_path=hc900 무관). ReportColumnMap.TcTag.

검증: /sparks 200(C-6111 31점 79.5~81.7℃), 디바운스가 데모 sim flapping 정확히 억제
(70초 후 알람 0 — 지속조건 아님). 실데이터의 지속 drawdown은 60s 후 발화.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-15 20:52:29 +09:00
parent 1b6a717e76
commit 005e0dc3dd
6 changed files with 76 additions and 9 deletions

View File

@@ -53,6 +53,37 @@ public class ReportController : ControllerBase
return Ok(new { Count = items.Count, Items = items });
}
/// <summary>카드 스파크라인 — 컬럼별 민감단 온도(TC) 최근 트렌드(history_1s 다운샘플).</summary>
[HttpGet("sparks")]
public async Task<IActionResult> Sparks(int minutes = 60, int points = 30, CancellationToken ct = default)
{
var conn = _db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
int bsec = Math.Max(30, minutes * 60 / Math.Max(5, points));
var items = new List<object>();
foreach (var col in _map.Columns())
{
var tc = _map.TcTag(col);
var pts = new List<double>();
if (tc != null)
{
await using var cmd = conn.CreateCommand();
// 순수 SQL 버킷(Timescale 함수 미사용 — search_path 무관)
cmd.CommandText = @"
SELECT floor(extract(epoch FROM recorded_at)/@bsec) AS b, avg(value::float) v
FROM hc900.history_1s
WHERE tagname=@tc AND recorded_at > now() - @win::interval AND value ~ '^-?[0-9]+(\.[0-9]+)?$'
GROUP BY b ORDER BY b";
void P(string n, object v) { var p = cmd.CreateParameter(); p.ParameterName = n; p.Value = v; cmd.Parameters.Add(p); }
P("@tc", tc); P("@win", $"{minutes} minutes"); P("@bsec", bsec);
await using var rd = await cmd.ExecuteReaderAsync(ct);
while (await rd.ReadAsync(ct)) if (!rd.IsDBNull(1)) pts.Add(rd.GetDouble(1));
}
items.Add(new { Column = col, Tag = tc, Points = pts });
}
return Ok(new { Minutes = minutes, Items = items });
}
/// <summary>활성 알람(kpi_alert) — cleaning/drawdown 진입·폐합 이탈. active=false면 해제 포함.</summary>
[HttpGet("alerts")]
public async Task<IActionResult> Alerts(bool activeOnly = true, CancellationToken ct = default)

View File

@@ -96,7 +96,8 @@
"Enabled": true,
"IntervalSeconds": 15,
"Source": "history_1s",
"ClosureTolerancePct": 2.0
"ClosureTolerancePct": 2.0,
"AlertDebounceSec": 60
},
"Cleaning": {
"VacMax": 300,

View File

@@ -237,6 +237,6 @@
<script src="/js/trend.js?v=20260611"></script>
<script src="/js/ff.js?v=20260604"></script>
<script src="/js/steam.js?v=20260606"></script>
<script src="/js/reports.js?v=20260615"></script>
<script src="/js/reports.js?v=20260615b"></script>
</body>
</html>

View File

@@ -139,14 +139,16 @@ function rmStart() {
async function rmRefresh() {
const grid = document.getElementById('rmGrid'); if (!grid) return;
try {
const [live, alerts] = await Promise.all([
const [live, alerts, sparks] = await Promise.all([
fetch('/api/report/live').then(r => r.json()),
fetch('/api/report/alerts').then(r => r.json())
fetch('/api/report/alerts').then(r => r.json()),
fetch('/api/report/sparks?minutes=60&points=30').then(r => r.json()).catch(() => ({ Items: [] }))
]);
// group
const byCol = {}, alByCol = {};
const byCol = {}, alByCol = {}, spByCol = {};
for (const it of (live.Items || [])) (byCol[it.Column] = byCol[it.Column] || {})[it.Kpi] = it;
for (const a of (alerts.Items || [])) (alByCol[a.Column] = alByCol[a.Column] || []).push(a);
for (const s of (sparks.Items || [])) spByCol[s.Column] = s.Points || [];
// 알람 배너
const banner = document.getElementById('rmAlerts');
@@ -158,7 +160,7 @@ async function rmRefresh() {
// 카드 그리드 (컬럼 정렬)
const cols = Object.keys(byCol).sort();
grid.innerHTML = cols.map(c => rmCard(c, byCol[c], alByCol[c] || [])).join('');
grid.innerHTML = cols.map(c => rmCard(c, byCol[c], alByCol[c] || [], spByCol[c] || [])).join('');
cols.forEach(c => { const el = document.getElementById('rmcard-' + cssId(c)); if (el) el.onclick = () => rmExpand(c); });
document.getElementById('rmStatus').textContent = '⟳ 갱신 ' + new Date().toLocaleTimeString();
@@ -167,7 +169,19 @@ async function rmRefresh() {
}
}
function rmCard(col, k, alerts) {
function rmSpark(pts, color) {
if (!pts || pts.length < 2) return '<div style="height:34px"></div>';
const w = 196, h = 28, mn = Math.min(...pts), mx = Math.max(...pts), rng = (mx - mn) || 1;
const dx = w / (pts.length - 1);
const d = pts.map((v, i) => `${i ? 'L' : 'M'}${(i * dx).toFixed(1)},${(h - ((v - mn) / rng) * h).toFixed(1)}`).join(' ');
const last = pts[pts.length - 1];
return `<svg width="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" style="display:block;margin-top:6px;height:28px">
<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg>
<div style="font-size:10px;color:var(--t2);display:flex;justify-content:space-between">
<span>민감단 ${mn.toFixed(1)}~${mx.toFixed(1)}℃</span><span>현재 ${last.toFixed(1)}</span></div>`;
}
function rmCard(col, k, alerts, spark) {
const st = (k.mass_balance_closure || k.production_total || {}).State || 'idle';
// 알람 모드 우선 표시
const modeAlert = alerts.find(a => a.Rule === 'cleaning' || a.Rule === 'drawdown');
@@ -197,7 +211,8 @@ function rmCard(col, k, alerts) {
<span>생산</span><span style="text-align:right;color:var(--t1,#ddd)">${num('production_total','kg')} kg</span>
<span>수율</span><span style="text-align:right;color:var(--t1,#ddd)">${num('yield_qv','ratio')}</span>
</div>
${chips ? `<div style="margin-top:8px">${chips}</div>` : ''}
${rmSpark(spark, color)}
${chips ? `<div style="margin-top:6px">${chips}</div>` : ''}
</div>`;
}

View File

@@ -27,6 +27,8 @@ public class Hc900LiveKpiService : BackgroundService
private readonly int _intervalSec;
private readonly string _source;
private readonly double _closureTol;
private readonly int _debounceSec;
private readonly Dictionary<string, DateTime> _cand = new(); // 알람 후보 시작시각(인메모리 디바운스)
public Hc900LiveKpiService(IServiceScopeFactory scopeFactory, ILogger<Hc900LiveKpiService> logger,
Hc900RealtimeService realtime, ReportColumnMap map, IConfiguration config)
@@ -36,6 +38,7 @@ public class Hc900LiveKpiService : BackgroundService
_intervalSec = Math.Max(5, config.GetValue("Report:LiveKpi:IntervalSeconds", 15));
_source = config.GetValue("Report:LiveKpi:Source", "history_1s")!; // 1초 버퍼 기본
_closureTol = config.GetValue("Report:LiveKpi:ClosureTolerancePct", 2.0);
_debounceSec = Math.Max(0, config.GetValue("Report:LiveKpi:AlertDebounceSec", 60));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -169,7 +172,21 @@ CREATE TABLE IF NOT EXISTS hc900.kpi_alert (
if (isNormal && closure.Status == "ok" && closure.Value is double cv && Math.Abs(cv - 100) > _closureTol)
desired.Add(("closure_deviation", "warning", $"폐합 {cv:F1}% (100±{_closureTol}% 이탈 — 계량 드리프트/누설/이송 점검)", cv));
}
await SyncAlertsAsync(conn, col, desired, ct);
// 디바운스: 조건이 _debounceSec 이상 연속 유지된 룰만 confirm(발화). flapping 방지.
var now = DateTime.UtcNow;
var rawRules = desired.Select(d => d.rule).ToHashSet();
var confirmed = new List<(string rule, string sev, string msg, double? val)>();
foreach (var d in desired)
{
var key = col + "|" + d.rule;
if (!_cand.TryGetValue(key, out var since)) { since = now; _cand[key] = since; }
if ((now - since).TotalSeconds >= _debounceSec) confirmed.Add(d);
}
foreach (var k in _cand.Keys.Where(k => k.StartsWith(col + "|") && !rawRules.Contains(k[(col.Length + 1)..])).ToList())
_cand.Remove(k); // 더 이상 후보 아님 → 디바운스 리셋
await SyncAlertsAsync(conn, col, confirmed, ct);
}
/// <summary>desired 룰은 active 유지/발화, 나머지 active 알람은 resolved 처리(edge-trigger).</summary>

View File

@@ -67,6 +67,9 @@ public sealed class ReportColumnMap
public bool HasClosure(string column)
=> _config.GetSection($"Report:Closure:{column}").Exists();
/// <summary>민감단(품질) 온도 태그 — 스파크라인/트렌드용.</summary>
public string? TcTag(string column) => Norm(_config[$"SteamAdvisor:Columns:{column}:TC"]);
/// <summary>동특성 대상 루프(하부온도 TICA-*A): PV vs OP(스팀밸브). 밸브 stiction/hunting 진단용.</summary>
public bool TryResolveDynamics(string column, out string? pvTag, out string? opTag)
{