From c64bf08aa5cdc0da1614252fac70ec7c3d135ec1 Mon Sep 17 00:00:00 2001 From: windpacer Date: Sun, 14 Jun 2026 18:04:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(report):=20cleaning/drawdown=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=20+=20=ED=8F=90=ED=95=A9=207=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=E2=80=94=20=EC=A0=81=EC=82=B0=20=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AD=20=EB=B9=84=EC=A0=95=EC=83=81=EC=9A=B4=EC=A0=84=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 적산(.QV) 메트릭이 비정상 운전 분을 포함해 풀기간/월간 집계가 틀어지던 문제 해결. - 3신호 마스크: (진공 PICA-*.PV>300) OR (제품 FICQ-*18.PV<10) OR (원료 FICQ-*01.PV<10). = cleaning(진공깨짐/제품~0) + drawdown(feed~0, 인벤토리 인출/컬럼간 이송) 제외. - QvDeltaAsync 재작성: 비정상분 제외 후 정상구간 양(+)증분만 합산(cap 5e4). 리셋 3종 자동처리(999999 wrap·cleaning 리셋·운전조건변경 리셋). excluded_min 메타. - appsettings Report:Cleaning(컬럼별 진공태그+임계) + Closure 7컬럼 전체 등록. 검증(풀기간 폐합): 마스크 후 6/7 컬럼 99~100% (C-6211 216.8→100.0, C-9111 133.2→99.9, C-9211 97.8→99.9, C-10211 121.9→99.1). C-10111 91.4%는 C-9111↔C-10111 연결라인 시운전 중 홀드업 축적(정상 비정상상태, KPI가 표시). 라이브: C-9111 일일(04-10) 99.46%, C-6111 일일(05-15) 99.04% 확인. 현장사실: C-9111↔C-10111 잇는 라인 추가·시운전 중 — C-9111 drawdown 시 인출물이 C-10111 feed로 유입(FICQ-10101 0→~1090, 진공 750→45). 개별 수지 불성립 주원인. Co-Authored-By: Claude Opus 4.8 --- ...랜-셀프서비스-분석리포트-MVP-P0-상세설계.md | 41 +++++++ src/Hc900Crawler/appsettings.json | 18 +++- .../Reporting/ReportColumnMap.cs | 22 ++++ .../Reporting/ReportMetricService.cs | 101 +++++++++++------- 4 files changed, 145 insertions(+), 37 deletions(-) diff --git a/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md index 1ead579..08840af 100644 --- a/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md +++ b/docs/작업플랜-셀프서비스-분석리포트-MVP-P0-상세설계.md @@ -534,6 +534,47 @@ document.getElementById('rpGen').onclick = async () => { **신뢰 블록(운전원 검산):** 템플릿에 `feed_qv`(IN)·`out_total`(OUT)·`mass_balance_closure`(%)를 한 표에 배치 → "9675 vs 9582 = 99% 닫힘"을 눈으로 검산. 샘플 템플릿에 반영. +## 13. Cleaning/Drawdown 데이터품질 게이트 (2026-06-14 검증) + +적산(.QV) 메트릭의 **결정적 정합성 조건**: 비정상 운전 분(分)을 제외해야 생산량·수율·폐합이 맞는다. 데이터로 검증한 3신호 마스크. + +**비정상(제외) 분 = 아래 중 하나:** +| 신호 | 조건 | 의미 | +|---|---|---| +| 진공 | `PICA-*.PV > 300` | vacuum 깨짐 = cleaning/비운전 (정상 50~113, 비정상 750+) | +| 제품~0 | `제품 FICQ-*18.PV < 10` | 제품 안 만듦 = cleaning | +| drawdown | `원료 FICQ-*01.PV < 10` | feed≈0인데 운전 = 인벤토리 인출/링크 이송 | + +**컬럼별 진공태그(P6는 무접미사, 그 외 A 접미사):** +`C-6111→PICA-6111`, `C-6211→PICA-6211`, `C-8111→PICA-8111A`, `C-9111→PICA-9111A`, `C-9211→PICA-9211A`, `C-10111→PICA-10111A`, `C-10211→PICA-10211A`. + +**QV Δ 알고리즘 (리셋 3종 + 마스크 통합):** +``` +prev=None +for 각 분(시간순): + if 비정상(위 3신호): prev=None; continue # 체인 끊기(cleaning/drawdown 리셋 흡수) + if prev≠None and 0 ≤ v−prev < 50000: total += v−prev # 양증분만 + prev = v +``` +이 한 규칙이 처리: ①999999 자동 wrap(하강 스킵, 손실≈0) ②cleaning 리셋(제외분) ③운전조건변경 리셋(리셋전 누적 보존, 하강 스킵). + +**검증 (풀기간 2026-02-09~06-05 폐합):** +| 컬럼 | 마스크前 | 3신호 마스크後 | +|---|---|---| +| C-6111 | 99.3 | **99.6** | +| C-6211 | 216.8 | **100.0** | +| C-8111 | 99.0 | **99.8** | +| C-9111 | 133.2 | **99.9** | +| C-9211 | 97.8 | **99.9** | +| C-10211 | 121.9 | **99.1** | +| C-10111 | 53~105 | 91.4 (※) | + +→ **6/7 컬럼 99~100% 폐합.** ※C-10111 91.4%는 버그 아님 — **C-9111→C-10111 연결라인** 테스트 중 C-10111이 받은 물질을 홀드업 축적(제품 미생산)한 정상 비정상상태. KPI가 "축적 중"을 정확히 표시. + +**중요 현장 사실 — C-9111↔C-10111 연결라인:** 품질이슈 대응으로 두 컬럼을 잇는 라인 추가·시운전 중. C-9111 drawdown(자체 feed≈0, 제품↑)일 때 그 인출물이 C-10111 feed로 유입(C-10111 `FICQ-10101.PV` 0→~1090, 진공 750→45 운전전환). 개별 컬럼 풀기간 수지가 안 닫히는 주된 원인이며, drawdown 마스크로 정상일만 남겨 해결. C-9111 계량 자체는 정상(정상일 99.2~99.8%). + +**구현 메모:** `Report:Cleaning` config(컬럼별 진공태그 + VacMax=300, ProductMin=10, FeedMin=10). `QvDeltaAsync`가 분 단위 마스크 적용. PV 메트릭(efficiency/yield/residual)도 동일 마스크. 결과 메타에 제외분 수 표기. + ## 11. 다음(P1 훅) - `dynamics` 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가. - 토큰에 `period=MONTHLY|YEARLY` 추가 → from/to 계산만 분기. diff --git a/src/Hc900Crawler/appsettings.json b/src/Hc900Crawler/appsettings.json index 15a0f34..027ffe2 100644 --- a/src/Hc900Crawler/appsettings.json +++ b/src/Hc900Crawler/appsettings.json @@ -87,8 +87,24 @@ } }, "Report": { + "Cleaning": { + "VacMax": 300, + "ProductMin": 10, + "FeedMin": 10, + "VacTag": { + "C-6111": "PICA-6111", "C-6211": "PICA-6211", "C-8111": "PICA-8111A", + "C-9111": "PICA-9111A", "C-9211": "PICA-9211A", + "C-10111": "PICA-10111A", "C-10211": "PICA-10211A" + } + }, "Closure": { - "C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] } + "C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] }, + "C-6211": { "Feed": "FICQ-6201", "Outputs": [ "FICQ-6218", "FICQ-6214", "FICQ-6216" ] }, + "C-8111": { "Feed": "FICQ-8101", "Outputs": [ "FICQ-8118", "FICQ-8114", "FICQ-8116" ] }, + "C-9111": { "Feed": "FICQ-9101", "Outputs": [ "FICQ-9118", "FICQ-9114", "FICQ-9116" ] }, + "C-9211": { "Feed": "FICQ-9201", "Outputs": [ "FICQ-9218", "FICQ-9214", "FICQ-9216" ] }, + "C-10111": { "Feed": "FICQ-10101", "Outputs": [ "FICQ-10118", "FICQ-10114A", "FICQ-10116" ] }, + "C-10211": { "Feed": "FICQ-10201", "Outputs": [ "FICQ-10218", "FICQ-10214", "FICQ-10216" ] } } }, "Kestrel": { diff --git a/src/Infrastructure/Reporting/ReportColumnMap.cs b/src/Infrastructure/Reporting/ReportColumnMap.cs index 2f452c9..42ada7f 100644 --- a/src/Infrastructure/Reporting/ReportColumnMap.cs +++ b/src/Infrastructure/Reporting/ReportColumnMap.cs @@ -11,6 +11,9 @@ 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); +/// 비정상운전(cleaning/drawdown) 제외 마스크. 진공高 또는 제품~0 또는 feed~0인 분 제외. +public sealed record CleaningSpec(string? VacTag, double VacMax, string ProductTag, double ProductMin, string FeedTag, double FeedMin); + /// /// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를 /// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2). @@ -117,6 +120,25 @@ public sealed class ReportColumnMap } } + /// cleaning/drawdown 마스크 해석. 제품·feed는 SteamAdvisor:Columns, 진공태그·임계는 Report:Cleaning. + public bool TryResolveCleaning(string column, out CleaningSpec? spec) + { + spec = null; + var sec = _config.GetSection($"SteamAdvisor:Columns:{column}"); + var product = Norm(sec["Product"]); + var feed = Norm(sec["Feed"]); + if (product is null || feed is null) return false; + + var cl = _config.GetSection("Report:Cleaning"); + var vacBase = _config[$"Report:Cleaning:VacTag:{column}"]; + string? vacTag = string.IsNullOrWhiteSpace(vacBase) ? null : (vacBase!.Contains('.') ? vacBase : vacBase + ".PV"); + double vmax = double.TryParse(cl["VacMax"], out var a) ? a : 300; + double pmin = double.TryParse(cl["ProductMin"], out var b) ? b : 10; + double fmin = double.TryParse(cl["FeedMin"], out var d) ? d : 10; + spec = new CleaningSpec(vacTag, vmax, product, pmin, feed, fmin); + return true; + } + /// 속성(.PV 등) 없으면 .PV 부여. private static string? Norm(string? tag) => string.IsNullOrWhiteSpace(tag) ? null : (tag!.Contains('.') ? tag : tag + ".PV"); diff --git a/src/Infrastructure/Reporting/ReportMetricService.cs b/src/Infrastructure/Reporting/ReportMetricService.cs index 81801b1..98071f7 100644 --- a/src/Infrastructure/Reporting/ReportMetricService.cs +++ b/src/Infrastructure/Reporting/ReportMetricService.cs @@ -50,7 +50,8 @@ public sealed class ReportMetricService : IReportMetricService 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); + _map.TryResolveCleaning(req.Column, out var clean); // 비정상운전 제외 마스크(없으면 null) + await ComputeQvAsync(res, conn, tbl, qspec, clean, fromUtc, toUtc, isFast, req.SessionId, ct); } else { @@ -73,24 +74,24 @@ public sealed class ReportMetricService : IReportMetricService return res; } - // ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed ── + // ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed. cleaning/drawdown 제외. ── private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s, - DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct) + CleaningSpec? cl, 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); + var (tot, n, excl) = await QvDeltaAsync(conn, tbl, s.A, cl, 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; + res.Value = tot; res.N = n; res.Extra["excluded_min"] = excl; } 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); + var (a, na, ea) = await QvDeltaAsync(conn, tbl, s.A, cl, fromUtc, toUtc, isFast, sid, ct); + var (b, nb, eb) = await QvDeltaAsync(conn, tbl, s.B!, cl, 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이하 또는 상한 초과 → 데이터 이상(분모 적산 부족 등) + res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["excluded_min"] = ea; + // 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상 if (res.Value <= 0 || (s.Max is double mx && res.Value > mx)) { res.Status = "no_data"; @@ -100,13 +101,13 @@ public sealed class ReportMetricService : IReportMetricService } else // Closure { - var (feed, nf, rf) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct); + var (feed, nf, ef) = await QvDeltaAsync(conn, tbl, s.A, cl, 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; + double outSum = 0; 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; + var (d, _, _) = await QvDeltaAsync(conn, tbl, s.Outputs[i], cl, fromUtc, toUtc, isFast, sid, ct); + outSum += d ?? 0; res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서) } res.Value = 100.0 * outSum / feed; // 폐합 % @@ -114,40 +115,68 @@ public sealed class ReportMetricService : IReportMetricService 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; + res.Extra["excluded_min"] = ef; } } /// - /// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건, - /// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실. + /// 적산 Δ. 비정상운전 분(cleaning=진공高/제품~0, drawdown=feed~0) 제외 후, 정상구간 양(+)증분만 합산(cap 5e4). + /// 리셋 3종 자동처리: 999999 wrap·cleaning 리셋·운전조건변경 리셋. cl=null이면 마스크 없이 양증분합산. + /// 반환: (Δ합, 정상분수, 제외분수). /// - 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) + private static async Task<(double? Total, int NNormal, int NExcluded)> QvDeltaAsync( + DbConnection conn, string tbl, string tag, CleaningSpec? cl, + DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct) { + bool hasVac = cl != null && !string.IsNullOrEmpty(cl.VacTag); + var pivot = new System.Text.StringBuilder("max(CASE WHEN tagname=@tag THEN value::float END) v"); + var inTags = new List { "@tag" }; + var mask = new List(); + if (cl != null) + { + pivot.Append(", max(CASE WHEN tagname=@ptag THEN value::float END) prod"); + pivot.Append(", max(CASE WHEN tagname=@ftag THEN value::float END) feed"); + mask.Add("prod < @pmin OR prod IS NULL"); + mask.Add("feed < @fmin OR feed IS NULL"); + inTags.Add("@ptag"); inTags.Add("@ftag"); + if (hasVac) + { + pivot.Append(", max(CASE WHEN tagname=@vtag THEN value::float END) vac"); + mask.Add("vac > @vmax OR vac IS NULL"); + inTags.Add("@vtag"); + } + } + string cleanExpr = mask.Count > 0 ? "(" + string.Join(" OR ", mask) + ")" : "false"; + string window = isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to"; + 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 +WITH pm AS ( + SELECT date_trunc('minute', recorded_at) ts, {pivot} 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 + WHERE tagname IN ({string.Join(",", inTags)}) AND value ~ '{NUMERIC}' AND ({window}) + GROUP BY 1 +), fl AS ( + SELECT ts, v, ({cleanExpr}) AS clean FROM pm WHERE v IS NOT NULL +), seq AS ( + SELECT ts, v, clean, + lag(v) OVER (ORDER BY ts) pv, + lag(clean) OVER (ORDER BY ts) pc + FROM fl ) -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);"; +SELECT coalesce(sum(v - pv) FILTER ( + WHERE pv IS NOT NULL AND NOT clean AND NOT coalesce(pc, true) + AND v >= pv AND v - pv < 50000), 0), + count(*) FILTER (WHERE NOT clean), + count(*) FILTER (WHERE clean) +FROM seq;"; AddP(cmd, "@tag", tag); + if (cl != null) + { + AddP(cmd, "@ptag", cl.ProductTag); AddP(cmd, "@ftag", cl.FeedTag); + AddP(cmd, "@pmin", cl.ProductMin); AddP(cmd, "@fmin", cl.FeedMin); + if (hasVac) { AddP(cmd, "@vtag", cl.VacTag!); AddP(cmd, "@vmax", cl.VacMax); } + } if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); } await using var rd = await cmd.ExecuteReaderAsync(ct);