Files
HC900-Crawler/src/Hc900Crawler/wwwroot/js/ff.js
windpacer f97be981a4 style(api): JSON 응답 필드 PascalCase 통일 (컨트롤러·DTO·JS)
C# 익명객체 응답 속성을 camelCase→PascalCase로 통일(key→Key, success→Success,
error→Error 등)하고, 프런트 JS의 응답 접근도 맞춰 변경(ramp.jobs→ramp.Jobs,
data.columns→data.Columns 등). Kb/Pid/Steam/Feedforward/Ollama/PointBuilder 등
전 컨트롤러와 대응 JS, ExperionDtos·TrendDtos, McpService 반영.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:31:14 +09:00

740 lines
47 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;
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 = `<div class="ff-ramp-hold">HOLD: ${esc(data.warnings?.join(', ') || '피드 불량')}</div>`;
return;
}
const goingUp = data.clampedTarget != null && data.clampedTarget > data.currentFeed;
const goingDn = data.clampedTarget != null && data.clampedTarget < data.currentFeed;
const dirBadge = goingUp ? '<span class="ff-ramp-dir ff-ramp-up">↑ 상승</span>' : goingDn ? '<span class="ff-ramp-dir ff-ramp-dn">↓ 하강</span>' : '<span class="ff-ramp-dir">—</span>';
const curEl = document.getElementById('ff-ramp-currentFeed');
if (curEl) curEl.value = data.currentFeed;
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>${dirBadge}</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>`;
}
}
// ── 작업 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(' · ') : '<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; }
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 = '<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(()=>{});
}
// 측류 추종 컨트롤 — ON이면 권장 WSP 연속 추종. OFF=취소(현재값 유지), 원복=시작 전 WSP로 복귀.
// (Commanded·LevelDriven 모두 — D·B처럼 피드결정형이면 LevelDriven도 추종 가능)
function ffTrackCtl(c, s) {
if (!s.writable) return '';
if (s.tracking) {
return `<div class="ff-track-ctl"><span class="ff-track-on" title="권장 WSP 연속 추종 중">추종 ●</span>`
+ `<span class="ff-track-row"><button class="btn sm" onclick="ffTrackOff(${c.columnId},'${esc(s.key)}')" title="추종 중지(현재값 유지)">OFF</button></span></div>`;
}
// OFF 상태 — 추종 시작 가능 조건(WriteGuard와 동일: 유효·과도아님·신뢰≠C)
const canOn = s.recommendedSp != null && s.valid && s.grade !== 'C' && !c.transient;
if (!canOn) return '';
return `<div class="ff-track-ctl"><button class="btn sm ff-track-btn" onclick="ffTrackOn(${c.columnId},'${esc(s.key)}')" title="권장 WSP를 연속 추종(쓰기 시작)">추종 ON</button></div>`;
}
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')
? `<button class="btn sm" onclick="ffRampCancel(${rj.columnId})">취소</button>` : '';
return `<div class="ff-modeline"><span class="ff-mode ${cls}">FEED 램프 ${esc(st)}${dry}</span> <small>${prog}${ceil}${hold}</small> ${cancelBtn}</div>`;
}
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
? `<div class="ff-thermo-band" style="top:${toY(tcTarget + tcBand)}%;height:${toY(tcTarget - tcBand) - toY(tcTarget + tcBand)}%"></div>`
: '';
const limitHtml = tll != null
? `<div class="ff-thermo-limit" style="top:${toY(tll)}%"></div>` : '';
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 `<div class="ff-thermo-mark" style="top:${y}%"><span class="${dotCls}">${isC?'◉':'●'}</span><span class="ff-thermo-lbl">${fmtVal(t.raw)}<small> ${label}${pct?' '+pct:''}</small></span></div>`;
}).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 ? `<span class="ff-tc-${devCls}">ε${dev >= 0 ? '+' : ''}${fmtVal(dev)}℃</span>` : '';
const actualHtml = (c.actualSteamOp != null && c.actualSteamOp !== c.steamRecOp)
? ` <span class="ff-tc-actual">실${fmtVal(c.actualSteamOp)}</span>` : '';
return `<div class="ff-thermo-ft">conf ${esc(c.steamConfidence||'')} · <span class="ff-tc-rec">SP→${fmtVal(c.steamRecOp)}</span>${actualHtml}<br>${devHtml}</div>`;
})()
: '';
return `<div class="ff-thermo">
<div class="ff-thermo-hd">T_C (℃)</div>
<div class="ff-thermo-body"><div class="ff-thermo-track">
<div class="ff-thermo-tick" style="top:0%"><span>${fmtVal(lo)}</span></div>
<div class="ff-thermo-tick" style="top:25%"><span>${fmtVal(lo+(hi-lo)*0.25)}</span></div>
<div class="ff-thermo-tick" style="top:50%"><span>${fmtVal(lo+(hi-lo)*0.5)}</span></div>
<div class="ff-thermo-tick" style="top:75%"><span>${fmtVal(lo+(hi-lo)*0.75)}</span></div>
<div class="ff-thermo-tick" style="top:100%"><span>${fmtVal(hi)}</span></div>
${bandHtml}
${limitHtml}
${markers}
</div></div>
${arrow ? `<div class="${arrowCls}">${arrow}</div>` : ''}
${footer}
</div>`;
}
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)}${ffTrackCtl(c,s)}</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>'
: c.mode === 'Normal' ? '<span class="ff-mode ff-mode-nrm">정상 ●</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 rampJob = ffRampJobs[c.columnId];
const rampActive = rampJob && (rampJob.state === 'Ramping' || rampJob.state === 'Hold');
const rampLine = rampJob ? ffRampLine(rampJob) : '';
const rampCtl = rampActive ? '' : `<div class="ff-feedramp">FEED Target SP
<input class="inp ff-rt" data-col="${c.columnId}" type="number" step="any" value="${ffRampTargets[c.columnId] ?? ''}"
oninput="ffRampTargets[${c.columnId}]=this.value" placeholder="목표">
<button class="btn sm danger" onclick="ffCardRampStart(${c.columnId}, ${c.feedFiltered})" title="목표까지 FEED SP를 램프율로 점진 상승/하강">램프 시작 ▶</button>
<span class="ff-rt-mode">${ffRampDryRun ? '[모의]' : '[실쓰기]'}</span></div>`;
const thermo = ffThermometer(c);
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>`
: '';
const hasThermo = !!thermo;
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'} ${hasThermo?'ff-has-thermo':''}">
<div class="ff-card-main">
<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}
${rampLine}
${rampCtl}
${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>
${thermo}
</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, 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 = `
<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(체크=자동쓰기 안 함 · 추종 ON은 가능)</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 class="ff-modal-subhd">FEED 램프 실행 <small>(작업 B)</small></div>
<label><span class="ff-desc">(선택) FEED SP override: 비우면 Feed태그의 .SP(WSP)로 자동</span><input class="inp" id="ff-f-feedSpNodeId" value="${esc(def.feedSpNodeId||'')}" placeholder="자동(Feed.SP)"></label>
<label><span class="ff-desc">FEED SP 하한(클램프)</span><input class="inp" type="number" step="any" id="ff-f-feedSpMin" value="${def.feedSpMin==null?0:def.feedSpMin}"></label>
<label><span class="ff-desc">FEED SP 상한(클램프)</span><input class="inp" type="number" step="any" id="ff-f-feedSpMax" value="${def.feedSpMax==null?1e9:def.feedSpMax}"></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 class="ff-modal-subhd">민감단(T_C) 전환·복귀</div>
<label><span class="ff-desc">T_C 하한(℃): 민감단 온도가 이 값 이하 시 전환류 트리거. -1e9=비활성</span><input class="inp" type="number" step="any" id="ff-f-tempLowLimit" value="${def.tempLowLimit}"></label>
<label><span class="ff-desc">T_C 복귀 목표(℃): ★민감단 전용 목표(reb-A와 다름). 빈칸=게이트 비활성</span><input class="inp" type="number" step="any" id="ff-f-tcReturnTcTarget" value="${def.tcReturnTcTarget==null?'':def.tcReturnTcTarget}"></label>
<label><span class="ff-desc">T_C 대역(±℃): T_C 목표 대역 반폭</span><input class="inp" type="number" step="any" id="ff-f-tcReturnTcBand" value="${def.tcReturnTcBand}"></label>
<label><span class="ff-desc">reb-A 복귀 목표(℃): 빈칸=게이트 비활성</span><input class="inp" type="number" step="any" id="ff-f-tcReturnRebTarget" value="${def.tcReturnRebTarget==null?'':def.tcReturnRebTarget}"></label>
<label><span class="ff-desc">reb-A 대역(±℃): reb-A 목표 대역 반폭</span><input class="inp" type="number" step="any" id="ff-f-tcReturnRebBand" value="${def.tcReturnRebBand}"></label>
<label><span class="ff-desc">ΔT(A-D) 기준(℃): 빈칸=게이트 비활성</span><input class="inp" type="number" step="any" id="ff-f-tcReturnDeltaAdRef" value="${def.tcReturnDeltaAdRef==null?'':def.tcReturnDeltaAdRef}"></label>
<label><span class="ff-desc">ΔT(A-D) 대역(±℃): 안정 대역 반폭</span><input class="inp" type="number" step="any" id="ff-f-tcReturnDeltaAdBand" value="${def.tcReturnDeltaAdBand}"></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="(선택) SP 태그 override — 비우면 flow태그의 .SP(WSP)로 자동. 예: FICQ-6113.SP">SP override</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="자동(flow.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,
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'; });
}