feat(report): cleaning/drawdown 마스크 + 폐합 7컬럼 — 적산 메트릭 비정상운전 제외

적산(.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 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-14 18:04:13 +09:00
parent 30a3286d35
commit c64bf08aa5
4 changed files with 145 additions and 37 deletions

View File

@@ -534,6 +534,47 @@ document.getElementById('rpGen').onclick = async () => {
**신뢰 블록(운전원 검산):** 템플릿에 `feed_qv`(IN)·`out_total`(OUT)·`mass_balance_closure`(%)를 한 표에 배치 → "9675 vs 9582 = 99% 닫힘"을 눈으로 검산. 샘플 템플릿에 반영. **신뢰 블록(운전원 검산):** 템플릿에 `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 ≤ vprev < 50000: total += vprev # 양증분만
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 훅) ## 11. 다음(P1 훅)
- `dynamics` 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가. - `dynamics` 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가.
- 토큰에 `period=MONTHLY|YEARLY` 추가 → from/to 계산만 분기. - 토큰에 `period=MONTHLY|YEARLY` 추가 → from/to 계산만 분기.

View File

@@ -87,8 +87,24 @@
} }
}, },
"Report": { "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": { "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": { "Kestrel": {

View File

@@ -11,6 +11,9 @@ public enum QvKind { Single, Ratio, Closure }
/// <summary>Max = 비율의 물리적 상한(초과 시 no_data). null이면 무검사.</summary> /// <summary>Max = 비율의 물리적 상한(초과 시 no_data). null이면 무검사.</summary>
public sealed record QvSpec(string Unit, QvKind Kind, string A, string? B, IReadOnlyList<string> Outputs, double? Max = null); public sealed record QvSpec(string Unit, QvKind Kind, string A, string? B, IReadOnlyList<string> Outputs, double? Max = null);
/// <summary>비정상운전(cleaning/drawdown) 제외 마스크. 진공高 또는 제품~0 또는 feed~0인 분 제외.</summary>
public sealed record CleaningSpec(string? VacTag, double VacMax, string ProductTag, double ProductMin, string FeedTag, double FeedMin);
/// <summary> /// <summary>
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를 /// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2). /// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 tag_metadata EU레인지로 대체 P2).
@@ -117,6 +120,25 @@ public sealed class ReportColumnMap
} }
} }
/// <summary>cleaning/drawdown 마스크 해석. 제품·feed는 SteamAdvisor:Columns, 진공태그·임계는 Report:Cleaning.</summary>
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;
}
/// <summary>속성(.PV 등) 없으면 .PV 부여.</summary> /// <summary>속성(.PV 등) 없으면 .PV 부여.</summary>
private static string? Norm(string? tag) private static string? Norm(string? tag)
=> string.IsNullOrWhiteSpace(tag) ? null : (tag!.Contains('.') ? tag : tag + ".PV"); => string.IsNullOrWhiteSpace(tag) ? null : (tag!.Contains('.') ? tag : tag + ".PV");

View File

@@ -50,7 +50,8 @@ public sealed class ReportMetricService : IReportMetricService
if (!_map.TryResolveQv(req.Column, req.Metric, out var qspec) || qspec is null) 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.Status = "error"; res.Error = $"미정의 QV 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
res.Unit = qspec.Unit; 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 else
{ {
@@ -73,24 +74,24 @@ public sealed class ReportMetricService : IReportMetricService
return res; 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, 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) 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; } 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) else if (s.Kind == QvKind.Ratio)
{ {
var (a, na, ra) = await QvDeltaAsync(conn, tbl, s.A, fromUtc, toUtc, isFast, sid, ct); var (a, na, ea) = await QvDeltaAsync(conn, tbl, s.A, cl, fromUtc, toUtc, isFast, sid, ct);
var (b, nb, rb) = await QvDeltaAsync(conn, tbl, s.B!, 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; } 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.Value = a / b; res.N = Math.Min(na, nb);
res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["resets"] = ra + rb; res.Extra["numer_qv"] = a; res.Extra["denom_qv"] = b; res.Extra["excluded_min"] = ea;
// 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상(분모 적산 부족 등) // 물리적 타당성 게이트: 비율이 음수/0이하 또는 상한 초과 → 데이터 이상
if (res.Value <= 0 || (s.Max is double mx && res.Value > mx)) if (res.Value <= 0 || (s.Max is double mx && res.Value > mx))
{ {
res.Status = "no_data"; res.Status = "no_data";
@@ -100,13 +101,13 @@ public sealed class ReportMetricService : IReportMetricService
} }
else // Closure 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; } 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++) for (int i = 0; i < s.Outputs.Count; i++)
{ {
var (d, _, ri) = await QvDeltaAsync(conn, tbl, s.Outputs[i], fromUtc, toUtc, isFast, sid, ct); var (d, _, _) = await QvDeltaAsync(conn, tbl, s.Outputs[i], cl, fromUtc, toUtc, isFast, sid, ct);
outSum += d ?? 0; resets += ri; outSum += d ?? 0;
res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서) res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서)
} }
res.Value = 100.0 * outSum / feed; // 폐합 % res.Value = 100.0 * outSum / feed; // 폐합 %
@@ -114,40 +115,68 @@ public sealed class ReportMetricService : IReportMetricService
res.Extra["feed_qv"] = feed; res.Extra["feed_qv"] = feed;
res.Extra["out_total"] = outSum; res.Extra["out_total"] = outSum;
res.Extra["product_qv"] = s.Outputs.Count > 0 ? res.Extra["out0_qv"] : null; res.Extra["product_qv"] = s.Outputs.Count > 0 ? res.Extra["out0_qv"] : null;
res.Extra["resets"] = resets; res.Extra["excluded_min"] = ef;
} }
} }
/// <summary> /// <summary>
/// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건, /// 적산 Δ. 비정상운전 분(cleaning=진공高/제품~0, drawdown=feed~0) 제외 후, 정상구간 양(+)증분만 합산(cap 5e4).
/// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실. /// 리셋 3종 자동처리: 999999 wrap·cleaning 리셋·운전조건변경 리셋. cl=null이면 마스크 없이 양증분합산.
/// 반환: (Δ합, 정상분수, 제외분수).
/// </summary> /// </summary>
private static async Task<(double? Total, int NSteps, int NResets)> QvDeltaAsync( private static async Task<(double? Total, int NNormal, int NExcluded)> QvDeltaAsync(
DbConnection conn, string tbl, string tag, DateTime fromUtc, DateTime toUtc, DbConnection conn, string tbl, string tag, CleaningSpec? cl,
bool isFast, int? sid, CancellationToken ct) 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<string> { "@tag" };
var mask = new List<string>();
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(); await using var cmd = conn.CreateCommand();
cmd.CommandText = $@" cmd.CommandText = $@"
WITH s AS ( WITH pm AS (
SELECT recorded_at, value::float v, SELECT date_trunc('minute', recorded_at) ts, {pivot}
lag(value::float) OVER (ORDER BY recorded_at) prev
FROM hc900.{tbl} FROM hc900.{tbl}
WHERE tagname=@tag AND value ~ '{NUMERIC}' WHERE tagname IN ({string.Join(",", inTags)}) AND value ~ '{NUMERIC}' AND ({window})
AND ({(isFast ? "session_id = @sid" : "recorded_at >= @from AND recorded_at < @to")}) GROUP BY 1
), seg AS ( ), fl AS (
SELECT recorded_at, v, SELECT ts, v, ({cleanExpr}) AS clean FROM pm WHERE v IS NOT NULL
sum(CASE WHEN prev IS NOT NULL AND v < prev - 1 THEN 1 ELSE 0 END) ), seq AS (
OVER (ORDER BY recorded_at) AS seg_id SELECT ts, v, clean,
FROM s lag(v) OVER (ORDER BY ts) pv,
), perseg AS ( lag(clean) OVER (ORDER BY ts) pc
SELECT (array_agg(v ORDER BY recorded_at))[1] AS first_v, FROM fl
(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 coalesce(sum(v - pv) FILTER (
(SELECT count(*) FROM s), WHERE pv IS NOT NULL AND NOT clean AND NOT coalesce(pc, true)
(SELECT count(*) FROM s WHERE prev IS NOT NULL AND v < prev - 1);"; AND v >= pv AND v - pv < 50000), 0),
count(*) FILTER (WHERE NOT clean),
count(*) FILTER (WHERE clean)
FROM seq;";
AddP(cmd, "@tag", tag); 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); } if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct); await using var rd = await cmd.ExecuteReaderAsync(ct);