Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)
WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
total reflux recovery, config form expansion
17 KiB
WO-7 (설정 편집 폼 확장 — 신규 필드 운전원 노출) — 완전코드 작업지시서
대상 실행자: 본 LLM보다 능력이 낮은 LLM. 추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙. 선행 완료 전제(검증됨): §0 + WO-1~6 전부 머지 완료. 백엔드(
ColumnConfig/StreamConfig신규 필드,ff_*DDL, ConfigStore Save/Load, ControllerMapConfig)는 이미 신규 필드를 저장·반환한다. 본 WO는 프론트 폼(ff.js)만 손댄다. 불변식: 쓰기 0건(설정 저장은 advisory config일 뿐). C# 코드 변경 없음.
배경 / 목적
현재 설정 모달(ffEditColumn)은 §0 이전의 기본 필드(Feed/압력/Scan/필터/스트림 K·θ·τ…)만 폼에 노출한다.
WO-2~6에서 추가된 온도/PCT·θ자동튜닝·느린바이어스·전환류 트리거/설정·스트림 환류/복귀SP 필드는
API로는 저장/조회되지만 폼에 입력칸이 없어 운전원이 화면에서 못 바꾼다(저장 시 undefined→백엔드 기본값 유지).
특히 운전원 질문 "균형 심각붕괴 트리거를 수정할 수 있나?" → 현재 폼엔 없음. 본 WO로 노출한다.
검증 사실:
GET /api/ff/config는tempTags, sensitiveTrayTag, dtdp, pRef, steamOpTag, thetaAutoTune, biasMaWindowSec, recoveryEnabled, recoveryAutoArm, imbalanceTriggerFrac, imbalanceTriggerSec, recoverySettleSec, returnRampSec, feedRecoverySp, deltaPTag, deltaPFloodLimit(컬럼) +isReflux, recoverySp(스트림)을 이미 반환한다(Controller MapConfig). 따라서 기존 컬럼 편집 시엔{...c}로 값이 이미 들어오고, 새 컬럼만 default 보강이 필요하다.
변경 파일 (총 2개)
src/Web/wwwroot/js/ff.js—def(새컬럼 기본값) +colHtml(입력칸) +ffStreamRow(스트림 2칸) +ffSaveForm(저장)src/Web/wwwroot/css/ff.css— 트리거 강조 스타일(선택)
STEP 1 — 새 컬럼 기본값(def)에 신규 필드 추가
파일: src/Web/wwwroot/js/ff.js
위치: ffEditColumn 함수의 const def = isNew ? {...} : {...}
찾기:
const def = isNew
? { name:'', enabled:false, feedTag:'', pressureTag:'',
scanSec:2, feedFilterTauSec:300, feedMoveThresholdPerMin:5,
pressFilterTauSec:60, pressureBand:3, settleSec:1800, staleSec:120, productKey:'P',
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'},
{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'},
{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'},
{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'}
] }
: { ...c, pressureTag: c.pressureTag||'' };
바꾸기:
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,
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},
{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},
{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},
{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}
] }
: { ...c, pressureTag: c.pressureTag||'',
tempTags: c.tempTags||[], sensitiveTrayTag: c.sensitiveTrayTag||'', steamOpTag: c.steamOpTag||'', deltaPTag: c.deltaPTag||'' };
기존 컬럼은
{...c}로 숫자/불리언 신규 필드가 이미 들어온다. 위 추가 라인은 null일 수 있는 문자열/배열 필드만 빈값 정규화(입력칸에undefined/null표시 방지).tempTags는 배열이므로 폼에선 콤마 문자열로 변환해 보여준다(STEP 2).
STEP 2 — 입력칸(colHtml)에 신규 섹션 2개 추가
파일: src/Web/wwwroot/js/ff.js
찾기 (colHtml의 두번째 .ff-modal-col 닫는 부분 + 백틱 종료):
<label><span class="ff-desc">Stale(초): 데이터 유효시간 — 마지막 갱신 후 이 시간 초과 시 사용 안 함</span><input class="inp" type="number" id="ff-f-staleSec" value="${def.staleSec}"></label>
</div>`;
바꾸기 (기존 2칸 뒤에 온도/θ·전환류 2칸을 추가):
<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>
</div>`;
STEP 3 — 스트림 행(ffStreamRow)에 환류/복귀SP 2칸 추가
3.1 스트림 테이블 헤더에 2칸 추가
파일: src/Web/wwwroot/js/ff.js
찾기:
<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>신뢰</th><th></th>
바꾸기:
<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>신뢰</th><th></th>
3.2 ffStreamRow의 <tr>에 입력칸 2개 추가
찾기:
<td><input type="checkbox" ${s.refluxFromProduct?'checked':''} data-idx="${i}" data-f="refluxFromProduct"></td>
<td><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></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><select class="inp ff-si" data-idx="${i}" data-f="grade">${gradeOpts}</select></td>
3.3 스트림 추가 버튼 기본값에도 신규 필드
ff-stream-add클릭 시 새 행 객체에 신규 필드 없으면 체크박스/값이 깨질 수 있다.
찾기:
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'
}, i));
바꾸기:
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
}, i));
STEP 4 — 저장(ffSaveForm)에서 신규 필드 읽기
파일: src/Web/wwwroot/js/ff.js
4.1 컬럼 레벨 필드 추가
찾기:
staleSec: +g('ff-f-staleSec').value,
productKey: g('ff-f-productKey').value,
advisoryOnly: true,
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
바꾸기:
staleSec: +g('ff-f-staleSec').value,
productKey: g('ff-f-productKey').value,
advisoryOnly: true,
// 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 === '' ? null : +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,
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
4.2 스트림 레벨 필드 추가
찾기:
rateUpPerMin: +v(null,'rateUpPerMin'), rateDnPerMin: +v(null,'rateDnPerMin'),
refluxFromProduct: v(null,'refluxFromProduct'), grade: v(null,'grade')
};
바꾸기:
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 === '' ? null : +x; })()
};
STEP 5 — ff.css (선택, 트리거 강조)
파일: src/Web/wwwroot/css/ff.css — 맨 끝에 추가:
/* WO-7 설정폼 신규 섹션 */
.ff-modal-subhd{font-weight:600;margin:4px 0 6px;color:var(--t1);border-bottom:1px solid var(--bd);padding-bottom:3px}
.ff-modal-subhd small{font-weight:400;color:var(--t2)}
.ff-recovery-col{background:rgba(90,0,0,.08);border-radius:6px;padding:6px}
.ff-trig{border-color:#ff8a80 !important}
STEP 6 — 검증
# 1) JS 문법
node -c src/Web/wwwroot/js/ff.js && echo "JS OK"
# 2) C# 미변경 확인(이 WO는 프론트 전용) — 빌드는 영향 없음(원하면)
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Error"
기대: JS OK. (C# 변경 없음 → 빌드 영향 없음.)
런타임 확인(브라우저)
Ctrl+F5로 캐시 무효화 후 Tab "유량 권장(FF)" → "설정 ▾" → 기존 컬럼 "편집" 또는 "+ 컬럼".- 모달에 온도/θ 칸과 전환류 평형복귀 칸(붉은 박스)이 보인다.
- 트리거 수정 확인(운전원 질문 대응): "불균형 트리거 비율"=0.15, "트리거 지속(초)"=300 으로 바꿔 저장 → 다시 "편집" 열어 값이 유지되는지 확인(= API 저장·재로드 라운드트립). → 운전원이 트리거를 직접 수정 가능.
- 스트림 표에 "전환류R"(체크) / "복귀SP" 칸이 보이고 저장·재로드 유지.
감독자 Sign-off
| 항목 | 상태 | 서명 |
|---|---|---|
| 새 컬럼 def에 신규 필드 기본값(undefined 표시 없음) | ✅ | windpacer 2026-05-31 |
| 온도/θ 섹션 입력칸 노출 | ✅ | windpacer 2026-05-31 |
| 전환류 트리거(비율·지속) 입력칸 노출 + 저장·재로드 유지 | ✅ | windpacer 2026-05-31 |
| 스트림 전환류R·복귀SP 칸 노출 | ✅ | windpacer 2026-05-31 |
| tempTags 콤마↔배열 변환, pRef/recoverySp 빈값→null | ✅ | windpacer 2026-05-31 |
| node -c 통과 | ✅ | windpacer 2026-05-31 |
주의(약한 LLM 함정)
- C# 손대지 말 것 — 백엔드는 이미 신규 필드 저장/반환. 본 WO는 ff.js(+css)만.
- tempTags는 배열↔콤마문자열 — 표시는
join(','), 저장은split(',')...filter(Boolean). - 빈값→null 매핑 —
pRef/recoverySp는 빈 문자열이면 null(백엔드가 NaN/NULL 시드 처리). 0과 빈값을 혼동 말 것. - 체크박스는
.checked—v(null,'isReflux')는 기존v헬퍼가 checkbox면el.checked(불리언) 반환하므로 그대로 사용. - 스트림 칸 추가는 헤더와 행 둘 다 —
<th>2개와<td>2개 개수 일치(안 맞으면 표 깨짐). - 스트림 add 버튼 기본객체에도 isReflux/recoverySp 추가(STEP 3.3) — 빠뜨리면 새 행 체크박스 깨짐.