P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1
@@ -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 계산만 분기.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user