diff --git a/src/Hc900Crawler/Controllers/ReportController.cs b/src/Hc900Crawler/Controllers/ReportController.cs index d75316d..baefc74 100644 --- a/src/Hc900Crawler/Controllers/ReportController.cs +++ b/src/Hc900Crawler/Controllers/ReportController.cs @@ -53,6 +53,37 @@ public class ReportController : ControllerBase return Ok(new { Count = items.Count, Items = items }); } + /// 카드 스파크라인 — 컬럼별 민감단 온도(TC) 최근 트렌드(history_1s 다운샘플). + [HttpGet("sparks")] + public async Task 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(); + foreach (var col in _map.Columns()) + { + var tc = _map.TcTag(col); + var pts = new List(); + 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 }); + } + /// 활성 알람(kpi_alert) — cleaning/drawdown 진입·폐합 이탈. active=false면 해제 포함. [HttpGet("alerts")] public async Task Alerts(bool activeOnly = true, CancellationToken ct = default) diff --git a/src/Hc900Crawler/appsettings.json b/src/Hc900Crawler/appsettings.json index 9c43427..7e3b8ca 100644 --- a/src/Hc900Crawler/appsettings.json +++ b/src/Hc900Crawler/appsettings.json @@ -96,7 +96,8 @@ "Enabled": true, "IntervalSeconds": 15, "Source": "history_1s", - "ClosureTolerancePct": 2.0 + "ClosureTolerancePct": 2.0, + "AlertDebounceSec": 60 }, "Cleaning": { "VacMax": 300, diff --git a/src/Hc900Crawler/wwwroot/index.html b/src/Hc900Crawler/wwwroot/index.html index 30dae1d..3012212 100644 --- a/src/Hc900Crawler/wwwroot/index.html +++ b/src/Hc900Crawler/wwwroot/index.html @@ -237,6 +237,6 @@ - + diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js index 229ab1d..6bed69e 100644 --- a/src/Hc900Crawler/wwwroot/js/reports.js +++ b/src/Hc900Crawler/wwwroot/js/reports.js @@ -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 '
'; + 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 ` + +
+ 민감단 ${mn.toFixed(1)}~${mx.toFixed(1)}℃현재 ${last.toFixed(1)}
`; +} + +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) { 생산${num('production_total','kg')} kg 수율${num('yield_qv','ratio')} - ${chips ? `
${chips}
` : ''} + ${rmSpark(spark, color)} + ${chips ? `
${chips}
` : ''} `; } diff --git a/src/Infrastructure/Hc900/Hc900LiveKpiService.cs b/src/Infrastructure/Hc900/Hc900LiveKpiService.cs index d7776c5..7a82c7d 100644 --- a/src/Infrastructure/Hc900/Hc900LiveKpiService.cs +++ b/src/Infrastructure/Hc900/Hc900LiveKpiService.cs @@ -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 _cand = new(); // 알람 후보 시작시각(인메모리 디바운스) public Hc900LiveKpiService(IServiceScopeFactory scopeFactory, ILogger 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); } /// desired 룰은 active 유지/발화, 나머지 active 알람은 resolved 처리(edge-trigger). diff --git a/src/Infrastructure/Reporting/ReportColumnMap.cs b/src/Infrastructure/Reporting/ReportColumnMap.cs index 61a20b9..0c0aa5c 100644 --- a/src/Infrastructure/Reporting/ReportColumnMap.cs +++ b/src/Infrastructure/Reporting/ReportColumnMap.cs @@ -67,6 +67,9 @@ public sealed class ReportColumnMap public bool HasClosure(string column) => _config.GetSection($"Report:Closure:{column}").Exists(); + /// 민감단(품질) 온도 태그 — 스파크라인/트렌드용. + public string? TcTag(string column) => Norm(_config[$"SteamAdvisor:Columns:{column}:TC"]); + /// 동특성 대상 루프(하부온도 TICA-*A): PV vs OP(스팀밸브). 밸브 stiction/hunting 진단용. public bool TryResolveDynamics(string column, out string? pvTag, out string? opTag) {