Files
ExperionCrawler/src/Web/wwwroot/js/ff.js
windpacer 90b15f8b34 feat: 온도 HIGH LIMIT 전환류 트리거 (컬럼별 UI 설정)
- ColumnConfig.TempHighLimit + ff_column_config.temp_high_limit(DDL/ConfigStore/MapConfig) + ff.js 설정폼
- ApplyRecovery sigTHigh: 온도태그 최고값(raw) > TempHighLimit → 단독 severe(운전원 명시 안전한계라 코러보 불요). 기본 1e9=비활성
- 컬럼별 설정이라 타 물질/컬럼 동일 루틴 재사용. 단위 3건(52/52). 매뉴얼 §12.2·§13.3 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:54:37 +09:00

510 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터.
Phase II: X-Kb-Token 인증 (설정/쓰기), auto-write 결과 표시. */
paneInit.ff = ffInit;
let ffTimer = null;
function ffToken() { return sessionStorage.getItem('kbToken') || ''; }
async function ffApi(method, path, body) {
const h = { 'Content-Type': 'application/json' };
const t = ffToken();
if (t) h['X-Kb-Token'] = t;
const res = await fetch(path, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
if (res.status === 401) throw new Error('인증 필요 — RAG 관리 탭에서 로그인하세요');
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-ramp-toggle').onclick = () => {
const r = document.getElementById('ff-ramp');
r.style.display = r.style.display === 'none' ? 'block' : 'none';
};
document.getElementById('ff-ramp-go').onclick = ffRampCompute;
document.getElementById('ff-new').onclick = () => ffEditColumn(null);
ffLoadConfig().catch(()=>{});
// WP7: sim override — 가능하면 로드(403 무시)
ffSimLoad();
}
// ── WP7: 램프 계산기 ──────────────────────────────────────────
async function ffRampCompute() {
const g = id => document.getElementById(id);
const params = new URLSearchParams({
columnId: g('ff-ramp-colId').value,
targetFeed: g('ff-ramp-targetFeed').value,
deltaIAllow: g('ff-ramp-deltaI').value,
sensibleGain: g('ff-ramp-sensibleGain').value || '',
feedTempRef: g('ff-ramp-feedTempRef').value || '',
floodLimit: g('ff-ramp-floodLimit').value || '',
n: g('ff-ramp-n').value
});
const host = g('ff-ramp-result');
host.innerHTML = '계산 중…';
try {
const data = await api('GET', '/api/ff/ramp-advisor?' + params.toString());
if (data.hold) {
host.innerHTML = `<div class="ff-ramp-hold">HOLD: ${esc(data.warnings?.join(', ') || '피드 불량')}</div>`;
return;
}
host.innerHTML = `
<table class="ff-ramp-table">
<tr><td>현재 피드</td><td class="ff-num">${fmtVal(data.currentFeed)}</td></tr>
<tr><td>목표 피드</td><td class="ff-num">${fmtVal(data.targetFeed)}</td></tr>
<tr><td>클램프 목표</td><td class="ff-num ff-rec">${fmtVal(data.clampedTarget)}</td></tr>
<tr><td>Ceiling</td><td class="ff-num">${fmtVal(data.ceiling?.value)} <small>(${esc(data.ceiling?.binding)})</small></td></tr>
<tr><td>램프율</td><td class="ff-num">${fmtVal(data.rampRate?.value)} kg/hr·min <small>(${esc(data.rampRate?.binding)})</small></td></tr>
<tr><td>예상 시간</td><td class="ff-num">${data.rampTimeMin != null ? Math.round(data.rampTimeMin) + '분' : ''}</td></tr>
<tr><td>스팀(현재)</td><td class="ff-num">${fmtVal(data.steam?.fiq6115From)}</td></tr>
<tr><td>스팀(목표)</td><td class="ff-num">${fmtVal(data.steam?.fiq6115To)}</td></tr>
</table>
<div class="ff-ramp-warn">${(data.warnings||[]).map(w => esc(w)).join('<br>')}</div>`;
} catch (e) {
host.innerHTML = `<div class="ff-ramp-err">오류: ${esc(e.message)}</div>`;
}
}
// ── WP7: Sim Override ─────────────────────────────────────────
async function ffSimLoad() {
try {
const data = await api('GET', '/api/ff/sim/override');
document.getElementById('ff-sim').style.display = 'block';
document.getElementById('ff-sim-enabled').checked = data.enabled;
document.getElementById('ff-sim-values').value = JSON.stringify(data.values || {}, null, 2);
} catch (e) {
// 403 또는 미구현 → 패널 숨김
}
document.getElementById('ff-sim-apply').onclick = ffSimApply;
document.getElementById('ff-sim-clear').onclick = ffSimClear;
const ca = document.getElementById('ff-comp-apply');
if (ca) { ca.onclick = ffCompApply; document.getElementById('ff-comp-clear').onclick = ffCompClear; ffCompRefresh(); }
}
async function ffCompRefresh() {
try {
const d = await ffApi('GET', '/api/ff/composition');
const el = document.getElementById('ff-comp-list');
const ents = Object.entries(d.fractions || {});
el.innerHTML = ents.length ? '입력됨: ' + ents.map(([k, v]) => `${esc(k)}=${v}`).join(' · ') : '<small>입력 없음 (config K 사용)</small>';
} catch (e) {}
}
async function ffCompApply() {
const g = id => document.getElementById(id);
const st = g('ff-comp-status');
try {
await ffApi('POST', '/api/ff/composition', { columnId: +g('ff-comp-col').value, streamKey: g('ff-comp-key').value, fraction: +g('ff-comp-frac').value });
st.textContent = '입력됨'; st.className = 'ff-msg'; ffCompRefresh();
} catch (e) { st.textContent = '실패: ' + e.message; st.className = 'ff-msg err'; }
}
async function ffCompClear() {
const g = id => document.getElementById(id);
const st = g('ff-comp-status');
try {
await ffApi('DELETE', `/api/ff/composition/${+g('ff-comp-col').value}/${encodeURIComponent(g('ff-comp-key').value)}`);
st.textContent = '해제됨'; st.className = 'ff-msg'; ffCompRefresh();
} catch (e) { st.textContent = '실패: ' + e.message; st.className = 'ff-msg err'; }
}
async function ffSimApply() {
const enabled = document.getElementById('ff-sim-enabled').checked;
let values = {};
try { values = JSON.parse(document.getElementById('ff-sim-values').value || '{}'); } catch (e) {}
try {
const data = await ffApi('POST', '/api/ff/sim/override', { enabled, values });
document.getElementById('ff-sim-status').textContent = '적용됨';
document.getElementById('ff-sim-status').className = 'ff-msg';
document.getElementById('ff-sim-values').value = JSON.stringify(data.values || {}, null, 2);
} catch (e) {
document.getElementById('ff-sim-status').textContent = '실패: ' + e.message;
document.getElementById('ff-sim-status').className = 'ff-msg err';
}
}
async function ffSimClear() {
try {
const data = await ffApi('DELETE', '/api/ff/sim/override');
document.getElementById('ff-sim-enabled').checked = false;
document.getElementById('ff-sim-values').value = '{}';
document.getElementById('ff-sim-status').textContent = '해제됨';
document.getElementById('ff-sim-status').className = 'ff-msg';
} catch (e) {
document.getElementById('ff-sim-status').textContent = '실패: ' + e.message;
document.getElementById('ff-sim-status').className = 'ff-msg err';
}
}
// ── 대시보드 (공개) ──────────────────────────────────────────────
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 = '<div class="ff-empty">활성 컬럼 없음</div>'; return; }
host.innerHTML = cols.map(ffCard).join('');
}
function ffTrendIco(t) { return t > 0 ? '▲' : t < 0 ? '▼' : ''; }
function ffArm(id) {
if (!confirm(`컬럼 ${id} 전환류 모드를 ARM(가동) 하시겠습니까?\n드로우 중단·전량 환류가 권장됩니다(실제 쓰기는 별도 인가).`)) return;
ffApi('POST', `/api/ff/recovery/${id}/arm`).then(()=>ffLoadDash()).catch(()=>{});
}
function ffCancelRecovery(id) {
ffApi('POST', `/api/ff/recovery/${id}/cancel`).then(()=>ffLoadDash()).catch(()=>{});
}
function ffCard(c) {
const rows = (c.streams || []).map(s => {
const lvlTag = s.levelTag || '';
const roleLabel = s.role === 'LevelDriven' && lvlTag
? `LevelDriven<br><span class="ff-lvl-by">레벨: ${esc(lvlTag)}</span>`
: s.role === 'Commanded' ? 'Commanded' : 'Monitor';
const writeInfo = s.lastWriteSp != null
? `<br><small class="ff-write${s.lastWriteError ? ' ff-write-err' : ''}">쓰기${s.lastWriteError ? ' 오류' : '됨'} ${fmtVal(s.lastWriteSp)}${s.lastWriteError ? ': '+esc(s.lastWriteError) : ''}</small>`
: '';
return `<tr class="${s.valid ? '' : 'ff-stale'}">
<td>${esc(s.key)}</td><td class="ff-tag">${esc(s.flowTag)}</td>
<td><span class="ff-role ff-role-${esc(s.role)}">${roleLabel}</span></td>
<td class="ff-num">${fmtVal(s.pv)}</td>
<td class="ff-num ff-rec">${s.recommendedSp==null?'':fmtVal(s.recommendedSp)}</td>
<td class="ff-num">${s.gap==null?'':fmtVal(s.gap)}</td>
<td>${ffTrendIco(s.trend)}</td>
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span>${s.kObsSuggest!=null ? `<br><small class="ff-kobs">K~${fmtVal(s.kObsSuggest)}</small>` : ''}${writeInfo}</td>
</tr>`;}).join('');
const banner = c.transient
? `<div class="ff-transient">과도상태: ${esc(c.transientReason)} — 권장값 정착 대기</div>` : '';
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.vLossMa!=null ? ` · V_loss(MA) ${fmtVal(c.vLossMa)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
const temps = (c.temps && c.temps.length)
? `<div class="ff-temps">${c.temps.map(t => `<span class="ff-temp${t.good?'':' ff-stale'}">${esc(t.tag)} ${t.raw==null?'':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` <small>PCT ${fmtVal(t.pct)}</small>`:''}</span>`).join(' · ')}</div>`
: '';
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)}${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
: '';
const front = c.frontPositionState
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
: '';
// WP7: 온도 프로파일 상태 뱃지
const tpBadge = c.tempProfileState ? (() => {
const tpClass = c.tempProfileState === '온도역전' ? 'ff-tp-inv'
: c.tempProfileState === '프로파일붕괴' ? 'ff-tp-collapse'
: c.tempProfileState === '약화' ? 'ff-tp-warn'
: c.tempProfileState === '정상' ? 'ff-tp-ok'
: 'ff-tp-na';
const spanStr = (c.tempSpan != null && c.tempSpanRef != null)
? ` <small>span ${fmtVal(c.tempSpan)}/${fmtVal(c.tempSpanRef)}</small>` : '';
const invStr = c.inversionPair ? ` <small>(${esc(c.inversionPair)})</small>` : '';
return `<div class="ff-tp-badge ${tpClass}">${esc(c.tempProfileState)}${invStr}${spanStr}</div>`;
})() : '';
// WP5 1단계: 2-point front (상부 CD / 하부 CB)
const f2cls = s => s === '상승' ? 'ff-f2-up' : s === '하강' ? 'ff-f2-dn' : 'ff-f2-ok';
const front2 = (c.upperFrontState || c.lowerFrontState) ? `<div class="ff-front2">
<span class="ff-f2 ${f2cls(c.lowerFrontState)}">하부(B) ${esc(c.lowerFrontState||'')}${c.lowerFrontMetric!=null?` <small>ΔT ${fmtVal(c.lowerFrontMetric)}</small>`:''}</span>
<span class="ff-f2 ${f2cls(c.upperFrontState)}">상부(D) ${esc(c.upperFrontState||'')}${c.upperFrontMetric!=null?` <small>ΔT ${fmtVal(c.upperFrontMetric)}</small>`:''}</span>
</div>` : '';
// WP5 2·3단계: 조성 권장SP(base+trim) — LevelDriven
const compSp = (c.streams||[]).filter(s=>s.recommendedSpComposition!=null).map(s=>{
const tr = s.trim!=null && s.trim!==0 ? `${s.trim>=0?'+':''}${fmtVal(s.trim)}` : '';
return `${esc(s.key)}: ${fmtVal(s.compositionBase)}${tr} = <b>${fmtVal(s.recommendedSpComposition)}</b>`;
}).join(' · ');
const comp = compSp ? `<div class="ff-comp">조성 권장SP(base+편차): ${compSp} <small>(advisory · 게인·부호 현장 calibrate)</small></div>` : '';
const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0);
const modeBadge =
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
: c.mode === 'Returning' ? '<span class="ff-mode ff-mode-ret">복귀 램프 ●</span>'
: armWait ? '<span class="ff-mode ff-mode-arm">전환류 권장 ⚠</span>'
: '';
const recoveryCtl =
armWait ? `<button class="btn sm danger" onclick="ffArm(${c.columnId})">전환류 ARM</button>`
: (c.mode==='Recovering'||c.mode==='Returning') ? `<button class="btn sm" onclick="ffCancelRecovery(${c.columnId})">취소(정상복귀)</button>`
: '';
const modeLine = (modeBadge || c.modeReason)
? `<div class="ff-modeline">${modeBadge} <small>${esc(c.modeReason||'')}</small> ${recoveryCtl}</div>` : '';
const writeBadge = c.autoWriteActive ? '<span class="ff-write-badge">자동 SP 쓰기</span>' : '';
const wgBlocked = c.writeGuardBlockedSp != null
? `<div class="ff-wg-blocked">쓰기 차단: ${esc(c.writeGuardReason)} (SP <b>${fmtVal(c.writeGuardBlockedSp)}</b>)</div>`
: '';
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
<div class="ff-col-head"><b>${esc(c.columnName)}</b>
<span class="ff-feed">FEED ${fmtVal(c.feedFiltered)}</span>
${writeBadge}
<span class="ff-time">${fmtTs(c.computedAt)}</span></div>
${modeLine}
${banner}
${wgBlocked}
<table class="ff-tbl"><thead><tr>
<th>스트림</th><th>태그</th><th>역할</th><th>PV</th><th>권장 SP</th><th>Δ</th><th>추세</th><th>신뢰</th>
</tr></thead><tbody>${rows}</tbody></table>
<div class="ff-mb">${esc(mb)}</div>
${temps}
${theta}
${front}
${tpBadge}
${front2}
${comp}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
</div>`;
}
// ── 설정 에디터 ──────────────────────────────────────────────────
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('') || '<div class="ff-empty">설정 없음</div>';
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 `<div class="ff-cfg-item"><b>${esc(c.name)}</b> (id ${c.id}) — feed ${esc(c.feedTag)},
스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'}
<button class="btn sm" data-edit="${c.id}">편집</button>
<button class="btn sm danger" data-del="${c.id}">삭제</button></div>`;
}
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',
// WO-2 온도/PCT · WO-3 θ자동튜닝 · WO-4 바이어스
tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600,
// WO-6 전환류 복귀
recoveryEnabled:false, recoveryAutoArm:false, imbalanceTriggerFrac:0.10, imbalanceTriggerSec:600,
recoverySettleSec:1800, returnRampSec:600, feedRecoverySp:0, deltaPTag:'', deltaPFloodLimit:1e9, tempHighLimit:1e9,
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',isReflux:false,recoverySp:0,spNodeId:''},
{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',isReflux:true,recoverySp:null,spNodeId:''},
{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',isReflux:false,recoverySp:0,spNodeId:''},
{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',isReflux:false,recoverySp:0,spNodeId:''}
] }
: { ...c, pressureTag: c.pressureTag||'',
tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' };
const colHtml = `
<div class="ff-modal-col">
<label>컬럼명 <input class="inp" id="ff-f-name" value="${esc(def.name)}"></label>
<label><input type="checkbox" id="ff-f-enabled" ${def.enabled?'checked':''}> 활성</label>
<label><input type="checkbox" id="ff-f-advisoryOnly" ${def.advisoryOnly!==false?'checked':''}> AdvisoryOnly(체크=권장만, 쓰기 안 함)</label>
<label>Feed 태그 <input class="inp" id="ff-f-feedTag" value="${esc(def.feedTag)}"></label>
<label>압력 태그 <input class="inp" id="ff-f-pressureTag" value="${esc(def.pressureTag)}"></label>
<label>Product Key <input class="inp" id="ff-f-productKey" value="${esc(def.productKey)}"></label>
</div>
<div class="ff-modal-col">
<label><span class="ff-desc">Scan(초): 계산 주기 — 빠를수록 민감하나 부하 증가</span><input class="inp" type="number" id="ff-f-scanSec" value="${def.scanSec}"></label>
<label><span class="ff-desc">Feed 필터 τ(초): 원료투입량 필터 — 잦은 변화 안정화로 헌팅 방지</span><input class="inp" type="number" id="ff-f-feedFilterTauSec" value="${def.feedFilterTauSec}"></label>
<label><span class="ff-desc">Feed 변동 임계(/분): 원료 투입량 분당 변화율 — 초과 시 과도상태 진입</span><input class="inp" type="number" id="ff-f-feedMoveThresholdPerMin" value="${def.feedMoveThresholdPerMin}"></label>
<label><span class="ff-desc">압력 필터 τ(초): 진공압 필터 — 잦은 변화 안정화로 헌팅 방지</span><input class="inp" type="number" id="ff-f-pressFilterTauSec" value="${def.pressFilterTauSec}"></label>
<label><span class="ff-desc">Pressure Band: 진공 설정값과 현재값 상하 변동폭 판정 기준</span><input class="inp" type="number" id="ff-f-pressureBand" value="${def.pressureBand}"></label>
<label><span class="ff-desc">Settle(초): 안정화 판단 기준 시간 — 이 시간 동안 안정 시 과도상태 해제</span><input class="inp" type="number" id="ff-f-settleSec" value="${def.settleSec}"></label>
<label><span class="ff-desc">Stale(초): 데이터 유효시간 — 마지막 갱신 후 이 시간 초과 시 사용 안 함</span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
</div>
<div class="ff-modal-col">
<div class="ff-modal-subhd">온도 프로파일 / θ 자동튜닝 <small>(WO-2·3·4)</small></div>
<label><span class="ff-desc">온도 태그(콤마구분, 상→하): 프로파일 PCT 모니터 대상. 비우면 온도기능 off</span><input class="inp" id="ff-f-tempTags" value="${esc((def.tempTags||[]).join(','))}"></label>
<label><span class="ff-desc">감도트레이 태그: 프론트(sweet-spot) 위치 지표. 비우면 상-하 차온 사용</span><input class="inp" id="ff-f-sensitiveTrayTag" value="${esc(def.sensitiveTrayTag||'')}"></label>
<label><span class="ff-desc">dT/dP(°C/압력): 압력보정온도(PCT) 계수. 0이면 생온도 사용</span><input class="inp" type="number" step="any" id="ff-f-dtdp" value="${def.dtdp}"></label>
<label><span class="ff-desc">P_ref(압력 기준점): 비우면 최초 정상압력으로 자동 시드</span><input class="inp" type="number" step="any" id="ff-f-pRef" value="${def.pRef==null?'':def.pRef}"></label>
<label><span class="ff-desc">스팀 OP 태그(예 tica-6111a.op): θ 추정 폐루프 오염 제거용</span><input class="inp" id="ff-f-steamOpTag" value="${esc(def.steamOpTag||'')}"></label>
<label><input type="checkbox" id="ff-f-thetaAutoTune" ${def.thetaAutoTune?'checked':''}> θ 자동튜닝(제안만, 자동반영 없음)</label>
<label><span class="ff-desc">바이어스 MA 창(초): K_obs·V_loss 장기평균 창(기본 6h=21600)</span><input class="inp" type="number" id="ff-f-biasMaWindowSec" value="${def.biasMaWindowSec}"></label>
</div>
<div class="ff-modal-col ff-recovery-col">
<div class="ff-modal-subhd">전환류 평형복귀 (WO-6) ★</div>
<label><input type="checkbox" id="ff-f-recoveryEnabled" ${def.recoveryEnabled?'checked':''}> 전환류 복귀 기능 사용</label>
<label><input type="checkbox" id="ff-f-recoveryAutoArm" ${def.recoveryAutoArm?'checked':''}> 자동 무장(체크 해제 시 운전원 ARM 필요)</label>
<label><span class="ff-desc">불균형 트리거 비율: |V_loss(MA)|/Feed 가 이 값 초과 지속 시 전환류 권장 (0.10 = 10%)</span><input class="inp ff-trig" type="number" step="any" id="ff-f-imbalanceTriggerFrac" value="${def.imbalanceTriggerFrac}"></label>
<label><span class="ff-desc">트리거 지속(초): 불균형이 이 시간 연속 지속돼야 발동(오발동 방지, 기본 600=10분)</span><input class="inp ff-trig" type="number" id="ff-f-imbalanceTriggerSec" value="${def.imbalanceTriggerSec}"></label>
<label><span class="ff-desc">평형 대기(초): 전환류 중 평형 회복 연속 만족 시간(기본 1800=30분)</span><input class="inp" type="number" id="ff-f-recoverySettleSec" value="${def.recoverySettleSec}"></label>
<label><span class="ff-desc">복귀 램프(초): 정상 복귀 시 드로우/피드 점진 복원 시간(기본 600)</span><input class="inp" type="number" id="ff-f-returnRampSec" value="${def.returnRampSec}"></label>
<label><span class="ff-desc">전환류 중 Feed 권장값: 보통 0(차단)</span><input class="inp" type="number" step="any" id="ff-f-feedRecoverySp" value="${def.feedRecoverySp}"></label>
<label><span class="ff-desc">차압(ΔP) 태그: 플러딩 트리거용(선택). 비우면 미사용</span><input class="inp" id="ff-f-deltaPTag" value="${esc(def.deltaPTag||'')}"></label>
<label><span class="ff-desc">ΔP 플러딩 상한: 초과 지속 시 전환류 트리거. 미사용 시 매우 큰 값</span><input class="inp" type="number" step="any" id="ff-f-deltaPFloodLimit" value="${def.deltaPFloodLimit}"></label>
<label><span class="ff-desc">온도 HIGH LIMIT(raw): 온도태그 중 최고값이 초과 시 전환류 트리거(단독). 미사용 시 매우 큰 값(1e9)</span><input class="inp" type="number" step="any" id="ff-f-tempHighLimit" value="${def.tempHighLimit==null?1e9:def.tempHighLimit}"></label>
</div>`;
modal.innerHTML = `
<div class="ff-modal-overlay"></div>
<div class="ff-modal-box">
<div class="ff-modal-hd">${isNew?'새 컬럼':'컬럼 편집'} <span class="ff-modal-id">${isNew?'':'(id '+c.id+')'}</span></div>
<div class="ff-modal-body">
<div class="ff-modal-grid">${colHtml}</div>
<div class="ff-modal-sec">
<div class="ff-modal-sec-hd">스트림
<span class="ff-key-legend">P=주생성물 R=환류(P rec) D=유출액 B=탑저</span>
<button class="btn sm" id="ff-stream-add">+ 추가</button>
</div>
<table class="ff-tbl ff-stream-tbl">
<thead><tr>
<th>Key</th><th>Flow 태그</th><th>역할</th><th>레벨태그</th><th>K</th><th>θ_up</th><th>θ_dn</th><th>τ</th>
<th>SP_min</th><th>SP_max</th><th>Rate_up</th><th>Rate_dn</th><th>환류</th><th title="전환류 시 전량환류 대상">전환류R</th><th title="전환류 시 이 스트림 권장값(비우면 0)">복귀SP</th><th title="OPC UA SP NodeId (예: ns=3;s=ficq-6113.sp)">SP NodeId</th><th>신뢰</th><th></th>
</tr></thead>
<tbody id="ff-stream-body">
${def.streams.map((s,i) => ffStreamRow(s,i)).join('')}
</tbody>
</table>
</div>
</div>
<div class="ff-modal-ft">
<span id="ff-modal-msg" class="ff-msg"></span>
<button class="btn-b" id="ff-modal-cancel">취소</button>
<button class="btn-a" id="ff-modal-save">${isNew?'생성':'저장'}</button>
</div>
</div>`;
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',isReflux:false,recoverySp:null,spNodeId:''
}, 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 => `<option ${r===s.role?'selected':''}>${r}</option>`).join('');
const gradeOpts = FF_GRADES.map(g => `<option ${g===s.grade?'selected':''}>${g}</option>`).join('');
const desc = FF_KEY_DESC[s.key] || '';
const lvlTagHtml = s.role==='LevelDriven'
? `<input class="inp ff-si ff-lvl-tag" value="${esc(s.levelTag||'')}" data-idx="${i}" data-f="levelTag" placeholder="레벨태그">`
: `<input type="hidden" data-idx="${i}" data-f="levelTag" value="">`;
return `<tr>
<td><input class="inp ff-si" value="${esc(s.key)}" title="${esc(desc)}" data-idx="${i}" data-f="key"></td>
<td><input class="inp ff-si" value="${esc(s.flowTag)}" data-idx="${i}" data-f="flowTag"></td>
<td><select class="inp ff-si" data-idx="${i}" data-f="role">${roleOpts}</select></td>
<td>${lvlTagHtml}</td>
<td><input class="inp ff-si" type="number" step="any" value="${s.targetCoeff}" data-idx="${i}" data-f="targetCoeff"></td>
<td><input class="inp ff-si" type="number" value="${s.thetaUpSec}" data-idx="${i}" data-f="thetaUpSec"></td>
<td><input class="inp ff-si" type="number" value="${s.thetaDnSec}" data-idx="${i}" data-f="thetaDnSec"></td>
<td><input class="inp ff-si" type="number" value="${s.tauSec}" data-idx="${i}" data-f="tauSec"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.spMin}" data-idx="${i}" data-f="spMin"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.spMax}" data-idx="${i}" data-f="spMax"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.rateUpPerMin}" data-idx="${i}" data-f="rateUpPerMin"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.rateDnPerMin}" data-idx="${i}" data-f="rateDnPerMin"></td>
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
<td><input type="checkbox" ${s.isReflux?'checked':''} data-idx="${i}" data-f="isReflux"></td>
<td><input class="inp ff-si" type="number" step="any" value="${s.recoverySp==null?'':s.recoverySp}" data-idx="${i}" data-f="recoverySp" placeholder="0"></td>
<td><input class="inp ff-si" value="${esc(s.spNodeId||'')}" data-idx="${i}" data-f="spNodeId" placeholder="예: ns=3;s=ficq-6113.sp"></td>
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
<td><button class="btn sm danger ff-stream-del" data-idx="${i}">✕</button></td>
</tr>`;
}
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: g('ff-f-advisoryOnly').checked,
// WO-2/3/4
tempTags: g('ff-f-tempTags').value.split(',').map(s=>s.trim()).filter(Boolean),
sensitiveTrayTag: g('ff-f-sensitiveTrayTag').value || null,
dtdp: +g('ff-f-dtdp').value,
pRef: g('ff-f-pRef').value === '' ? undefined : +g('ff-f-pRef').value,
steamOpTag: g('ff-f-steamOpTag').value || null,
thetaAutoTune: g('ff-f-thetaAutoTune').checked,
biasMaWindowSec: +g('ff-f-biasMaWindowSec').value,
// WO-6
recoveryEnabled: g('ff-f-recoveryEnabled').checked,
recoveryAutoArm: g('ff-f-recoveryAutoArm').checked,
imbalanceTriggerFrac: +g('ff-f-imbalanceTriggerFrac').value,
imbalanceTriggerSec: +g('ff-f-imbalanceTriggerSec').value,
recoverySettleSec: +g('ff-f-recoverySettleSec').value,
returnRampSec: +g('ff-f-returnRampSec').value,
feedRecoverySp: +g('ff-f-feedRecoverySp').value,
deltaPTag: g('ff-f-deltaPTag').value || null,
deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value,
tempHighLimit: +g('ff-f-tempHighLimit').value,
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'),
isReflux: v(null,'isReflux'),
recoverySp: (() => { const x = v(null,'recoverySp'); return x === '' ? undefined : +x; })(),
spNodeId: v(null,'spNodeId') || null
};
})
};
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'; });
}