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>
740 lines
47 KiB
JavaScript
740 lines
47 KiB
JavaScript
/* 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 (상부 C−D / 하부 C−B)
|
||
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'; });
|
||
}
|