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

494 lines
20 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-3 (P-1 θ 자동튜닝, passive 교차상관) — 완전코드 작업지시서
> **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 아래 "찾기/바꾸기" 앵커와 전체 코드 블록을 그대로 복붙**한다.
> **선행 완료 전제(필수)**: §0 + WO-1 + **WO-2 머지 완료**. 즉 `ColumnConfig.SteamOpTag/ThetaAutoTune/SensitiveTrayTag`,
> `StreamAdvisory.ThetaSuggestUpSec/DnSec/Conf`(§0), `BuildTemps`/`ColumnState.PRefSeeded/PRefValue`(WO-2),
> `AdvisoryResult.Temps`·`PvSnapshot.Temps`는 **이미 존재**한다(다시 만들지 말 것). WO-2가 안 됐으면 WO-2 먼저.
> **불변식**: advisory — 제어 레지스터 쓰기 0건. **config의 θ는 절대 변경하지 않는다.** 화면에 "제안"만 표시(운전원이 수동 반영).
## 목적
정상 운전 중 **자연 외란**으로 피드→온도(PCT) 전달지연 θ를 **passive 교차상관**으로 식별해 commanded 스트림에 **제안**한다.
spec §13.4: `θ = argmax_τ ρ(ΔF(t), ΔPCT(t+τ))`, **스팀 OP(TICA.OP)를 부분상관으로 제거**해 폐루프 오염 회피(함정 ④).
외란 부족·신뢰 낮으면 **제안 억제(null)**. seed θ가 전부 placeholder인 문제(PhaseI §5.8)를 데이터로 보정.
> **현실 경고(spec §13.2·§13.7)**: 단일점 생온도 SNR 낮음 → θ는 **신뢰도 등급 붙은 추정치**. 데모 온도는 인위생성이라
> 실플랜트 전 가동 스위치 `ThetaAutoTune`는 **기본 false**. 본 WO는 블록·배선·테스트까지 턴키로 두되 옵트인.
## 변경 파일 (총 6개)
1. `src/Infrastructure/Control/CrossCorrLagEstimator.cs`**신규** 블록
2. `src/Core/Application/Feedforward/FeedforwardModels.cs``PvSnapshot.SteamOp` init 프로퍼티
3. `src/Infrastructure/Control/FeedforwardSupervisor.cs` — 스팀 OP 읽기(.op는 .pv 아님)
4. `src/Infrastructure/Control/FeedforwardEngine.cs``ColumnState` 필드 + `ApplyThetaSuggestion` + Tick 배선
5. `src/Web/wwwroot/js/ff.js` — θ 제안 표시 (Controller는 §0에서 이미 `thetaSuggest*` 노출 — **변경 없음**)
6. `src/Web/wwwroot/css/ff.css` — θ 행 스타일
7. `tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs`**신규** 테스트
---
## STEP 1 — 신규 파일 `CrossCorrLagEstimator.cs`
**신규 파일**: `src/Infrastructure/Control/CrossCorrLagEstimator.cs`
```csharp
namespace ExperionCrawler.Infrastructure.Control;
/// <summary>
/// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ.
/// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4).
/// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요).
/// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환).
/// </summary>
public sealed class CrossCorrLagEstimator
{
private readonly int _maxLag; // 탐색할 최대 지연(샘플)
private readonly int _hist; // 보존 이력(샘플)
private readonly double _minStd; // 외란 최소 표준편차(미달 시 억제)
private readonly int _recomputeEvery; // argmax 재계산 주기(호출 수)
private readonly Queue<double> _f = new();
private readonly Queue<double> _r = new();
private readonly Queue<double> _s = new();
private int _sinceCompute;
private (double thetaUpSec, double thetaDnSec, double conf)? _last;
public CrossCorrLagEstimator(int maxLagSamples, int historySamples, double minSignalStd, int recomputeEvery = 30)
{
_maxLag = Math.Max(1, maxLagSamples);
_hist = Math.Max(_maxLag * 2, historySamples);
_minStd = minSignalStd;
_recomputeEvery = Math.Max(1, recomputeEvery);
}
public (double thetaUpSec, double thetaDnSec, double conf)? Push(
double dFeed, double dResponse, double dSteam, double tsSec)
{
_f.Enqueue(dFeed); _r.Enqueue(dResponse); _s.Enqueue(dSteam);
while (_f.Count > _hist) { _f.Dequeue(); _r.Dequeue(); _s.Dequeue(); }
if (_f.Count < _maxLag * 2) return _last; // 외란 누적 부족 → 직전 결과(초기 null)
_sinceCompute++;
if (_last is not null && _sinceCompute < _recomputeEvery) return _last; // 캐시
_sinceCompute = 0;
var f = _f.ToArray(); var r = _r.ToArray(); var s = _s.ToArray();
int n = f.Length;
if (Std(f) < _minStd) { _last = null; return null; } // 피드 외란 없음 → 억제
// 부분상관: r에서 s의 동시점 선형성분 제거 (잔차)
double beta = Cov(r, s) / Math.Max(1e-12, Var(s));
var resid = new double[n];
for (int i = 0; i < n; i++) resid[i] = r[i] - beta * s[i];
// 방향별 θ (상승/하강 비대칭). 표본 부족 시 NaN.
var (tu, cu) = BestLag(f, resid, n, x => x > 0, tsSec);
var (td, cd) = BestLag(f, resid, n, x => x < 0, tsSec);
bool haveUp = !double.IsNaN(tu), haveDn = !double.IsNaN(td);
if (!haveUp && !haveDn) { _last = null; return null; }
if (!haveUp) { tu = td; cu = cd; }
if (!haveDn) { td = tu; cd = cu; }
double conf = Math.Min(cu, cd);
if (conf < 0.3) { _last = null; return null; } // 신뢰 부족 → 억제
_last = (tu, td, conf);
return _last;
}
/// <summary>mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0).</summary>
private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func<double, bool> mask, double tsSec)
{
int masked = 0;
for (int i = 0; i < n; i++) if (mask(f[i])) masked++;
if (masked < _maxLag) return (double.NaN, 0.0);
double bestRho = double.NegativeInfinity; int bestTau = 0;
for (int tau = 0; tau <= _maxLag; tau++)
{
double sfr = 0, sff = 0, srr = 0; int m = 0;
for (int i = 0; i + tau < n; i++)
{
if (!mask(f[i])) continue;
double a = f[i], b = resid[i + tau];
sfr += a * b; sff += a * a; srr += b * b; m++;
}
if (m < 3 || sff <= 0 || srr <= 0) continue;
double rho = sfr / Math.Sqrt(sff * srr); // Δ신호라 비중심 상관
if (rho > bestRho) { bestRho = rho; bestTau = tau; }
}
if (double.IsNegativeInfinity(bestRho)) return (double.NaN, 0.0);
return (bestTau * tsSec, Math.Max(0.0, bestRho));
}
private static double Mean(double[] a) { double s = 0; foreach (var x in a) s += x; return s / a.Length; }
private static double Var(double[] a) { double m = Mean(a), s = 0; foreach (var x in a) s += (x - m) * (x - m); return s / a.Length; }
private static double Std(double[] a) => Math.Sqrt(Var(a));
private static double Cov(double[] a, double[] b)
{ double ma = Mean(a), mb = Mean(b), s = 0; for (int i = 0; i < a.Length; i++) s += (a[i] - ma) * (b[i] - mb); return s / a.Length; }
}
```
---
## STEP 2 — `FeedforwardModels.cs` : `PvSnapshot.SteamOp` 추가
**파일**: `src/Core/Application/Feedforward/FeedforwardModels.cs`
**찾기** (WO-2가 추가한 `PvSnapshot`의 Temps 프로퍼티):
```csharp
IReadOnlyDictionary<string, TagSample> Streams)
{
public IReadOnlyList<TagSample>? Temps { get; init; }
}
```
**바꾸기**:
```csharp
IReadOnlyDictionary<string, TagSample> Streams)
{
public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
}
```
---
## STEP 3 — `FeedforwardSupervisor.cs` : 스팀 OP 읽기
> ⚠️ **`SteamOpTag`은 `.OP`(컨트롤러 출력)이지 `.pv`가 아니다.** `Sample()`/`PvTag()`는 `.pv`를 강제 부착하므로
> 스팀엔 쓰면 안 된다. 아래처럼 **태그를 그대로(소문자) 읽는 SampleExact**를 추가한다.
### 3.1 읽을 태그 목록에 SteamOpTag 추가
**찾기** (WO-2가 추가한 TempTags 줄):
```csharp
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
```
**바꾸기**:
```csharp
tags.AddRange(cfg.TempTags.Select(PvTag)); // WO-2 온도 프로파일
if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로)
```
### 3.2 SampleExact 헬퍼 추가 (Sample 바로 뒤)
**찾기** (기존 `Sample` 로컬함수의 닫는 부분):
```csharp
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
var feed = Sample(cfg.FeedTag);
```
**바꾸기**:
```csharp
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
// WO-3: .op 등 비-.pv 태그를 접미사 강제 없이 그대로 읽음
TagSample SampleExact(string rawTag)
{
var tag = rawTag.ToLowerInvariant();
if (rows.TryGetValue(tag, out var r)
&& double.TryParse(r.LiveValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
{
bool fresh = (DateTime.UtcNow - r.Timestamp.ToUniversalTime()).TotalSeconds <= cfg.StaleSec;
return new TagSample(tag, v, Good: fresh, r.Timestamp);
}
return new TagSample(tag, double.NaN, Good: false, DateTime.MinValue);
}
var feed = Sample(cfg.FeedTag);
```
### 3.3 PvSnapshot에 SteamOp 채우기
> 전제: WO-2에서 이 return은 이미 `{ Temps = temps }` 형태다.
**찾기**:
```csharp
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps };
```
**바꾸기**:
```csharp
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null;
return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam };
```
---
## STEP 4 — `FeedforwardEngine.cs` : 상태필드 + θ 제안 배선
**파일**: `src/Infrastructure/Control/FeedforwardEngine.cs`
### 4.1 `ColumnState`에 θ 추정 상태 추가
> 전제: WO-2에서 `PRefSeeded`/`PRefValue`가 이미 추가됨.
**찾기**:
```csharp
public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
```
**바꾸기**:
```csharp
public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN;
// WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존)
public CrossCorrLagEstimator? ThetaEst { get; set; }
public double PrevFeedFiltered { get; set; } = double.NaN;
public double PrevRespPct { get; set; } = double.NaN;
public double PrevSteamOp { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
```
### 4.2 Tick 배선 — return 직전에 θ 제안 적용
> 전제: WO-2에서 return이 `var temps = BuildTemps(...)` + `{ Temps = temps }` 형태다.
**찾기**:
```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 };
```
**바꾸기**:
```csharp
var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터
ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory)
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps };
```
### 4.3 `ApplyThetaSuggestion` 메서드 추가 (BuildTemps 바로 뒤)
> 전제: WO-2가 추가한 `BuildTemps` 메서드는 `return list;` + `}` 로 끝난다.
**찾기** (BuildTemps의 마지막):
```csharp
list.Add(new TempPoint(t.Tag, raw, pct, good));
}
return list;
}
```
**바꾸기**:
```csharp
list.Add(new TempPoint(t.Tag, raw, pct, good));
}
return list;
}
// ── WO-3 P-1: passive θ 식별 → commanded 스트림에 "제안"만(config θ 무변경) ──────
private static void ApplyThetaSuggestion(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts,
IReadOnlyList<TempPoint>? temps, ref List<StreamAdvisory> outs)
{
if (!cfg.ThetaAutoTune) return; // 옵트인(기본 off)
if (temps is null || temps.Count == 0) return;
// 응답 신호 = 민감트레이 PCT(없으면 첫 온도 PCT)
double respPct = double.NaN;
if (cfg.SensitiveTrayTag is not null)
{
var key = cfg.SensitiveTrayTag.EndsWith(".pv") ? cfg.SensitiveTrayTag : cfg.SensitiveTrayTag + ".pv";
foreach (var tp in temps) if (tp.Tag == key && tp.Good) { respPct = tp.Pct; break; }
}
if (double.IsNaN(respPct) && temps[0].Good) respPct = temps[0].Pct;
if (double.IsNaN(respPct)) return;
double feedNow = st.FeedFilter.Value;
double steamNow = pv.SteamOp is { Good: true } so && Num.IsFinite(so.Value) ? so.Value : 0.0;
// 1차차분(Δ=사전백색화). 최초 호출은 prev가 NaN이라 Δ=0(시드)
double dF = Num.IsFinite(st.PrevFeedFiltered) ? feedNow - st.PrevFeedFiltered : 0.0;
double dR = Num.IsFinite(st.PrevRespPct) ? respPct - st.PrevRespPct : 0.0;
double dS = Num.IsFinite(st.PrevSteamOp) ? steamNow - st.PrevSteamOp : 0.0;
st.PrevFeedFiltered = feedNow; st.PrevRespPct = respPct; st.PrevSteamOp = steamNow;
st.ThetaEst ??= new CrossCorrLagEstimator(
maxLagSamples: Math.Max(1, (int)Math.Round(1200.0 / Math.Max(1e-6, ts))), // ~20분 지연 탐색
historySamples: Math.Max(1, (int)Math.Round(3600.0 / Math.Max(1e-6, ts))), // ~1시간 이력
minSignalStd: 1e-9);
var est = st.ThetaEst.Push(dF, dR, dS, ts);
if (est is null) return;
var (tu, td, conf) = est.Value;
outs = outs.Select(a => a.Role == StreamRole.Commanded
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
: a).ToList();
}
```
> **Controller 변경 없음**: §0에서 `MapColumn`이 이미 `thetaSuggestUpSec/DnSec/Conf`를 노출한다.
---
## STEP 5 — `ff.js` : θ 제안 표시
**파일**: `src/Web/wwwroot/js/ff.js`
### 5.1 θ 제안 const 추가 (return 직전)
**찾기**:
```javascript
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
```
**바꾸기**:
```javascript
const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null);
const theta = thetaSug.length
? `<div class="ff-theta">θ 제안 <small>(passive)</small>: ${thetaSug.map(s=>`${esc(s.key)}${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s <small>conf ${fmtVal(s.thetaSuggestConf)}</small>`).join(' · ')} — 운전원 수동 반영</div>`
: '';
return `
<div class="ff-col-card ${c.enabled?'':'ff-disabled'}">
```
### 5.2 카드 본문에 ${theta} 삽입
> 전제: WO-2에서 mb 아래에 `${temps}`가 이미 들어가 있다.
**찾기**:
```javascript
<div class="ff-mb">${esc(mb)}</div>
${temps}
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
**바꾸기**:
```javascript
<div class="ff-mb">${esc(mb)}</div>
${temps}
${theta}
<div class="ff-note">LevelDriven(D·B) 레벨 제어(LIC) SP를 결정. 권장값은 참고 인가는 운전원.</div>
```
---
## STEP 6 — `ff.css` : θ 행 스타일
**파일**: `src/Web/wwwroot/css/ff.css`
**파일 맨 끝에 추가**:
```css
/* WO-3 θ 자동튜닝 제안 행 */
.ff-theta{font-size:12px;color:#cdb4ff;margin-top:6px}
.ff-theta small{color:var(--t2)}
```
---
## STEP 7 — 신규 테스트 `FeedforwardThetaTests.cs`
**신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardThetaTests.cs`
```csharp
using System;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardThetaTests
{
// 알려진 지연(5 샘플)으로 응답이 피드를 따라가면 θ≈5*ts 로 식별되어야 함
[Fact]
public void Estimator_finds_known_lag()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-9, recomputeEvery: 1);
var feed = new System.Collections.Generic.List<double>();
(double thetaUpSec, double thetaDnSec, double conf)? last = null;
for (int t = 0; t < 400; t++)
{
double df = Math.Sin(t * 0.3); // 풍부한 양/음 외란
feed.Add(df);
double dr = t >= 5 ? feed[t - 5] : 0.0; // 응답 = 피드 5샘플 지연
last = est.Push(df, dr, 0.0, tsSec: 1.0); // 스팀 0
}
Assert.NotNull(last);
Assert.InRange(last!.Value.thetaUpSec, 4.0, 6.0);
Assert.InRange(last!.Value.thetaDnSec, 4.0, 6.0);
Assert.True(last!.Value.conf > 0.5);
}
// 피드 외란이 없으면(평탄) 제안 억제(null)
[Fact]
public void Estimator_suppresses_when_no_excitation()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-6, recomputeEvery: 1);
(double, double, double)? last = (0, 0, 0);
for (int t = 0; t < 200; t++) last = est.Push(0.0, 0.0, 0.0, 1.0); // Δ 전부 0
Assert.Null(last);
}
}
```
---
## STEP 8 — 검증 (반드시 실행하고 결과 첨부)
```bash
# 1) 빌드
dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error"
# 2) 테스트 — WO-2까지 12 + WO-3 신규 2 = 14
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"
# 5) config θ 무변경 불변식 — 엔진이 cfg.Theta*를 쓰기(대입)하지 않는지
grep -nE "cfg\.(ThetaUpSec|ThetaDnSec)\s*=" src/Infrastructure/Control/*.cs || echo "config theta 무변경 OK"
```
**기대 결과**:
| 항목 | 기대 |
|:--|:--|
| 빌드 | `0 Warning(s) 0 Error(s)` |
| 테스트 | `Passed! - Failed: 0, Passed: 14` |
| JS | `JS OK` |
| 쓰기 | `WRITE 0건 OK` |
| config θ | `config theta 무변경 OK` |
### 런타임 확인(선택)
- `ff_column_config``theta_auto_tune=TRUE`, `steam_op_tag='tica-6111a.op'`, `sensitive_tray_tag='ti-6111c'`, `temp_tags='ti-6111b,ti-6111c,ti-6111d'`, `dtdp=0.5` 설정.
- 외란 충분히 누적(~1시간)된 뒤 카드에 "θ 제안 P ↑NNs ↓NNs conf 0.x" 표시. **config θ는 그대로**(제안만).
---
## 감독자 Sign-off
| 항목 | 상태 | 서명 |
|:--|:--:|:--:|
| CrossCorrLagEstimator: 알려진 지연 식별 | ✅ | windpacer 2026-05-31 |
| 외란 부족/저신뢰 시 null 억제 | ✅ | windpacer 2026-05-31 |
| 부분상관으로 스팀 제거(폐루프 오염 회피) | ✅ | windpacer 2026-05-31 |
| SteamOpTag을 .pv 강제 없이 SampleExact로 읽음 | ✅ | windpacer 2026-05-31 |
| **config θ 무변경**(제안 전용) | ✅ | windpacer 2026-05-31 |
| ThetaAutoTune=false면 완전 무동작(옵트인) | ✅ | windpacer 2026-05-31 |
| 빌드 0/0 · 테스트 14/14 · 쓰기 0건 | ✅ | windpacer 2026-05-31 |
---
## 주의(약한 LLM이 흔히 깨먹는 지점)
1. **config θ에 대입 금지**`cfg.ThetaUpSec = ...` 같은 코드 절대 금지. `StreamAdvisory.ThetaSuggest*`(제안)에만 쓴다.
2. **SteamOpTag은 .op**`Sample()`(=.pv 강제) 쓰지 말고 `SampleExact()`로. 실측 태그 접미사 확인.
3. **WO-2 선행 필수**`BuildTemps`/`PvSnapshot.Temps`/`ColumnState.PRef*`가 없으면 앵커가 안 맞는다. WO-2 먼저.
4. **positional record 금지**`PvSnapshot.SteamOp`는 init 프로퍼티로(생성자 인자 추가 금지). 생성은 `new PvSnapshot(...) { Temps=.., SteamOp=.. }`.
5. **테스트는 estimator를 직접** 호출(엔진 경유 X) — Δ를 직접 Push. recomputeEvery=1로 즉시 계산.
6. **첫 제안까지 시간** — maxLag*2 샘플 누적 전엔 null(정상). 실운전 ~1시간. 조급해하지 말 것.
</content>