From 30a3286d353f02e8e8442c610826755c58ac0ca6 Mon Sep 17 00:00:00 2001 From: windpacer Date: Sun, 14 Jun 2026 07:54:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20=EC=A0=81=EC=82=B0(.QV)=20?= =?UTF-8?q?=EC=A0=95=ED=99=95=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20+=20?= =?UTF-8?q?=EB=AC=BC=EC=A7=88=EC=88=98=EC=A7=80=20=ED=8F=90=ED=95=A9=20+?= =?UTF-8?q?=20=EC=9B=B9=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PV-중앙값 근사 대신 유량 적산(.QV)으로 정확한 적분 메트릭 추가, 웹에서 바로 보기. - QV 메트릭 4종: production_total·yield_qv·energy_intensity_qv·mass_balance_closure. 적산 Δ 헬퍼(QvDeltaAsync)는 값 급감(reset/wrap)으로 구간분할 후 구간별 (마지막-처음) 합산 → gap·양자화 강건, DCS 일일적산과 동일 의미(노이즈 과대계상 없음). - 매핑은 appsettings SteamAdvisor:Columns 재사용(.QV 치환, 7컬럼). 폐합 스트림은 Report:Closure:C-6111(Feed + Outputs[제품·경비물·중비물]). - 웹 대시보드: GET /api/report/columns, /api/report/summary → 리포트 탭에서 컬럼·날짜 선택 시 물질수지 신뢰블록(IN/OUT/폐합%, 98~101% 색상) + 메트릭 표를 즉시 렌더. 엑셀 export와 병존. - 물리적 타당성 게이트: 수율>1.5·에너지원단위>5·≤0 → no_data+사유(garbage 차단). 검증(2026-05-15 C-6111): 생산 8455kg·수율 0.8739·에너지 0.7791·폐합 99.04% (feed 9675/out 9582), 17주 폐합 99.1~99.8%. C-9111 garbage 수율 → no_data 처리 확인. Co-Authored-By: Claude Opus 4.8 --- .../리포트템플릿-예시-C6111-daily.xlsx | Bin 5403 -> 5706 bytes ...랜-셀프서비스-분석리포트-MVP-P0-상세설계.md | 20 +++ .../Controllers/ReportController.cs | 31 +++- src/Hc900Crawler/appsettings.json | 5 + src/Hc900Crawler/wwwroot/js/reports.js | 145 +++++++++++++----- src/Hc900Crawler/wwwroot/panes/reports.html | 90 ++++++----- .../Reporting/ReportColumnMap.cs | 63 +++++++- .../Reporting/ReportMetricService.cs | 109 ++++++++++++- 8 files changed, 377 insertions(+), 86 deletions(-) diff --git a/docs/templates/리포트템플릿-예시-C6111-daily.xlsx b/docs/templates/리포트템플릿-예시-C6111-daily.xlsx index 6c9858e96cc19ba76ae0ca2229e91a69d6fb1a47..48c856c1763916eae3ae0d20f80970936ece2c24 100644 GIT binary patch delta 2502 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 GY5xL_Yu57s diff --git a/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md index 5daf32b..1ead579 100644 --- a/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md +++ b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md @@ -514,6 +514,26 @@ document.getElementById('rpGen').onclick = async () => { 추정: 백엔드 핵심(메트릭+채움+컨트롤러) ~1.5일, store/DDL ~0.5일, 프론트 ~0.5일, 검증/데모 ~0.5일 → **약 3일**. +## 12. 적산(.QV) 메트릭 추가 (2026-06-13 구현) + +각 유량태그의 `.QV`(적산/totalizer)를 활용. PV-중앙값 근사 대신 **정확한 적분** → gap·양자화 무관, "비율의 중앙값" 모호성 해소. 폐합이 닫혀(99.1~99.8%) 적산기 신뢰성도 입증됨. + +**추가 메트릭(4종):** +| metric | 정의 | 비고 | +|---|---|---| +| `production_total` | ΔQV(제품) | 생산량 kg = MES 생산리포트 숫자 | +| `yield_qv` | Δproduct/Δfeed | 정확 수율 | +| `energy_intensity_qv` | Δsteam/Δproduct | 정확 에너지원단위 | +| `mass_balance_closure` | 100·ΣΔout/Δfeed | 폐합 % + Extra(feed_qv/out_total/out0~2_qv) | + +**적산 Δ 헬퍼(`QvDeltaAsync`)** — 핵심. 값 급감(설정된 컬럼 목록(웹 UI 셀렉트용). + [HttpGet("columns")] + public IActionResult Columns() + => Ok(_map.Columns().Select(c => new { Column = c, HasClosure = _map.HasClosure(c) })); /// 단건 메트릭(미리보기/디버그). [HttpPost("metric")] public async Task Metric([FromBody] MetricRequestDto req, CancellationToken ct) => Ok(await _metrics.ComputeAsync(req, ct)); + /// 웹에서 바로 보기 — 한 컬럼·날짜의 전 메트릭을 한 번에. + [HttpGet("summary")] + public async Task Summary(string column = "C-6111", DateTime? date = null, + string source = "history_table", int? sessionId = null, CancellationToken ct = default) + { + var d = (date ?? DateTime.UtcNow.AddHours(9).AddDays(-1)).Date; // 기본 = 어제(KST) + var results = new List(); + foreach (var m in SUMMARY_METRICS) + results.Add(await _metrics.ComputeAsync(new MetricRequestDto + { + Column = column, Metric = m, PeriodDateKst = d, + SourceTable = source, SessionId = sessionId + }, ct)); + return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Metrics = results }); + } + /// 엑셀 템플릿 등록. [HttpPost("template")] public async Task Upload([FromForm] IFormFile file, [FromForm] string name, diff --git a/src/Hc900Crawler/appsettings.json b/src/Hc900Crawler/appsettings.json index 1d80f8d..15a0f34 100644 --- a/src/Hc900Crawler/appsettings.json +++ b/src/Hc900Crawler/appsettings.json @@ -86,6 +86,11 @@ "C-10211": { "Feed": "FICQ-10201.PV", "Product": "FICQ-10218.PV", "TC": "TI-10211C.PV", "SteamOp": "TICA-10211A.OP", "SteamFlow": "FIQ-10215" } } }, + "Report": { + "Closure": { + "C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] } + } + }, "Kestrel": { "Endpoints": { "Http": { diff --git a/src/Hc900Crawler/wwwroot/js/reports.js b/src/Hc900Crawler/wwwroot/js/reports.js index 57effb3..140095f 100644 --- a/src/Hc900Crawler/wwwroot/js/reports.js +++ b/src/Hc900Crawler/wwwroot/js/reports.js @@ -1,55 +1,120 @@ -/* P0 셀프서비스 리포트 — 패널 JS. - pane HTML은 innerHTML로 주입되므로 diff --git a/src/Infrastructure/Reporting/ReportColumnMap.cs b/src/Infrastructure/Reporting/ReportColumnMap.cs index 983610e..2f452c9 100644 --- a/src/Infrastructure/Reporting/ReportColumnMap.cs +++ b/src/Infrastructure/Reporting/ReportColumnMap.cs @@ -6,6 +6,11 @@ namespace Hc900Crawler.Infrastructure.Reporting; public sealed record MetricTag(string Tag, double Lo, double Hi); public sealed record MetricSpec(string Unit, MetricTag A, MetricTag B); +/// 적산(.QV) 메트릭 스펙. Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOutputs/ΔA(=feed). +public enum QvKind { Single, Ratio, Closure } +/// Max = 비율의 물리적 상한(초과 시 no_data). null이면 무검사. +public sealed record QvSpec(string Unit, QvKind Kind, string A, string? B, IReadOnlyList Outputs, double? Max = null); + /// /// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를 /// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2). @@ -20,6 +25,14 @@ public sealed class ReportColumnMap private static readonly (double lo, double hi) STEAM = (50, 3000); private static readonly (double lo, double hi) FLOW = (100, 1500); + /// 설정된 컬럼 키 목록(SteamAdvisor:Columns). + public IReadOnlyList Columns() + => _config.GetSection("SteamAdvisor:Columns").GetChildren().Select(c => c.Key).ToList(); + + /// 해당 컬럼에 폐합(물질수지) 설정이 있는지. + public bool HasClosure(string column) + => _config.GetSection($"Report:Closure:{column}").Exists(); + public bool TryResolve(string column, string metric, out MetricSpec? spec) { spec = null; @@ -60,11 +73,59 @@ public sealed class ReportColumnMap } } + /// + /// 적산(.QV) 메트릭 해석. production_total/yield_qv/energy_intensity_qv 는 SteamAdvisor:Columns 재사용, + /// mass_balance_closure 는 appsettings `Report:Closure:{col}`(Feed + Outputs[]) 사용. + /// + public bool TryResolveQv(string column, string metric, out QvSpec? spec) + { + spec = null; + var sec = _config.GetSection($"SteamAdvisor:Columns:{column}"); + string? feed = ToQv(sec["Feed"]); + string? product = ToQv(sec["Product"]); + string? steam = ToQv(sec["SteamFlow"]); + + switch (metric) + { + case "production_total": + if (product is null) return false; + spec = new QvSpec("kg", QvKind.Single, product, null, Array.Empty()); + return true; + + case "yield_qv": // 제품/원료: 질량 보존상 ≤ ~1 + if (product is null || feed is null) return false; + spec = new QvSpec("제품/원료", QvKind.Ratio, product, feed, Array.Empty(), Max: 1.5); + return true; + + case "energy_intensity_qv": // 스팀/제품: 물리적으로 수 이내 + if (steam is null || product is null) return false; + spec = new QvSpec("kg스팀/kg제품", QvKind.Ratio, steam, product, Array.Empty(), Max: 5.0); + return true; + + case "mass_balance_closure": + var cl = _config.GetSection($"Report:Closure:{column}"); + if (!cl.Exists()) return false; + var f = ToQv(cl["Feed"]); + var outs = cl.GetSection("Outputs").Get() + ?.Select(ToQv).Where(x => x != null).Select(x => x!).ToList(); + if (f is null || outs is null || outs.Count == 0) return false; + spec = new QvSpec("%", QvKind.Closure, f, null, outs); + return true; + + default: + return false; + } + } + /// 속성(.PV 등) 없으면 .PV 부여. private static string? Norm(string? tag) => string.IsNullOrWhiteSpace(tag) ? null : (tag!.Contains('.') ? tag : tag + ".PV"); - /// 마지막 '.' 이후(속성) 제거 → 루프 베이스. + /// 속성 무시하고 베이스+.QV (적산값). + private static string? ToQv(string? tag) + => string.IsNullOrWhiteSpace(tag) ? null : StripAttr(tag!) + ".QV"; + + /// 마지막 '.' 이후(속성) 제거 → 루프/계기 베이스. private static string StripAttr(string tag) { var i = tag.LastIndexOf('.'); return i < 0 ? tag : tag[..i]; } } diff --git a/src/Infrastructure/Reporting/ReportMetricService.cs b/src/Infrastructure/Reporting/ReportMetricService.cs index 8717be8..81801b1 100644 --- a/src/Infrastructure/Reporting/ReportMetricService.cs +++ b/src/Infrastructure/Reporting/ReportMetricService.cs @@ -23,6 +23,9 @@ public sealed class ReportMetricService : IReportMetricService public ReportMetricService(Hc900DbContext ctx, ILogger logger, ReportColumnMap map) { _ctx = ctx; _logger = logger; _map = map; } + private static readonly HashSet QV_METRICS = + new() { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" }; + public async Task ComputeAsync(MetricRequestDto req, CancellationToken ct = default) { bool isFast = req.SourceTable == "fast_record"; @@ -32,10 +35,6 @@ public sealed class ReportMetricService : IReportMetricService Source = req.SourceTable, SamplingMs = isFast ? 0 : 60000 }; - if (!_map.TryResolve(req.Column, req.Metric, out var spec) || spec is null) - { res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; } - res.Unit = spec.Unit; - // KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC) var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9); var toUtc = fromUtc.AddDays(1); @@ -46,10 +45,23 @@ public sealed class ReportMetricService : IReportMetricService var conn = _ctx.Database.GetDbConnection(); if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); - if (req.Metric == "control_residual") - await ResidualAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct); + if (QV_METRICS.Contains(req.Metric)) + { + if (!_map.TryResolveQv(req.Column, req.Metric, out var qspec) || qspec is null) + { res.Status = "error"; res.Error = $"미정의 QV 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; } + res.Unit = qspec.Unit; + await ComputeQvAsync(res, conn, tbl, qspec, fromUtc, toUtc, isFast, req.SessionId, ct); + } else - await RatioAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct); + { + if (!_map.TryResolve(req.Column, req.Metric, out var spec) || spec is null) + { res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; } + res.Unit = spec.Unit; + if (req.Metric == "control_residual") + await ResidualAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct); + else + await RatioAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct); + } if (isFast && res.Status == "ok") res.SamplingMs = await FastSamplingMsAsync(conn, req.SessionId ?? -1, ct); } @@ -61,6 +73,89 @@ public sealed class ReportMetricService : IReportMetricService return res; } + // ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed ── + private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s, + DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct) + { + if (s.Kind == QvKind.Single) + { + var (tot, n, resets) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct); + if (tot is null) { res.Status = "no_data"; res.Value = null; return; } + res.Value = tot; res.N = n; res.Extra["resets"] = resets; + } + else if (s.Kind == QvKind.Ratio) + { + var (a, na, ra) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct); + var (b, nb, rb) = await QvDeltaAsync(conn, tbl, s.B!, fromUtc, toUtc, isFast, sid, ct); + if (a is null || b is null || b == 0) { res.Status = "no_data"; res.Value = null; return; } + res.Value = a / b; res.N = Math.Min(na, nb); + res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["resets"] = ra + rb; + // 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상(분모 적산 부족 등) + if (res.Value <= 0 || (s.Max is double mx && res.Value > mx)) + { + res.Status = "no_data"; + res.Error = $"비물리적 비율 {res.Value:F1} (분모 적산 Δ={b:F0} 비정상 추정)"; + res.Value = null; + } + } + else // Closure + { + var (feed, nf, rf) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct); + if (feed is null || feed == 0) { res.Status = "no_data"; res.Value = null; return; } + double outSum = 0; int resets = rf; + for (int i = 0; i < s.Outputs.Count; i++) + { + var (d, _, ri) = await QvDeltaAsync(conn, tbl, s.Outputs[i], fromUtc, toUtc, isFast, sid, ct); + outSum += d ?? 0; resets += ri; + res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서) + } + res.Value = 100.0 * outSum / feed; // 폐합 % + res.N = nf; + res.Extra["feed_qv"] = feed; + res.Extra["out_total"] = outSum; + res.Extra["product_qv"] = s.Outputs.Count > 0 ? res.Extra["out0_qv"] : null; + res.Extra["resets"] = resets; + } + } + + /// + /// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건, + /// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실. + /// + private static async Task<(double? Total, int NSteps, int NResets)> QvDeltaAsync( + DbConnection conn, string tbl, string tag, DateTime fromUtc, DateTime toUtc, + bool isFast, int? sid, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +WITH s AS ( + SELECT recorded_at, value::float v, + lag(value::float) OVER (ORDER BY recorded_at) prev + FROM hc900.{tbl} + WHERE tagname=@tag AND value ~ '{NUMERIC}' + AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")}) +), seg AS ( + SELECT recorded_at, v, + sum(CASE WHEN prev IS NOT NULL AND v < prev - 1 THEN 1 ELSE 0 END) + OVER (ORDER BY recorded_at) AS seg_id + FROM s +), perseg AS ( + SELECT (array_agg(v ORDER BY recorded_at))[1] AS first_v, + (array_agg(v ORDER BY recorded_at DESC))[1] AS last_v + FROM seg GROUP BY seg_id +) +SELECT (SELECT sum(last_v - first_v) FROM perseg), + (SELECT count(*) FROM s), + (SELECT count(*) FROM s WHERE prev IS NOT NULL AND v < prev - 1);"; + AddP(cmd, "@tag", tag); + if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); } + + await using var rd = await cmd.ExecuteReaderAsync(ct); + if (await rd.ReadAsync(ct) && !rd.IsDBNull(0)) + return (rd.GetDouble(0), (int)rd.GetInt64(1), (int)rd.GetInt64(2)); + return (null, 0, 0); + } + // ── 비율: median(A/B) (분 버킷 피벗, 둘 다 유효한 분만). 클린율 = 1 - 2*good/raw ── private async Task RatioAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s, DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)