Files
ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-PhaseI.md

50 KiB
Raw Permalink Blame History

측류추출 통합유량 — §12 엔진 구현 코딩 (Phase I)

본 문서의 성격: 측류추출식-통합유량설정공식.md §12(advisory 엔진)의 구현 코딩 명세 + 검증 절차. 감독자(auditor)가 diagnosis-checklist.md 8단계로 진단한 뒤 실제 프로젝트에 반영한다. 진단 통과 전에는 DI 등록·빌드 편입 금지 (코드는 신규 파일로만 존재, 기존 동작 무영향).

전제(불변식): 본 Phase는 advisory(보조지표) 전용 — 제어 레지스터(SP/OP)에 쓰기 호출 0건. AUTO/MANUAL 무관하게 권장 SP를 계산해 저장·표시만 한다. RSP 쓰기는 Phase III.


0. Phase 분할 (무엇을 지금 코딩하나)

범위 Phase I (본 문서, 코드) Phase II (플랜) Phase III (플랜)
순수 연산블록 EMA/MA·DeadTimeBuffer·FirstOrderLag·RateLimiter(비대칭)·Clamp·Derivative·PressureComp CrossCorrLagEstimator·DiffTemp·FrontPositionIndicator
엔진 FeedforwardEngine.Tick(role-aware, 과도게이트, confidence) θ 자동튜닝·느린 바이어스 적응·analyzer corroborate
수급 realtime_table PV 읽기 + AdvisoryStore RSP 쓰기 + WriteGuard + 워치독·데드맨
설정 DB 테이블 + 로더 Web UI 설정/대시보드(Tab 18) 운전원 채택 스위치
출력 권장 SP 저장 + 읽기 API 화면 시각화 컨트롤러 SP 추종

§13(온도기반 θ/sweet-spot)·§14(오차예산 등급·계절 바이어스)의 보정 항목은 §6 플랜에 매핑.


1. 파일 배치 & 네임스페이스 (신규만)

src/Core/Application/Feedforward/
  FeedforwardModels.cs          # enum·ColumnConfig·StreamConfig·PvSnapshot·AdvisoryResult (record)
  IFeedforwardStores.cs         # IFeedforwardConfigStore·IFeedforwardAdvisoryStore
src/Infrastructure/Control/
  ComputationBlocks.cs          # 순수 연산블록 (단위테스트 대상)
  FeedforwardEngine.cs          # Tick (순수 함수, I/O 없음)
  FeedforwardSupervisor.cs      # BackgroundService — PV 읽기→Tick→저장 (쓰기 없음)
  FeedforwardAdvisoryStore.cs   # in-memory 최신 결과
  FeedforwardConfigStore.cs     # DB 로더 (ff_column_config / ff_stream_config)
src/Web/Controllers/
  FeedforwardController.cs       # GET advisory/config (camelCase), admin config CRUD
tests/ (또는 신규 테스트 프로젝트)
  FeedforwardBlocksTests.cs / FeedforwardEngineTests.cs

네임스페이스: ExperionCrawler.Core.Application.Feedforward, ExperionCrawler.Infrastructure.Control, 컨트롤러는 ExperionCrawler.Web.Controllers.

1.1 프로젝트 구조 전제 (G1) ★

  • 단일 프로젝트: 앱 코드는 전부 src/Web/ExperionCrawler.csproj(Microsoft.NET.Sdk.Web) 하나로 컴파일된다. src/Core·src/Infrastructure·src/Web폴더일 뿐 별도 csproj가 아니다.
  • 따라서 위 신규 파일들은 새 csproj·ProjectReference를 만들지 말고 해당 폴더에 추가만 하면 같은 프로젝트로 빌드된다.
  • 빌드: dotnet build src/Web/ExperionCrawler.csproj (솔루션 파일 없음).
  • 단, 테스트 프로젝트만 별도 csproj로 신설한다(§5.7).

2. 코드 — Core 모델 (FeedforwardModels.cs)

namespace ExperionCrawler.Core.Application.Feedforward;

/// <summary>스트림 역할. §9.3 D5: 레벨 폐루프가 있으면 D·B는 LevelDriven.</summary>
public enum StreamRole { Commanded, LevelDriven, Monitor }

/// <summary>보정 신뢰도 등급. §14.3 A 견고 / B 한계 / C 취약.</summary>
public enum Confidence { A, B, C }

/// <summary>스트림(유량 1개) 설정. 경험상수는 Web UI(Phase II)에서 공급, DB 저장.</summary>
public sealed record StreamConfig
{
    public string     Key          { get; init; } = "";   // 논리명: "D","P","B","R"
    public string     FlowTag      { get; init; } = "";   // realtime base tag: "ficq-6118"
    public StreamRole Role         { get; init; } = StreamRole.Monitor;
    public double     TargetCoeff  { get; init; }          // K_t (commanded/levelDriven), 또는 R_f(reflux)
    public double     ThetaUpSec   { get; init; }          // 전달 데드타임(피드 상승), D7 비대칭
    public double     ThetaDnSec   { get; init; }          // 전달 데드타임(피드 하강)
    public double     TauSec       { get; init; }          // 1차 지체
    public double     SpMin        { get; init; }
    public double     SpMax        { get; init; } = double.MaxValue;
    public double     RateUpPerMin { get; init; } = double.MaxValue;
    public double     RateDnPerMin { get; init; } = double.MaxValue;
    public bool       RefluxFromProduct { get; init; }     // R = R_f × P_sp
    public Confidence Grade        { get; init; } = Confidence.A;
}

/// <summary>컬럼 1개 설정. 다중 컬럼 공유 — 새 컬럼 = row 추가만.</summary>
public sealed record ColumnConfig
{
    public int     Id           { get; init; }
    public string  Name         { get; init; } = "";
    public bool    Enabled      { get; init; }
    public bool    AdvisoryOnly { get; init; } = true;     // Phase I 강제 true
    public string  FeedTag      { get; init; } = "";       // base tag (".pv"는 로더가 부착)
    public string? PressureTag  { get; init; }
    public IReadOnlyList<string> LevelTags { get; init; } = Array.Empty<string>();
    public double  ScanSec               { get; init; } = 2.0;
    public double  FeedFilterTauSec      { get; init; } = 300.0;  // §11.5 노이즈 필터
    public double  FeedMoveThresholdPerMin { get; init; } = 0.0;  // 과도 판정(0=비활성)
    public double  PressFilterTauSec      { get; init; } = 60.0;  // 압력 1차저역통과 시정수(초) — 원시압력 대비 필터값이 PressureBand 이상 벗어나면 과도판정
    public double  PressureBand          { get; init; } = double.MaxValue;
    public double  SettleSec             { get; init; } = 0.0;    // T_SETTLE
    public double  StaleSec              { get; init; } = 120.0;  // PV 신선도 한계
    public string? ProductKey            { get; init; } = "P";    // reflux 참조 대상
    public IReadOnlyList<StreamConfig> Streams { get; init; } = Array.Empty<StreamConfig>();
}

/// <summary>읽은 PV 1개 (신선도·품질 포함).</summary>
public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp);

/// <summary>한 스캔의 입력 스냅샷.</summary>
public sealed record PvSnapshot(
    TagSample Feed,
    TagSample? Pressure,
    IReadOnlyList<TagSample> Levels,
    IReadOnlyDictionary<string, TagSample> Streams);   // key = StreamConfig.Key

/// <summary>스트림별 권장 결과.</summary>
public sealed record StreamAdvisory(
    string Key, string FlowTag, StreamRole Role,
    double Pv, double? RecommendedSp, double? Gap,
    int Trend,           // -1 하강 / 0 / +1 상승
    bool Valid,          // 과도 중이면 false("정착 대기")
    Confidence Grade,
    string Note);

/// <summary>컬럼 1회 Tick 결과 (저장·표시 전용).</summary>
public sealed record AdvisoryResult(
    int ColumnId, string ColumnName, DateTime ComputedAt,
    bool Enabled, bool Transient, string TransientReason,
    double FeedFiltered,
    IReadOnlyList<StreamAdvisory> Streams,
    double? VLoss, double? Yield, string MassBalanceState);

3. 코드 — 순수 연산블록 (ComputationBlocks.cs)

모두 I/O 없음·결정론적 → 단위테스트 용이. 각 인스턴스는 단일 스레드(엔진 루프) 소유 → 락 불필요.

namespace ExperionCrawler.Infrastructure.Control;

public static class Num
{
    public static double Clamp(double x, double lo, double hi) => Math.Max(lo, Math.Min(hi, x));
    public static bool   IsFinite(double x) => !double.IsNaN(x) && !double.IsInfinity(x);
}

/// <summary>1차 저역통과(EMA). DCS Lag/Filter 블록 등가.</summary>
public sealed class FirstOrderLag
{
    private double _y;
    private bool   _seeded;
    public double Value => _y;
    public bool   Seeded => _seeded;
    public void   Seed(double v) { _y = v; _seeded = true; }
    public double Step(double x, double tauSec, double tsSec)
    {
        if (!_seeded) { Seed(x); return _y; }
        if (tauSec <= 0.0) { _y = x; return _y; }
        var a = tsSec / (tauSec + tsSec);     // 0<a<1
        _y += (x - _y) * a;
        return _y;
    }
}

/// <summary>진짜 윈도우 이동평균(MA). 노이즈 제거 대안(§11.5). DCS가 구현 어려운 블록.</summary>
public sealed class MovingAverage
{
    private readonly Queue<double> _buf = new();
    private readonly int _window;
    private double _sum;
    public MovingAverage(int windowSamples) => _window = Math.Max(1, windowSamples);
    public double Push(double x)
    {
        _buf.Enqueue(x); _sum += x;
        while (_buf.Count > _window) _sum -= _buf.Dequeue();
        return _sum / _buf.Count;
    }
}

/// <summary>가변 전달지연(데드타임) 링버퍼. 용량은 요청된 최대 n 으로 **증가만**(축소 금지),
/// θ(=n)가 스캔마다 바뀌어도(비대칭 θup/θdn, D7) **읽기 오프셋만 가변** — 히스토리 보존.
/// ※ 진단 정정(2026-05-30): n 변경 시 Resize-재시드하던 초안은 비대칭 θ에서 부호반전마다 지연선 소실 → 폐기.</summary>
public sealed class DeadTimeBuffer
{
    private double[] _buf = Array.Empty<double>();   // 항상 가득 찬 링(시드 사전충전)
    private int  _cap;                                // 용량(보존 샘플 수). 증가만.
    private int  _head;                               // 다음 덮어쓸 위치(=가장 오래된)
    private bool _seeded;

    public double Through(double x, double thetaSec, double tsSec)
    {
        int n = (int)Math.Round(thetaSec / Math.Max(1e-6, tsSec));
        if (n <= 0) return x;                         // θ<ts → 지연 없음
        EnsureCapacity(n + 1, x);                     // n 샘플 지연 → n+1 슬롯
        _buf[_head] = x;                              // 가장 오래된 자리에 현재값
        int newest = _head;
        _head = (_head + 1) % _cap;
        int idx = ((newest - n) % _cap + _cap) % _cap;
        return _buf[idx];                             // n 스캔 전 값
    }

    private void EnsureCapacity(int need, double seed)
    {
        if (_seeded && need <= _cap) return;          // 축소·재시드 없음
        int newCap = Math.Max(need, Math.Max(_cap, 1));
        var nb = new double[newCap];
        if (!_seeded) Array.Fill(nb, seed);           // 최초: bumpless 사전충전
        else
        {
            int extra = newCap - _cap;
            double oldest = _buf[_head];
            for (int i = 0; i < extra; i++) nb[i] = oldest;                       // 앞쪽 패딩
            for (int k = 0; k < _cap; k++) nb[extra + k] = _buf[(_head + k) % _cap]; // 오래된→최신
        }
        _buf = nb; _cap = newCap; _head = 0; _seeded = true;
    }
}

/// <summary>비대칭 변화율 제한(/min). §11.4 D7 (up≠dn).</summary>
public sealed class RateLimiter
{
    private double _last;
    private bool   _seeded;
    public double Last => _last;
    public void   Seed(double v) { _last = v; _seeded = true; }
    public double Step(double target, double rateUpPerMin, double rateDnPerMin, double tsSec)
    {
        if (!_seeded) { Seed(target); return _last; }
        var up = Math.Abs(rateUpPerMin) * tsSec / 60.0;
        var dn = Math.Abs(rateDnPerMin) * tsSec / 60.0;
        var d  = Num.Clamp(target - _last, -dn, up);
        _last += d;
        return _last;
    }
}

/// <summary>per-second 미분(dF/dt, dM/dt).</summary>
public sealed class Derivative
{
    private double _prev;
    private bool   _seeded;
    public double Update(double x, double tsSec)
    {
        if (!_seeded) { _prev = x; _seeded = true; return 0.0; }
        var d = (x - _prev) / Math.Max(1e-6, tsSec);
        _prev = x;
        return d;
    }
}

/// <summary>압력보정온도 PCT = T - dTdP·(P-Pref). §13.3 (Phase I는 모니터 보조).</summary>
public static class TempCorrection
{
    public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP)
        => tMeas - dTdP * (p - pRef);
}

4. 코드 — 엔진 (FeedforwardEngine.cs)

순수 함수: I/O·시간·DB 접근 없음. 입력=cfg·snapshot·state, 출력=AdvisoryResult. 상태(ColumnState)는 호출자(Supervisor)가 컬럼별 1개 보유 → 컬럼 간 격리.

using ExperionCrawler.Core.Application.Feedforward;

namespace ExperionCrawler.Infrastructure.Control;

public sealed class StreamState
{
    public DeadTimeBuffer Dead { get; } = new();
    public FirstOrderLag  Lag  { get; } = new();
    public RateLimiter    Rate { get; } = new();
    public double LastRec { get; set; } = double.NaN;
}

public sealed class ColumnState
{
    public FirstOrderLag FeedFilter  { get; } = new();
    public FirstOrderLag PressFilter { get; } = new();
    public Derivative    FeedDeriv   { get; } = new();
    public double        SettleTimerSec { get; set; }
    public bool          Initialized { get; set; }
    public Dictionary<string, StreamState> Streams { get; } = new();

    public StreamState Stream(string key)
    {
        if (!Streams.TryGetValue(key, out var s)) { s = new StreamState(); Streams[key] = s; }
        return s;
    }
}

public sealed class FeedforwardEngine
{
    public AdvisoryResult Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now)
    {
        var ts = cfg.ScanSec;

        // ── 0) FEED 품질 게이트 → BAD면 직전 권장 홀드 ──────────────────────
        if (!pv.Feed.Good || !Num.IsFinite(pv.Feed.Value))
            return Hold(cfg, st, now, "FEED BAD");

        // ── 1) FEED 노이즈 필터 ─────────────────────────────────────────────
        var ff = st.FeedFilter.Step(pv.Feed.Value, cfg.FeedFilterTauSec, ts);

        // 최초 정상값으로 bumpless 시드
        if (!st.Initialized) { SeedAll(cfg, pv, st, ff); st.Initialized = true; }

        // ── 2) 과도/압력 게이트 (§11.6 D6·D11) ──────────────────────────────
        var  dF       = st.FeedDeriv.Update(ff, ts);                 // per sec
        bool moving   = cfg.FeedMoveThresholdPerMin > 0
                        && Math.Abs(dF) * 60.0 > cfg.FeedMoveThresholdPerMin;
        bool pUnstable = false;
        if (pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value))
        {
            var pf = st.PressFilter.Step(pp.Value, cfg.PressFilterTauSec, ts);
            pUnstable = Math.Abs(pp.Value - pf) > cfg.PressureBand;
        }
        if (moving || pUnstable) st.SettleTimerSec = cfg.SettleSec;
        else                     st.SettleTimerSec = Math.Max(0.0, st.SettleTimerSec - ts);
        bool   transient = moving || pUnstable || st.SettleTimerSec > 0.0;
        string treason   = moving ? "FEED 이동"
                         : pUnstable ? "압력 불안정"
                         : st.SettleTimerSec > 0.0 ? $"정착 대기 {st.SettleTimerSec:F0}s" : "";

        // ── 3) 스트림별 권장값 (2-pass: reflux는 P 권장 참조) ───────────────
        var outs       = new List<StreamAdvisory>(cfg.Streams.Count);
        double? prodRec = null;

        foreach (var s in cfg.Streams)
        {
            if (s.RefluxFromProduct) continue;                       // pass2에서
            var (rec, note) = ComputeStream(s, ff, dF, ts, st.Stream(s.Key));
            if (s.Key == cfg.ProductKey) prodRec = rec;
            outs.Add(BuildAdvisory(s, pv, rec, note, transient, st.Stream(s.Key)));
        }
        foreach (var s in cfg.Streams)
        {
            if (!s.RefluxFromProduct) continue;
            var stt = st.Stream(s.Key);
            double? rec = null;
            if (prodRec is double p)
            {
                var raw = Num.Clamp(s.TargetCoeff * p, s.SpMin, s.SpMax);
                rec = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
            }
            outs.Add(BuildAdvisory(s, pv, rec, "외부환류 R=R_f×P (P 지연 상속)", transient, stt));
        }

        // ── 4) 물질수지 모니터 (정상상태에서만, §10.6·§11.6) ────────────────
        double? vloss = null, yield = null;
        string  mbState;
        if (transient)
            mbState = $"정착 대기 ({st.SettleTimerSec:F0}s)";
        else if (TryStreamPv(pv, "D", out var d) && TryStreamPv(pv, "P", out var pp2)
              && TryStreamPv(pv, "B", out var b) && ff > 1e-6)
        {
            vloss   = ff - (d + pp2 + b);
            yield   = 100.0 * pp2 / ff;
            mbState = Math.Abs(vloss.Value) > 0.03 * ff ? "물질수지 불일치(계측 점검)"
                    : vloss.Value < 0                   ? "음의 손실(스팬 오류 의심)"
                    : "정상";
        }
        else mbState = "입력 부족";

        return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
            transient, treason, ff, outs, vloss, yield, mbState);
    }

    // ── helpers ─────────────────────────────────────────────────────────────

    private static (double? rec, string note) ComputeStream(
        StreamConfig s, double ff, double dF, double ts, StreamState stt)
    {
        switch (s.Role)
        {
            case StreamRole.Commanded:
                double theta = dF >= 0 ? s.ThetaUpSec : s.ThetaDnSec;   // D7 비대칭
                double fd    = stt.Dead.Through(ff, theta, ts);
                fd           = stt.Lag.Step(fd, s.TauSec, ts);
                double raw   = Num.Clamp(s.TargetCoeff * fd, s.SpMin, s.SpMax);
                double rec   = stt.Rate.Step(raw, s.RateUpPerMin, s.RateDnPerMin, ts);
                return (rec, "");
            case StreamRole.LevelDriven:
                return (s.TargetCoeff * ff, "레벨 제어 구동 — 기대치(deadtime 미적용)");  // §9.3 D5
            default: // Monitor
                return (null, "모니터(SP 없음)");
        }
    }

    private static StreamAdvisory BuildAdvisory(
        StreamConfig s, PvSnapshot pv, double? rec, string note, bool transient, StreamState stt)
    {
        double curPv = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : double.NaN;
        int    trend = rec is double r && Num.IsFinite(stt.LastRec)
                       ? Math.Sign(r - stt.LastRec) : 0;
        if (rec is double rr) stt.LastRec = rr;
        double? gap = (rec is double g && Num.IsFinite(curPv)) ? g - curPv : null;
        return new StreamAdvisory(s.Key, s.FlowTag, s.Role, curPv, rec, gap, trend,
            valid: !transient && s.Role != StreamRole.Monitor, s.Grade, note);
    }

    private static bool TryStreamPv(PvSnapshot pv, string key, out double v)
    {
        v = double.NaN;
        if (pv.Streams.TryGetValue(key, out var s) && s.Good && Num.IsFinite(s.Value)) { v = s.Value; return true; }
        return false;
    }

    private static void SeedAll(ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ff)
    {
        st.FeedDeriv.Update(ff, cfg.ScanSec);
        foreach (var s in cfg.Streams)
        {
            var stt = st.Stream(s.Key);
            double seed = pv.Streams.TryGetValue(s.Key, out var smp) && smp.Good ? smp.Value : ff * s.TargetCoeff;
            stt.Lag.Seed(seed);
            stt.Rate.Seed(seed);
            stt.LastRec = seed;
        }
    }

    private AdvisoryResult Hold(ColumnConfig cfg, ColumnState st, DateTime now, string reason)
    {
        var outs = cfg.Streams.Select(s =>
        {
            var stt = st.Stream(s.Key);
            double? rec = Num.IsFinite(stt.LastRec) ? stt.LastRec : (double?)null;
            return new StreamAdvisory(s.Key, s.FlowTag, s.Role, double.NaN, rec, null, 0,
                valid: false, s.Grade, $"홀드: {reason}");
        }).ToList();
        return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, true, reason,
            st.FeedFilter.Value, outs, null, null, $"홀드: {reason}");
    }
}

5. 코드 — 수급/저장/Supervisor

5.1 Stores (IFeedforwardStores.cs)

namespace ExperionCrawler.Core.Application.Feedforward;

public interface IFeedforwardConfigStore
{
    Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default);
}

public interface IFeedforwardAdvisoryStore
{
    void Set(AdvisoryResult result);
    AdvisoryResult? Get(int columnId);
    IReadOnlyCollection<AdvisoryResult> GetAll();
}

5.2 In-memory advisory store (FeedforwardAdvisoryStore.cs)

using System.Collections.Concurrent;
using ExperionCrawler.Core.Application.Feedforward;

namespace ExperionCrawler.Infrastructure.Control;

public sealed class FeedforwardAdvisoryStore : IFeedforwardAdvisoryStore
{
    private readonly ConcurrentDictionary<int, AdvisoryResult> _latest = new();
    public void Set(AdvisoryResult r) => _latest[r.ColumnId] = r;   // 단일 writer(Supervisor)
    public AdvisoryResult? Get(int id) => _latest.TryGetValue(id, out var r) ? r : null;
    public IReadOnlyCollection<AdvisoryResult> GetAll() => _latest.Values.ToArray();
}

5.3 Supervisor (FeedforwardSupervisor.cs) — 쓰기 없음

using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Globalization;

namespace ExperionCrawler.Infrastructure.Control;

public sealed class FeedforwardSupervisor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly FeedforwardEngine     _engine;
    private readonly IFeedforwardAdvisoryStore _store;
    private readonly ILogger<FeedforwardSupervisor> _logger;
    private readonly Dictionary<int, ColumnState> _states = new();   // 컬럼별 상태(단일 루프 소유)

    public FeedforwardSupervisor(
        IServiceScopeFactory scopeFactory, FeedforwardEngine engine,
        IFeedforwardAdvisoryStore store, ILogger<FeedforwardSupervisor> logger)
    { _scopeFactory = scopeFactory; _engine = engine; _store = store; _logger = logger; }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // 부팅 비블로킹: 잠깐 양보 후 진입 (ExperionRealtimeService 패턴)
        await Task.Yield();
        while (!ct.IsCancellationRequested)
        {
            double minScan = 2.0;
            try
            {
                using var scope = _scopeFactory.CreateScope();
                var cfgStore = scope.ServiceProvider.GetRequiredService<IFeedforwardConfigStore>();
                var db       = scope.ServiceProvider.GetRequiredService<IExperionDbService>();

                var columns = await cfgStore.LoadAllAsync(ct);
                var enabled = columns.Where(c => c.Enabled).ToList();
                if (enabled.Count > 0) minScan = enabled.Min(c => c.ScanSec);

                foreach (var cfg in enabled)
                {
                    try
                    {
                        var snap = await BuildSnapshotAsync(db, cfg);
                        var st   = GetState(cfg.Id);
                        var res  = _engine.Tick(cfg, snap, st, DateTime.UtcNow);
                        _store.Set(res);                          // ★ 저장만 — 쓰기 없음
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "FF tick 실패: column {Id}", cfg.Id);
                    }
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "FF supervisor 루프 오류");
            }
            await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(minScan, 1.0, 10.0)), ct);
        }
    }

    private ColumnState GetState(int id)
    {
        if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
        return s;
    }

    private async Task<PvSnapshot> BuildSnapshotAsync(IExperionDbService db, ColumnConfig cfg)
    {
        // 필요한 .pv 태그 목록 구성
        // ★ GetRealtimeRecordsByTagNamesAsync는 tags.Contains(x.TagName) 정확·대소문자구분 매칭.
        //   realtime_table은 소문자 저장 → 반드시 소문자로 질의.
        string PvTag(string baseTag)
        {
            var t = baseTag.ToLowerInvariant();
            return t.EndsWith(".pv") ? t : t + ".pv";
        }
        var feedTag = PvTag(cfg.FeedTag);
        var tags = new List<string> { feedTag };
        if (cfg.PressureTag is not null) tags.Add(PvTag(cfg.PressureTag));
        tags.AddRange(cfg.LevelTags.Select(PvTag));
        tags.AddRange(cfg.Streams.Select(s => PvTag(s.FlowTag)));

        var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags))   // 기존 인터페이스 재사용
                   .ToDictionary(r => r.TagName.ToLowerInvariant(), r => r);

        TagSample Sample(string baseTag)
        {
            var tag = PvTag(baseTag);
            if (rows.TryGetValue(tag.ToLowerInvariant(), 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);
        var press = cfg.PressureTag is null ? null : Sample(cfg.PressureTag);
        var levels = cfg.LevelTags.Select(Sample).ToList();
        var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
        return new PvSnapshot(feed, press, levels, streams);
    }
}

확인됨: RealtimePoint(src/Core/Domain/Entities/ExperionEntities.cs:72)는 TagName·LiveValue(string?, [Column("livevalue")]Timestamp·NodeId를 가진다. 본 코드의 사용과 일치.

5.4 Config DDL + 로더 (FeedforwardConfigStore.cs)

DDL 삽입 위치(G4): src/Infrastructure/Database/ExperionDbContext.csExperionDbService.InitializeAsync (클래스 ExperionDbService, 약 line 285~). 기존 await _ctx.Database.ExecuteSqlRawAsync("""...""") 블록들이 나열된 끝부분(예: 마지막 뷰 생성 뒤, return true; 직전)에 아래 두 테이블 생성을 멱등 추가한다.

-- ExperionDbService.InitializeAsync 내, 기존 ExecuteSqlRawAsync 블록들 끝에 추가 (멱등)
CREATE TABLE IF NOT EXISTS ff_column_config (
    id          SERIAL PRIMARY KEY,
    name        TEXT NOT NULL,
    enabled     BOOLEAN NOT NULL DEFAULT FALSE,
    feed_tag    TEXT NOT NULL,
    pressure_tag TEXT,
    level_tags  TEXT,                     -- 콤마 구분
    scan_sec    DOUBLE PRECISION NOT NULL DEFAULT 2,
    feed_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 300,
    feed_move_thr_per_min DOUBLE PRECISION NOT NULL DEFAULT 0,
    press_filter_tau_sec DOUBLE PRECISION NOT NULL DEFAULT 60,
    pressure_band DOUBLE PRECISION NOT NULL DEFAULT 1e9,
    settle_sec  DOUBLE PRECISION NOT NULL DEFAULT 0,
    stale_sec   DOUBLE PRECISION NOT NULL DEFAULT 120,
    product_key TEXT NOT NULL DEFAULT 'P',
    advisory_only BOOLEAN NOT NULL DEFAULT TRUE   -- Phase I 강제
);
CREATE TABLE IF NOT EXISTS ff_stream_config (
    id          SERIAL PRIMARY KEY,
    column_id   INTEGER NOT NULL REFERENCES ff_column_config(id) ON DELETE CASCADE,
    key         TEXT NOT NULL,
    flow_tag    TEXT NOT NULL,
    role        TEXT NOT NULL,            -- Commanded|LevelDriven|Monitor
    target_coeff DOUBLE PRECISION NOT NULL DEFAULT 0,
    theta_up_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
    theta_dn_sec DOUBLE PRECISION NOT NULL DEFAULT 0,
    tau_sec     DOUBLE PRECISION NOT NULL DEFAULT 0,
    sp_min      DOUBLE PRECISION NOT NULL DEFAULT 0,
    sp_max      DOUBLE PRECISION NOT NULL DEFAULT 1e9,
    rate_up_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
    rate_dn_per_min DOUBLE PRECISION NOT NULL DEFAULT 1e9,
    reflux_from_product BOOLEAN NOT NULL DEFAULT FALSE,
    grade       TEXT NOT NULL DEFAULT 'A'
);

CREATE TABLE 문은 각각 별도의 await _ctx.Database.ExecuteSqlRawAsync("""...""") 호출로 넣는다 (EF의 다중문장 제약 회피 — 기존 코드도 한 호출에 한 문장).

로더 전체 코드 — EF ExperionDbContext의 ADO 커넥션 사용(신규 엔티티 매핑 불필요, 사용자 입력 없음→인젝션 없음):

using System.Data;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Database;   // ExperionDbContext
using Microsoft.EntityFrameworkCore;             // GetDbConnection()
using Microsoft.Extensions.Logging;

namespace ExperionCrawler.Infrastructure.Control;

public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
{
    private readonly ExperionDbContext _ctx;
    private readonly ILogger<FeedforwardConfigStore> _logger;

    public FeedforwardConfigStore(ExperionDbContext ctx, ILogger<FeedforwardConfigStore> logger)
    { _ctx = ctx; _logger = logger; }

    public async Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default)
    {
        var conn = _ctx.Database.GetDbConnection();
        if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);

        // 1) 컬럼
        var cols = new Dictionary<int, (ColumnConfig cfg, List<StreamConfig> streams)>();
        await using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = """
                SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
                       feed_filter_tau_sec, feed_move_thr_per_min,
                       press_filter_tau_sec, pressure_band, settle_sec,
                       stale_sec, product_key
                FROM ff_column_config
                """;
            await using var rd = await cmd.ExecuteReaderAsync(ct);
            while (await rd.ReadAsync(ct))
            {
                var levelTags = rd.IsDBNull(5)
                    ? Array.Empty<string>()
                    : rd.GetString(5)
                        .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                        .Select(t => t.ToLowerInvariant()).ToArray();

                var cfg = new ColumnConfig
                {
                    Id                      = rd.GetInt32(0),
                    Name                    = rd.GetString(1),
                    Enabled                 = rd.GetBoolean(2),
                    AdvisoryOnly            = true,                                  // ★ 불변식 강제(DB 무관)
                    FeedTag                 = rd.GetString(3).ToLowerInvariant(),
                    PressureTag             = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(),
                    LevelTags               = levelTags,
                    ScanSec                 = rd.GetDouble(6),
                    FeedFilterTauSec        = rd.GetDouble(7),
                    FeedMoveThresholdPerMin = rd.GetDouble(8),
                    PressFilterTauSec       = rd.GetDouble(9),
                    PressureBand            = rd.GetDouble(10),
                    SettleSec               = rd.GetDouble(11),
                    StaleSec                = rd.GetDouble(12),
                    ProductKey              = rd.GetString(13),
                    Streams                 = Array.Empty<StreamConfig>()
                };
                cols[cfg.Id] = (cfg, new List<StreamConfig>());
            }
        }

        // 2) 스트림
        await using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText = """
                SELECT column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec,
                       tau_sec, sp_min, sp_max, rate_up_per_min, rate_dn_per_min,
                       reflux_from_product, grade
                FROM ff_stream_config
                ORDER BY id
                """;
            await using var rd = await cmd.ExecuteReaderAsync(ct);
            while (await rd.ReadAsync(ct))
            {
                int colId = rd.GetInt32(0);
                if (!cols.TryGetValue(colId, out var entry)) continue;
                entry.streams.Add(new StreamConfig
                {
                    Key               = rd.GetString(1),
                    FlowTag           = rd.GetString(2).ToLowerInvariant(),
                    Role              = Enum.TryParse<StreamRole>(rd.GetString(3), true, out var role) ? role : StreamRole.Monitor,
                    TargetCoeff       = rd.GetDouble(4),
                    ThetaUpSec        = rd.GetDouble(5),
                    ThetaDnSec        = rd.GetDouble(6),
                    TauSec            = rd.GetDouble(7),
                    SpMin             = rd.GetDouble(8),
                    SpMax             = rd.GetDouble(9),
                    RateUpPerMin      = rd.GetDouble(10),
                    RateDnPerMin      = rd.GetDouble(11),
                    RefluxFromProduct = rd.GetBoolean(12),
                    Grade             = Enum.TryParse<Confidence>(rd.GetString(13), true, out var g) ? g : Confidence.A
                });
            }
        }

        return cols.Values.Select(e => e.cfg with { Streams = e.streams }).ToList();
    }
}

GetDouble/GetBoolean 컬럼은 DDL이 DOUBLE PRECISION/BOOLEAN이라 타입 일치. NULL은 위 DDL NOT NULL DEFAULT로 방지. 커넥션은 EF가 소유하므로 닫지 않는다(열기만 보장).

5.5 Controller (FeedforwardController.cs) — 읽기 (camelCase 필수)

using ExperionCrawler.Core.Application.Feedforward;
using Microsoft.AspNetCore.Mvc;

namespace ExperionCrawler.Web.Controllers;

[ApiController]
[Route("api/ff")]
public sealed class FeedforwardController : ControllerBase
{
    private readonly IFeedforwardAdvisoryStore _store;
    public FeedforwardController(IFeedforwardAdvisoryStore store) => _store = store;

    [HttpGet("advisory")]
    public IActionResult GetAll() => Ok(new
    {
        columns = _store.GetAll().Select(MapColumn)
    });

    [HttpGet("advisory/{columnId:int}")]
    public IActionResult Get(int columnId)
    {
        var r = _store.Get(columnId);
        return r is null ? NotFound() : Ok(MapColumn(r));
    }

    // CODING_CONVENTIONS §1: 모든 키 camelCase 명시 (shorthand/typed 반환 금지)
    private static object MapColumn(AdvisoryResult r) => new
    {
        columnId         = r.ColumnId,
        columnName       = r.ColumnName,
        computedAt       = r.ComputedAt,
        enabled          = r.Enabled,
        transient        = r.Transient,
        transientReason  = r.TransientReason,
        feedFiltered     = r.FeedFiltered,
        vLoss            = r.VLoss,
        yield            = r.Yield,
        massBalanceState = r.MassBalanceState,
        streams          = r.Streams.Select(s => new
        {
            key           = s.Key,
            flowTag       = s.FlowTag,
            role          = s.Role.ToString(),
            pv            = double.IsNaN(s.Pv) ? (double?)null : s.Pv,
            recommendedSp = s.RecommendedSp,
            gap           = s.Gap,
            trend         = s.Trend,
            valid         = s.Valid,
            grade         = s.Grade.ToString(),
            note          = s.Note
        })
    };
}

5.6 DI 등록 (진단 통과 후에만 Program.cs에 추가)

builder.Services.AddSingleton<FeedforwardEngine>();
builder.Services.AddSingleton<IFeedforwardAdvisoryStore, FeedforwardAdvisoryStore>();
builder.Services.AddScoped<IFeedforwardConfigStore, FeedforwardConfigStore>();
builder.Services.AddHostedService<FeedforwardSupervisor>();

5.7 테스트 프로젝트 스캐폴드 (G3) — C# 테스트 인프라가 없으므로 신설

저장소에 C# 테스트 프로젝트가 없다(Python pytest만 존재). 순수 블록·엔진 검증용 xUnit 프로젝트를 신설한다.

# 1) 테스트 프로젝트 생성 + 앱 프로젝트 참조 (솔루션 파일 없음 → 직접 참조)
dotnet new xunit -o tests/ExperionCrawler.Tests
dotnet add tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj \
           reference src/Web/ExperionCrawler.csproj
# 2) 실행
dotnet test tests/ExperionCrawler.Tests/ExperionCrawler.Tests.csproj

Web SDK 프로젝트도 테스트에서 참조 가능. 테스트 대상 클래스(FeedforwardEngine·연산블록)는 모두 public. DB·OPC 의존이 없는 순수 로직만 테스트(Supervisor·ConfigStore는 통합검증 §8.2에서 수동).

예시 테스트(FeedforwardBlocksTests.cs) — 가장 까다로운 3개:

using ExperionCrawler.Infrastructure.Control;
using Xunit;

public class FeedforwardBlocksTests
{
    [Fact]
    public void DeadTime_delays_by_n_samples()
    {
        var d = new DeadTimeBuffer();
        // θ=10s, ts=2s → n=5 스캔 지연. 버퍼는 첫 입력(10)으로 시드.
        Assert.Equal(10.0, d.Through(10, 10, 2), 3);              // call1 시드 → 10
        for (int i = 0; i < 4; i++)                              // call2~5: 입력 20, 출력은 옛값 10
            Assert.Equal(10.0, d.Through(20, 10, 2), 3);
        Assert.Equal(10.0, d.Through(20, 10, 2), 3);             // call6: 아직 10 (지연 5)
        Assert.Equal(20.0, d.Through(20, 10, 2), 3);             // call7: 비로소 20 등장
    }

    [Fact]   // 진단 정정 회귀: 비대칭 θ 토글에도 지연선 보존
    public void DeadTime_asymmetric_theta_preserves_history()
    {
        var d = new DeadTimeBuffer(); double ts = 2;
        for (int i = 0; i < 12; i++) d.Through(0, 20, ts);       // θ=20→n=10, 0 충전
        Assert.Equal(0.0, d.Through(100, 10, ts), 3);            // θ=10→n=5, 아직 0(누출 없음)
        Assert.Equal(0.0, d.Through(100, 20, ts), 3);            // θ 복귀해도 0
    }

    [Fact]
    public void RateLimiter_clamps_asymmetric_up_down()
    {
        var r = new RateLimiter();
        r.Seed(0);
        // up=60/min, ts=1s → 스캔당 +1 한도
        Assert.Equal(1, r.Step(100, 60, 600, 1), 3);
        // dn=600/min → 스캔당 -10 한도
        r.Seed(100);
        Assert.Equal(90, r.Step(0, 60, 600, 1), 3);
    }

    [Fact]
    public void FirstOrderLag_reaches_63pct_after_tau()
    {
        var l = new FirstOrderLag();
        l.Seed(0);
        double y = 0; double ts = 1, tau = 10;
        for (int i = 0; i < 10; i++) y = l.Step(1.0, tau, ts);   // τ=10s, 10스캔
        Assert.InRange(y, 0.60, 0.66);                            // ≈63%
    }
}

5.8 실행용 예시 설정 (G5) — C-6111 seed

end-to-end 가동 검증을 위한 placeholder seed. θ/τ는 §11.4 전형값, 한계는 대략치 — 실측·운전원 값으로 교체 전까지 임시(Phase II Web UI 공급). 태그는 §확인된 실재 태그.

-- C-6111 (P6-1) advisory 1컬럼. 진단·DI 등록 후 1회 실행.
INSERT INTO ff_column_config
  (name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
   feed_filter_tau_sec, feed_move_thr_per_min, pressure_band, settle_sec, stale_sec, product_key)
VALUES
  ('C-6111', TRUE, 'ficq-6101', 'pica-6111', 'li-6111,lica-6113', 2,
   300, 5, 3, 1800, 120, 'P')
RETURNING id;
-- 위 id를 :cid 로 사용 (예시는 1로 가정)

-- 스트림: P=commanded, R=reflux(P 경유), D·B=level_driven(LICA-6113 인벤토리 구동), 모니터 없음
INSERT INTO ff_stream_config
  (column_id, key, flow_tag, role, target_coeff, theta_up_sec, theta_dn_sec, tau_sec,
   sp_min, sp_max, rate_up_per_min, rate_dn_per_min, reflux_from_product, grade) VALUES
  (1, 'P', 'ficq-6118', 'Commanded',   0.95,  60,  60,  900,  0, 950, 30, 60, FALSE, 'A'),
  (1, 'R', 'ficq-6113', 'Commanded',   0.80,   0,   0,    0,  0,1100, 30, 30, TRUE,  'A'),
  (1, 'D', 'ficq-6114', 'LevelDriven', 0.02,   0,   0,    0,  0,  60,  0,  0, FALSE, 'B'),
  (1, 'B', 'ficq-6116', 'LevelDriven', 0.03,   0,   0,    0,  0,  80,  0,  0, FALSE, 'B');

placeholder 경고: 위 θ(P=60s)·τ(P=900s=15min)·rate·sp_max는 임시 추정. §11.4 bump test/Phase II로 교체. D·B는 LevelDriven이라 deadtime/rate 미적용(기대치 K·F만 표시). reflux R은 P 권장값 경유(§4 엔진).


6. 나머지 항목 보정 플랜 (§13·§14 → Phase II/III)

ID 항목 근거(§) Phase 구현 방향
P-1 θ 자동튜닝 (passive 교차상관) §13.4 II CrossCorrLagEstimator: ΔF·ΔS(=TICA.OP) 다입력 부분상관 → θ_i + 신뢰도. StreamConfig.θ를 제안만(운전원 승인 시 반영). 폐루프 오염 회피(스팀 2nd 입력)
P-2 PCT/차온 모니터 §13.3·§14.1 II TempCorrection(已구현) + DiffTemp. PCT는 dT/dP·Pref 계절 재캘리브레이션. 컬럼 설정에 tempTags·dTdP·pRef 추가
P-3 Sweet-spot/프론트 위치 지표 §13.5 II FrontPositionIndicator: 민감트레이 PCT/ΔT → 드리프트 시 환류/boilup 트림 권장(advisory). analyzer 있으면 우선
P-4 느린 바이어스 적응 (K·k_V) §14.4 II 물질수지 장기 이동평균으로 K_obs·k_V 추세 산출 → 운전원에게 "계절 보정 제안". 자동 변경 아님
P-5 confidence 자동 강등 §14.3 II 입력 신선도·압력안정·analyzer 부재 등으로 A→B→C 동적 강등, UI 색상
P-6 Web UI 설정/대시보드(Tab 18) §12.7 II 컬럼별 경험상수 폼(admin) + 권장 SP 카드(현재/권장/Δ/추세/valid). Tab16/17 패턴. press_filter_tau_sec 포함 — 압력 1차저역통과 시정수(초), 운전자가 압력노이즈 특성에 맞춰 조정
P-7 RSP 감독제어 쓰기 §12.8 Stage3 III WriteGuard(min/max·rate·Δcap) + 워치독·데드맨 + 운전원 채택 스위치. ExperionOpcWriteClient 가드 0건 → 선행 구현 필수

Phase I 코드는 P-1~P-7의 확장점(설정 필드·블록 인터페이스) 을 남겨두되, 본 Phase에선 미구현.


7. 검증 절차 (diagnosis-checklist.md 8단계 기준) — 감독자 진단용

감독자는 아래를 순서대로 수행하고, 발견 항목을 STEP 8 보고서 양식으로 기록한다. 본 코드는 신규 파일이므로 STEP 3의 "전체 파일 읽기"는 §2~§5 코드블록 전체를 대상으로 한다.

STEP 1 — 맥락

  • 레이어: Core(모델/인터페이스), Infrastructure/Control(블록·엔진·Supervisor), Web(읽기 컨트롤러).
  • 관련 문서: 측류추출식-통합유량설정공식.md §9~§14, 측류추출-시간지연-적용방식.md, 본 문서 §0 Scope.
  • 의도적 설계 확인: ① 쓰기 없음(advisory) ② D·B는 LevelDriven ③ 엔진 상태는 단일 루프 소유(락 없음) ④ AdvisoryOnly 강제.

STEP 2 — 구조

  • 신규 파일 목록(§1) 확인, 기존 파일 변경은 DI 등록 + 부트 DDL 2테이블뿐(나머지 무변경).
  • 의존: IExperionDbService.GetRealtimeRecordsByTagNamesAsync, RealtimePoint, ExperionDbContext.

STEP 3 — 코드 읽기 (전체, 건너뛰기 금지)

순서: 모델(§2) → 블록(§3) → 엔진(§4) → stores/supervisor(§5) → 컨트롤러(§5.5).

STEP 4 — 호출 계층 지도

BackgroundService.ExecuteAsync (루프)
  → scope: IFeedforwardConfigStore.LoadAllAsync   (DB read)
  → scope: IExperionDbService.GetRealtimeRecordsByTagNamesAsync (DB read)
  → FeedforwardEngine.Tick   (순수, I/O 없음)         ← try-catch는 컬럼 단위 + 루프 전체
  → IFeedforwardAdvisoryStore.Set  (in-memory)
HTTP GET /api/ff/advisory → AdvisoryStore.Get(All)  (read only)
─ 쓰기 경로 없음 (ExperionOpcWriteClient 미참조) ─

STEP 5 — 패턴 매칭 (자가 사전점검 결과 동봉)

체크 본 코드 상태 처리
미정의 참조 RealtimePoint.LiveValue/TagName/Timestamp 실제 엔티티(ExperionEntities.cs:72)와 일치 확인 OK
async 내 blocking DB는 await, 엔진은 순수 OK
Race condition 엔진 상태는 Supervisor 단일 루프 소유, AdvisoryStore는 단일 writer + ConcurrentDictionary OK
Task.Delay 고정값 루프 주기는 cfg.ScanSec 기반(clamp 1~10s) OK(폴링 본질)
DB 커넥션 누수 using var scope per iteration OK
예외 삼킴 컬럼 단위 LogWarning, 루프 LogError OK
0 나눗셈 ff>1e-6 가드, Max(1e-6,..) OK
NaN 전파 Num.IsFinite 게이트, BAD→Hold OK
설정 하드코딩 상수는 cfg/DB OK (단, Resize seed·press filter τ=60 하드코딩 → 해소: PressFilterTauSec ColumnConfig 속성으로 승격, DDL·로더·엔진·Web UI(Tab 18) 연동)
SQL Injection ConfigStore는 사용자 입력 없이 전체 행 SELECT(WHERE/파라미터 없음) OK (인젝션 표면 없음)
쓰기 불변식 코드 전체에 SP/OP write 호출 없음 ★ 감독자 grep 확인

STEP 6 — 교차검증 (의심 항목별 Q1~Q4)

  • RealtimePoint 속성명: 확인 완료(ExperionEntities.cs:72, LiveValue/TagName/Timestamp 일치) → 보고서 제외.
  • press filter τ=60 하드코딩: Q4(장애 시나리오?) → 없음 → LOW.
  • ConfigStore 인젝션: 사용자 입력 없는 전체 SELECT → 표면 없음 → 보고서 제외.
  • DeadTimeBuffer 지연 정확도: 단위테스트(§5.7)로 n-샘플 지연 검증 → write-후-read는 n1 버그(이미 정정).

STEP 7 — 심각도 (감독자 판정)

  • HIGH: 빌드/런타임 즉시 실패(예: 엔티티 속성명 불일치, EF 매핑 누락).
  • MED: 로더 인젝션, config 핫리로드 시 상태 누수(컬럼 삭제 후 _states 잔존 → 경미).
  • LOW: 하드코딩 상수, 미사용 using.

STEP 8 — 보고서 양식 (감독자가 채움)

### [n]. [제목] (HIGH/MED/LOW)
문제: …
근거: 파일:줄 — 코드 인용
영향: …
수정: …

8. 단위·통합 검증 계획 (감독자 진단 통과 후)

8.1 단위테스트 (순수 블록·엔진)

대상 케이스
DeadTimeBuffer θ=10s/ts=2s → 5스캔 지연 정확, θ<ts 즉시통과, 길이변경 시 재시드 bump 없음
FirstOrderLag 스텝 입력 τ후 63% 도달, τ=0 즉시통과, 미시드 첫값 시드
MovingAverage 윈도 평균·윈도 초과 시 가장 오래된 값 제거
RateLimiter up≠dn 비대칭 클램프, 시드 bumpless
FeedforwardEngine ① FEED 스텝 시 commanded는 θ 지연 후 변화 ② level_driven은 즉시 K·F ③ 과도 중 valid=false·물질수지 SETTLING ④ FEED BAD→Hold(직전 권장 유지) ⑤ reflux=R_f×P_rec

8.2 빌드/런타임 (감독자 승인 후)

  • dotnet build src/Web/ExperionCrawler.csproj 경고 0/에러 0.
  • DI 등록 후 기동 → GET /api/ff/advisory 200, 컬럼 결과 camelCase 확인.
  • 쓰기 불변식 검증: grep -rn "WriteTagAsync\|SetModeAsync" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs0건.
  • 다컬럼 격리: 한 컬럼 FEED를 BAD로 만들어도 타 컬럼 advisory 정상.
  • 재기동 bumpless: 첫 정상 PV로 권장값 점프 없이 시드.

8.3 제출 전 자가검증 (체크리스트 §STEP8)

  • 각 지적이 "파일 몇 줄"로 지목되는가
  • HIGH는 재현 시나리오 1문장
  • Q1~Q4 통과 항목만 포함
  • "더 좋은 제안"과 "틀림"을 혼동하지 않음
  • 쓰기 0건 불변식 재확인

9. 턴키 상태 & 잔여 항목

턴키 갭 G1~G5 — 전부 해소(다른 LLM이 코드베이스 추가 탐색 없이 구현 가능):

해소
G1 프로젝트 구조 §1.1 — 단일 ExperionCrawler.csproj, 새 csproj 금지·파일만 추가
G2 ConfigStore 본문 §5.4 — ADO 로더 전체 코드(EF 커넥션, 사용자입력 없음)
G3 테스트 인프라 §5.7 — xUnit 신설 명령 + 예시 테스트(블록 3종)
G4 DDL 위치 §5.4 — ExperionDbService.InitializeAsync 내 멱등 추가, 위치 명시
G5 실행 seed §5.8 — C-6111 placeholder INSERT(실재 태그, role 매핑 포함)

구현 순서(다른 LLM용): ① 신규 파일 8개 추가(§2~§5, 같은 csproj) → ② ExperionDbService.InitializeAsync에 DDL 2블록(§5.4) → ③ Program.cs DI 4줄(§5.6) → ④ dotnet build src/Web/ExperionCrawler.csproj → ⑤ 테스트 프로젝트(§5.7) dotnet test → ⑥ seed INSERT(§5.8) → ⑦ GET /api/ff/advisory 확인 + 쓰기 0건 grep(§8.2).

여전히 사람/감독자 판단이 필요한 것(코드 갭 아님):

  1. 경험상수 실측치 — seed의 θ/τ/K/limits는 placeholder. bump test·운전원 값으로 교체(Phase II Web UI).
  2. 컬럼 삭제 시 _states/AdvisoryStore 정리(현재 누적) — 경미, 장기 운영 시 cleanup 추가.
  3. Phase II 진입 기준(운전원 advisory 신뢰 확보 정의).

10. 구현 진단 결과 (2026-05-30)

diagnosis-checklist.md 8단계 + 실측 검증 완료.

실측: dotnet build 경고0/에러0 · 단위테스트 4/4 · 쓰기 호출 0건(grep) · DDL 2테이블 분리 정상 · DI 4줄 · ConfigStore SELECT↔reader 인덱스 일치.

발견·조치:

# 등급 항목 조치
1 MED 비대칭 θ(θup≠θdn) 시 DeadTimeBuffer가 n 변경마다 Resize-재시드 → 부호반전마다 지연선 소실(D7 기능 불능) 수정 완료 — 고정용량(증가전용) 링 + 가변 읽기오프셋. 회귀테스트 DeadTime_asymmetric_theta_preserves_history 추가(4/4)
2 LOW 물질수지 키 "D"/"P"/"B" 하드코딩 — 다른 명명 컬럼은 모니터 무효 잔존(모니터 전용·무해). Phase II에서 ColumnConfig에 키 매핑화 검토
3 LOW config 중복 Key → ToDictionary 예외 컬럼 단위 try-catch로 격리됨. Phase II에서 설정 검증 추가
4 NIT MovingAverage 미사용(엔진은 EMA), Monitor 스트림 seed 무해 — 대안 블록

결론: MED 1건 수정 완료, 빌드·테스트·쓰기불변식 통과 → 머지 가능. 잔여는 LOW/판단 항목.