/* 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 = `
HOLD: ${esc(data.warnings?.join(', ') || '피드 불량')}
`;
return;
}
host.innerHTML = `
| 현재 피드 | ${fmtVal(data.currentFeed)} |
| 목표 피드 | ${fmtVal(data.targetFeed)} |
| 클램프 목표 | ${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)}
`;
}
}
// ── 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; }
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 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
레벨: ${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)} |
${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 ? '전환류 권장 ⚠'
: '';
const recoveryCtl =
armWait ? ``
: (c.mode==='Recovering'||c.mode==='Returning') ? ``
: '';
const modeLine = (modeBadge || c.modeReason)
? `${modeBadge} ${esc(c.modeReason||'')} ${recoveryCtl}
` : '';
const writeBadge = c.autoWriteActive ? '자동 SP 쓰기' : '';
const wgBlocked = c.writeGuardBlockedSp != null
? `쓰기 차단: ${esc(c.writeGuardReason)} (SP ${fmtVal(c.writeGuardBlockedSp)})
`
: '';
return `
${esc(c.columnName)}
FEED ${fmtVal(c.feedFiltered)}
${writeBadge}
${fmtTs(c.computedAt)}
${modeLine}
${banner}
${wgBlocked}
| 스트림 | 태그 | 역할 | PV | 권장 SP | Δ | 추세 | 신뢰 |
${rows}
${esc(mb)}
${temps}
${theta}
${front}
${tpBadge}
${front2}
${comp}
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',
// 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 = `
`;
modal.innerHTML = `
${isNew?'새 컬럼':'컬럼 편집'} ${isNew?'':'(id '+c.id+')'}
${colHtml}
스트림
P=주생성물 R=환류(P rec) D=유출액 B=탑저
| Key | Flow 태그 | 역할 | 레벨태그 | K | θ_up | θ_dn | τ |
SP_min | SP_max | Rate_up | Rate_dn | 환류 | 전환류R | 복귀SP | SP NodeId | 신뢰 | |
${def.streams.map((s,i) => ffStreamRow(s,i)).join('')}
`;
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,
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'; });
}