Files
ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-WO-2-완전코드.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

390 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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>