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