# WO-4 (P-4 느린 바이어스 적응) — 완전코드 작업지시서 > **대상 실행자**: 본 LLM보다 능력이 낮은 LLM. **추론·탐색 금지. 찾기/바꾸기 앵커와 전체 코드 블록을 그대로 복붙**. > **선행 완료 전제**: §0 + WO-1 + WO-2 + WO-3 머지 완료. `ColumnConfig.BiasMaWindowSec`, `AdvisoryResult.VLossMa`, > `StreamAdvisory.KObsSuggest`, `MovingAverage`(ComputationBlocks)는 **이미 존재**(다시 만들지 말 것). > **불변식**: advisory — 쓰기 0건. K_obs·V_loss는 **장기 MA "제안/추세"** 일 뿐 엔진 K(=config TargetCoeff)는 변경 안 함. ## 목적 계절 CW 스윙 등 **크지만 느린 외란**(spec §14.4)을 정밀모델 대신 **장기 이동평균**으로 흡수. - `V_loss`는 순간값 신뢰불가(§5.3·§14.3 B등급) → **장기 MA(VLossMa)** 로만 의미 → 대시보드 표시 + **WO-6 트리거 입력**. - commanded 스트림별 **K_obs = PV/FEED_filtered 의 MA** → config K와 비교해 계절 드리프트 "제안". - **정상상태에서만 누적**(transient·BAD 제외) → 과도 표본 오염 방지. ## 변경 파일 (총 4개) 1. `src/Infrastructure/Control/FeedforwardEngine.cs` — `ColumnState` MA 필드 + `ApplyBias` + Tick 배선 2. `src/Web/wwwroot/js/ff.js` — VLossMa·KObs 표시 (Controller는 §0에서 `vLossMa`/`kObsSuggest` 이미 노출 — **변경 없음**) 3. `src/Web/wwwroot/css/ff.css` — 바이어스 행 스타일 4. `tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs` — **신규** 테스트 --- ## STEP 1 — `FeedforwardEngine.cs` **파일**: `src/Infrastructure/Control/FeedforwardEngine.cs` ### 1.1 `ColumnState`에 MA 상태 추가 > 전제: WO-3에서 `PrevSteamOp` / `ThetaEst` 등이 이미 추가됨. **찾기**: ```csharp public double PrevSteamOp { get; set; } = double.NaN; public Dictionary Streams { get; } = new(); ``` **바꾸기**: ```csharp public double PrevSteamOp { get; set; } = double.NaN; // WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적) public MovingAverage? VLossMaBlock { get; set; } public Dictionary KObsMa { get; } = new(); public Dictionary Streams { get; } = new(); ``` ### 1.2 Tick 배선 — return 직전, θ 제안 다음 > 전제: WO-3 이후 return 영역은 아래와 같다. **찾기**: ```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 }; ``` **바꾸기**: ```csharp var temps = BuildTemps(cfg, pv, st); // WO-2 PCT 모니터 ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory) ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스 return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, transient, treason, ff, outs, vloss, yield, mbState) { Temps = temps, VLossMa = vLossMa }; ``` ### 1.3 `ApplyBias` 메서드 추가 (ApplyThetaSuggestion 바로 뒤) > 전제: WO-3가 추가한 `ApplyThetaSuggestion`은 `.ToList();` + `}` 로 끝난다(아래 앵커는 그 마지막 2줄). **찾기**: ```csharp outs = outs.Select(a => a.Role == StreamRole.Commanded ? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf } : a).ToList(); } ``` **바꾸기**: ```csharp outs = outs.Select(a => a.Role == StreamRole.Commanded ? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf } : a).ToList(); } // ── WO-4 P-4: 느린 바이어스 장기 MA (정상상태에서만 누적, config 무변경) ────── private static void ApplyBias(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff, double? vloss, bool transient, ref List outs, out double? vLossMa) { int window = Math.Max(1, (int)Math.Round(cfg.BiasMaWindowSec / Math.Max(1e-6, cfg.ScanSec))); vLossMa = null; // V_loss 장기 MA (정상상태 + vloss 산출된 경우에만 누적) if (!transient && vloss.HasValue && Num.IsFinite(vloss.Value)) { st.VLossMaBlock ??= new MovingAverage(window); vLossMa = st.VLossMaBlock.Push(vloss.Value); } else if (st.VLossMaBlock is not null) { vLossMa = st.VLossMaBlock.Value; // 과도 중엔 갱신 없이 직전 MA 유지(표시 연속성) } // commanded 스트림별 K_obs = PV/FF 의 MA → 제안 if (transient || ff <= 1e-6) return; outs = outs.Select(a => { if (a.Role != StreamRole.Commanded) return a; if (!(pv.Streams.TryGetValue(a.Key, out var smp) && smp.Good && Num.IsFinite(smp.Value))) return a; if (!st.KObsMa.TryGetValue(a.Key, out var ma)) { ma = new MovingAverage(window); st.KObsMa[a.Key] = ma; } double kObs = ma.Push(smp.Value / ff); return a with { KObsSuggest = kObs }; }).ToList(); } ``` > **`MovingAverage`에 `Value` 프로퍼티가 없으면** 추가 필요. 확인: 현재 `MovingAverage`는 `Push`만 있고 `Value`가 없을 수 있다 → STEP 1.4 참조. ### 1.4 `MovingAverage.Value` 보강 (필요 시) **파일**: `src/Infrastructure/Control/ComputationBlocks.cs` **찾기**: ```csharp public double Push(double x) { _buf.Enqueue(x); _sum += x; while (_buf.Count > _window) _sum -= _buf.Dequeue(); return _sum / _buf.Count; } ``` **바꾸기**: ```csharp public double Value => _buf.Count > 0 ? _sum / _buf.Count : double.NaN; public double Push(double x) { _buf.Enqueue(x); _sum += x; while (_buf.Count > _window) _sum -= _buf.Dequeue(); return _sum / _buf.Count; } ``` --- ## STEP 2 — `ff.js` : VLossMa·KObs 표시 **파일**: `src/Web/wwwroot/js/ff.js` ### 2.1 mb 문자열에 VLossMa 추가 **찾기**: ```javascript const mb = `물질수지: ${esc(c.massBalanceState)}` + (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') + (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : ''); ``` **바꾸기**: ```javascript const mb = `물질수지: ${esc(c.massBalanceState)}` + (c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') + (c.vLossMa!=null ? ` · V_loss(MA) ${fmtVal(c.vLossMa)}` : '') + (c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : ''); ``` ### 2.2 스트림 행에 KObs 제안 (신뢰 셀 title에 병기) **찾기**: ```javascript ${esc(s.grade)} ``` **바꾸기**: ```javascript ${esc(s.grade)}${s.kObsSuggest!=null ? `
K~${fmtVal(s.kObsSuggest)}` : ''} ``` --- ## STEP 3 — `ff.css` **파일**: `src/Web/wwwroot/css/ff.css` — 맨 끝에 추가: ```css /* WO-4 K_obs 제안 */ .ff-kobs{color:#9fd;opacity:.8} ``` --- ## STEP 4 — 신규 테스트 `FeedforwardBiasTests.cs` **신규 파일**: `tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs` ```csharp using System; using System.Collections.Generic; using ExperionCrawler.Core.Application.Feedforward; using ExperionCrawler.Infrastructure.Control; using Xunit; namespace ExperionCrawler.Tests; public class FeedforwardBiasTests { private static ColumnConfig Cfg() => new() { Id = 1, Name = "C-BIAS", Enabled = true, FeedTag = "f", ProductKey = "P", ScanSec = 2, BiasMaWindowSec = 20, // 10 샘플 창 Streams = new[] { new StreamConfig { Key = "P", FlowTag = "p", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 }, new StreamConfig { Key = "D", FlowTag = "d", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.02 }, new StreamConfig { Key = "B", FlowTag = "b", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.03 }, } }; // FEED 100 고정, P=95 → K_obs ≈ 0.95, D/B는 물질수지 충족용 private static PvSnapshot Snap() => new( new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), new Dictionary { ["P"] = new("p", 95, true, DateTime.UtcNow), ["D"] = new("d", 2, true, DateTime.UtcNow), ["B"] = new("b", 3, true, DateTime.UtcNow), }); [Fact] public void KObs_and_VLossMa_accumulate_in_steady_state() { var engine = new FeedforwardEngine(); var st = new ColumnState(); AdvisoryResult res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow); for (int i = 0; i < 20; i++) res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow); var p = res.Streams.Find(s => s.Key == "P")!; Assert.NotNull(p.KObsSuggest); Assert.InRange(p.KObsSuggest!.Value, 0.94, 0.96); // 95/100 Assert.NotNull(res.VLossMa); Assert.InRange(res.VLossMa!.Value, -0.5, 0.5); // 100-(95+2+3)=0 } } ``` --- ## STEP 5 — 검증 ```bash dotnet build src/Web/ExperionCrawler.csproj 2>&1 | grep -E "Build succeeded|Warning|Error" dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj 2>&1 | grep -E "Passed!|Failed!" node -c src/Web/wwwroot/js/ff.js && echo "JS OK" grep -rnE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs || echo "WRITE 0건 OK" grep -nE "cfg\.TargetCoeff\s*=|s\.TargetCoeff\s*=" src/Infrastructure/Control/*.cs || echo "config K 무변경 OK" ``` **기대**: 빌드 0/0 · 테스트 **15/15**(WO-3까지 14 + 신규 1) · JS OK · 쓰기 0건 · config K 무변경 OK. --- ## 감독자 Sign-off | 항목 | 상태 | 서명 | |:--|:--:|:--:| | 정상상태에서만 MA 누적(과도 표본 배제) | ✅ | windpacer 2026-05-31 | | K_obs = PV/FF MA, config K 무변경 | ✅ | windpacer 2026-05-31 | | VLossMa 산출(WO-6 트리거 입력) | ✅ | windpacer 2026-05-31 | | MovingAverage.Value 보강 | ✅ | windpacer 2026-05-31 | | 빌드 0/0 · 테스트 15/15 · 쓰기 0건 | ✅ | windpacer 2026-05-31 | ## 주의(약한 LLM 함정) 1. **config K(TargetCoeff) 대입 금지** — `KObsSuggest`에만 쓴다(제안). 2. **과도 중 MA 갱신 금지** — `transient` 시 Push 안 함(직전 값만 표시). 3. **MovingAverage.Value** 없으면 STEP 1.4로 보강(빌드 에러 방지). 4. positional record 인자추가 금지 — `VLossMa`/`KObsSuggest`는 init 프로퍼티(§0 기존).