diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 31a5ea8..2813307 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -1061,6 +1061,48 @@ public class ExperionDbService : IExperionDbService GROUP BY area_code ORDER BY area_code """); + // ── Feedforward advisory engine config tables ───────────────────── + await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS ff_column_config ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + feed_tag TEXT NOT NULL, + pressure_tag TEXT, + level_tags TEXT, + scan_sec DOUBLE PRECISION NOT NULL DEFAULT 2, + feed_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 300, + feed_move_thr_per_min DOUBLE PRECISION NOT NULL DEFAULT 0, + press_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 60, + pressure_band DOUBLE PRECISION NOT NULL DEFAULT 1e9, + settle_sec DOUBLE PRECISION NOT NULL DEFAULT 0, + stale_sec DOUBLE PRECISION NOT NULL DEFAULT 120, + product_key TEXT NOT NULL DEFAULT 'P', + advisory_only BOOLEAN NOT NULL DEFAULT TRUE + ); + """); + await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS ff_stream_config ( + id SERIAL PRIMARY KEY, + column_id INTEGER NOT NULL REFERENCES ff_column_config(id) ON DELETE CASCADE, + key TEXT NOT NULL, + flow_tag TEXT NOT NULL, + role TEXT NOT NULL, + target_coeff DOUBLE PRECISION NOT NULL DEFAULT 0, + theta_up_sec DOUBLE PRECISION NOT NULL DEFAULT 0, + theta_dn_sec DOUBLE PRECISION NOT NULL DEFAULT 0, + tau_sec DOUBLE PRECISION NOT NULL DEFAULT 0, + sp_min DOUBLE PRECISION NOT NULL DEFAULT 0, + sp_max DOUBLE PRECISION NOT NULL DEFAULT 1e9, + rate_up_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9, + rate_dn_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9, + reflux_from_product BOOLEAN NOT NULL DEFAULT FALSE, + grade TEXT NOT NULL DEFAULT 'A', + level_tag TEXT + ); + ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS level_tag TEXT; + """); + _logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)"); return true; } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index f5ff576..257890a 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -121,6 +121,12 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService(); +// ── Feedforward Advisory Engine ─────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + // ── P&ID Services ─────────────────────────────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/Web/wwwroot/css/ff.css b/src/Web/wwwroot/css/ff.css new file mode 100644 index 0000000..8d41120 --- /dev/null +++ b/src/Web/wwwroot/css/ff.css @@ -0,0 +1,49 @@ +.ff-wrap{padding:16px;color:var(--t1)} +.ff-head{display:flex;align-items:center;gap:12px;margin-bottom:12px} +.ff-badge{font-size:12px;color:var(--t2);border:1px solid var(--bd);border-radius:10px;padding:2px 8px} +.ff-dash{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:12px} +.ff-col-card{background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:12px} +.ff-col-card.ff-disabled{opacity:.5} +.ff-col-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px} +.ff-transient{background:#3a2e00;color:#ffd24d;padding:4px 8px;border-radius:4px;font-size:13px;margin:4px 0} +.ff-tbl{width:100%;border-collapse:collapse;font-size:13px} +.ff-tbl th,.ff-tbl td{padding:3px 6px;border-bottom:1px solid var(--bd);text-align:left} +.ff-num{text-align:right;font-variant-numeric:tabular-nums} +.ff-rec{font-weight:600;color:#7fd1ff} +.ff-stale{opacity:.45} +.ff-role-LevelDriven{color:#9aa}.ff-role-Monitor{color:#777}.ff-role-Commanded{color:#7fd1ff} +.ff-grade-A{color:#4caf50}.ff-grade-B{color:#ffb300}.ff-grade-C{color:#ff5252} +.ff-mb,.ff-note{font-size:12px;color:var(--t2);margin-top:6px} +.ff-msg.err{color:#ff5252} +.ff-cfg{margin-top:16px;border-top:1px solid var(--bd);padding-top:12px} +.ff-cfg-bar{display:flex;gap:8px;align-items:center;margin-bottom:12px} +.ff-cfg-item{padding:6px 0;border-bottom:1px solid var(--bd);font-size:13px} +.ff-msg{font-size:12px;margin-left:8px} + +/* ── 폼 에디터 모달 ───────────────────────────────── */ +.ff-modal{position:fixed;inset:0;z-index:900;display:flex;align-items:center;justify-content:center} +.ff-modal-overlay{position:absolute;inset:0;background:rgba(0,0,0,.55)} +.ff-modal-box{position:relative;background:var(--s2);border:1px solid var(--bd2);border-radius:var(--rl);width:min(1100px,95vw);max-height:92vh;display:flex;flex-direction:column} +.ff-modal-hd{padding:14px 16px;border-bottom:1px solid var(--bd);font-weight:700;font-size:15px;color:var(--t0)} +.ff-modal-id{font-weight:400;font-size:12px;color:var(--t2);margin-left:8px} +.ff-modal-body{overflow-y:auto;flex:1;padding:16px} +.ff-modal-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px} +.ff-modal-col{display:flex;flex-direction:column;gap:8px} +.ff-modal-col label{font-size:12px;color:var(--t2);display:flex;flex-direction:column;gap:2px} +.ff-modal-col label input[type="checkbox"]{margin:6px 0} +.ff-modal-sec-hd{display:flex;align-items:center;gap:8px;font-weight:700;font-size:13px;color:var(--t0);margin-bottom:8px} +.ff-key-legend{font-weight:400;font-size:11px;color:var(--t2)} +.ff-stream-tbl{font-size:11px} +.ff-stream-tbl th,.ff-stream-tbl td{padding:2px 4px;white-space:nowrap} +.ff-stream-tbl .inp{width:64px;min-width:0;padding:4px 6px;font-size:11px} +.ff-stream-tbl select.inp{width:auto} +.ff-stream-tbl td:first-child .inp{width:48px} +.ff-stream-tbl td:nth-child(2) .inp{width:80px} +.ff-stream-tbl td input[type="checkbox"]{margin:0} +.ff-modal-ft{display:flex;gap:8px;align-items:center;justify-content:flex-end;padding:12px 16px;border-top:1px solid var(--bd)} +.ff-modal-ft .ff-msg{margin-right:auto} +.danger{color:var(--red);border:1px solid var(--red)}.danger:hover{background:var(--red);color:#fff} +.ff-lvl-hint{font-size:10px;color:var(--t2);margin-left:4px;white-space:nowrap} +.ff-lvl-by{font-size:10px;color:var(--t2);font-weight:400} +.ff-lvl-tag{width:72px!important;font-size:10px!important;padding:2px 4px!important} +.ff-desc{font-size:12px;color:var(--t3);line-height:1.4} diff --git a/src/Web/wwwroot/css/pid.css b/src/Web/wwwroot/css/pid.css index df5e219..46d18ab 100644 --- a/src/Web/wwwroot/css/pid.css +++ b/src/Web/wwwroot/css/pid.css @@ -1,4 +1,7 @@ /* ── P&ID 추출 스타일 ───────────────────────────────────────── */ +#pane-pid { + margin: 0 -16px; +} #pane-pid .card-cap { display: flex; justify-content: space-between; diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html index b26f4b6..87d224a 100644 --- a/src/Web/wwwroot/index.html +++ b/src/Web/wwwroot/index.html @@ -16,6 +16,7 @@ +
@@ -110,6 +111,10 @@ 17 트렌드 +
@@ -137,6 +142,7 @@
+
@@ -239,5 +245,6 @@ + diff --git a/src/Web/wwwroot/js/ff.js b/src/Web/wwwroot/js/ff.js new file mode 100644 index 0000000..ad8fc6b --- /dev/null +++ b/src/Web/wwwroot/js/ff.js @@ -0,0 +1,263 @@ +/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터. + Phase I: 인증 없음. 쓰기 API 추가 시 X-Kb-Token 인증 재도입. */ +paneInit.ff = ffInit; + +let ffTimer = null; + +async function ffApi(method, path, body) { + const h = { 'Content-Type': 'application/json' }; + const res = await fetch(path, { method, headers: h, body: body ? JSON.stringify(body) : undefined }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + return res.status === 204 ? null : res.json(); +} + +async function ffInit() { + if (ffTimer) { clearInterval(ffTimer); ffTimer = null; } + await ffLoadDash(); + ffTimer = setInterval(ffLoadDash, 3000); + + document.getElementById('ff-cfg-toggle').onclick = () => { + const c = document.getElementById('ff-cfg'); + c.style.display = c.style.display === 'none' ? 'block' : 'none'; + }; + document.getElementById('ff-new').onclick = () => ffEditColumn(null); + ffLoadConfig().catch(()=>{}); +} + +// ── 대시보드 (공개) ────────────────────────────────────────────── +async function ffLoadDash() { + let data; + try { data = await api('GET', '/api/ff/advisory'); } + catch (e) { return; } + const host = document.getElementById('ff-dash'); + if (!host) { clearInterval(ffTimer); ffTimer = null; return; } + const cols = data.columns || []; + if (!cols.length) { host.innerHTML = '
활성 컬럼 없음
'; return; } + host.innerHTML = cols.map(ffCard).join(''); +} + +function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : '–'; } + +function ffCard(c) { + const rows = (c.streams || []).map(s => { + const lvlTag = s.levelTag || ''; + const roleLabel = s.role === 'LevelDriven' && lvlTag + ? `LevelDriven
레벨: ${esc(lvlTag)}` + : s.role === 'Commanded' ? 'Commanded' : 'Monitor'; + return ` + ${esc(s.key)}${esc(s.flowTag)} + ${roleLabel} + ${fmtVal(s.pv)} + ${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)} + ${s.gap==null?'–':fmtVal(s.gap)} + ${ffTrendIco(s.trend)} + ${esc(s.grade)} + `;}).join(''); + const banner = c.transient + ? `
과도상태: ${esc(c.transientReason)} — 권장값 정착 대기
` : ''; + const mb = `물질수지: ${esc(c.massBalanceState)}` + + (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') + + (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : ''); + return ` +
+
${esc(c.columnName)} + FEED ${fmtVal(c.feedFiltered)} + ${fmtTs(c.computedAt)}
+ ${banner} + + + ${rows}
스트림태그역할PV권장 SPΔ추세신뢰
+
${esc(mb)}
+
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
+
`; +} + +// ── 설정 에디터 ────────────────────────────────────────────────── +function ffMsg(m, err) { const e=document.getElementById('ff-cfg-msg'); e.textContent=m; e.className='ff-msg'+(err?' err':''); } + +async function ffLoadConfig() { + const data = await ffApi('GET', '/api/ff/config'); + const host = document.getElementById('ff-cfg-list'); + host.innerHTML = (data.columns||[]).map(ffCfgRow).join('') || '
설정 없음
'; + host.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => + ffEditColumn(data.columns.find(c => c.id == b.dataset.edit))); + host.querySelectorAll('[data-del]').forEach(b => b.onclick = () => ffDelete(b.dataset.del)); +} +function ffCfgRow(c) { + return `
${esc(c.name)} (id ${c.id}) — feed ${esc(c.feedTag)}, + 스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'} + +
`; +} +async function ffDelete(id) { + if (!confirm(`컬럼 ${id} 삭제?`)) return; + try { await ffApi('DELETE', `/api/ff/config/${id}`); await ffLoadConfig(); ffMsg('삭제됨'); } + catch (e) { ffMsg('삭제 실패', true); } +} + +// ── 폼 에디터 모달 ────────────────────────────────────────────── +const FF_ROLES = ['Commanded','LevelDriven','Monitor']; +const FF_GRADES = ['A','B','C']; +const FF_KEY_DESC = { P:'주생성물(Feedforward 계산)', R:'환류(R_f × P_sp)', D:'유출액(LevelDriven 기대치)', B:'탑저(LevelDriven 기대치)' }; + +function ffEditColumn(c) { + const existing = document.getElementById('ff-modal'); + if (existing) existing.remove(); + const isNew = !c; + + const modal = document.createElement('div'); + modal.id = 'ff-modal'; + modal.className = 'ff-modal'; + + const def = isNew + ? { name:'', enabled:false, feedTag:'', pressureTag:'', + scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5, + pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P', + streams:[ + {key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A'}, + {key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A'}, + {key:'D',flowTag:'',role:'LevelDriven',levelTag:'lica-6113',targetCoeff:0.02,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'}, + {key:'B',flowTag:'',role:'LevelDriven',levelTag:'li-6111',targetCoeff:0.03,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:0,rateDnPerMin:0,refluxFromProduct:false,grade:'B'} + ] } + : { ...c, pressureTag: c.pressureTag||'' }; + + const colHtml = ` +
+ + + + + +
+
+ + + + + + + +
`; + + modal.innerHTML = ` +
+
+
${isNew?'새 컬럼':'컬럼 편집'} ${isNew?'':'(id '+c.id+')'}
+
+
${colHtml}
+
+
스트림 + P=주생성물 R=환류(P rec) D=유출액 B=탑저 + +
+ + + + + + + ${def.streams.map((s,i) => ffStreamRow(s,i)).join('')} + +
KeyFlow 태그역할레벨태그Kθ_upθ_dnτSP_minSP_maxRate_upRate_dn환류신뢰
+
+
+
+ + + +
+
`; + + document.body.appendChild(modal); + + document.getElementById('ff-stream-add').onclick = () => { + const tb = document.getElementById('ff-stream-body'); + const i = tb.children.length; + tb.insertAdjacentHTML('beforeend', ffStreamRow({ + key:'',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0,thetaUpSec:0,thetaDnSec:0, + tauSec:0,spMin:0,spMax:1e9,rateUpPerMin:1e9,rateDnPerMin:1e9, + refluxFromProduct:false,grade:'A' + }, i)); + ffWireStreamRow(tb.lastElementChild); + }; + + document.querySelectorAll('#ff-stream-body tr').forEach(ffWireStreamRow); + document.getElementById('ff-modal-cancel').onclick = () => modal.remove(); + document.getElementById('ff-modal-save').onclick = () => ffSaveForm(c?.id); + document.querySelector('.ff-modal-overlay').onclick = () => modal.remove(); +} + +function ffStreamRow(s, i) { + const roleOpts = FF_ROLES.map(r => ``).join(''); + const gradeOpts = FF_GRADES.map(g => ``).join(''); + const desc = FF_KEY_DESC[s.key] || ''; + const lvlTagHtml = s.role==='LevelDriven' + ? `` + : ``; + return ` + + + + ${lvlTagHtml} + + + + + + + + + + + + `; +} + +function ffWireStreamRow(tr) { + const del = tr.querySelector('.ff-stream-del'); + if (del) del.onclick = () => { tr.remove(); }; +} + +function ffSaveForm(existingId) { + const g = id => document.getElementById(id); + const body = { + id: existingId || 0, + name: g('ff-f-name').value, + enabled: g('ff-f-enabled').checked, + feedTag: g('ff-f-feedTag').value, + pressureTag: g('ff-f-pressureTag').value || null, + scanSec: +g('ff-f-scanSec').value, + feedFilterTauSec: +g('ff-f-feedFilterTauSec').value, + feedMoveThresholdPerMin: +g('ff-f-feedMoveThresholdPerMin').value, + pressFilterTauSec: +g('ff-f-pressFilterTauSec').value, + pressureBand: +g('ff-f-pressureBand').value, + settleSec: +g('ff-f-settleSec').value, + staleSec: +g('ff-f-staleSec').value, + productKey: g('ff-f-productKey').value, + advisoryOnly: true, + streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => { + const v = (sel, f) => { + const el = tr.querySelector(`[data-f="${f}"]`); + if (!el) return ''; + if (el.type === 'checkbox') return el.checked; + if (el.tagName === 'SELECT') return el.value; + return el.value; + }; + return { + key: v(null,'key'), flowTag: v(null,'flowTag'), role: v(null,'role'), + levelTag: v(null,'levelTag') || null, + targetCoeff: +v(null,'targetCoeff'), thetaUpSec: +v(null,'thetaUpSec'), + thetaDnSec: +v(null,'thetaDnSec'), tauSec: +v(null,'tauSec'), + spMin: +v(null,'spMin'), spMax: +v(null,'spMax'), + rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'), + refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade') + }; + }) + }; + if (existingId) body.id = existingId; + + const msg = g('ff-modal-msg'); + ffApi('POST', '/api/ff/config', body) + .then(() => { document.getElementById('ff-modal').remove(); ffLoadConfig(); ffMsg('저장됨'); }) + .catch(e => { msg.textContent = '저장 실패: '+e.message; msg.className = 'ff-msg err'; }); +} diff --git a/src/Web/wwwroot/panes/ff.html b/src/Web/wwwroot/panes/ff.html new file mode 100644 index 0000000..5b78e4e --- /dev/null +++ b/src/Web/wwwroot/panes/ff.html @@ -0,0 +1,19 @@ +
+
+

측류추출 유량 권장 (Advisory · 보조지표)

+ 읽기 전용 — 권장값. 인가는 운전원 + +
+ + +
불러오는 중…
+ + + +