# 측류추출 통합유량 — §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; /// 스트림 역할. §9.3 D5: 레벨 폐루프가 있으면 D·B는 LevelDriven. public enum StreamRole { Commanded, LevelDriven, Monitor } /// 보정 신뢰도 등급. §14.3 A 견고 / B 한계 / C 취약. public enum Confidence { A, B, C } /// 스트림(유량 1개) 설정. 경험상수는 Web UI(Phase II)에서 공급, DB 저장. 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; } /// 컬럼 1개 설정. 다중 컬럼 공유 — 새 컬럼 = row 추가만. 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 LevelTags { get; init; } = Array.Empty(); 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 Streams { get; init; } = Array.Empty(); } /// 읽은 PV 1개 (신선도·품질 포함). public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp); /// 한 스캔의 입력 스냅샷. public sealed record PvSnapshot( TagSample Feed, TagSample? Pressure, IReadOnlyList Levels, IReadOnlyDictionary Streams); // key = StreamConfig.Key /// 스트림별 권장 결과. 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); /// 컬럼 1회 Tick 결과 (저장·표시 전용). public sealed record AdvisoryResult( int ColumnId, string ColumnName, DateTime ComputedAt, bool Enabled, bool Transient, string TransientReason, double FeedFiltered, IReadOnlyList 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); } /// 1차 저역통과(EMA). DCS Lag/Filter 블록 등가. 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진짜 윈도우 이동평균(MA). 노이즈 제거 대안(§11.5). DCS가 구현 어려운 블록. public sealed class MovingAverage { private readonly Queue _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; } } /// 가변 전달지연(데드타임) 링버퍼. 용량은 요청된 최대 n 으로 **증가만**(축소 금지), /// θ(=n)가 스캔마다 바뀌어도(비대칭 θup/θdn, D7) **읽기 오프셋만 가변** — 히스토리 보존. /// ※ 진단 정정(2026-05-30): n 변경 시 Resize-재시드하던 초안은 비대칭 θ에서 부호반전마다 지연선 소실 → 폐기. public sealed class DeadTimeBuffer { private double[] _buf = Array.Empty(); // 항상 가득 찬 링(시드 사전충전) 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; // θ비대칭 변화율 제한(/min). §11.4 D7 (up≠dn). 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; } } /// per-second 미분(dF/dt, dM/dt). 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; } } /// 압력보정온도 PCT = T - dTdP·(P-Pref). §13.3 (Phase I는 모니터 보조). 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 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(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> LoadAllAsync(CancellationToken ct = default); } public interface IFeedforwardAdvisoryStore { void Set(AdvisoryResult result); AdvisoryResult? Get(int columnId); IReadOnlyCollection 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 _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 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 _logger; private readonly Dictionary _states = new(); // 컬럼별 상태(단일 루프 소유) public FeedforwardSupervisor( IServiceScopeFactory scopeFactory, FeedforwardEngine engine, IFeedforwardAdvisoryStore store, ILogger 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(); var db = scope.ServiceProvider.GetRequiredService(); 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 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 { 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 _logger; public FeedforwardConfigStore(ExperionDbContext ctx, ILogger logger) { _ctx = ctx; _logger = logger; } public async Task> LoadAllAsync(CancellationToken ct = default) { var conn = _ctx.Database.GetDbConnection(); if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); // 1) 컬럼 var cols = new Dictionary 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() : 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() }; cols[cfg.Id] = (cfg, new List()); } } // 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(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(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(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHostedService(); ``` ### 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는 n−1 버그(이미 정정). ### STEP 7 — 심각도 (감독자 판정) - HIGH: 빌드/런타임 즉시 실패(예: 엔티티 속성명 불일치, EF 매핑 누락). - MED: 로더 인젝션, config 핫리로드 시 상태 누수(컬럼 삭제 후 `_states` 잔존 → 경미). - LOW: 하드코딩 상수, 미사용 using. ### STEP 8 — 보고서 양식 (감독자가 채움) ``` ### [n]. [제목] (HIGH/MED/LOW) 문제: … 근거: 파일:줄 — 코드 인용 영향: … 수정: … ``` --- ## 8. 단위·통합 검증 계획 (감독자 진단 통과 후) ### 8.1 단위테스트 (순수 블록·엔진) | 대상 | 케이스 | |:-----|:-------| | `DeadTimeBuffer` | θ=10s/ts=2s → 5스캔 지연 정확, θ