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

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개)

  1. src/Infrastructure/Control/FeedforwardEngine.csColumnState 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 등이 이미 추가됨.

찾기:

    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();
    }

MovingAverageValue 프로퍼티가 없으면 추가 필요. 확인: 현재 MovingAveragePush만 있고 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 함정)

  1. config K(TargetCoeff) 대입 금지KObsSuggest에만 쓴다(제안).
  2. 과도 중 MA 갱신 금지transient 시 Push 안 함(직전 값만 표시).
  3. MovingAverage.Value 없으면 STEP 1.4로 보강(빌드 에러 방지).
  4. positional record 인자추가 금지 — VLossMa/KObsSuggest는 init 프로퍼티(§0 기존).