feat(report): 적산(.QV) 정확 메트릭 + 물질수지 폐합 + 웹 대시보드

PV-중앙값 근사 대신 유량 적산(.QV)으로 정확한 적분 메트릭 추가, 웹에서 바로 보기.

- QV 메트릭 4종: production_total·yield_qv·energy_intensity_qv·mass_balance_closure.
  적산 Δ 헬퍼(QvDeltaAsync)는 값 급감(reset/wrap)으로 구간분할 후 구간별 (마지막-처음)
  합산 → gap·양자화 강건, DCS 일일적산과 동일 의미(노이즈 과대계상 없음).
- 매핑은 appsettings SteamAdvisor:Columns 재사용(.QV 치환, 7컬럼). 폐합 스트림은
  Report:Closure:C-6111(Feed + Outputs[제품·경비물·중비물]).
- 웹 대시보드: GET /api/report/columns, /api/report/summary →
  리포트 탭에서 컬럼·날짜 선택 시 물질수지 신뢰블록(IN/OUT/폐합%, 98~101% 색상)
  + 메트릭 표를 즉시 렌더. 엑셀 export와 병존.
- 물리적 타당성 게이트: 수율>1.5·에너지원단위>5·≤0 → no_data+사유(garbage 차단).

검증(2026-05-15 C-6111): 생산 8455kg·수율 0.8739·에너지 0.7791·폐합 99.04%
(feed 9675/out 9582), 17주 폐합 99.1~99.8%. C-9111 garbage 수율 → no_data 처리 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-14 07:54:34 +09:00
parent 37a464ab96
commit 30a3286d35
8 changed files with 377 additions and 86 deletions

View File

@@ -514,6 +514,26 @@ document.getElementById('rpGen').onclick = async () => {
추정: 백엔드 핵심(메트릭+채움+컨트롤러) ~1.5일, store/DDL ~0.5일, 프론트 ~0.5일, 검증/데모 ~0.5일 → **약 3일**. 추정: 백엔드 핵심(메트릭+채움+컨트롤러) ~1.5일, store/DDL ~0.5일, 프론트 ~0.5일, 검증/데모 ~0.5일 → **약 3일**.
## 12. 적산(.QV) 메트릭 추가 (2026-06-13 구현)
각 유량태그의 `.QV`(적산/totalizer)를 활용. PV-중앙값 근사 대신 **정확한 적분** → gap·양자화 무관, "비율의 중앙값" 모호성 해소. 폐합이 닫혀(99.1~99.8%) 적산기 신뢰성도 입증됨.
**추가 메트릭(4종):**
| metric | 정의 | 비고 |
|---|---|---|
| `production_total` | ΔQV(제품) | 생산량 kg = MES 생산리포트 숫자 |
| `yield_qv` | Δproduct/Δfeed | 정확 수율 |
| `energy_intensity_qv` | Δsteam/Δproduct | 정확 에너지원단위 |
| `mass_balance_closure` | 100·ΣΔout/Δfeed | 폐합 % + Extra(feed_qv/out_total/out0~2_qv) |
**적산 Δ 헬퍼(`QvDeltaAsync`)** — 핵심. 값 급감(<prev1)으로 **리셋/wrap 구간 분할 후 구간별 (마지막−처음) 합산**:
- gap 강건(끝점 차이가 정답), 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미), wrap(1e6) 꼭대기 잔량(소량)만 손실.
- 검증(2026-05-15 C-6111): production 8454.7kg·yield 0.8739·energy 0.7791·closure 99.04%(feed 9675/out 9582). 폐합 17주 99.1~99.8%.
**매핑 재사용:** production/yield/energy = `SteamAdvisor:Columns`의 Feed/Product/SteamFlow를 `.QV`로 치환(7컬럼 무료). 폐합은 신규 `appsettings:Report:Closure:{col}`(Feed + Outputs[제품,경비물,중비물]) — C-6111만 등록(타컬럼은 P&ID 그래프 유도 후 확장).
**신뢰 블록(운전원 검산):** 템플릿에 `feed_qv`(IN)·`out_total`(OUT)·`mass_balance_closure`(%)를 한 표에 배치 → "9675 vs 9582 = 99% 닫힘"을 눈으로 검산. 샘플 템플릿에 반영.
## 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

@@ -12,15 +12,42 @@ public class ReportController : ControllerBase
private readonly IReportMetricService _metrics; private readonly IReportMetricService _metrics;
private readonly ReportFillService _fill; private readonly ReportFillService _fill;
private readonly IReportTemplateStore _store; private readonly IReportTemplateStore _store;
private readonly ReportColumnMap _map;
public ReportController(IReportMetricService metrics, ReportFillService fill, IReportTemplateStore store) // 웹 대시보드 기본 메트릭 세트
{ _metrics = metrics; _fill = fill; _store = store; } private static readonly string[] SUMMARY_METRICS =
{ "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure", "control_residual" };
public ReportController(IReportMetricService metrics, ReportFillService fill,
IReportTemplateStore store, ReportColumnMap map)
{ _metrics = metrics; _fill = fill; _store = store; _map = map; }
/// <summary>설정된 컬럼 목록(웹 UI 셀렉트용).</summary>
[HttpGet("columns")]
public IActionResult Columns()
=> Ok(_map.Columns().Select(c => new { Column = c, HasClosure = _map.HasClosure(c) }));
/// <summary>단건 메트릭(미리보기/디버그).</summary> /// <summary>단건 메트릭(미리보기/디버그).</summary>
[HttpPost("metric")] [HttpPost("metric")]
public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct) public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken ct)
=> Ok(await _metrics.ComputeAsync(req, ct)); => Ok(await _metrics.ComputeAsync(req, ct));
/// <summary>웹에서 바로 보기 — 한 컬럼·날짜의 전 메트릭을 한 번에.</summary>
[HttpGet("summary")]
public async Task<IActionResult> Summary(string column = "C-6111", DateTime? date = null,
string source = "history_table", int? sessionId = null, CancellationToken ct = default)
{
var d = (date ?? DateTime.UtcNow.AddHours(9).AddDays(-1)).Date; // 기본 = 어제(KST)
var results = new List<MetricResultDto>();
foreach (var m in SUMMARY_METRICS)
results.Add(await _metrics.ComputeAsync(new MetricRequestDto
{
Column = column, Metric = m, PeriodDateKst = d,
SourceTable = source, SessionId = sessionId
}, ct));
return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Metrics = results });
}
/// <summary>엑셀 템플릿 등록.</summary> /// <summary>엑셀 템플릿 등록.</summary>
[HttpPost("template")] [HttpPost("template")]
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name, public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name,

View File

@@ -86,6 +86,11 @@
"C-10211": { "Feed": "FICQ-10201.PV", "Product": "FICQ-10218.PV", "TC": "TI-10211C.PV", "SteamOp": "TICA-10211A.OP", "SteamFlow": "FIQ-10215" } "C-10211": { "Feed": "FICQ-10201.PV", "Product": "FICQ-10218.PV", "TC": "TI-10211C.PV", "SteamOp": "TICA-10211A.OP", "SteamFlow": "FIQ-10215" }
} }
}, },
"Report": {
"Closure": {
"C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] }
}
},
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {
"Http": { "Http": {

View File

@@ -1,55 +1,120 @@
/* P0 셀프서비스 리포트 — 패널 JS. /* P0 리포트 패널 — ① 웹에서 바로 보기 + ② 엑셀 export.
pane HTML은 innerHTML 주입되므로 <script> 실행 안 됨 → 전역 로드 + paneInit 등록. pane HTML은 innerHTML 주입이라 <script> 실행 → 전역 로드 + paneInit 등록(멱등). */
paneInit['reports']는 탭 활성화 때마다 호출되므로 바인딩은 멱등하게(onclick 재할당). */
paneInit['reports'] = function () { paneInit['reports'] = function () {
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const tpl = $('rpTpl'), date = $('rpDate'), src = $('rpSource'); const yKst = () => new Date(Date.now() + 9 * 3600e3 - 86400e3).toISOString().slice(0, 10);
const sessWrap = $('rpSessionWrap'), sess = $('rpSession');
const gen = $('rpGen'), status = $('rpStatus');
if (!gen) return;
// 기본 날짜 = 어제(KST) // ── 공통: 소스 select → session 입력 토글 ──
if (date && !date.value) { const tog = (sel, wrap) => { const s = $(sel), w = $(wrap); if (s && w) { s.onchange = () => w.style.display = s.value === 'fast_record' ? '' : 'none'; s.onchange(); } };
const d = new Date(Date.now() + 9 * 3600e3 - 86400e3); tog('rvSource', 'rvSessWrap'); tog('rpSource', 'rpSessionWrap');
date.value = d.toISOString().slice(0, 10);
// ── ① 웹에서 바로 보기 ──
const col = $('rvCol'), date = $('rvDate'), go = $('rvGo'), out = $('rvOut');
if (date && !date.value) date.value = yKst();
if (col && !col.dataset.loaded) {
fetch('/api/report/columns').then(r => r.json()).then(list => {
col.innerHTML = list.map(c => `<option value="${c.Column}">${c.Column}${c.HasClosure ? '' : ' (폐합X)'}</option>`).join('');
col.dataset.loaded = '1';
}).catch(() => { col.innerHTML = '<option value="C-6111">C-6111</option>'; });
} }
src.onchange = () => { sessWrap.style.display = src.value === 'fast_record' ? '' : 'none'; }; if (go) go.onclick = async () => {
src.onchange(); out.innerHTML = '<span class="mono">⏳ 조회 중...</span>';
try {
let url = `/api/report/summary?column=${encodeURIComponent(col.value)}&date=${date.value}&source=${$('rvSource').value}`;
if ($('rvSource').value === 'fast_record' && $('rvSess').value) url += `&sessionId=${$('rvSess').value}`;
const r = await fetch(url);
if (!r.ok) throw new Error('조회 실패 ' + r.status);
out.innerHTML = renderSummary(await r.json());
} catch (e) { out.innerHTML = `<span class="mono" style="color:#e66">❌ ${typeof esc === 'function' ? esc(e.message) : e.message}</span>`; }
};
gen.onclick = async () => { // ── ② 엑셀 export ──
const tpl = $('rpTpl'), gen = $('rpGen'), status = $('rpStatus');
if ($('rpDate') && !$('rpDate').value) $('rpDate').value = yKst();
if (gen) gen.onclick = async () => {
if (!tpl.files[0]) { status.textContent = '⚠️ 엑셀 템플릿을 선택하세요.'; return; } if (!tpl.files[0]) { status.textContent = '⚠️ 엑셀 템플릿을 선택하세요.'; return; }
if (!date.value) { status.textContent = '⚠️ 날짜를 선택하세요.'; return; }
gen.disabled = true; status.textContent = '⏳ 생성 중...'; gen.disabled = true; status.textContent = '⏳ 생성 중...';
try { try {
// ① 템플릿 등록 const fd = new FormData(); fd.append('file', tpl.files[0]); fd.append('name', tpl.files[0].name);
const fd = new FormData();
fd.append('file', tpl.files[0]);
fd.append('name', tpl.files[0].name);
const up = await fetch('/api/report/template', { method: 'POST', body: fd }); const up = await fetch('/api/report/template', { method: 'POST', body: fd });
if (!up.ok) throw new Error('템플릿 업로드 실패 ' + up.status); if (!up.ok) throw new Error('업로드 실패 ' + up.status);
const { Id } = await up.json(); const { Id } = await up.json();
let url = `/api/report/generate?templateId=${Id}&date=${$('rpDate').value}&source=${$('rpSource').value}`;
// ② 생성 if ($('rpSource').value === 'fast_record' && $('rpSession').value) url += `&sessionId=${$('rpSession').value}`;
let url = `/api/report/generate?templateId=${Id}&date=${date.value}&source=${src.value}`;
if (src.value === 'fast_record' && sess.value) url += `&sessionId=${sess.value}`;
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) throw new Error('생성 실패 ' + resp.status); if (!resp.ok) throw new Error('생성 실패 ' + resp.status);
const st = resp.headers.get('X-Report-Status') || '?';
const reportStatus = resp.headers.get('X-Report-Status') || '?'; const a = document.createElement('a'); a.href = URL.createObjectURL(await resp.blob());
const blob = await resp.blob(); a.download = `report_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
const a = document.createElement('a'); status.textContent = `✅ 완료 (상태=${st})`;
a.href = URL.createObjectURL(blob); } catch (e) { status.textContent = '❌ ' + e.message; } finally { gen.disabled = false; }
a.download = `report_${date.value}.xlsx`;
a.click();
URL.revokeObjectURL(a.href);
status.textContent = `✅ 완료 (templateId=${Id}, 상태=${reportStatus})\n` +
(reportStatus === 'partial' ? '※ 일부 셀 N/A/ERR — 셀 주석 확인' :
reportStatus === 'error' ? '※ 토큰 없음 또는 전체 실패 — 셀 주석 확인' : '');
} catch (e) {
status.textContent = '❌ ' + e.message;
} finally {
gen.disabled = false;
}
}; };
}; };
/* ── 렌더 헬퍼 ───────────────────────────────────────────── */
function rpFmt(v, kind) {
if (v === null || v === undefined) return '—';
if (kind === 'kg') return Math.round(v).toLocaleString();
if (kind === 'ratio') return Number(v).toFixed(4);
if (kind === 'pct') return Number(v).toFixed(2);
return Number(v).toFixed(3);
}
function rpMeta(m) {
if (m.Status !== 'ok') return `<span style="color:#e66">${m.Status}${m.Error ? ' · ' + m.Error : ''}</span>`;
return `<span style="color:var(--t2)">src=${m.Source} ${m.SamplingMs}ms · n=${m.N}</span>`;
}
function rpVal(m, kind) { return m.Status === 'ok' && m.Value != null ? rpFmt(m.Value, kind) : (m.Status === 'no_data' ? 'N/A' : 'ERR'); }
function renderSummary(d) {
const by = {}; (d.Metrics || []).forEach(m => by[m.Metric] = m);
const cl = by['mass_balance_closure'];
let html = `<div class="mono" style="margin-bottom:10px;color:var(--t2)">${d.Column} · ${d.Date} · ${d.Source}</div>`;
// 물질수지 신뢰블록
if (cl) {
const pct = cl.Status === 'ok' ? cl.Value : null;
const ok = pct != null && pct >= 98 && pct <= 101;
const color = pct == null ? '#888' : (ok ? '#2ea043' : '#e5534b');
const e = cl.Extra || {};
html += `
<div style="border:1px solid ${color};border-radius:8px;padding:14px;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
<div>
<div style="font-size:12px;color:var(--t2)">물질수지 폐합 (운전원 검산)</div>
<div><b>원료(IN)</b> ${rpFmt(e.feed_qv,'kg')} &nbsp;→&nbsp; <b>회수(OUT)</b> ${rpFmt(e.out_total,'kg')} kg</div>
<div style="font-size:12px;color:var(--t2);margin-top:2px">
제품 ${rpFmt(e.out0_qv,'kg')} · 경비물 ${rpFmt(e.out1_qv,'kg')} · 중비물 ${rpFmt(e.out2_qv,'kg')}</div>
</div>
<div style="text-align:right">
<div style="font-size:30px;font-weight:700;color:${color}">${pct == null ? 'N/A' : rpFmt(pct,'pct') + '%'}</div>
<div style="font-size:11px;color:var(--t2)">${pct == null ? '' : (ok ? '✓ 정상(98~101%)' : '⚠ 베이스라인 이탈 — 계량 점검')}</div>
</div>
</div>
</div>`;
}
// 메트릭 표
const rows = [
['생산량 (제품 적산Δ)', by['production_total'], 'kg'],
['수율 (제품/원료)', by['yield_qv'], 'ratio'],
['에너지원단위 (스팀/제품)', by['energy_intensity_qv'], 'ratio'],
['하부온도 평균잔차', by['control_residual'], 'degC'],
].filter(r => r[1]);
html += `<table style="border-collapse:collapse;width:100%;font-size:14px">
<tr style="text-align:left;color:var(--t2);border-bottom:1px solid var(--bd,#333)">
<th style="padding:6px 8px">항목</th><th>값</th><th>단위</th><th>근거</th></tr>`;
for (const [label, m, kind] of rows) {
html += `<tr style="border-bottom:1px solid var(--bd,#222)">
<td style="padding:6px 8px">${label}</td>
<td style="font-weight:600">${rpVal(m, kind)}</td>
<td style="color:var(--t2)">${m.Unit || ''}</td>
<td style="font-size:11px">${rpMeta(m)}</td></tr>`;
if (m.Metric === 'control_residual' && m.Extra && m.Extra.sd != null)
html += `<tr style="border-bottom:1px solid var(--bd,#222)"><td style="padding:4px 8px 4px 20px;color:var(--t2)">└ 잔차 표준편차</td>
<td>${rpFmt(m.Extra.sd,'degC')}</td><td colspan="2"></td></tr>`;
}
html += `</table>`;
return html;
}

View File

@@ -1,43 +1,61 @@
<div class="report-pane" style="padding:20px;max-width:680px"> <div class="report-pane" style="padding:20px;max-width:880px">
<h2 style="margin-top:0">리포트 생성 (P0)</h2> <h2 style="margin-top:0">리포트 (P0)</h2>
<p style="color:var(--t2);font-size:13px">
운전원 엑셀 템플릿의 셀에 <code>{{ metric=energy_efficiency; column=C-6111 }}</code> 형태 토큰을 박아두면,
선택한 날짜의 결정론 계산값으로 채워 다운로드합니다. 채운 셀엔 해상도 메타(소스/sampling/표본수)가 주석으로 붙습니다.
</p>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:16px"> <!-- ── ① 웹에서 바로 보기 ──────────────────────────────── -->
<label>① 엑셀 템플릿(.xlsx) <section style="margin-bottom:28px">
<input type="file" id="rpTpl" accept=".xlsx,.xlsm" style="display:block;margin-top:4px"> <h3 style="margin:0 0 8px">① 웹에서 바로 보기</h3>
</label> <div style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
<label>컬럼<br><select id="rvCol" style="margin-top:4px"></select></label>
<label>날짜(KST)<br><input type="date" id="rvDate" style="margin-top:4px"></label>
<label>소스<br>
<select id="rvSource" style="margin-top:4px">
<option value="history_table">history (60초)</option>
<option value="fast_record">fastRecord</option>
</select>
</label>
<label id="rvSessWrap" style="display:none">session id<br><input type="number" id="rvSess" style="margin-top:4px"></label>
<button id="rvGo" class="btn">조회</button>
</div>
<div id="rvOut" style="margin-top:16px"></div>
</section>
<label>② 날짜 (KST) <!-- ── ② 엑셀 폼으로 내보내기 ──────────────────────────── -->
<input type="date" id="rpDate" style="display:block;margin-top:4px"> <section style="border-top:1px solid var(--bd,#333);padding-top:18px">
</label> <h3 style="margin:0 0 8px">② 엑셀 폼으로 내보내기</h3>
<p style="color:var(--t2);font-size:13px;margin-top:0">
엑셀 템플릿 셀에 <code>{{ metric=mass_balance_closure; column=C-6111 }}</code> 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.
</p>
<div style="display:flex;flex-direction:column;gap:10px;max-width:560px">
<label>① 엑셀 템플릿(.xlsx)<input type="file" id="rpTpl" accept=".xlsx,.xlsm" style="display:block;margin-top:4px"></label>
<label>② 날짜(KST)<input type="date" id="rpDate" style="display:block;margin-top:4px"></label>
<label>③ 소스
<select id="rpSource" style="display:block;margin-top:4px">
<option value="history_table">history (60초)</option>
<option value="fast_record">fastRecord</option>
</select>
</label>
<label id="rpSessionWrap" style="display:none">session id<input type="number" id="rpSession" style="display:block;margin-top:4px"></label>
<button id="rpGen" class="btn" style="align-self:flex-start">리포트 생성·다운로드</button>
</div>
<div id="rpStatus" class="mono" style="margin-top:12px;white-space:pre-wrap"></div>
<label>③ 데이터 소스 <details style="margin-top:14px">
<select id="rpSource" style="display:block;margin-top:4px"> <summary style="cursor:pointer;color:var(--t2)">토큰 치트시트</summary>
<option value="history_table">history (60초 상시)</option> <pre style="font-size:12px;background:var(--bg2,#1a1a1a);padding:10px;border-radius:6px">
<option value="fast_record">fastRecord 세션</option> ■ 적산(.QV) 기반 — 정확·gap강건 (권장)
</select> {{ metric=production_total; column=C-6111 }} → 생산량 적산 Δ (kg)
</label> {{ metric=yield_qv; column=C-6111 }} → 수율(제품Δ/원료Δ)
{{ metric=energy_intensity_qv; column=C-6111 }} → 에너지원단위(스팀Δ/제품Δ)
{{ metric=mass_balance_closure; column=C-6111 }} → 폐합률 % (OUT/IN)
{{ metric=mass_balance_closure; column=C-6111; field=feed_qv }} → 원료 적산 Δ (IN)
{{ metric=mass_balance_closure; column=C-6111; field=out_total }} → 회수 합 (OUT)
{{ metric=mass_balance_closure; column=C-6111; field=out0_qv }} → 제품(경비/중비는 out1/out2)
<label id="rpSessionWrap" style="display:none">fastRecord session id ■ PV 기반 (근사)
<input type="number" id="rpSession" placeholder="예: 12" style="display:block;margin-top:4px">
</label>
<button id="rpGen" class="btn" style="margin-top:8px;align-self:flex-start">리포트 생성·다운로드</button>
</div>
<div id="rpStatus" class="mono" style="margin-top:14px;white-space:pre-wrap"></div>
<details style="margin-top:18px">
<summary style="cursor:pointer;color:var(--t2)">토큰 치트시트</summary>
<pre style="font-size:12px;background:var(--bg2,#1a1a1a);padding:10px;border-radius:6px">
{{ metric=energy_efficiency; column=C-6111 }} → 일일 스팀/제품 중앙값
{{ metric=yield; column=C-6111 }} → 제품/원료
{{ metric=control_residual; column=C-6111 }} → 평균 잔차(PV-SP) {{ metric=control_residual; column=C-6111 }} → 평균 잔차(PV-SP)
{{ metric=control_residual; column=C-6111; field=sd }} → 잔차 표준편차 {{ metric=control_residual; column=C-6111; field=sd }} → 잔차 표준편차
{{ metric=control_residual; column=C-6111; field=out_pct_0_5 }} → |잔차|>0.5℃ 시간비율 </pre>
</pre> </details>
</details> </section>
</div> </div>
<script>/* reports.js 가 paneInit['reports'] 등록 */</script>

View File

@@ -6,6 +6,11 @@ namespace Hc900Crawler.Infrastructure.Reporting;
public sealed record MetricTag(string Tag, double Lo, double Hi); public sealed record MetricTag(string Tag, double Lo, double Hi);
public sealed record MetricSpec(string Unit, MetricTag A, MetricTag B); public sealed record MetricSpec(string Unit, MetricTag A, MetricTag B);
/// <summary>적산(.QV) 메트릭 스펙. Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOutputs/ΔA(=feed).</summary>
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> /// <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).
@@ -20,6 +25,14 @@ public sealed class ReportColumnMap
private static readonly (double lo, double hi) STEAM = (50, 3000); private static readonly (double lo, double hi) STEAM = (50, 3000);
private static readonly (double lo, double hi) FLOW = (100, 1500); private static readonly (double lo, double hi) FLOW = (100, 1500);
/// <summary>설정된 컬럼 키 목록(SteamAdvisor:Columns).</summary>
public IReadOnlyList<string> Columns()
=> _config.GetSection("SteamAdvisor:Columns").GetChildren().Select(c => c.Key).ToList();
/// <summary>해당 컬럼에 폐합(물질수지) 설정이 있는지.</summary>
public bool HasClosure(string column)
=> _config.GetSection($"Report:Closure:{column}").Exists();
public bool TryResolve(string column, string metric, out MetricSpec? spec) public bool TryResolve(string column, string metric, out MetricSpec? spec)
{ {
spec = null; spec = null;
@@ -60,11 +73,59 @@ public sealed class ReportColumnMap
} }
} }
/// <summary>
/// 적산(.QV) 메트릭 해석. production_total/yield_qv/energy_intensity_qv 는 SteamAdvisor:Columns 재사용,
/// mass_balance_closure 는 appsettings `Report:Closure:{col}`(Feed + Outputs[]) 사용.
/// </summary>
public bool TryResolveQv(string column, string metric, out QvSpec? spec)
{
spec = null;
var sec = _config.GetSection($"SteamAdvisor:Columns:{column}");
string? feed = ToQv(sec["Feed"]);
string? product = ToQv(sec["Product"]);
string? steam = ToQv(sec["SteamFlow"]);
switch (metric)
{
case "production_total":
if (product is null) return false;
spec = new QvSpec("kg", QvKind.Single, product, null, Array.Empty<string>());
return true;
case "yield_qv": // 제품/원료: 질량 보존상 ≤ ~1
if (product is null || feed is null) return false;
spec = new QvSpec("제품/원료", QvKind.Ratio, product, feed, Array.Empty<string>(), Max: 1.5);
return true;
case "energy_intensity_qv": // 스팀/제품: 물리적으로 수 이내
if (steam is null || product is null) return false;
spec = new QvSpec("kg스팀/kg제품", QvKind.Ratio, steam, product, Array.Empty<string>(), Max: 5.0);
return true;
case "mass_balance_closure":
var cl = _config.GetSection($"Report:Closure:{column}");
if (!cl.Exists()) return false;
var f = ToQv(cl["Feed"]);
var outs = cl.GetSection("Outputs").Get<string[]>()
?.Select(ToQv).Where(x => x != null).Select(x => x!).ToList();
if (f is null || outs is null || outs.Count == 0) return false;
spec = new QvSpec("%", QvKind.Closure, f, null, outs);
return true;
default:
return false;
}
}
/// <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");
/// <summary>마지막 '.' 이후(속성) 제거 → 루프 베이스.</summary> /// <summary>속성 무시하고 베이스+.QV (적산값).</summary>
private static string? ToQv(string? tag)
=> string.IsNullOrWhiteSpace(tag) ? null : StripAttr(tag!) + ".QV";
/// <summary>마지막 '.' 이후(속성) 제거 → 루프/계기 베이스.</summary>
private static string StripAttr(string tag) private static string StripAttr(string tag)
{ var i = tag.LastIndexOf('.'); return i < 0 ? tag : tag[..i]; } { var i = tag.LastIndexOf('.'); return i < 0 ? tag : tag[..i]; }
} }

View File

@@ -23,6 +23,9 @@ public sealed class ReportMetricService : IReportMetricService
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map) public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap map)
{ _ctx = ctx; _logger = logger; _map = map; } { _ctx = ctx; _logger = logger; _map = map; }
private static readonly HashSet<string> QV_METRICS =
new() { "production_total", "yield_qv", "energy_intensity_qv", "mass_balance_closure" };
public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default) public async Task<MetricResultDto> ComputeAsync(MetricRequestDto req, CancellationToken ct = default)
{ {
bool isFast = req.SourceTable == "fast_record"; bool isFast = req.SourceTable == "fast_record";
@@ -32,10 +35,6 @@ public sealed class ReportMetricService : IReportMetricService
Source = req.SourceTable, SamplingMs = isFast ? 0 : 60000 Source = req.SourceTable, SamplingMs = isFast ? 0 : 60000
}; };
if (!_map.TryResolve(req.Column, req.Metric, out var spec) || spec is null)
{ res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
res.Unit = spec.Unit;
// KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC) // KST 날짜 [00:00, +1d) → UTC (recorded_at은 UTC)
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9); var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
var toUtc = fromUtc.AddDays(1); var toUtc = fromUtc.AddDays(1);
@@ -46,10 +45,23 @@ public sealed class ReportMetricService : IReportMetricService
var conn = _ctx.Database.GetDbConnection(); var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
if (req.Metric == "control_residual") if (QV_METRICS.Contains(req.Metric))
await ResidualAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct); {
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);
}
else else
await RatioAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct); {
if (!_map.TryResolve(req.Column, req.Metric, out var spec) || spec is null)
{ res.Status = "error"; res.Error = $"미정의 매핑: {req.Column}/{req.Metric}"; res.Value = null; return res; }
res.Unit = spec.Unit;
if (req.Metric == "control_residual")
await ResidualAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
else
await RatioAsync(res, conn, tbl, spec, fromUtc, toUtc, isFast, req.SessionId, ct);
}
if (isFast && res.Status == "ok") res.SamplingMs = await FastSamplingMsAsync(conn, req.SessionId ?? -1, ct); if (isFast && res.Status == "ok") res.SamplingMs = await FastSamplingMsAsync(conn, req.SessionId ?? -1, ct);
} }
@@ -61,6 +73,89 @@ public sealed class ReportMetricService : IReportMetricService
return res; return res;
} }
// ── 적산(.QV) 메트릭: Single=ΔA, Ratio=ΔA/ΔB, Closure=100·ΣΔOut/Δfeed ──
private async Task ComputeQvAsync(MetricResultDto res, DbConnection conn, string tbl, QvSpec s,
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);
if (tot is null) { res.Status = "no_data"; res.Value = null; return; }
res.Value = tot; res.N = n; res.Extra["resets"] = resets;
}
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);
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이하 또는 상한 초과 → 데이터 이상(분모 적산 부족 등)
if (res.Value <= 0 || (s.Max is double mx && res.Value > mx))
{
res.Status = "no_data";
res.Error = $"비물리적 비율 {res.Value:F1} (분모 적산 Δ={b:F0} 비정상 추정)";
res.Value = null;
}
}
else // Closure
{
var (feed, nf, rf) = await QvDeltaAsync(conn, tbl, s.A, 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;
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;
res.Extra[$"out{i}_qv"] = d; // out0=제품, out1=경비물, out2=중비물 (config 순서)
}
res.Value = 100.0 * outSum / feed; // 폐합 %
res.N = nf;
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;
}
}
/// <summary>
/// 적산 Δ. 리셋/wrap(값 급감)으로 구간 분할 후 구간별 (마지막−처음) 합. gap·양자화에 강건,
/// 노이즈 과대계상 없음(=DCS 일일적산과 동일 의미). wrap당 꼭대기 잔량(≈소량)만 손실.
/// </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)
{
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
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
)
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);";
AddP(cmd, "@tag", tag);
if (isFast) AddP(cmd, "@sid", sid ?? -1); else { AddP(cmd, "@from", fromUtc); AddP(cmd, "@to", toUtc); }
await using var rd = await cmd.ExecuteReaderAsync(ct);
if (await rd.ReadAsync(ct) && !rd.IsDBNull(0))
return (rd.GetDouble(0), (int)rd.GetInt64(1), (int)rd.GetInt64(2));
return (null, 0, 0);
}
// ── 비율: median(A/B) (분 버킷 피벗, 둘 다 유효한 분만). 클린율 = 1 - 2*good/raw ── // ── 비율: median(A/B) (분 버킷 피벗, 둘 다 유효한 분만). 클린율 = 1 - 2*good/raw ──
private async Task RatioAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s, private async Task RatioAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct) DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)