From c3a5258bf20810bd4829ab70bf3b73513dfb8429 Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 15 Jun 2026 08:15:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20P1d=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=95=8C=EB=9E=8C=20=EC=97=94=EC=A7=84=20(kpi=5Fal?= =?UTF-8?q?ert)=20=E2=80=94=20P1=20=EC=98=A8=EB=9D=BC=EC=9D=B8=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=95=88=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운전모드/폐합 기반 실시간 알람 — 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 --- scripts/sql/p1_historian.sql | 7 ++ .../Controllers/ReportController.cs | 26 +++++++ src/Hc900Crawler/appsettings.json | 3 +- .../Hc900/Hc900LiveKpiService.cs | 76 ++++++++++++++++++- 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/scripts/sql/p1_historian.sql b/scripts/sql/p1_historian.sql index 4168050..da79543 100644 --- a/scripts/sql/p1_historian.sql +++ b/scripts/sql/p1_historian.sql @@ -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)); diff --git a/src/Hc900Crawler/Controllers/ReportController.cs b/src/Hc900Crawler/Controllers/ReportController.cs index d35ab3f..d75316d 100644 --- a/src/Hc900Crawler/Controllers/ReportController.cs +++ b/src/Hc900Crawler/Controllers/ReportController.cs @@ -53,6 +53,32 @@ public class ReportController : ControllerBase return Ok(new { Count = items.Count, Items = items }); } + /// 활성 알람(kpi_alert) — cleaning/drawdown 진입·폐합 이탈. active=false면 해제 포함. + [HttpGet("alerts")] + public async Task 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(); + 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(6), + UpdatedAt = rd.GetFieldValue(7), + ResolvedAt = rd.IsDBNull(8) ? (DateTime?)null : rd.GetFieldValue(8) + }); + return Ok(new { Count = items.Count, Items = items }); + } + /// 설정된 컬럼 목록(웹 UI 셀렉트용). [HttpGet("columns")] public IActionResult Columns() diff --git a/src/Hc900Crawler/appsettings.json b/src/Hc900Crawler/appsettings.json index 2423d3d..9c43427 100644 --- a/src/Hc900Crawler/appsettings.json +++ b/src/Hc900Crawler/appsettings.json @@ -95,7 +95,8 @@ "LiveKpi": { "Enabled": true, "IntervalSeconds": 15, - "Source": "history_1s" + "Source": "history_1s", + "ClosureTolerancePct": 2.0 }, "Cleaning": { "VacMax": 300, diff --git a/src/Infrastructure/Hc900/Hc900LiveKpiService.cs b/src/Infrastructure/Hc900/Hc900LiveKpiService.cs index dbdcfdd..6693a88 100644 --- a/src/Infrastructure/Hc900/Hc900LiveKpiService.cs +++ b/src/Infrastructure/Hc900/Hc900LiveKpiService.cs @@ -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 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); } + + /// P1d 알람: 최신 샘플로 운전모드(cleaning/drawdown/normal) 판정 + 폐합 이탈 룰 → kpi_alert edge-trigger. + 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); + } + + /// desired 룰은 active 유지/발화, 나머지 active 알람은 resolved 처리(edge-trigger). + 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); + } }