/* ff.js — 측류추출 유량 권장(FF) 대시보드 + 설정 에디터. Phase II: X-Kb-Token 인증 (설정/쓰기), auto-write 결과 표시. */ paneInit.ff = ffInit; let ffTimer = null; let ffRampJobs = {}; // columnId → FEED 램프 Job 상태 let ffRampDryRun = true; let ffRampTargets = {}; // columnId → 입력 중인 FEED Target SP(폴링 재렌더에도 보존) 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-ramp-start').onclick = ffRampStart; 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 = `
HOLD: ${esc(data.warnings?.join(', ') || '피드 불량')}
`; return; } const goingUp = data.clampedTarget != null && data.clampedTarget > data.currentFeed; const goingDn = data.clampedTarget != null && data.clampedTarget < data.currentFeed; const dirBadge = goingUp ? '↑ 상승' : goingDn ? '↓ 하강' : ''; const curEl = document.getElementById('ff-ramp-currentFeed'); if (curEl) curEl.value = data.currentFeed; host.innerHTML = `
현재 피드${fmtVal(data.currentFeed)}
목표 피드${fmtVal(data.targetFeed)}
방향${dirBadge}
클램프 목표${fmtVal(data.clampedTarget)}
Ceiling${fmtVal(data.ceiling?.value)} (${esc(data.ceiling?.binding)})
램프율${fmtVal(data.rampRate?.value)} kg/hr·min (${esc(data.rampRate?.binding)})
예상 시간${data.rampTimeMin != null ? Math.round(data.rampTimeMin) + '분' : '–'}
스팀(현재)${fmtVal(data.steam?.fiq6115From)}
스팀(목표)${fmtVal(data.steam?.fiq6115To)}
${(data.warnings||[]).map(w => esc(w)).join('
')}
`; } catch (e) { host.innerHTML = `
오류: ${esc(e.message)}
`; } } // ── 작업 B: FEED 램프 시작 ──────────────────────────────────── async function ffRampStart() { const g = id => document.getElementById(id); const colId = +g('ff-ramp-colId').value; const target = +g('ff-ramp-targetFeed').value; if (!Number.isFinite(colId) || !Number.isFinite(target)) { alert('columnId·targetFeed 확인'); return; } const mode = ffRampDryRun ? '모의(DryRun — 실제 쓰기 없음)' : '실쓰기'; const dir = target > +(g('ff-ramp-currentFeed')?.value || 0) ? '올립니다' : '내립니다'; if (!confirm(`컬럼 ${colId} FEED를 ${target}까지 단계적으로 ${dir} [${mode}]. 시작하시겠습니까?`)) return; try { const r = await ffApi('POST', `/api/ff/feed-ramp/${colId}/start`, { targetFeed: target }); const modeEl = g('ff-ramp-mode'); if (modeEl) modeEl.textContent = r.dryRun ? '모의(DryRun)' : '실쓰기 모드'; ffLoadDash(); } catch (e) { alert('램프 시작 실패: ' + e.message); } } // ── 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(' · ') : '입력 없음 (config K 사용)'; } 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; } try { const ramp = await api('GET', '/api/ff/feed-ramp'); ffRampJobs = {}; (ramp.Jobs || []).forEach(j => ffRampJobs[j.columnId] = j); ffRampDryRun = !!ramp.DryRun; const modeEl = document.getElementById('ff-ramp-mode'); if (modeEl) modeEl.textContent = ffRampDryRun ? '모의(DryRun)' : '실쓰기 모드'; } catch (e) { /* 무시 */ } const host = document.getElementById('ff-dash'); if (!host) { clearInterval(ffTimer); ffTimer = null; return; } // FEED Target SP 입력 중이면 재렌더 보류(타이핑/포커스 유지) const ae = document.activeElement; if (ae && ae.classList && ae.classList.contains('ff-rt')) 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 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(()=>{}); } // 측류 추종 컨트롤 — ON이면 권장 WSP 연속 추종. OFF=취소(현재값 유지), 원복=시작 전 WSP로 복귀. // (Commanded·LevelDriven 모두 — D·B처럼 피드결정형이면 LevelDriven도 추종 가능) function ffTrackCtl(c, s) { if (!s.writable) return ''; if (s.tracking) { return `
추종 ●` + `
`; } // OFF 상태 — 추종 시작 가능 조건(WriteGuard와 동일: 유효·과도아님·신뢰≠C) const canOn = s.recommendedSp != null && s.valid && s.grade !== 'C' && !c.transient; if (!canOn) return ''; return `
`; } async function ffTrackOn(columnId, key) { if (!confirm(`${key} 스트림 추종을 켭니다 — 권장 WSP를 연속으로 컨트롤러에 씁니다. 시작?`)) return; try { await ffApi('POST', `/api/ff/track/${columnId}/${encodeURIComponent(key)}/on`); ffLoadDash(); } catch (e) { alert('추종 ON 실패: ' + e.message); } } async function ffTrackOff(columnId, key) { try { await ffApi('POST', `/api/ff/track/${columnId}/${encodeURIComponent(key)}/off`); ffLoadDash(); } catch (e) { alert('추종 OFF 실패: ' + e.message); } } // FEED 램프 상태 줄 (활성 Job 있을 때) function ffRampLine(rj) { const st = rj.state; const cls = st === 'Hold' ? 'ff-mode-arm' : st === 'Reached' ? 'ff-mode-rec' : st === 'Canceled' ? '' : 'ff-mode-ret'; const prog = rj.lastWrittenSp != null ? `FEED SP ${fmtVal(rj.lastWrittenSp)} → ${fmtVal(rj.targetFeed)}` : `목표 ${fmtVal(rj.targetFeed)}`; const ceil = rj.ceiling != null ? ` (ceiling ${fmtVal(rj.ceiling)})` : ''; const hold = rj.hold ? ` ⚠ ${esc(rj.hold)}` : ''; const dry = rj.dryRun ? ' [모의]' : ''; const cancelBtn = (st === 'Ramping' || st === 'Hold') ? `` : ''; return `
FEED 램프 ${esc(st)}${dry} ${prog}${ceil}${hold} ${cancelBtn}
`; } function ffRampCancel(id) { if (!confirm(`컬럼 ${id} FEED 램프를 취소(현재 SP 유지)하시겠습니까?`)) return; ffApi('POST', `/api/ff/feed-ramp/${id}/cancel`).then(() => ffLoadDash()).catch(e => alert(e.message)); } // 카드의 FEED Target SP로 램프 시작 function ffCardRampStart(colId, currentFeed) { const inp = document.querySelector(`.ff-rt[data-col="${colId}"]`); const target = inp ? +inp.value : NaN; if (!Number.isFinite(target)) { alert('FEED Target SP를 입력하세요'); return; } const mode = ffRampDryRun ? '모의(DryRun — 실제 쓰기 없음)' : '실쓰기'; const dir = target > currentFeed ? '상승' : '하강'; if (!confirm(`컬럼 ${colId} FEED를 ${target}까지 램프율로 점진 ${dir} [${mode}]. 시작?`)) return; ffApi('POST', `/api/ff/feed-ramp/${colId}/start`, { targetFeed: target }) .then(() => ffLoadDash()).catch(e => alert('램프 시작 실패: ' + e.message)); } // ── FF 온도계 위젯 (T_C sweet spot) ────────────────────────────── function ffThermometer(c) { const temps = c.temps; if (!temps || temps.length < 2) return ''; const good = temps.filter(t => t.good && t.raw != null); if (good.length < 2) return ''; const tcTarget = c.tcReturnTcTarget; const tcBand = c.tcReturnTcBand || 1; const tll = c.tempLowLimit; const cTag = c.sensitiveTrayTag; const cKey = cTag ? (cTag.endsWith('.pv') ? cTag : cTag + '.pv') : ''; let cIdx = cKey ? temps.findIndex(t => t.tag === cKey) : -1; if (cIdx < 0) cIdx = Math.round((temps.length - 1) * 0.7); cIdx = Math.min(cIdx, temps.length - 1); const vals = good.map(t => t.raw); let lo = Math.min(...vals), hi = Math.max(...vals); if (tcTarget != null) { lo = Math.min(lo, tcTarget - tcBand); hi = Math.max(hi, tcTarget + tcBand); } if (tll != null) lo = Math.min(lo, tll); const pad = (hi - lo) * 0.08 || 2; lo -= pad; hi += pad; const range = hi - lo || 1; const toY = v => Math.max(0, Math.min(100, (1 - (v - lo) / range) * 100)); const bandHtml = tcTarget != null ? `
` : ''; const limitHtml = tll != null ? `
` : ''; const markerLabels = temps.map((t, i) => { const tag = (t.tag || '').replace(/\.pv$/i, '').toUpperCase(); const m = tag.match(/[A-Z]+$/); const suffix = m ? m[0] : ''; if (suffix && !suffix.match(/^\d+$/)) return suffix; const def = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; return def[i] || 'T' + i; }); const markerPcts = temps.map((_, i) => `${Math.round((i / Math.max(temps.length - 1, 1)) * 100)}%`); const n = temps.length - 1; const markers = temps.map((t, i) => { if (!t.good || t.raw == null) return ''; const y = n > 0 ? (1 - i / n) * 100 : 50; // 인덱스 기준 위치 (0=A=밑→100%, n=D=위→0%) const isC = i === cIdx; const label = markerLabels[i]; const pct = markerPcts[i]; const cState = isC && tcTarget != null ? (t.raw < tcTarget - tcBand ? 'low' : t.raw > tcTarget + tcBand ? 'high' : 'ok') : ''; const dotCls = isC ? 'ff-thermo-dot ff-thermo-c' + (cState ? ' ff-tc-' + cState : '') : 'ff-thermo-dot'; return `
${isC?'◉':'●'}${fmtVal(t.raw)} ${label}${pct?' '+pct:''}
`; }).join(''); const cState = (cIdx >= 0 && temps[cIdx]?.good && temps[cIdx].raw != null && tcTarget != null) ? (temps[cIdx].raw < tcTarget - tcBand ? 'low' : temps[cIdx].raw > tcTarget + tcBand ? 'high' : 'ok') : ''; const arrow = cState === 'low' ? '▼' : cState === 'high' ? '▲' : ''; const arrowCls = 'ff-thermo-arrow' + (cState ? ' ff-tc-' + cState : ''); // 옵션 푸터: Module 1 T_C 유지 SP 제안 + 실제 OP 비교 const footer = (c.steamRecOp != null) ? (() => { const dev = c.tcDeviation; const devCls = dev != null ? (Math.abs(dev) > 1 ? 'low' : Math.abs(dev) > 0.3 ? 'warn' : 'ok') : ''; const devHtml = dev != null ? `ε${dev >= 0 ? '+' : ''}${fmtVal(dev)}℃` : ''; const actualHtml = (c.actualSteamOp != null && c.actualSteamOp !== c.steamRecOp) ? ` 실${fmtVal(c.actualSteamOp)}` : ''; return `
conf ${esc(c.steamConfidence||'')} · SP→${fmtVal(c.steamRecOp)}${actualHtml}
${devHtml}
`; })() : ''; return `
T_C (℃)
${fmtVal(lo)}
${fmtVal(lo+(hi-lo)*0.25)}
${fmtVal(lo+(hi-lo)*0.5)}
${fmtVal(lo+(hi-lo)*0.75)}
${fmtVal(hi)}
${bandHtml} ${limitHtml} ${markers}
${arrow ? `
${arrow}
` : ''} ${footer}
`; } 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'; const writeInfo = s.lastWriteSp != null ? `
쓰기${s.lastWriteError ? ' 오류' : '됨'} ${fmtVal(s.lastWriteSp)}${s.lastWriteError ? ': '+esc(s.lastWriteError) : ''}` : ''; return ` ${esc(s.key)}${esc(s.flowTag)} ${roleLabel} ${fmtVal(s.pv)} ${s.recommendedSp==null?'–':fmtVal(s.recommendedSp)}${ffTrackCtl(c,s)} ${s.gap==null?'–':fmtVal(s.gap)} ${ffTrendIco(s.trend)} ${esc(s.grade)}${s.kObsSuggest!=null ? `
K~${fmtVal(s.kObsSuggest)}` : ''}${writeInfo} `;}).join(''); const banner = c.transient ? `
과도상태: ${esc(c.transientReason)} — 권장값 정착 대기
` : ''; 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) ? `
${c.temps.map(t => `${esc(t.tag)} ${t.raw==null?'–':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` PCT ${fmtVal(t.pct)}`:''}`).join(' · ')}
` : ''; const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null); const theta = thetaSug.length ? `
θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
` : ''; const front = c.frontPositionState ? `
프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → ${esc(c.frontTrimAdvice)}`:''}
` : ''; // 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) ? ` span ${fmtVal(c.tempSpan)}/${fmtVal(c.tempSpanRef)}` : ''; const invStr = c.inversionPair ? ` (${esc(c.inversionPair)})` : ''; return `
${esc(c.tempProfileState)}${invStr}${spanStr}
`; })() : ''; // WP5 1단계: 2-point front (상부 C−D / 하부 C−B) const f2cls = s => s === '상승' ? 'ff-f2-up' : s === '하강' ? 'ff-f2-dn' : 'ff-f2-ok'; const front2 = (c.upperFrontState || c.lowerFrontState) ? `
하부(B) ${esc(c.lowerFrontState||'–')}${c.lowerFrontMetric!=null?` ΔT ${fmtVal(c.lowerFrontMetric)}`:''} 상부(D) ${esc(c.upperFrontState||'–')}${c.upperFrontMetric!=null?` ΔT ${fmtVal(c.upperFrontMetric)}`:''}
` : ''; // 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} = ${fmtVal(s.recommendedSpComposition)}`; }).join(' · '); const comp = compSp ? `
조성 권장SP(base+편차): ${compSp} (advisory · 게인·부호 현장 calibrate)
` : ''; const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); const modeBadge = c.mode === 'Recovering' ? '전환류 복귀중 ●' : c.mode === 'Returning' ? '복귀 램프 ●' : armWait ? '전환류 권장 ⚠' : c.mode === 'Normal' ? '정상 ●' : ''; const recoveryCtl = armWait ? `` : (c.mode==='Recovering'||c.mode==='Returning') ? `` : ''; const modeLine = (modeBadge || c.modeReason) ? `
${modeBadge} ${esc(c.modeReason||'')} ${recoveryCtl}
` : ''; const rampJob = ffRampJobs[c.columnId]; const rampActive = rampJob && (rampJob.state === 'Ramping' || rampJob.state === 'Hold'); const rampLine = rampJob ? ffRampLine(rampJob) : ''; const rampCtl = rampActive ? '' : `
FEED Target SP ${ffRampDryRun ? '[모의]' : '[실쓰기]'}
`; const thermo = ffThermometer(c); const writeBadge = c.autoWriteActive ? '자동 SP 쓰기' : ''; const wgBlocked = c.writeGuardBlockedSp != null ? `
쓰기 차단: ${esc(c.writeGuardReason)} (SP ${fmtVal(c.writeGuardBlockedSp)})
` : ''; const hasThermo = !!thermo; return `
${esc(c.columnName)} FEED ${fmtVal(c.feedFiltered)} ${writeBadge} ${fmtTs(c.computedAt)}
${modeLine} ${rampLine} ${rampCtl} ${banner} ${wgBlocked} ${rows}
스트림태그역할PV권장 SPΔ추세신뢰
${esc(mb)}
${temps} ${theta} ${front} ${tpBadge} ${front2} ${comp}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
${thermo}
`; } // ── 설정 에디터 ────────────────────────────────────────────────── 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, controllerId:'C1', feedTag:'', pressureTag:'', feedSpNodeId:'', feedSpMin:0, feedSpMax:1e9, 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, // 민감단(T_C) 전환·복귀 tempLowLimit:-1e9, tcReturnRebTarget:null, tcReturnRebBand:0.5, tcReturnDeltaAdRef:null, tcReturnDeltaAdBand:0.4, tcReturnTcTarget:null, tcReturnTcBand:1.0, 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||'', controllerId: c.controllerId||'C1', feedSpNodeId: c.feedSpNodeId||'', feedSpMin: c.feedSpMin==null?0:c.feedSpMin, feedSpMax: c.feedSpMax==null?1e9:c.feedSpMax, tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'', tempLowLimit: c.tempLowLimit??-1e9, tcReturnRebTarget: c.tcReturnRebTarget, tcReturnRebBand: c.tcReturnRebBand??0.5, tcReturnDeltaAdRef: c.tcReturnDeltaAdRef, tcReturnDeltaAdBand: c.tcReturnDeltaAdBand??0.4, tcReturnTcTarget: c.tcReturnTcTarget, tcReturnTcBand: c.tcReturnTcBand??1.0 }; const colHtml = `
FEED 램프 실행 (작업 B)
온도 프로파일 / θ 자동튜닝 (WO-2·3·4)
전환류 평형복귀 (WO-6) ★
민감단(T_C) 전환·복귀
`; 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환류전환류R복귀SPSP override신뢰
`; 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 => ``).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, feedSpNodeId: g('ff-f-feedSpNodeId').value || null, feedSpMin: +g('ff-f-feedSpMin').value, feedSpMax: +g('ff-f-feedSpMax').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, tempLowLimit: +g('ff-f-tempLowLimit').value, tcReturnRebTarget: g('ff-f-tcReturnRebTarget').value === '' ? undefined : +g('ff-f-tcReturnRebTarget').value, tcReturnRebBand: +g('ff-f-tcReturnRebBand').value, tcReturnDeltaAdRef: g('ff-f-tcReturnDeltaAdRef').value === '' ? undefined : +g('ff-f-tcReturnDeltaAdRef').value, tcReturnDeltaAdBand: +g('ff-f-tcReturnDeltaAdBand').value, tcReturnTcTarget: g('ff-f-tcReturnTcTarget').value === '' ? undefined : +g('ff-f-tcReturnTcTarget').value, tcReturnTcBand: +g('ff-f-tcReturnTcBand').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'; }); }