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:
@@ -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)
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 15,
|
||||
"Source": "history_1s",
|
||||
"ClosureTolerancePct": 2.0
|
||||
"ClosureTolerancePct": 2.0,
|
||||
"AlertDebounceSec": 60
|
||||
},
|
||||
"Cleaning": {
|
||||
"VacMax": 300,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user