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
494 lines
20 KiB
Markdown
494 lines
20 KiB
Markdown
# 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>
|