P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1

Open
windpacer wants to merge 43 commits from feat/p0-selfservice-report into main
4 changed files with 145 additions and 37 deletions
Showing only changes of commit c64bf08aa5 - Show all commits

View File

@@ -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 ≤ 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 훅)
- `dynamics` 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가.
- 토큰에 `period=MONTHLY|YEARLY` 추가 → from/to 계산만 분기.

View File

@@ -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": {

View File

@@ -11,6 +11,9 @@ public enum QvKind { Single, Ratio, Closure }
/// <summary>Max = 비율의 물리적 상한(초과 시 no_data). null이면 무검사.</summary>
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>
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 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>
private static string? Norm(string? tag)
=> 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)
{ 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;
}
}
/// <summary>
/// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건,
/// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실.
/// 적산 Δ. 비정상운전 분(cleaning=진공高/제품~0, drawdown=feed~0) 제외 후, 정상구간 양(+)증분만 합산(cap 5e4).
/// 리셋 3종 자동처리: 999999 wrap·cleaning 리셋·운전조건변경 리셋. cl=null이면 마스크 없이 양증분합산.
/// 반환: (Δ합, 정상분수, 제외분수).
/// </summary>
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<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();
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);