fix(report): 카드 state = 운전모드 단일 진실원 + 이송 메시지 컬럼별 정정

- ★카드 state를 production_total(누적적산)이 아니라 운전모드(순간 feed/product/vacuum)에서
  도출. 적산기가 평평해도(데모) 유량 흐르면 normal로 표시 — "살아있는데 미가동" 모순 해결.
  DetectModeAsync(idle/cleaning/drawdown/normal/no_data) = 카드 state·알람 공통 진실원.
  idle = feed≈0 AND product≈0(진짜 미가동)일 때만. 버퍼에 없으면 no_data(idle 아님).
- 알람도 같은 모드 사용(state-알람 진실원 일치). 이송 메시지는 Report:Transfer config로
  컬럼별 정정 — C-9111↔C-10111만 "이송" 언급, 그 외는 "인벤토리 인출"만. 배너 룰접두사 중복 제거.

검증: C-6111 state=normal(이전 idle 모순 해결), C-9111 no_data(버퍼밖 정직표시),
C-8111 drawdown 메시지 이송 미언급.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-16 11:17:13 +09:00
parent 005e0dc3dd
commit 333d5f2a7b
5 changed files with 39 additions and 22 deletions

View File

@@ -87,6 +87,10 @@
}
},
"Report": {
"Transfer": {
"C-9111": "C-10111",
"C-10111": "C-9111"
},
"Historian": {
"Enabled": true,
"IntervalSeconds": 1,

View File

@@ -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=20260615b"></script>
<script src="/js/reports.js?v=20260615d"></script>
</body>
</html>

View File

@@ -123,8 +123,8 @@ function renderSummary(d) {
}
/* ── ① 라이브 모니터 (상태 카드 그리드) ─────────────────────── */
const RM_COLOR = { normal:'#2ea043', idle:'#8b949e', error:'#e5534b', cleaning:'#3a6ea5', drawdown:'#d29922' };
const RM_LABEL = { normal:'정상', idle:'미가동', error:'오류', cleaning:'세정', drawdown:'drawdown' };
const RM_COLOR = { normal:'#2ea043', idle:'#8b949e', no_data:'#6e7681', error:'#e5534b', cleaning:'#3a6ea5', drawdown:'#d29922' };
const RM_LABEL = { normal:'정상', idle:'미가동', no_data:'데이터없음', error:'오류', cleaning:'세정', drawdown:'인출' };
function rmStart() {
const auto = document.getElementById('rmAuto');
@@ -156,7 +156,7 @@ async function rmRefresh() {
banner.innerHTML = al.length === 0 ? ''
: `<div style="border:1px solid #d29922;border-radius:8px;padding:8px 12px;background:rgba(210,153,34,.08)">
<b>알람 ${al.length}</b> &nbsp;` +
al.map(a => `<span style="margin-right:14px">${a.Severity === 'warning' ? '🟠' : '🟡'} <b>${a.Column}</b> ${a.Rule} ${esc2(a.Message || '')}</span>`).join('') + `</div>`;
al.map(a => `<span style="margin-right:14px">${a.Severity === 'warning' ? '🟠' : '🟡'} <b>${a.Column}</b> · ${esc2(a.Message || '')}</span>`).join('') + `</div>`;
// 카드 그리드 (컬럼 정렬)
const cols = Object.keys(byCol).sort();

View File

@@ -69,10 +69,8 @@ public class Hc900LiveKpiService : BackgroundService
results.Add(await metrics.ComputeAsync(new MetricRequestDto
{ Column = col, Metric = kpi, PeriodDateKst = todayKst, SourceTable = _source }, stoppingToken));
// 컬럼 상태: error=계산오류, 생산>0=normal, 그 외(0·no_data)=idle(미가동)
var prod = results.First(r => r.Metric == "production_total");
string state = prod.Status == "error" ? "error"
: prod.Value is > 0 ? "normal" : "idle";
// ★카드 state 단일 진실원 = 운전모드(순간 feed/product/vacuum). 누적생산이 아님.
var (state, feedNow) = await DetectModeAsync(conn, col, stoppingToken);
foreach (var r in results)
{
@@ -91,8 +89,8 @@ public class Hc900LiveKpiService : BackgroundService
await UpsertAsync(conn, col, "out_total", todayKst, otv, "kg", state, null, "ok", stoppingToken);
}
// P1d: 실시간 알람 (운전모드 + 폐합 이탈)
await EvaluateAlertsAsync(conn, col, closure, stoppingToken);
// P1d: 실시간 알람 — 같은 모드 기반(state와 진실원 일치)
await EvaluateAlertsAsync(conn, col, state, feedNow, closure, stoppingToken);
}
_logger.LogDebug("[LiveKpi] {N}개 KPI 갱신 @ {Day}", written, todayKst);
}
@@ -138,11 +136,10 @@ CREATE TABLE IF NOT EXISTS hc900.kpi_alert (
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)
/// <summary>★운전모드 단일 진실원: 최신 feed/product/vacuum 샘플로 idle/cleaning/drawdown/normal 분류. 카드 state·알람 공통.</summary>
private async Task<(string mode, double? feed)> DetectModeAsync(System.Data.Common.DbConnection conn, string col, CancellationToken ct)
{
if (!_map.TryResolveCleaning(col, out var cl) || cl is null) return;
if (!_map.TryResolveCleaning(col, out var cl) || cl is null) return ("no_data", null);
double? vac = null, prod = null, feed = null;
await using (var q = conn.CreateCommand())
{
@@ -160,18 +157,31 @@ CREATE TABLE IF NOT EXISTS hc900.kpi_alert (
feed = rd.IsDBNull(2) ? null : rd.GetDouble(2);
}
}
if (prod is null && feed is null) return ("no_data", feed);
bool fLow = (feed ?? 0) < cl.FeedMin, pLow = (prod ?? 0) < cl.ProductMin, vHigh = vac.HasValue && vac > cl.VacMax;
string mode = (fLow && pLow) ? "idle" // 아무것도 안 흐름 = 진짜 미가동
: vHigh ? "cleaning" // 진공 깨짐 = 세정/비운전
: (fLow) ? "drawdown" // feed≈0 + product 흐름 = 인벤토리 인출
: (pLow) ? "cleaning" // feed 흐름 + product≈0 = 세정/startup
: "normal"; // 둘 다 흐름 + 진공 정상
return (mode, feed);
}
/// <summary>P1d 알람: 운전모드(카드 state와 동일 진실원) + 폐합 이탈 → kpi_alert edge-trigger.</summary>
private async Task EvaluateAlertsAsync(System.Data.Common.DbConnection conn, string col, string mode, double? feed,
MetricResultDto closure, CancellationToken ct)
{
var desired = new List<(string rule, string sev, string msg, double? val)>();
if (prod is not null || feed is not null) // 데이터 있을 때만 평가(없으면 전부 해제)
if (mode == "cleaning") desired.Add(("cleaning", "info", "진공 高 또는 제품~0 (세정/비운전)", null));
else if (mode == "drawdown")
{
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));
var partner = _map.TransferPartner(col);
var msg = partner is null ? "feed≈0 인데 출력 — 인벤토리 인출"
: $"feed≈0 인데 출력 — 인벤토리 인출 또는 {partner} 이송";
desired.Add(("drawdown", "info", msg, feed));
}
else if (mode == "normal" && closure.Status == "ok" && closure.Value is double cv && Math.Abs(cv - 100) > _closureTol)
desired.Add(("closure_deviation", "warning", $"폐합 {cv:F1}% (100±{_closureTol}% 이탈 — 계량 드리프트/누설/이송 점검)", cv));
// 디바운스: 조건이 _debounceSec 이상 연속 유지된 룰만 confirm(발화). flapping 방지.
var now = DateTime.UtcNow;

View File

@@ -70,6 +70,9 @@ public sealed class ReportColumnMap
/// <summary>민감단(품질) 온도 태그 — 스파크라인/트렌드용.</summary>
public string? TcTag(string column) => Norm(_config[$"SteamAdvisor:Columns:{column}:TC"]);
/// <summary>컬럼간 이송 라인 상대 컬럼(있으면). 예: C-9111↔C-10111. 없으면 null(=순수 인벤토리 인출).</summary>
public string? TransferPartner(string column) => _config[$"Report:Transfer:{column}"];
/// <summary>동특성 대상 루프(하부온도 TICA-*A): PV vs OP(스팀밸브). 밸브 stiction/hunting 진단용.</summary>
public bool TryResolveDynamics(string column, out string? pvTag, out string? opTag)
{