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);