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
16 KiB
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개=테스트)
src/Infrastructure/Control/ComputationBlocks.cs—DiffTemp추가src/Infrastructure/Control/FeedforwardEngine.cs—ColumnState필드 +BuildTemps+ Tick 배선src/Infrastructure/Control/FeedforwardSupervisor.cs—BuildSnapshotAsync에 온도 읽기src/Web/Controllers/FeedforwardController.cs—MapColumn에temps노출(NaN→null)src/Web/wwwroot/js/ff.js— 카드에 온도행src/Web/wwwroot/css/ff.css— 온도행 스타일tests/ExperionCrawler.Tests/FeedforwardTempTests.cs— 신규 테스트
STEP 1 — ComputationBlocks.cs : DiffTemp 추가
파일: src/Infrastructure/Control/ComputationBlocks.cs
찾기 (파일 맨 끝의 TempCorrection 클래스 전체):
public static class TempCorrection
{
public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
=> tMeas - dTdP * (p - pRef);
}
바꾸기 (그 뒤에 DiffTemp 추가 — TempCorrection은 그대로 두고 아래 블록을 이어붙임):
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 시드 상태 추가
찾기:
public double SettleTimerSec { get; set; }
public bool Initialized { get; set; }
public Dictionary<string, StreamState> Streams { get; } = new();
바꾸기:
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):
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState);
}
바꾸기:
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) 앵커로 잡는다(유일).
찾기:
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
바꾸기:
tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
3.2 PvSnapshot에 Temps 채우기
찾기:
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);
바꾸기:
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
찾기:
frontPositionState = r.FrontPositionState,
frontTrimAdvice = r.FrontTrimAdvice,
streams = r.Streams.Select(s => new
바꾸기 (NaN→null 변환은 검증된 코드베이스의 camelCase/NaN 규칙):
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):
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
return `
바꾸기:
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 줄이 있다):
<div class="ff-mb">${esc(mb)}</div>
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
바꾸기 (mb와 note 사이에 ${temps} 삽입):
<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
파일 맨 끝에 추가:
/* 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
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 — 검증 (반드시 실행하고 결과를 보고서에 첨부)
# 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이 흔히 깨먹는 지점)
- §0를 다시 만들지 말 것 —
TempTags/PRef/Temps/TempPoint·DDL 컬럼은 이미 존재. 중복 추가 시 빌드 깨짐. - positional record에 새 필드 추가 금지 —
AdvisoryResult.Temps·PvSnapshot.Temps는 이미 init 프로퍼티. 생성은new (...) { Temps = ... }형태(이미 §0에서 추가됨). - NaN을 그대로 JSON에 넣지 말 것 — Controller에서 raw/pct는
double.IsNaN(..) ? null : ... Sample()재사용 —.pv부착·소문자·신선도 판정이 이미 들어있으니 온도태그도 동일 헬퍼로.- 테스트의
Snap은{ Temps = ... }로 PvSnapshot 생성 — 엔진은pv.Temps를 읽지 태그를 읽지 않는다(태그→PV는 Supervisor 책임).