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
11 KiB
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개)
src/Infrastructure/Control/FeedforwardEngine.cs—ColumnStateMA 필드 +ApplyBias+ Tick 배선src/Web/wwwroot/js/ff.js— VLossMa·KObs 표시 (Controller는 §0에서vLossMa/kObsSuggest이미 노출 — 변경 없음)src/Web/wwwroot/css/ff.css— 바이어스 행 스타일tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs— 신규 테스트
STEP 1 — FeedforwardEngine.cs
파일: src/Infrastructure/Control/FeedforwardEngine.cs
1.1 ColumnState에 MA 상태 추가
전제: WO-3에서
PrevSteamOp/ThetaEst등이 이미 추가됨.
찾기:
public double PrevSteamOp { get; set; } = double.NaN;
public Dictionary<string, StreamState> Streams { get; } = new();
바꾸기:
public double PrevSteamOp { get; set; } = double.NaN;
// WO-4: 느린 바이어스 장기 MA (정상상태에서만 누적)
public MovingAverage? VLossMaBlock { get; set; }
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
public Dictionary<string, StreamState> Streams { get; } = new();
1.2 Tick 배선 — return 직전, θ 제안 다음
전제: WO-3 이후 return 영역은 아래와 같다.
찾기:
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 };
바꾸기:
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줄).
찾기:
outs = outs.Select(a => a.Role == StreamRole.Commanded
? a with { ThetaSuggestUpSec = tu, ThetaSuggestDnSec = td, ThetaSuggestConf = conf }
: a).ToList();
}
바꾸기:
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<StreamAdvisory> 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
찾기:
public double Push(double x)
{
_buf.Enqueue(x); _sum += x;
while (_buf.Count > _window) _sum -= _buf.Dequeue();
return _sum / _buf.Count;
}
바꾸기:
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 추가
찾기:
const mb = `물질수지: ${esc(c.massBalanceState)}` +
(c.vLoss!=null ? ` · V_loss ${fmtVal(c.vLoss)}` : '') +
(c.yield!=null ? ` · 수율 ${fmtVal(c.yield)}%` : '');
바꾸기:
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에 병기)
찾기:
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span></td>
바꾸기:
<td><span class="ff-grade ff-grade-${esc(s.grade)}"${s.gradeReason ? ` title="${esc(s.gradeReason)}"` : ''}>${esc(s.grade)}</span>${s.kObsSuggest!=null ? `<br><small class="ff-kobs">K~${fmtVal(s.kObsSuggest)}</small>` : ''}</td>
STEP 3 — ff.css
파일: src/Web/wwwroot/css/ff.css — 맨 끝에 추가:
/* WO-4 K_obs 제안 */
.ff-kobs{color:#9fd;opacity:.8}
STEP 4 — 신규 테스트 FeedforwardBiasTests.cs
신규 파일: tests/ExperionCrawler.Tests/FeedforwardBiasTests.cs
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<TagSample>(),
new Dictionary<string, TagSample> {
["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 — 검증
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 함정)
- config K(TargetCoeff) 대입 금지 —
KObsSuggest에만 쓴다(제안). - 과도 중 MA 갱신 금지 —
transient시 Push 안 함(직전 값만 표시). - MovingAverage.Value 없으면 STEP 1.4로 보강(빌드 에러 방지).
- positional record 인자추가 금지 —
VLossMa/KObsSuggest는 init 프로퍼티(§0 기존).