feat(report): P1d 실시간 알람 엔진 (kpi_alert) — P1 온라인 히스토리안 완료

운전모드/폐합 기반 실시간 알람 — P1 마지막 조각.

- kpi_alert 테이블(edge-trigger: 진입 active, 해제 resolved, DB상태라 크래시 무관).
- Hc900LiveKpiService 알람 평가: 최신 history_1s 샘플로 운전모드(cleaning/drawdown/normal) 판정
  + 폐합 이탈(normal & |closure-100|>tol). 룰: cleaning/drawdown(info), closure_deviation(warning).
- GET /api/report/alerts. Report:LiveKpi:ClosureTolerancePct(기본 2%) config.

검증: 라이브 C-8111 drawdown 알람 발화(feed≈0 포착), opened_at 고정·updated_at 갱신(edge-trigger),
/alerts 정상. (데모 sim은 임계 빈번교차로 flapping 가능 — M분 디바운스는 운영 튜닝.)

P1 완료: P1a 1초버퍼 · P1b 연속집계 · P1c 온라인누적기 · P1d 알람.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-15 08:15:29 +09:00
parent b820e6c33a
commit c3a5258bf2
4 changed files with 110 additions and 2 deletions

View File

@@ -43,3 +43,10 @@ CREATE TABLE IF NOT EXISTS hc900.live_kpi (
value double precision, unit text, state text, excluded_min int, status text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (column_id, kpi, window_start));
-- P1d: 실시간 알람 (edge-trigger: 진입 active, 해제 resolved)
CREATE TABLE IF NOT EXISTS hc900.kpi_alert (
column_id text NOT NULL, rule text NOT NULL, severity text, active boolean NOT NULL DEFAULT true,
message text, value double precision,
opened_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz,
PRIMARY KEY (column_id, rule));

View File

@@ -53,6 +53,32 @@ public class ReportController : ControllerBase
return Ok(new { Count = items.Count, Items = items });
}
/// <summary>활성 알람(kpi_alert) — cleaning/drawdown 진입·폐합 이탈. active=false면 해제 포함.</summary>
[HttpGet("alerts")]
public async Task<IActionResult> Alerts(bool activeOnly = true, CancellationToken ct = default)
{
var conn = _db.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = @"SELECT column_id, rule, severity, active, message, value, opened_at, updated_at, resolved_at
FROM hc900.kpi_alert" + (activeOnly ? " WHERE active" : "") +
" ORDER BY active DESC, opened_at DESC";
var items = new List<object>();
await using var rd = await cmd.ExecuteReaderAsync(ct);
while (await rd.ReadAsync(ct))
items.Add(new {
Column = rd.GetString(0), Rule = rd.GetString(1),
Severity = rd.IsDBNull(2) ? null : rd.GetString(2),
Active = rd.GetBoolean(3),
Message = rd.IsDBNull(4) ? null : rd.GetString(4),
Value = rd.IsDBNull(5) ? (double?)null : rd.GetDouble(5),
OpenedAt = rd.GetFieldValue<DateTime>(6),
UpdatedAt = rd.GetFieldValue<DateTime>(7),
ResolvedAt = rd.IsDBNull(8) ? (DateTime?)null : rd.GetFieldValue<DateTime>(8)
});
return Ok(new { Count = items.Count, Items = items });
}
/// <summary>설정된 컬럼 목록(웹 UI 셀렉트용).</summary>
[HttpGet("columns")]
public IActionResult Columns()

View File

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

View File

@@ -26,6 +26,7 @@ public class Hc900LiveKpiService : BackgroundService
private readonly bool _enabled;
private readonly int _intervalSec;
private readonly string _source;
private readonly double _closureTol;
public Hc900LiveKpiService(IServiceScopeFactory scopeFactory, ILogger<Hc900LiveKpiService> logger,
Hc900RealtimeService realtime, ReportColumnMap map, IConfiguration config)
@@ -34,6 +35,7 @@ public class Hc900LiveKpiService : BackgroundService
_enabled = config.GetValue("Report:LiveKpi:Enabled", true);
_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);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -75,6 +77,10 @@ public class Hc900LiveKpiService : BackgroundService
await UpsertAsync(conn, col, r.Metric, todayKst, r.Value, r.Unit, state, excl, r.Status, stoppingToken);
written++;
}
// P1d: 실시간 알람 (운전모드 + 폐합 이탈)
var closure = results.First(r => r.Metric == "mass_balance_closure");
await EvaluateAlertsAsync(conn, col, closure, stoppingToken);
}
_logger.LogDebug("[LiveKpi] {N}개 KPI 갱신 @ {Day}", written, todayKst);
}
@@ -111,7 +117,75 @@ CREATE TABLE IF NOT EXISTS hc900.live_kpi (
column_id text NOT NULL, kpi text NOT NULL, window_start date NOT NULL,
value double precision, unit text, state text, excluded_min int, status text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (column_id, kpi, window_start))";
PRIMARY KEY (column_id, kpi, window_start));
CREATE TABLE IF NOT EXISTS hc900.kpi_alert (
column_id text NOT NULL, rule text NOT NULL, severity text, active boolean NOT NULL DEFAULT true,
message text, value double precision,
opened_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), resolved_at timestamptz,
PRIMARY KEY (column_id, rule));";
await cmd.ExecuteNonQueryAsync(ct);
}
/// <summary>P1d 알람: 최신 샘플로 운전모드(cleaning/drawdown/normal) 판정 + 폐합 이탈 룰 → kpi_alert edge-trigger.</summary>
private async Task EvaluateAlertsAsync(System.Data.Common.DbConnection conn, string col, MetricResultDto closure, CancellationToken ct)
{
if (!_map.TryResolveCleaning(col, out var cl) || cl is null) return;
double? vac = null, prod = null, feed = null;
await using (var q = conn.CreateCommand())
{
q.CommandText = $@"SELECT
(SELECT value::float FROM hc900.{_source} WHERE tagname=@vac AND recorded_at > now()-interval '3 min' ORDER BY recorded_at DESC LIMIT 1),
(SELECT value::float FROM hc900.{_source} WHERE tagname=@prod AND recorded_at > now()-interval '3 min' ORDER BY recorded_at DESC LIMIT 1),
(SELECT value::float FROM hc900.{_source} WHERE tagname=@feed AND recorded_at > now()-interval '3 min' ORDER BY recorded_at DESC LIMIT 1)";
void P(string n, object? v) { var p = q.CreateParameter(); p.ParameterName = n; p.Value = v ?? DBNull.Value; q.Parameters.Add(p); }
P("@vac", cl.VacTag ?? ""); P("@prod", cl.ProductTag); P("@feed", cl.FeedTag);
await using var rd = await q.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct))
{
vac = rd.IsDBNull(0) ? null : rd.GetDouble(0);
prod = rd.IsDBNull(1) ? null : rd.GetDouble(1);
feed = rd.IsDBNull(2) ? null : rd.GetDouble(2);
}
}
var desired = new List<(string rule, string sev, string msg, double? val)>();
if (prod is not null || feed is not null) // 데이터 있을 때만 평가(없으면 전부 해제)
{
bool isClean = (prod ?? 0) < cl.ProductMin || (vac.HasValue && vac > cl.VacMax);
bool isDraw = (feed ?? 0) < cl.FeedMin && (prod ?? 0) >= cl.ProductMin;
bool isNormal = (feed ?? 0) >= cl.FeedMin && (prod ?? 0) >= cl.ProductMin && (!vac.HasValue || vac <= cl.VacMax);
if (isClean) desired.Add(("cleaning", "info", "세정/비운전 진입(진공 高 또는 제품~0)", null));
else if (isDraw) desired.Add(("drawdown", "info", "drawdown — feed≈0 인데 출력(인벤토리 인출/컬럼간 이송)", feed));
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);
}
/// <summary>desired 룰은 active 유지/발화, 나머지 active 알람은 resolved 처리(edge-trigger).</summary>
private static async Task SyncAlertsAsync(System.Data.Common.DbConnection conn, string col,
List<(string rule, string sev, string msg, double? val)> desired, CancellationToken ct)
{
foreach (var (rule, sev, msg, val) in desired)
{
await using var up = conn.CreateCommand();
up.CommandText = @"
INSERT INTO hc900.kpi_alert (column_id, rule, severity, active, message, value, opened_at, updated_at, resolved_at)
VALUES (@c,@r,@s,true,@m,@v,now(),now(),NULL)
ON CONFLICT (column_id, rule) DO UPDATE SET
severity=EXCLUDED.severity, active=true, message=EXCLUDED.message, value=EXCLUDED.value, updated_at=now(),
opened_at = CASE WHEN hc900.kpi_alert.active THEN hc900.kpi_alert.opened_at ELSE now() END,
resolved_at = NULL";
void P(string n, object? v) { var p = up.CreateParameter(); p.ParameterName = n; p.Value = v ?? DBNull.Value; up.Parameters.Add(p); }
P("@c", col); P("@r", rule); P("@s", sev); P("@m", msg); P("@v", val);
await up.ExecuteNonQueryAsync(ct);
}
await using var res = conn.CreateCommand();
res.CommandText = @"UPDATE hc900.kpi_alert SET active=false, resolved_at=now(), updated_at=now()
WHERE column_id=@c AND active AND NOT (rule = ANY(@rules))";
var pc = res.CreateParameter(); pc.ParameterName = "@c"; pc.Value = col; res.Parameters.Add(pc);
var pr = res.CreateParameter(); pr.ParameterName = "@rules"; pr.Value = desired.Select(d => d.rule).ToArray(); res.Parameters.Add(pr);
await res.ExecuteNonQueryAsync(ct);
}
}