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

1068 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 측류추출 통합유량 — §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`)
```csharp
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 없음·결정론적** → 단위테스트 용이. 각 인스턴스는 **단일 스레드(엔진 루프)** 소유 → 락 불필요.
```csharp
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개 보유 → 컬럼 간 격리.
```csharp
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`)
```csharp
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`)
```csharp
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`) — 쓰기 없음
```csharp
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.cs`**`ExperionDbService.InitializeAsync`**
(클래스 `ExperionDbService`, 약 line 285~). 기존 `await _ctx.Database.ExecuteSqlRawAsync("""...""")` 블록들이
나열된 끝부분(예: 마지막 뷰 생성 뒤, `return true;` 직전)에 아래 두 테이블 생성을 **멱등 추가**한다.
```sql
-- 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 커넥션 사용(신규 엔티티 매핑 불필요, 사용자 입력 없음→인젝션 없음):
```csharp
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 필수)
```csharp
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`에 추가)
```csharp
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 프로젝트를 신설한다.
```bash
# 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개:**
```csharp
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 공급). 태그는 §확인된 실재 태그.
```sql
-- 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.cs`**0건**.
- 다컬럼 격리: 한 컬럼 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/판단 항목.