# WO-2 (P-2 PCT/차온 모니터) — 완전코드 작업지시서 > **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 아래 "찾기/바꾸기" 앵커와 전체 코드 블록을 그대로 복붙**한다. > **선행 완료 전제(검증됨)**: §0(모델·DDL·ConfigStore·Controller 공통확장)과 WO-1(P-5)은 이미 머지됨. > 즉 `ColumnConfig.TempTags/SensitiveTrayTag/DTdP/PRef`, `PvSnapshot.Temps`, `AdvisoryResult.Temps`, `TempPoint`, > `ff_column_config.temp_tags/dtdp/p_ref/sensitive_tray_tag` 컬럼은 **이미 존재**한다(다시 만들지 말 것). > **불변식**: 본 WO는 advisory(모니터) — 제어 레지스터 쓰기 0건. PCT는 표시·WO-5 입력일 뿐 권장SP에 영향 없음. ## 목적 죽은 코드 `TempCorrection.PressureCompensated`를 **엔진에 배선**하고, 컬럼 온도 프로파일을 **압력보정온도(PCT)** 로 산출해 `AdvisoryResult.Temps`에 담아 대시보드에 표시한다. 진공노이즈(≈0.5°C/torr, spec §14.1) 제거. `DiffTemp` 블록도 추가(WO-5에서 소비). ## 변경 파일 (총 6개 — 전부 기존 파일 수정, 신규 파일 1개=테스트) 1. `src/Infrastructure/Control/ComputationBlocks.cs` — `DiffTemp` 추가 2. `src/Infrastructure/Control/FeedforwardEngine.cs` — `ColumnState` 필드 + `BuildTemps` + Tick 배선 3. `src/Infrastructure/Control/FeedforwardSupervisor.cs` — `BuildSnapshotAsync`에 온도 읽기 4. `src/Web/Controllers/FeedforwardController.cs` — `MapColumn`에 `temps` 노출(NaN→null) 5. `src/Web/wwwroot/js/ff.js` — 카드에 온도행 6. `src/Web/wwwroot/css/ff.css` — 온도행 스타일 7. `tests/ExperionCrawler.Tests/FeedforwardTempTests.cs` — **신규** 테스트 --- ## STEP 1 — `ComputationBlocks.cs` : `DiffTemp` 추가 **파일**: `src/Infrastructure/Control/ComputationBlocks.cs` **찾기** (파일 맨 끝의 `TempCorrection` 클래스 전체): ```csharp public static class TempCorrection { public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP) => tMeas - dTdP * (p - pRef); } ``` **바꾸기** (그 뒤에 `DiffTemp` 추가 — `TempCorrection`은 그대로 두고 아래 블록을 이어붙임): ```csharp public static class TempCorrection { public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP) => tMeas - dTdP * (p - pRef); } /// 차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. spec §13.3. /// 본 WO-2에선 블록만 추가(단위테스트 대상). 실제 소비는 WO-5(FrontPositionIndicator). public static class DiffTemp { /// 두 트레이 차온 (상단 - 하단). public static double Delta(double tHi, double tLo) => tHi - tLo; /// 이중차온(곡률) — 프론트 위치 민감. public static double Double(double tA, double tB, double tC) => (tA - tB) - (tB - tC); } ``` --- ## STEP 2 — `FeedforwardEngine.cs` : 상태필드 + PCT 산출 배선 **파일**: `src/Infrastructure/Control/FeedforwardEngine.cs` ### 2.1 `ColumnState`에 PRef 시드 상태 추가 **찾기**: ```csharp public double SettleTimerSec { get; set; } public bool Initialized { get; set; } public Dictionary Streams { get; } = new(); ``` **바꾸기**: ```csharp public double SettleTimerSec { get; set; } public bool Initialized { get; set; } // WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드) public bool PRefSeeded { get; set; } public double PRefValue { get; set; } = double.NaN; public Dictionary Streams { get; } = new(); ``` ### 2.2 Tick 말미에서 PCT 산출 → AdvisoryResult.Temps **찾기** (Tick 메서드의 마지막 return): ```csharp return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, transient, treason, ff, outs, vloss, yield, mbState); } ``` **바꾸기**: ```csharp var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터 return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, transient, treason, ff, outs, vloss, yield, mbState) { Temps = temps }; } // ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ─────────── private static IReadOnlyList? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st) { if (pv.Temps is null || pv.Temps.Count == 0) return null; bool havePress = pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value); double pNow = havePress ? pv.Pressure!.Value : double.NaN; // 기준 압력: cfg.PRef 우선, NaN이면 최초 정상압력으로 시드(컬럼상태에 보존) double pRef = cfg.PRef; if (double.IsNaN(pRef)) { if (!st.PRefSeeded && havePress) { st.PRefValue = pNow; st.PRefSeeded = true; } pRef = st.PRefSeeded ? st.PRefValue : double.NaN; } var list = new List(pv.Temps.Count); foreach (var t in pv.Temps) { bool good = t.Good && Num.IsFinite(t.Value); double raw = good ? t.Value : double.NaN; double pct = raw; // dTdP==0(생온도) 또는 압력/기준 불가 시 PCT=raw(보정 안 함) if (good && cfg.DTdP != 0.0 && havePress && Num.IsFinite(pRef)) pct = TempCorrection.PressureCompensated(raw, pNow, pRef, cfg.DTdP); list.Add(new TempPoint(t.Tag, raw, pct, good)); } return list; } ``` > Hold(FEED BAD) 경로는 Temps=null 유지(컬럼 정지 상황이라 모니터 생략). 의도적 단순화. --- ## STEP 3 — `FeedforwardSupervisor.cs` : 온도 PV 읽기 **파일**: `src/Infrastructure/Control/FeedforwardSupervisor.cs` ### 3.1 읽을 태그 목록에 TempTags 추가 > ⚠️ 현재 파일엔 `LevelTags` 줄과 `FlowTag` 줄 사이에 **스트림 LevelTag 줄이 끼어 있다**. 그래서 아래는 **단일 줄(FlowTag) 앵커**로 잡는다(유일). **찾기**: ```csharp tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); ``` **바꾸기**: ```csharp tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag))); tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일 ``` ### 3.2 PvSnapshot에 Temps 채우기 **찾기**: ```csharp var levels = cfg.LevelTags.Select(Sample).ToList(); var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag)); return new PvSnapshot(feed, press, levels, streams); ``` **바꾸기**: ```csharp var levels = cfg.LevelTags.Select(Sample).ToList(); var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag)); var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null; return new PvSnapshot(feed, press, levels, streams) { Temps = temps }; ``` > `Sample(baseTag)`은 `.pv` 부착·소문자·신선도(StaleSec) 판정을 이미 수행한다(기존 헬퍼 재사용). `TempPoint.Tag`에는 `.pv` 부착된 소문자 태그가 들어간다. --- ## STEP 4 — `FeedforwardController.cs` : `MapColumn`에 temps 노출 **파일**: `src/Web/Controllers/FeedforwardController.cs` **찾기**: ```csharp frontPositionState = r.FrontPositionState, frontTrimAdvice = r.FrontTrimAdvice, streams = r.Streams.Select(s => new ``` **바꾸기** (NaN→null 변환은 검증된 코드베이스의 camelCase/NaN 규칙): ```csharp frontPositionState = r.FrontPositionState, frontTrimAdvice = r.FrontTrimAdvice, temps = r.Temps?.Select(t => new { tag = t.Tag, raw = double.IsNaN(t.Raw) ? (double?)null : t.Raw, pct = double.IsNaN(t.Pct) ? (double?)null : t.Pct, good = t.Good }), streams = r.Streams.Select(s => new ``` > **이유**: System.Text.Json 기본 설정은 NaN 직렬화 시 예외. 기존 `pv = double.IsNaN(...) ? null : ...` 패턴과 동일하게 raw/pct를 가드한다. --- ## STEP 5 — `ff.js` : 카드에 온도행 **파일**: `src/Web/wwwroot/js/ff.js` **찾기** (`ffCard` 함수의 mb 구성 ~ return): ```javascript const mb = `물질수지: ${esc(c.massBalanceState)}` + (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') + (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : ''); return ` ``` **바꾸기**: ```javascript const mb = `물질수지: ${esc(c.massBalanceState)}` + (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') + (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : ''); const temps = (c.temps && c.temps.length) ? `
${c.temps.map(t => `${esc(t.tag)} ${t.raw==null?'–':fmtVal(t.raw)}${(t.pct!=null && t.pct!==t.raw)?` PCT ${fmtVal(t.pct)}`:''}`).join(' · ')}
` : ''; return ` ``` **찾기** (카드 return 내 mb div + 그 아래 note div — 현재 파일에는 mb가 `${esc(mb)}`이고 바로 아래 ff-note 줄이 있다): ```javascript
${esc(mb)}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
``` **바꾸기** (mb와 note 사이에 `${temps}` 삽입): ```javascript
${esc(mb)}
${temps}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
``` --- ## STEP 6 — `ff.css` : 온도행 스타일 **파일**: `src/Web/wwwroot/css/ff.css` **파일 맨 끝에 추가**: ```css /* WO-2 온도 프로파일(PCT) 모니터 행 */ .ff-temps{font-size:12px;color:var(--t2);margin-top:6px;display:flex;flex-wrap:wrap;gap:4px 10px} .ff-temp{white-space:nowrap} .ff-temp small{color:#7fd1ff} .ff-temp.ff-stale{opacity:.45} ``` --- ## STEP 7 — 신규 테스트 `FeedforwardTempTests.cs` **신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardTempTests.cs` ```csharp using System; using System.Collections.Generic; using ExperionCrawler.Core.Application.Feedforward; using ExperionCrawler.Infrastructure.Control; using Xunit; namespace ExperionCrawler.Tests; public class FeedforwardTempTests { // ── 순수 블록 ──────────────────────────────────────────────── [Fact] public void TempCorrection_compensates_pressure() { // P가 기준보다 높으면(진공 약화) PCT는 raw보다 낮아짐(dTdP>0) Assert.Equal(99.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.5), 6); // dTdP=0 → 보정 없음 Assert.Equal(100.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.0), 6); } [Fact] public void DiffTemp_delta_and_double() { Assert.Equal(2.0, DiffTemp.Delta(81, 79), 6); Assert.Equal(0.0, DiffTemp.Double(82, 81, 80), 6); // 등간격 → 곡률 0 Assert.Equal(1.0, DiffTemp.Double(83, 81, 80), 6); // (83-81)-(81-80)=1 } // ── 엔진 배선 ──────────────────────────────────────────────── private static ColumnConfig Cfg(double dtdp, double pRef) => new() { Id = 1, Name = "C-TEMP", Enabled = true, FeedTag = "f", ProductKey = "P", ScanSec = 2, DTdP = dtdp, PRef = pRef, PressureTag = "p", TempTags = new[] { "t1" }, Streams = new[] { new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 } } }; private static PvSnapshot Snap(double pressure, double temp) => new( new TagSample("f", 100, true, DateTime.UtcNow), new TagSample("p", pressure, true, DateTime.UtcNow), Array.Empty(), new Dictionary { ["P"] = new TagSample("ficq-6118", 95, true, DateTime.UtcNow)}) { Temps = new[] { new TagSample("t1", temp, true, DateTime.UtcNow) } }; [Fact] public void Engine_populates_pct_with_explicit_pref() { var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.5, pRef: 50), Snap(pressure: 52, temp: 100), new ColumnState(), DateTime.UtcNow); Assert.NotNull(res.Temps); var tp = res.Temps![0]; Assert.Equal("t1", tp.Tag); Assert.Equal(100.0, tp.Raw, 6); Assert.Equal(99.0, tp.Pct, 6); // 100 - 0.5*(52-50) } [Fact] public void Engine_seeds_pref_on_first_tick_when_nan() { var engine = new FeedforwardEngine(); var st = new ColumnState(); // tick1: pRef 미지정(NaN) → 첫 압력 50으로 시드 → PCT=raw(차이 0) var r1 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 50, temp: 100), st, DateTime.UtcNow); Assert.Equal(100.0, r1.Temps![0].Pct, 6); // tick2: 압력 54로 변동 → PCT = 100 - 0.5*(54-50) = 98 var r2 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 54, temp: 100), st, DateTime.UtcNow); Assert.Equal(98.0, r2.Temps![0].Pct, 6); } [Fact] public void Engine_no_pct_when_dtdp_zero() { var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.0, pRef: 50), Snap(pressure: 80, temp: 100), new ColumnState(), DateTime.UtcNow); Assert.Equal(100.0, res.Temps![0].Pct, 6); // 생온도 = raw } } ``` --- ## STEP 8 — 검증 (반드시 실행하고 결과를 보고서에 첨부) ```bash # 1) C# 빌드 — 경고0/에러0 이어야 함 dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error" # 2) 테스트 — 기존 7 + 신규 5 = 12 통과 이어야 함 dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!" # 3) JS 문법 node -c src/Web/wwwroot/js/ff.js && echo "JS OK" # 4) 쓰기 불변식(FF 경로 0건) grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK" ``` **기대 결과**: | 항목 | 기대 | |:--|:--| | 빌드 | `Build succeeded. 0 Warning(s) 0 Error(s)` | | 테스트 | `Passed! - Failed: 0, Passed: 12` | | JS | `JS OK` | | 쓰기 | `WRITE 0건 OK` | ### 런타임 확인(선택) - `ff_column_config`에 `temp_tags='ti-6111b,ti-6111c,ti-6111d'`, `dtdp=0.5`, `p_ref=NULL`(시드) 또는 실측값 설정. - Tab 18 진입 → 카드 하단에 `ti-6111b ... PCT ...` 행 표시. 진공(pica-6111) 흔들 때 raw는 출렁이나 PCT는 평탄(공통모드 상쇄). --- ## 감독자 Sign-off (검수 후 서명) | 항목 | 상태 | 서명 | |:--|:--:|:--:| | DiffTemp 블록 + 단위테스트 | ✅ | windpacer 2026-05-31 | | 엔진 BuildTemps 배선 (cfg.PRef 우선 / NaN 시드) | ✅ | windpacer 2026-05-31 | | dTdP=0 → PCT=raw (생온도 패스스루) | ✅ | windpacer 2026-05-31 | | Supervisor TempTags 읽기 + PvSnapshot.Temps | ✅ | windpacer 2026-05-31 | | Controller temps 노출 (NaN→null) | ✅ | windpacer 2026-05-31 | | ff.js 온도행 + node -c 통과 | ✅ | windpacer 2026-05-31 | | 빌드 0/0 · 테스트 12/12 · 쓰기 0건 | ✅ | windpacer 2026-05-31 | --- ## 주의(약한 LLM이 흔히 깨먹는 지점) 1. **§0를 다시 만들지 말 것** — `TempTags/PRef/Temps/TempPoint`·DDL 컬럼은 이미 존재. 중복 추가 시 빌드 깨짐. 2. **positional record에 새 필드 추가 금지** — `AdvisoryResult.Temps`·`PvSnapshot.Temps`는 이미 init 프로퍼티. 생성은 `new (...) { Temps = ... }` 형태(이미 §0에서 추가됨). 3. **NaN을 그대로 JSON에 넣지 말 것** — Controller에서 raw/pct는 `double.IsNaN(..) ? null : ..`. 4. **`Sample()` 재사용** — `.pv` 부착·소문자·신선도 판정이 이미 들어있으니 온도태그도 동일 헬퍼로. 5. **테스트의 `Snap`은 `{ Temps = ... }`로 PvSnapshot 생성** — 엔진은 `pv.Temps`를 읽지 태그를 읽지 않는다(태그→PV는 Supervisor 책임).