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:
BIN
docs/templates/리포트템플릿-예시-C6111-daily.xlsx
vendored
BIN
docs/templates/리포트템플릿-예시-C6111-daily.xlsx
vendored
Binary file not shown.
@@ -514,6 +514,26 @@ document.getElementById('rpGen').onclick = async () => {
|
||||
|
||||
추정: 백엔드 핵심(메트릭+채움+컨트롤러) ~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`)** — 핵심. 값 급감(<prev−1)으로 **리셋/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 훅)
|
||||
- `dynamics` 메트릭(fast_record 전용, FOPDT/stiction) — 같은 인터페이스에 metric 1개 추가.
|
||||
- 토큰에 `period=MONTHLY|YEARLY` 추가 → from/to 계산만 분기.
|
||||
|
||||
@@ -12,15 +12,42 @@ public class ReportController : ControllerBase
|
||||
private readonly IReportMetricService _metrics;
|
||||
private readonly ReportFillService _fill;
|
||||
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>
|
||||
[HttpPost("metric")]
|
||||
public async Task<IActionResult> Metric([FromBody] MetricRequestDto req, CancellationToken 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>
|
||||
[HttpPost("template")]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name,
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
},
|
||||
"Report": {
|
||||
"Closure": {
|
||||
"C-6111": { "Feed": "FICQ-6101", "Outputs": [ "FICQ-6118", "FICQ-6114", "FICQ-6116" ] }
|
||||
}
|
||||
},
|
||||
"Kestrel": {
|
||||
"Endpoints": {
|
||||
"Http": {
|
||||
|
||||
@@ -1,55 +1,120 @@
|
||||
/* P0 셀프서비스 리포트 — 패널 JS.
|
||||
pane HTML은 innerHTML로 주입되므로 <script>는 실행 안 됨 → 전역 로드 + paneInit 등록.
|
||||
paneInit['reports']는 탭 활성화 때마다 호출되므로 바인딩은 멱등하게(onclick 재할당). */
|
||||
/* P0 리포트 패널 — ① 웹에서 바로 보기 + ② 엑셀 export.
|
||||
pane HTML은 innerHTML 주입이라 <script> 미실행 → 전역 로드 + paneInit 등록(멱등). */
|
||||
paneInit['reports'] = function () {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const tpl = $('rpTpl'), date = $('rpDate'), src = $('rpSource');
|
||||
const sessWrap = $('rpSessionWrap'), sess = $('rpSession');
|
||||
const gen = $('rpGen'), status = $('rpStatus');
|
||||
if (!gen) return;
|
||||
const yKst = () => new Date(Date.now() + 9 * 3600e3 - 86400e3).toISOString().slice(0, 10);
|
||||
|
||||
// 기본 날짜 = 어제(KST)
|
||||
if (date && !date.value) {
|
||||
const d = new Date(Date.now() + 9 * 3600e3 - 86400e3);
|
||||
date.value = d.toISOString().slice(0, 10);
|
||||
// ── 공통: 소스 select → session 입력 토글 ──
|
||||
const tog = (sel, wrap) => { const s = $(sel), w = $(wrap); if (s && w) { s.onchange = () => w.style.display = s.value === 'fast_record' ? '' : 'none'; s.onchange(); } };
|
||||
tog('rvSource', 'rvSessWrap'); tog('rpSource', 'rpSessionWrap');
|
||||
|
||||
// ── ① 웹에서 바로 보기 ──
|
||||
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'; };
|
||||
src.onchange();
|
||||
if (go) go.onclick = async () => {
|
||||
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 (!date.value) { status.textContent = '⚠️ 날짜를 선택하세요.'; return; }
|
||||
gen.disabled = true; status.textContent = '⏳ 생성 중...';
|
||||
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 });
|
||||
if (!up.ok) throw new Error('템플릿 업로드 실패 ' + up.status);
|
||||
if (!up.ok) throw new Error('업로드 실패 ' + up.status);
|
||||
const { Id } = await up.json();
|
||||
|
||||
// ② 생성
|
||||
let url = `/api/report/generate?templateId=${Id}&date=${date.value}&source=${src.value}`;
|
||||
if (src.value === 'fast_record' && sess.value) url += `&sessionId=${sess.value}`;
|
||||
let url = `/api/report/generate?templateId=${Id}&date=${$('rpDate').value}&source=${$('rpSource').value}`;
|
||||
if ($('rpSource').value === 'fast_record' && $('rpSession').value) url += `&sessionId=${$('rpSession').value}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('생성 실패 ' + resp.status);
|
||||
|
||||
const reportStatus = resp.headers.get('X-Report-Status') || '?';
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
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;
|
||||
}
|
||||
const st = resp.headers.get('X-Report-Status') || '?';
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(await resp.blob());
|
||||
a.download = `report_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
|
||||
status.textContent = `✅ 완료 (상태=${st})`;
|
||||
} 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')} → <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;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,61 @@
|
||||
<div class="report-pane" style="padding:20px;max-width:680px">
|
||||
<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 class="report-pane" style="padding:20px;max-width:880px">
|
||||
<h2 style="margin-top:0">리포트 (P0)</h2>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:16px">
|
||||
<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>
|
||||
<!-- ── ① 웹에서 바로 보기 ──────────────────────────────── -->
|
||||
<section style="margin-bottom:28px">
|
||||
<h3 style="margin:0 0 8px">① 웹에서 바로 보기</h3>
|
||||
<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="rpSessionWrap" style="display:none">fastRecord session id
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div id="rpStatus" class="mono" style="margin-top:14px;white-space:pre-wrap"></div>
|
||||
<!-- ── ② 엑셀 폼으로 내보내기 ──────────────────────────── -->
|
||||
<section style="border-top:1px solid var(--bd,#333);padding-top:18px">
|
||||
<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>
|
||||
|
||||
<details style="margin-top:18px">
|
||||
<details style="margin-top:14px">
|
||||
<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 }} → 제품/원료
|
||||
■ 적산(.QV) 기반 — 정확·gap강건 (권장)
|
||||
{{ metric=production_total; column=C-6111 }} → 생산량 적산 Δ (kg)
|
||||
{{ 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)
|
||||
|
||||
■ PV 기반 (근사)
|
||||
{{ metric=control_residual; column=C-6111 }} → 평균 잔차(PV-SP)
|
||||
{{ metric=control_residual; column=C-6111; field=sd }} → 잔차 표준편차
|
||||
{{ metric=control_residual; column=C-6111; field=out_pct_0_5 }} → |잔차|>0.5℃ 시간비율
|
||||
</pre>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
<script>/* reports.js 가 paneInit['reports'] 등록 */</script>
|
||||
|
||||
@@ -6,6 +6,11 @@ namespace Hc900Crawler.Infrastructure.Reporting;
|
||||
public sealed record MetricTag(string Tag, double Lo, double Hi);
|
||||
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>
|
||||
/// 컬럼→태그 매핑. 기존 appsettings `SteamAdvisor:Columns`(Feed/Product/TC/SteamOp/SteamFlow)를
|
||||
/// 단일 진실원으로 재사용한다(멀티컬럼 무료). 클린범위는 역할별 기본값(향후 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) 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)
|
||||
{
|
||||
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>
|
||||
private static string? Norm(string? tag)
|
||||
=> 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)
|
||||
{ var i = tag.LastIndexOf('.'); return i < 0 ? tag : tag[..i]; }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
public ReportMetricService(Hc900DbContext ctx, ILogger<ReportMetricService> logger, ReportColumnMap 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)
|
||||
{
|
||||
bool isFast = req.SourceTable == "fast_record";
|
||||
@@ -32,10 +35,6 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
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)
|
||||
var fromUtc = DateTime.SpecifyKind(req.PeriodDateKst.Date, DateTimeKind.Unspecified).AddHours(-9);
|
||||
var toUtc = fromUtc.AddDays(1);
|
||||
@@ -46,10 +45,23 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
if (QV_METRICS.Contains(req.Metric))
|
||||
{
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -61,6 +73,89 @@ public sealed class ReportMetricService : IReportMetricService
|
||||
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 ──
|
||||
private async Task RatioAsync(MetricResultDto res, DbConnection conn, string tbl, MetricSpec s,
|
||||
DateTime fromUtc, DateTime toUtc, bool isFast, int? sid, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user