From 27586baf0787a4cfce0052e75e403edcf88b2637 Mon Sep 17 00:00:00 2001 From: windpacer Date: Fri, 19 Jun 2026 11:18:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20=EC=97=B0=EA=B3=84=EC=9A=B4?= =?UTF-8?q?=EC=A0=84=20=ED=86=B5=ED=95=A9=20=ED=8F=90=ED=95=A9=20=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=20mass=5Fbalance=5Fclosure=5Flinked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이송 배관으로 묶인 두 컬럼(C-9111↔C-10111)을 하나의 envelope로 보는 통합 물질수지. 한쪽 feed≈0(인출/이송)인 연계운전일엔 단독 폐합이 수학적으로 N/A지만, 양 컬럼의 외부 feed 합 / 최종 출력 합으로 보면 닫힌다. 내부 이송 스트림은 어느 컬럼 계량에도 안 잡혀(비계량) envelope에서 자동 상쇄. - Report:Transfer로 파트너 해석(없으면 미정의 → 단독컬럼엔 error). C-9111↔C-10111만 설정. - cleaning 마스크 미적용(연계 시 feed≈0이 정상 → drawdown 마스크 쓰면 전구간 제외). - summary는 TransferPartner 있는 컬럼에만 추가, 웹에 통합 폐합 신뢰블록, 토큰 치트시트. 검증(2026-05-15 C-9111): IN 23,975.9 / OUT 23,923.8 / 폐합 99.78% (수동검증 일치). Co-Authored-By: Claude Opus 4.8 --- .../Controllers/ReportController.cs | 5 ++- src/Hc900Crawler/wwwroot/js/reports.js | 23 +++++++++++++ src/Hc900Crawler/wwwroot/panes/reports.html | 1 + .../Reporting/ReportColumnMap.cs | 29 ++++++++++++++++ .../Reporting/ReportMetricService.cs | 34 +++++++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/Hc900Crawler/Controllers/ReportController.cs b/src/Hc900Crawler/Controllers/ReportController.cs index ce56524..476ea4c 100644 --- a/src/Hc900Crawler/Controllers/ReportController.cs +++ b/src/Hc900Crawler/Controllers/ReportController.cs @@ -128,8 +128,11 @@ GROUP BY b ORDER BY b"; string source = "history_table", string period = "DAILY", int? sessionId = null, CancellationToken ct = default) { var d = (date ?? DateTime.UtcNow.AddHours(9).AddDays(-1)).Date; // 기본 = 어제(KST) + var metricNames = new List(SUMMARY_METRICS); + if (_map.TransferPartner(column) is not null) // 이송 연계 컬럼(C-9111↔C-10111)만 통합 폐합 추가 + metricNames.Add("mass_balance_closure_linked"); var results = new List(); - foreach (var m in SUMMARY_METRICS) + foreach (var m in metricNames) results.Add(await _metrics.ComputeAsync(new MetricRequestDto { Column = column, Metric = m, PeriodDateKst = d, Period = period, diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js index 6eb7430..b2fd4dc 100644 --- a/src/Hc900Crawler/wwwroot/js/reports.js +++ b/src/Hc900Crawler/wwwroot/js/reports.js @@ -197,6 +197,29 @@ function renderSummary(d) { `; } + // 연계운전 통합 폐합 (이송으로 묶인 두 컬럼 합산) — 결과 있을 때만 + const lk = by['mass_balance_closure_linked']; + if (lk) { + const pct = lk.Status === 'ok' ? lk.Value : null; + const ok = pct != null && pct >= 98 && pct <= 101; + const color = pct == null ? '#888' : (ok ? '#2ea043' : '#e5534b'); + const e = lk.Extra || {}; + html += ` +
+
+
+
통합 물질수지 폐합 (이송 연계 컬럼 합산)
+
통합 IN ${rpFmt(e.feed_qv,'kg')}  →  통합 OUT ${rpFmt(e.out_total,'kg')} kg
+
단독 폐합이 N/A(연계운전 feed≈0)인 날 두 컬럼을 합쳐 검산
+
+
+
${pct == null ? 'N/A' : rpFmt(pct,'pct') + '%'}
+
${pct == null ? (lk.Error || '') : (ok ? '✓ 정상' : '⚠ 이탈')}
+
+
+
`; + } + // 메트릭 표 const rows = [ ['생산량 (제품 적산Δ)', by['production_total'], 'kg'], diff --git a/src/Hc900Crawler/wwwroot/panes/reports.html b/src/Hc900Crawler/wwwroot/panes/reports.html index d6e64dd..20c5859 100644 --- a/src/Hc900Crawler/wwwroot/panes/reports.html +++ b/src/Hc900Crawler/wwwroot/panes/reports.html @@ -110,6 +110,7 @@ {{ metric=mass_balance_closure; column=C-6111; field=feed_qv }} → 원료 적산 Δ (IN) {{ metric=mass_balance_closure; column=C-6111; field=out_total }} → 회수 합 (OUT) {{ metric=mass_balance_closure; column=C-6111; field=out0_qv }} → 제품(경비/중비는 out1/out2) +{{ metric=mass_balance_closure_linked; column=C-9111 }} → 통합 폐합 % (이송 연계 C-9111+C-10111 합산) ■ PV 기반 (근사) {{ metric=control_residual; column=C-6111 }} → 평균 잔차(PV-SP) diff --git a/src/Infrastructure/Reporting/ReportColumnMap.cs b/src/Infrastructure/Reporting/ReportColumnMap.cs index 5598fd9..6923fd1 100644 --- a/src/Infrastructure/Reporting/ReportColumnMap.cs +++ b/src/Infrastructure/Reporting/ReportColumnMap.cs @@ -14,6 +14,9 @@ public sealed record QvSpec(string Unit, QvKind Kind, string A, string? B, IRead /// 비정상운전(cleaning/drawdown) 제외 마스크. 진공高 또는 제품~0 또는 feed~0인 분 제외. public sealed record CleaningSpec(string? VacTag, double VacMax, string ProductTag, double ProductMin, string FeedTag, double FeedMin); +/// 연계운전 통합 폐합. 이송으로 묶인 두 컬럼의 외부 feed 합 / 최종 출력 합. 내부 이송은 비계량이라 자동 상쇄. +public sealed record LinkedClosureSpec(string Unit, IReadOnlyList Feeds, IReadOnlyList Outputs, string Partner); + /// /// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를 /// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위(스파이크/드롭아웃 경계)는 tag_metadata @@ -182,6 +185,32 @@ public sealed class ReportColumnMap } } + /// + /// 연계운전 통합 폐합 해석. Report:Transfer:{col}로 이송 파트너를 찾아, 양 컬럼의 + /// Report:Closure(Feed + Outputs).QV를 합쳐 envelope 폐합을 만든다. 파트너 없으면 false + /// (= 배관 연계 없는 단독 컬럼 → 통합 폐합 미정의). C-9111↔C-10111 한 쌍에만 설정됨. + /// + public bool TryResolveLinkedClosure(string column, out LinkedClosureSpec? spec) + { + spec = null; + var partner = _config[$"Report:Transfer:{column}"]; + if (string.IsNullOrWhiteSpace(partner)) return false; + + var feeds = new List(); + var outs = new List(); + foreach (var col in new[] { column, partner }) + { + var cl = _config.GetSection($"Report:Closure:{col}"); + if (!cl.Exists()) return false; + if (ToQv(cl["Feed"]) is string f) feeds.Add(f); + foreach (var o in cl.GetSection("Outputs").Get() ?? Array.Empty()) + if (ToQv(o) is string oq) outs.Add(oq); + } + if (feeds.Count == 0 || outs.Count == 0) return false; + spec = new LinkedClosureSpec("%", feeds, outs, partner); + return true; + } + /// cleaning/drawdown 마스크 해석. 제품·feed는 SteamAdvisor:Columns, 진공태그·임계는 Report:Cleaning. public bool TryResolveCleaning(string column, out CleaningSpec? spec) { diff --git a/src/Infrastructure/Reporting/ReportMetricService.cs b/src/Infrastructure/Reporting/ReportMetricService.cs index 877e018..615875e 100644 --- a/src/Infrastructure/Reporting/ReportMetricService.cs +++ b/src/Infrastructure/Reporting/ReportMetricService.cs @@ -77,6 +77,13 @@ public sealed class ReportMetricService : IReportMetricService { await DynamicsAsync(res, conn, tbl, req.Column, fromUtc, toUtc, isFast, req.SessionId, ct); } + else if (req.Metric == "mass_balance_closure_linked") + { + if (!_map.TryResolveLinkedClosure(req.Column, out var lspec) || lspec is null) + { res.Status = "error"; res.Error = $"연계 폐합 미정의(이송 파트너 없음): {req.Column}"; res.Value = null; return res; } + res.Unit = lspec.Unit; + await ComputeLinkedClosureAsync(res, conn, tbl, lspec, fromUtc, toUtc, isFast, req.SessionId, ct); + } else if (QV_METRICS.Contains(req.Metric)) { if (!_map.TryResolveQv(req.Column, req.Metric, out var qspec) || qspec is null) @@ -207,6 +214,33 @@ FROM seq2"; } } + // ── 연계운전 통합 폐합: 두 컬럼 외부 feed 합 / 최종 출력 합. cleaning 마스크 미적용(null) — + // 연계 시 한쪽 feed≈0은 정상(drawdown)이라 마스크를 쓰면 전구간 제외됨. 내부 이송은 + // 비계량(어느 컬럼 feed/output에도 안 잡힘)이라 envelope에서 자동 상쇄. ── + private async Task ComputeLinkedClosureAsync(MetricResultDto res, DbConnection conn, string tbl, + LinkedClosureSpec s, DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct) + { + double inSum = 0; int nMin = 0; + foreach (var f in s.Feeds) + { + var (d, n, _) = await QvDeltaAsync(conn, tbl, f, null, fromUtc, toUtc, isFast, sid, ct); + inSum += d ?? 0; nMin = Math.Max(nMin, n); + } + if (inSum <= 0) { res.Status = "no_data"; res.Value = null; res.Error = "통합 IN(외부 feed 합) Δ≤0"; return; } + + double outSum = 0; + for (int i = 0; i < s.Outputs.Count; i++) + { + var (d, _, _) = await QvDeltaAsync(conn, tbl, s.Outputs[i], null, fromUtc, toUtc, isFast, sid, ct); + outSum += d ?? 0; + res.Extra[$"out{i}_qv"] = d; + } + res.Value = 100.0 * outSum / inSum; // 통합 폐합 % + res.N = nMin; + res.Extra["feed_qv"] = inSum; // 통합 IN + res.Extra["out_total"] = outSum; // 통합 OUT + } + /// /// 적산 Δ. 비정상운전 분(cleaning=진공高/제품~0, drawdown=feed~0) 제외 후, 정상구간 양(+)증분만 합산(cap 5e4). /// 리셋 3종 자동처리: 999999 wrap·cleaning 리셋·운전조건변경 리셋. cl=null이면 마스크 없이 양증분합산. -- 2.49.1