# 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);
}

/// <summary>차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. spec §13.3.
/// 본 WO-2에선 블록만 추가(단위테스트 대상). 실제 소비는 WO-5(FrontPositionIndicator).</summary>
public static class DiffTemp
{
    /// <summary>두 트레이 차온 (상단 - 하단).</summary>
    public static double Delta(double tHi, double tLo) => tHi - tLo;

    /// <summary>이중차온(곡률) — 프론트 위치 민감.</summary>
    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<string, StreamState> 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<string, StreamState> 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<TempPoint>? 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<TempPoint>(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)
    ? `<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>`
    : '';
  return `
```

**찾기** (카드 return 내 mb div + 그 아래 note div — 현재 파일에는 mb가 `${esc(mb)}`이고 바로 아래 ff-note 줄이 있다):
```javascript
    <div class="ff-mb">${esc(mb)}</div>
    <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
```

**바꾸기** (mb와 note 사이에 `${temps}` 삽입):
```javascript
    <div class="ff-mb">${esc(mb)}</div>
    ${temps}
    <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
```

---

## 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<TagSample>(),
        new Dictionary<string, TagSample> { ["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 책임).
</content>
