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

Open
windpacer wants to merge 43 commits from feat/p0-selfservice-report into main
3 changed files with 194 additions and 35 deletions
Showing only changes of commit ea73097da6 - Show all commits

View File

@@ -5,6 +5,7 @@ using Hc900Crawler.Infrastructure.Control;
using Hc900Crawler.Infrastructure.Database;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Hc900Crawler.Web.Controllers;
@@ -190,6 +191,9 @@ public sealed class SteamAdvisorController : ControllerBase
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
if (prod is null) tref = tref with { }; // no-op, keep tref for metadata
var flow = await FetchFlowValues(col);
var bases = FlowBases(ToSuffix(col));
return Ok(new
{
column = tref.Column,
@@ -202,6 +206,22 @@ public sealed class SteamAdvisorController : ControllerBase
spanRef = prod?.SpanAD,
products = tref.Products,
timestamp = DateTime.UtcNow,
flow = new {
feed = flow["feed"],
reflux = flow["reflux"],
overhead = flow["overhead"],
bottom = flow["bottom"],
product = flow["product"],
steam = flow["steam"],
},
flowTags = new {
feed = $"{bases["feed"]}.PV",
reflux = $"{bases["reflux"]}.PV",
overhead = $"{bases["overhead"]}.PV",
bottom = $"{bases["bottom"]}.PV",
product = $"{bases["product"]}.PV",
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
},
});
}
@@ -225,8 +245,13 @@ public sealed class SteamAdvisorController : ControllerBase
if ((to.Value - from.Value).TotalHours > 24)
from = to.Value.AddHours(-24);
var tagMap = TagsFor(ToSuffix(col));
var tagNames = tagMap.Values.ToList();
// 태그명 변경 이력 대응: TI-xxxxB(현재) ↔ TI-xxxxB.PV(과거). 두 변형 모두 조회 후 스냅샷별 coalesce.
var tagCands = TagCandidatesFor(ToSuffix(col));
var tagNames = tagCands.Values.SelectMany(c => c).Distinct().ToList();
// 유량 SP/OP도 history 조회에 포함
var flowBases = FlowBases(ToSuffix(col));
var flowSpOp = flowBases.Values.SelectMany(b => new[] { $"{b}.SP", $"{b}.OP" }).ToList();
tagNames.AddRange(flowSpOp);
var result = await _db.QueryHistoryWithIntervalAsync(
new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000));
@@ -234,16 +259,22 @@ public sealed class SteamAdvisorController : ControllerBase
var snapshots = new List<object>();
var products = tref.Products;
static double? V(IReadOnlyDictionary<string, string?> d, string tag)
=> d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null;
foreach (var row in result.Rows)
{
var cur = tagMap.ToDictionary(kv => kv.Key, kv =>
var cur = tagCands.ToDictionary(kv => kv.Key, kv =>
{
if (row.Values.TryGetValue(kv.Value, out var s) && double.TryParse(s, out var v))
return (double?)v;
return null;
foreach (var t in kv.Value)
if (row.Values.TryGetValue(t, out var s) && double.TryParse(s, out var v))
return (double?)v;
return (double?)null;
});
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
object FlowRole(string baseTag, string role)
=> new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") };
snapshots.Add(new
{
ts = row.TimeBucket,
@@ -251,6 +282,22 @@ public sealed class SteamAdvisorController : ControllerBase
stages,
vacuum,
spanAD,
flow = new {
feed = FlowRole(flowBases["feed"], "feed"),
reflux = FlowRole(flowBases["reflux"], "reflux"),
overhead = FlowRole(flowBases["overhead"], "overhead"),
bottom = FlowRole(flowBases["bottom"], "bottom"),
product = FlowRole(flowBases["product"], "product"),
steam = new { pv = cur.GetValueOrDefault("steam") },
},
flowTags = new {
feed = $"{flowBases["feed"]}.PV",
reflux = $"{flowBases["reflux"]}.PV",
overhead = $"{flowBases["overhead"]}.PV",
bottom = $"{flowBases["bottom"]}.PV",
product = $"{flowBases["product"]}.PV",
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
},
});
}
@@ -305,6 +352,51 @@ public sealed class SteamAdvisorController : ControllerBase
});
}
// FICQ 유량 루프의 PV/SP/OP를 realtime_table에서 직접 조회.
// v_tag_summary는 base_tag||'.pv'(소문자)로 조인하나 realtime은 '.PV'(대문자) 저장이라
// pv/sp/op가 전부 NULL로 나옴 → 뷰 우회하고 정확한 태그명으로 직접 조회.
// 익명 객체 { pv, sp, op } 반환 — ValueTuple은 STJ가 필드라 직렬화하지 않아 {}로 나감.
private async Task<Dictionary<string, object>> FetchFlowValues(string col)
{
var ficqBases = FlowBases(ToSuffix(col)); // role → "FICQ-6101" 등
var p = ToSuffix(col);
var ficqPrefix = p.Substring(0, p.Length - 2);
var steamTag = $"FIQ-{ficqPrefix}15.PV";
// role별 .PV/.SP/.OP 태그를 한 번에 조회
var allTags = ficqBases.Values
.SelectMany(b => new[] { $"{b}.PV", $"{b}.SP", $"{b}.OP" })
.Append(steamTag)
.ToArray();
var live = await _ctx.RealtimePoints
.Where(r => allTags.Contains(r.TagName))
.GroupBy(r => r.TagName)
.ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(r => r.Timestamp).FirstOrDefault()?.LiveValue);
static double? P(Dictionary<string, string?> d, string tag)
=> d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null;
var result = new Dictionary<string, object>();
foreach (var (role, baseTag) in ficqBases)
result[role] = new { pv = P(live, $"{baseTag}.PV"), sp = P(live, $"{baseTag}.SP"), op = P(live, $"{baseTag}.OP") };
result["steam"] = new { pv = P(live, steamTag) };
return result;
}
// FICQ 유량 루프 base 태그명. ficqPrefix = 컬럼suffix 앞 2자리("6111"→"61", "10111"→"101").
private static Dictionary<string, string> FlowBases(string p)
{
var ficqPrefix = p.Substring(0, p.Length - 2);
return new Dictionary<string, string>
{
["feed"] = $"FICQ-{ficqPrefix}01",
["reflux"] = $"FICQ-{ficqPrefix}13",
["overhead"] = $"FICQ-{ficqPrefix}14",
["bottom"] = $"FICQ-{ficqPrefix}16",
["product"] = $"FICQ-{ficqPrefix}18",
};
}
private static (TempProduct? prod, List<object> stages, object vacuum, double? spanAD) ComputeStages(
Dictionary<string, double?> cur, TempRef tref)
{
@@ -347,17 +439,26 @@ public sealed class SteamAdvisorController : ControllerBase
}, spanAD);
}
// roles_for(C# 미러) — 단별 온도/진공 태그. c6111_extract COLUMN_EXCEPTIONS 대응.
// roles_for(C# 미러) — 단별 온도/진공/유량 태그. c6111_extract COLUMN_EXCEPTIONS 대응.
// p = numeric suffix ("6111", "6211", "8111", ...)
// FICQ 접두사 = p.Substring(0, p.Length-2) → "6111"→"61", "10111"→"101"
private static Dictionary<string, string> TagsFor(string p)
{
var ficqPrefix = p.Substring(0, p.Length - 2);
var m = new Dictionary<string, string>
{
["reb_temp"] = $"TICA-{p}A.PV",
// T_B/T_C/T_D 시그널 태그 — register-map 통일로 .PV 접미사 포함
["T_B"] = $"TI-{p}B.PV",
["T_C"] = $"TI-{p}C.PV",
["T_D"] = $"TI-{p}D.PV",
["vacuum"] = $"PICA-{p}.PV",
["feed"] = $"FICQ-{ficqPrefix}01.PV",
["reflux"] = $"FICQ-{ficqPrefix}13.PV",
["overhead"] = $"FICQ-{ficqPrefix}14.PV",
["bottom"] = $"FICQ-{ficqPrefix}16.PV",
["product"] = $"FICQ-{ficqPrefix}18.PV",
["steam"] = $"FIQ-{ficqPrefix}15.PV",
};
switch (p)
{
@@ -370,6 +471,18 @@ public sealed class SteamAdvisorController : ControllerBase
return m;
}
// 과거 이력용 태그 후보(우선순위). T_B/T_C/T_D는 현재 .PV 접미사가 표준,
// 과거(무접미사) 폴백으로 두 변형 모두 조회. 나머지 role은 단일 태그.
private static Dictionary<string, string[]> TagCandidatesFor(string p)
{
var result = new Dictionary<string, string[]>();
foreach (var (role, tag) in TagsFor(p))
result[role] = (role is "T_B" or "T_C" or "T_D")
? new[] { tag, tag.Replace(".PV", "") }
: new[] { tag };
return result;
}
// 컬럼키 "C-6111" → numeric suffix "6111"
private static string ToSuffix(string col) => col.StartsWith("C-") ? col[2..] : col;
}

View File

@@ -82,6 +82,7 @@ let stTempIsLive = true;
const ST_TEMP_COLS = [['C-6111','6-1차'],['C-6211','6-2차'],['C-8111','8차'],['C-9111','9-1차'],['C-9211','9-2차'],['C-10111','10-1차'],['C-10211','10-2차']];
const ST_STAGE_LABEL = { reb_temp:'reb-A(보텀)', T_B:'T_B', T_C:'T_C(민감단)', T_D:'T_D(탑)' };
const stFmt = v => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (+v).toFixed(1);
const escHtml = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const stFmtDT = ts => {
const d = new Date(ts);
const kst = new Date(d.getTime() + 9 * 3600000);
@@ -128,8 +129,6 @@ async function stTempTick() {
stTempLive = await api('GET', `/api/steam/tempprofile/${col}`);
if (stTempIsLive) {
stRenderTemp();
} else {
stTempUpdateBadges();
}
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
} catch (e) {
@@ -201,26 +200,31 @@ function stTempScrub() {
stRenderTemp(snap);
}
// 배지만 갱신 (과거 모드에서 현재값 badge update)
function stTempUpdateBadges() {
if (!stTempLive) return;
document.getElementById('st-temp-product').textContent = '제품 ' + (stTempLive.matchedProduct || '—');
// 배지만 갱신 (snap 있으면 해당 스냅샷 기준, 없으면 라이브 기준)
function stTempUpdateBadges(src) {
const s = src || stTempLive;
if (!s) return;
document.getElementById('st-temp-product').textContent = '제품 ' + (s.matchedProduct || '—');
const vb = document.getElementById('st-temp-vac');
vb.textContent = `진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})` + (stTempLive.vacuum.deviated ? ' ⚠이격' : '');
vb.style.background = stTempLive.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
vb.style.color = stTempLive.vacuum.deviated ? '#f66' : '#66f';
vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
vb.style.color = s.vacuum.deviated ? '#f66' : '#66f';
}
let stRenderTempCol = '';
function stRenderTemp(snap) {
stTempUpdateBadges();
if (!stTempLive) {
const el = document.getElementById('st-chart-temp');
if (el) el.innerHTML = '<div style="padding:40px;text-align:center;color:#555">데이터 없음</div>';
const meta = document.getElementById('st-temp-meta');
if (meta) { meta.style.display = 'block'; meta.innerHTML = '<div style="padding:8px;color:#f66">데이터 로딩 실패</div>'; }
return;
}
const snapSrc = snap || stTempLive;
stTempUpdateBadges(snapSrc);
stRenderTempCol = document.getElementById('st-temp-col').value;
const stages = stTempLive.stages;
if (!stages || !stages.length) return;
@@ -234,29 +238,25 @@ function stRenderTemp(snap) {
const el = document.getElementById('st-chart-temp');
const chart = echarts.getInstanceByDom(el) || echarts.init(el);
const snapSrc = snap || stTempLive;
// 실시간 모드: thick blue one line
// 과거 모드: thin dashed live + thick amber 선택시점
const leg = ['기준밴드', '기준'];
const leg = ['정상범위', '중앙값'];
const series = [
{ name: '_lo', type: 'line', data: lo, lineStyle: { opacity: 0 }, stack: 'band', symbol: 'none', silent: true },
{ name: '기준밴드', type: 'line', data: band, stack: 'band', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(80,140,200,0.18)' }, symbol: 'none', silent: true },
{ name: '기준', type: 'line', data: med, lineStyle: { type: 'dashed', color: '#688' }, symbol: 'none' },
{ name: '정상범위', type: 'line', data: band, stack: 'band', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(80,140,200,0.18)' }, symbol: 'none', silent: true },
{ name: '중앙값', type: 'line', data: med, lineStyle: { type: 'dashed', color: '#688' }, symbol: 'none' },
];
if (snap) {
// 과거 모드: thin dashed live + thick amber 선택시점
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
const snapD = snapSrc.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#fa3' } }));
series.push({ name: '현재', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 1, type: 'dashed' }, symbol: 'none', silent: true });
series.push({ name: '선택시점', type: 'line', data: snapD, lineStyle: { color: '#fa3', width: 2.5 }, symbolSize: 9 });
leg.push('현재', '선택시점');
series.push({ name: '실시간참조', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 1, type: 'dashed' }, symbol: 'none', silent: true });
series.push({ name: '선택시점', type: 'line', data: snapD, lineStyle: { color: '#fa3', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
leg.push('실시간참조', '선택시점');
} else {
// 실시간 모드: thick blue
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
series.push({ name: '현재', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 2.5 }, symbolSize: 9 });
leg.push('현재');
series.push({ name: '실시간', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
leg.push('실시간');
}
chart.setOption({
@@ -277,14 +277,52 @@ function stRenderTemp(snap) {
});
chart.resize();
// 상세 메타 (선택 시점 기준)
// 상세 메타 — 두 개의 표 (온도 + 유량)
const meta = document.getElementById('st-temp-meta');
meta.style.display = 'block';
const lines = snapSrc.stages.map(s =>
`${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃ 기준 ${stFmt(s.refMedian)}±${stFmt(s.refStd)} z=${s.z != null ? s.z.toFixed(1) : '—'}${s.deviated ? ' ⚠이격' : ''}`);
if (snapSrc.spanAD != null) lines.push(`ΔT(A-D) ${stFmt(snapSrc.spanAD)}`);
if (snapSrc.vacuum) lines.push(`진공 ${stFmt(snapSrc.vacuum.current)} 기준 ${stFmt(snapSrc.vacuum.refMedian)}±${stFmt(snapSrc.vacuum.refStd)} z=${snapSrc.vacuum.z != null ? snapSrc.vacuum.z.toFixed(1) : '—'}${snapSrc.vacuum.deviated ? ' ⚠이격' : ''}`);
meta.textContent = lines.join('\n');
// 왼쪽 표: 온도
// 표는 D(탑)→C→B→A(보텀) 순서로 표시 (차트는 A→B→C→D 유지)
let stgRows = snapSrc.stages.map(s => {
const label = ST_STAGE_LABEL[s.stage] || s.stage;
const cur = stFmt(s.current) + '℃';
const ref = s.refMedian != null ? `${stFmt(s.refMedian)}±${stFmt(s.refStd)}` : '—';
return `<tr><td>${label}</td><td>${cur}</td><td>${ref}</td></tr>`;
}).reverse();
if (snapSrc.spanAD != null) {
stgRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.spanAD)}℃</td><td>—</td></tr>`);
}
if (snapSrc.vacuum) {
const v = snapSrc.vacuum;
const cur = stFmt(v.current);
const ref = v.refMedian != null ? `${stFmt(v.refMedian)}±${stFmt(v.refStd)}` : '—';
stgRows.push(`<tr><td>진공</td><td>${cur}</td><td>${ref}</td></tr>`);
}
// 오른쪽 표: 유량
let flowHtml = '';
if (snapSrc.flow) {
const f = snapSrc.flow;
const ft = snapSrc.flowTags || {};
const flowHeaders = [
'<th></th>',
`<th>FEED<br><small>${escHtml((ft.feed || 'FICQ-??01.PV').replace('.PV',''))}</small></th>`,
`<th>REFLUX<br><small>${escHtml((ft.reflux || 'FICQ-??13.PV').replace('.PV',''))}</small></th>`,
`<th>제품추출<br><small>${escHtml((ft.product || 'FICQ-??18.PV').replace('.PV',''))}</small></th>`,
`<th>경비물<br><small>${escHtml((ft.overhead || 'FICQ-??14.PV').replace('.PV',''))}</small></th>`,
`<th>중비물<br><small>${escHtml((ft.bottom || 'FICQ-??16.PV').replace('.PV',''))}</small></th>`,
`<th>스팀<br><small>${escHtml((ft.steam || 'FIQ-???15.PV').replace('.PV',''))}</small></th>`,
].join('');
const stPv = stFmt(f.steam?.pv);
const flowRows = [
`<tr><td>PV</td><td>${stFmt(f.feed?.pv)}</td><td>${stFmt(f.reflux?.pv)}</td><td>${stFmt(f.product?.pv)}</td><td>${stFmt(f.overhead?.pv)}</td><td>${stFmt(f.bottom?.pv)}</td><td>${stPv}</td></tr>`,
`<tr><td>SP</td><td>${stFmt(f.feed?.sp)}</td><td>${stFmt(f.reflux?.sp)}</td><td>${stFmt(f.product?.sp)}</td><td>${stFmt(f.overhead?.sp)}</td><td>${stFmt(f.bottom?.sp)}</td><td>—</td></tr>`,
`<tr><td>OP</td><td>${stFmt(f.feed?.op)}</td><td>${stFmt(f.reflux?.op)}</td><td>${stFmt(f.product?.op)}</td><td>${stFmt(f.overhead?.op)}</td><td>${stFmt(f.bottom?.op)}</td><td>—</td></tr>`,
].join('');
flowHtml = `<table class="st-meta-flow"><thead><tr>${flowHeaders}</tr></thead><tbody>${flowRows}</tbody></table>`;
}
meta.innerHTML = `<div class="st-meta-tables"><table class="st-meta-temp">${stgRows.join('')}</table>${flowHtml}</div>`;
}
/* ── Live panel ── */

View File

@@ -190,5 +190,13 @@
.st-scrub-slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:14px; height:14px; border-radius:50%; background:#4af; border:2px solid #0a121a; cursor:pointer; }
.st-scrub-slider::-moz-range-thumb { width:14px; height:14px; border-radius:50%; background:#4af; border:2px solid #0a121a; cursor:pointer; }
.st-scrub-labels { display:flex; justify-content:space-between; font-size:9px; color:#555; margin-top:2px; }
.st-meta-tables { display:flex; gap:16px; margin-top:8px; font-size:12px; font-family:monospace; }
.st-meta-temp, .st-meta-flow { border-collapse:collapse; }
.st-meta-temp td { padding:2px 8px; border-bottom:1px solid #1a2a3a; text-align:left; white-space:nowrap; }
.st-meta-flow th { padding:2px 6px; border-bottom:1px solid #2a3a4a; color:#888; font-weight:normal; text-align:right; }
.st-meta-flow th small { display:block; font-size:9px; color:#555; margin-top:2px; text-align:left; }
.st-meta-flow td { padding:2px 6px; border-bottom:1px solid #1a2a3a; color:#ccc; text-align:right; white-space:nowrap; }
.st-meta-flow td:first-child { color:#666; font-weight:bold; text-align:left; }
</style>