From ea73097da6f502c29294d9cd54698928f08cd73a Mon Sep 17 00:00:00 2001 From: windpacer Date: Wed, 10 Jun 2026 08:11:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SteamAdvisor=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20+=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../Controllers/SteamAdvisorController.cs | 127 +++++++++++++++++- src/Hc900Crawler/wwwroot/js/steam.js | 94 +++++++++---- src/Hc900Crawler/wwwroot/panes/steam.html | 8 ++ 3 files changed, 194 insertions(+), 35 deletions(-) diff --git a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs index 77abca9..dac53c7 100644 --- a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs +++ b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs @@ -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(); var products = tref.Products; + static double? V(IReadOnlyDictionary 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> 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 d, string tag) + => d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null; + + var result = new Dictionary(); + 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 FlowBases(string p) + { + var ficqPrefix = p.Substring(0, p.Length - 2); + return new Dictionary + { + ["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 stages, object vacuum, double? spanAD) ComputeStages( Dictionary 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 TagsFor(string p) { + var ficqPrefix = p.Substring(0, p.Length - 2); var m = new Dictionary { ["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 TagCandidatesFor(string p) + { + var result = new Dictionary(); + 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; } diff --git a/src/Hc900Crawler/wwwroot/js/steam.js b/src/Hc900Crawler/wwwroot/js/steam.js index c4d0d7e..a9f750f 100644 --- a/src/Hc900Crawler/wwwroot/js/steam.js +++ b/src/Hc900Crawler/wwwroot/js/steam.js @@ -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,'&').replace(//g,'>').replace(/"/g,'"'); 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 = '
데이터 없음
'; + const meta = document.getElementById('st-temp-meta'); + if (meta) { meta.style.display = 'block'; meta.innerHTML = '
데이터 로딩 실패
'; } 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 `${label}${cur}${ref}`; + }).reverse(); + if (snapSrc.spanAD != null) { + stgRows.push(`ΔT(A-D)${stFmt(snapSrc.spanAD)}℃—`); + } + 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(`진공${cur}${ref}`); + } + + // 오른쪽 표: 유량 + let flowHtml = ''; + if (snapSrc.flow) { + const f = snapSrc.flow; + const ft = snapSrc.flowTags || {}; + const flowHeaders = [ + '', + `FEED
${escHtml((ft.feed || 'FICQ-??01.PV').replace('.PV',''))}`, + `REFLUX
${escHtml((ft.reflux || 'FICQ-??13.PV').replace('.PV',''))}`, + `제품추출
${escHtml((ft.product || 'FICQ-??18.PV').replace('.PV',''))}`, + `경비물
${escHtml((ft.overhead || 'FICQ-??14.PV').replace('.PV',''))}`, + `중비물
${escHtml((ft.bottom || 'FICQ-??16.PV').replace('.PV',''))}`, + `스팀
${escHtml((ft.steam || 'FIQ-???15.PV').replace('.PV',''))}`, + ].join(''); + const stPv = stFmt(f.steam?.pv); + const flowRows = [ + `PV${stFmt(f.feed?.pv)}${stFmt(f.reflux?.pv)}${stFmt(f.product?.pv)}${stFmt(f.overhead?.pv)}${stFmt(f.bottom?.pv)}${stPv}`, + `SP${stFmt(f.feed?.sp)}${stFmt(f.reflux?.sp)}${stFmt(f.product?.sp)}${stFmt(f.overhead?.sp)}${stFmt(f.bottom?.sp)}—`, + `OP${stFmt(f.feed?.op)}${stFmt(f.reflux?.op)}${stFmt(f.product?.op)}${stFmt(f.overhead?.op)}${stFmt(f.bottom?.op)}—`, + ].join(''); + flowHtml = `${flowHeaders}${flowRows}
`; + } + + meta.innerHTML = `
${stgRows.join('')}
${flowHtml}
`; } /* ── Live panel ── */ diff --git a/src/Hc900Crawler/wwwroot/panes/steam.html b/src/Hc900Crawler/wwwroot/panes/steam.html index bc13fe1..ee9c821 100644 --- a/src/Hc900Crawler/wwwroot/panes/steam.html +++ b/src/Hc900Crawler/wwwroot/panes/steam.html @@ -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; }