# 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; /// /// Passive 전달지연(θ) 식별. ΔF(피드 변화)와 ΔR(응답=PCT 변화)의 교차상관 최대 지연 = θ. /// 스팀 ΔS(=TICA.OP 변화)를 2번째 입력으로 **부분상관**(partial corr)해 폐루프 오염 제거(spec §13.4). /// 입력은 이미 1차차분(Δ=사전백색화)된 값. I/O 없음, 컬럼 루프 단일 소유(락 불필요). /// 비용 분산을 위해 매 호출 누적하되 argmax 계산은 recomputeEvery 호출마다 1회(나머지는 캐시 반환). /// public sealed class CrossCorrLagEstimator { private readonly int _maxLag; // 탐색할 최대 지연(샘플) private readonly int _hist; // 보존 이력(샘플) private readonly double _minStd; // 외란 최소 표준편차(미달 시 억제) private readonly int _recomputeEvery; // argmax 재계산 주기(호출 수) private readonly Queue _f = new(); private readonly Queue _r = new(); private readonly Queue _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; } /// mask를 만족하는 피드 표본에 대해 ρ(τ) 최대인 τ(초)와 conf 반환. 표본 부족 시 (NaN,0). private (double theta, double conf) BestLag(double[] f, double[] resid, int n, Func 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 Streams) { public IReadOnlyList? Temps { get; init; } } ``` **바꾸기**: ```csharp IReadOnlyDictionary Streams) { public IReadOnlyList? 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 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 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? temps, ref List 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 `
``` **바꾸기**: ```javascript const thetaSug = (c.streams||[]).filter(s => s.thetaSuggestConf != null); const theta = thetaSug.length ? `
θ 제안 (passive): ${thetaSug.map(s=>`${esc(s.key)} ↑${fmtVal(s.thetaSuggestUpSec)}s ↓${fmtVal(s.thetaSuggestDnSec)}s conf ${fmtVal(s.thetaSuggestConf)}`).join(' · ')} — 운전원 수동 반영
` : ''; return `
``` ### 5.2 카드 본문에 ${theta} 삽입 > 전제: WO-2에서 mb 아래에 `${temps}`가 이미 들어가 있다. **찾기**: ```javascript
${esc(mb)}
${temps}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
``` **바꾸기**: ```javascript
${esc(mb)}
${temps} ${theta}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
``` --- ## 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 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시간. 조급해하지 말 것.