Files
ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-WO-7-설정폼확장-완전코드.md
windpacer 7c26aa7361 feat: Phase II auto-write (WriteGuard, audit, auth) + WO-2~7 완료
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
2026-05-31 20:30:06 +09:00

17 KiB

WO-7 (설정 편집 폼 확장 — 신규 필드 운전원 노출) — 완전코드 작업지시서

대상 실행자: 본 LLM보다 능력이 낮은 LLM. 추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙. 선행 완료 전제(검증됨): §0 + WO-1~6 전부 머지 완료. 백엔드(ColumnConfig/StreamConfig 신규 필드, ff_* DDL, ConfigStore Save/Load, Controller MapConfig)는 이미 신규 필드를 저장·반환한다. 본 WO는 프론트 폼(ff.js)만 손댄다. 불변식: 쓰기 0건(설정 저장은 advisory config일 뿐). C# 코드 변경 없음.

배경 / 목적

현재 설정 모달(ffEditColumn)은 §0 이전의 기본 필드(Feed/압력/Scan/필터/스트림 K·θ·τ…)만 폼에 노출한다. WO-2~6에서 추가된 온도/PCT·θ자동튜닝·느린바이어스·전환류 트리거/설정·스트림 환류/복귀SP 필드는 API로는 저장/조회되지만 폼에 입력칸이 없어 운전원이 화면에서 못 바꾼다(저장 시 undefined→백엔드 기본값 유지). 특히 운전원 질문 "균형 심각붕괴 트리거를 수정할 수 있나?" → 현재 폼엔 없음. 본 WO로 노출한다.

검증 사실: GET /api/ff/configtempTags, sensitiveTrayTag, dtdp, pRef, steamOpTag, thetaAutoTune, biasMaWindowSec, recoveryEnabled, recoveryAutoArm, imbalanceTriggerFrac, imbalanceTriggerSec, recoverySettleSec, returnRampSec, feedRecoverySp, deltaPTag, deltaPFloodLimit(컬럼) + isReflux, recoverySp(스트림)을 이미 반환한다(Controller MapConfig). 따라서 기존 컬럼 편집 시{...c}로 값이 이미 들어오고, 새 컬럼만 default 보강이 필요하다.

변경 파일 (총 2개)

  1. src/Web/wwwroot/js/ff.jsdef(새컬럼 기본값) + colHtml(입력칸) + ffStreamRow(스트림 2칸) + ffSaveForm(저장)
  2. 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# 변경 없음 → 빌드 영향 없음.)

런타임 확인(브라우저)

  1. Ctrl+F5로 캐시 무효화 후 Tab "유량 권장(FF)" → "설정 ▾" → 기존 컬럼 "편집" 또는 "+ 컬럼".
  2. 모달에 온도/θ 칸전환류 평형복귀 칸(붉은 박스)이 보인다.
  3. 트리거 수정 확인(운전원 질문 대응): "불균형 트리거 비율"=0.15, "트리거 지속(초)"=300 으로 바꿔 저장 → 다시 "편집" 열어 값이 유지되는지 확인(= API 저장·재로드 라운드트립). → 운전원이 트리거를 직접 수정 가능.
  4. 스트림 표에 "전환류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 함정)

  1. C# 손대지 말 것 — 백엔드는 이미 신규 필드 저장/반환. 본 WO는 ff.js(+css)만.
  2. tempTags는 배열↔콤마문자열 — 표시는 join(','), 저장은 split(',')...filter(Boolean).
  3. 빈값→null 매핑pRef/recoverySp는 빈 문자열이면 null(백엔드가 NaN/NULL 시드 처리). 0과 빈값을 혼동 말 것.
  4. 체크박스는 .checkedv(null,'isReflux')는 기존 v 헬퍼가 checkbox면 el.checked(불리언) 반환하므로 그대로 사용.
  5. 스트림 칸 추가는 헤더와 행 둘 다<th> 2개와 <td> 2개 개수 일치(안 맞으면 표 깨짐).
  6. 스트림 add 버튼 기본객체에도 isReflux/recoverySp 추가(STEP 3.3) — 빠뜨리면 새 행 체크박스 깨짐.