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
390 lines
16 KiB
Markdown
390 lines
16 KiB
Markdown
# 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>
|