diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-PhaseI.md b/docs/측류추출식-통합유량설정공식-구현코딩-PhaseI.md new file mode 100644 index 0000000..4ddd833 --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-PhaseI.md @@ -0,0 +1,1067 @@ +# 측류추출 통합유량 — §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스캔 지연 정확, θ **성격**: PhaseI(advisory 엔진)·PhaseII-UI(Tab 18)·PhaseIII(auto-write) 문서의 **§6 잔여 보정항목(P-1~P-5)** 과 +> **신규 안전기능(전환류 평형복귀 모드)** 의 턴키 구현 작업지시서. 다른 LLM이 코드베이스 추가 탐색 없이 구현하도록 +> **검증된 현재 코드 기준선** 위에서 작성한다. +> +> **불변식(PhaseI 계승)**: P-1~P-5 및 전환류 모드의 **권장(advisory) 계산까지는 제어 레지스터 쓰기 0건**. +> 실제 SP 쓰기(전환류 자동 실행 포함)는 **전부 PhaseIII(WriteGuard)** 경유. 본 문서는 "권장값/모드 산출 + 표시"까지가 범위. +> +> **작업 순서(영향도·의존성 반영)**: §A 문서감리 선반영 → **WO-1(P-5)** → **WO-2(P-2)** → **WO-3(P-1)** → +> **WO-4(P-4)** → **WO-5(P-3)** → **WO-6(전환류 복귀)** → §C 통합검증. P-7은 기존 PhaseIII 문서(§D 정정 메모 참조). + +--- + +## §A. 선행 — 기존 문서·코드 감리 결과 (먼저 반영할 것) ★ + +본 작업 착수 전 아래 문서 드리프트를 인지하고 **§B 현재 코드 기준선을 정본**으로 삼는다. (기존 PhaseI/II/III 문서를 글자대로 따르면 깨지는 지점들.) + +| # | 등급 | 위치 | 문제 | 조치 | +|:-:|:----:|:-----|:-----|:-----| +| A1 | **HIGH** | PhaseI §5.4 DDL·§5.8 seed·§2 모델, PhaseII §2.2 | `ff_stream_config.level_tag`(TEXT) 컬럼이 **실제 스키마·`StreamConfig.LevelTag`·`StreamAdvisory.LevelTag`·ConfigStore SELECT(인덱스 14)·SaveColumn INSERT·Controller `levelTag`** 에 전부 존재하나 **그 문서들엔 누락** | 본 문서는 **현재 코드 기준선(§B)** 을 정본으로 삼는다. 기존 DDL 문서를 그대로 따르지 말 것 | +| A2 | **WO-6 선결** | `src/Web/Program.cs:124~128` | (중복 버그 아님 — 현재 `AddHostedService()` **단일 등록**으로 정상.) 단, WO-6 복구 컨트롤러가 `FeedforwardSupervisor`에 주입 접근(ColumnState ARM)하려면 **singleton 노출 필요** | `AddHostedService()`(128) → **`AddSingleton()` + `AddHostedService(sp=>sp.GetRequiredService())`** 2줄로 교체(단일 인스턴스를 hosted+injectable로). **WO-6 착수 시에만** 변경, 그전엔 현 상태 유지 | +| A3 | **MED** | PhaseII §2.3 본문 vs §8 | §2.3 본문은 `IKbAuthService`/`IsAdminAsync`로 config CRUD를 막는 코드를 보여주나, §8과 **실제 컨트롤러는 인증 제거**(config CRUD 공개) | 본 문서는 **인증 없는 현재 컨트롤러(§B)** 기준. auth 재도입은 PhaseIII(쓰기 시)에서만 | +| A4 | LOW | PhaseI §2 | `StreamAdvisory` 레코드에 `LevelTag` 없음, enum에 `[JsonStringEnumConverter]` 표기 없음 — 실제는 둘 다 있음 | §B 기준선 사용 | +| A5 | LOW | PhaseI §6 P-2 | "TempCorrection(已구현)" — `TempCorrection.PressureCompensated`는 존재하나 **엔진에서 호출되지 않는 죽은 코드** | WO-2에서 배선 | +| A6 | LOW | PhaseIII §6 | 변경대상 `src/Infrastructure/OpcUa/OpcUaClientService.cs`는 **그 이름의 파일이 없음**. 단, **`src/Infrastructure/OpcUa/ExperionOpcWriteClient.cs`(namespace `...Infrastructure.Control`)가 이미 존재**(쓰기 클라이언트 — 가드 없음). NodeId `ns=3;s="{tag}.sp"` 규칙 미검증 | §D 참조 — PhaseIII는 **기존 `ExperionOpcWriteClient` 재사용/확장**(신규 파일 X). NodeId는 서버 브라우즈로 확정 | + +> **advisory 쓰기 불변식의 정확한 범위**: 코드베이스 전체엔 범용 `ExperionOpcWriteClient`(다른 기능용)가 **존재**한다. +> 불변식은 **"FF advisory 경로가 그 쓰기 클라이언트를 호출하지 않는다"** 는 의미. +> 검증 grep은 **FF 파일에 한정**: `grep -rE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs` → 0건(현재 유지됨). + +--- + +## §B. 검증된 현재 코드 기준선 (정본 — 2026-05-31 실독) + +다른 LLM은 아래를 **사실로 간주**하고, 기존 PhaseI/II/III 문서와 충돌 시 **본 §B를 우선**한다. + +### B.1 모델 (`src/Core/Application/Feedforward/FeedforwardModels.cs`) +- `enum StreamRole { Commanded, LevelDriven, Monitor }` · `enum Confidence { A, B, C }` — 둘 다 `[JsonConverter(typeof(JsonStringEnumConverter))]`. +- `StreamConfig`: Key, FlowTag, Role, **LevelTag(string?)**, TargetCoeff, ThetaUpSec, ThetaDnSec, TauSec, SpMin, SpMax, RateUpPerMin, RateDnPerMin, RefluxFromProduct, Grade. +- `ColumnConfig`: Id, Name, Enabled, AdvisoryOnly(=true 강제), FeedTag, PressureTag, LevelTags, ScanSec, FeedFilterTauSec, FeedMoveThresholdPerMin, PressFilterTauSec, PressureBand, SettleSec, StaleSec, ProductKey, Streams. +- `TagSample(Tag, Value, Good, Timestamp)`. +- `PvSnapshot(Feed, Pressure?, Levels[], Streams{key→TagSample})`. +- `StreamAdvisory(Key, FlowTag, Role, Pv, RecommendedSp?, Gap?, Trend, Valid, Grade, **LevelTag?**, Note)`. +- `AdvisoryResult(ColumnId, ColumnName, ComputedAt, Enabled, Transient, TransientReason, FeedFiltered, Streams[], VLoss?, Yield?, MassBalanceState)`. + +### B.2 연산블록 (`src/Infrastructure/Control/ComputationBlocks.cs`) +`Num`(Clamp/IsFinite), `FirstOrderLag`(Seed/Step), `MovingAverage(windowSamples)`(Push), `DeadTimeBuffer`(Through — 증가전용 링·비대칭 θ 보존), `RateLimiter`(Seed/Step 비대칭), `Derivative`(Update), `TempCorrection.PressureCompensated`(**미배선 죽은 코드**). + +### B.3 엔진 (`src/Infrastructure/Control/FeedforwardEngine.cs`) +- `StreamState{ Dead, Lag, Rate, LastRec }`, `ColumnState{ FeedFilter, PressFilter, FeedDeriv, SettleTimerSec, Initialized, Streams{key→StreamState} }`. +- `FeedforwardEngine.Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now) → AdvisoryResult` — **순수함수(I/O 없음)**. +- 흐름: FEED 품질게이트→Hold / FEED 필터(EMA) / 시드 / 과도·압력 게이트(transient) / 스트림 2-pass(commanded→reflux) / 물질수지(transient 아닐 때 `vloss=ff-(D+P+B)`, `yield=100*P/ff`). +- 역할별: Commanded=비대칭θ DeadTime→Lag→K→RateLimit / LevelDriven=`K*ff`(즉시) / Monitor=null. + +### B.4 수급/저장/컨트롤러/DI +- `FeedforwardSupervisor`(BackgroundService): 스코프마다 `IFeedforwardConfigStore.LoadAllAsync` + `IExperionDbService.GetRealtimeRecordsByTagNamesAsync`로 `PvSnapshot` 구성 → `Tick` → `IFeedforwardAdvisoryStore.Set`. 쓰기 없음. 컬럼별 `ColumnState` 단일 루프 소유(락 없음). +- `IFeedforwardConfigStore{ LoadAllAsync, SaveColumnAsync, DeleteColumnAsync }`, `IFeedforwardAdvisoryStore{ Set, Get, GetAll }`. +- `FeedforwardConfigStore`: ADO(EF 커넥션). LoadAll은 컬럼+스트림 2쿼리(스트림 SELECT에 `level_tag` 인덱스 14 포함). Save/Delete는 **파라미터화 `P()` 헬퍼 + 트랜잭션**. +- `FeedforwardController` (`api/ff`): `GET/POST/DELETE config`(**인증 없음**), `GET advisory`·`advisory/{id}`(공개). `MapColumn`/`MapConfig`로 camelCase 명시. +- DI(Program.cs:124~128): Engine=Singleton, AdvisoryStore=Singleton, ConfigStore=Scoped, Supervisor=**단일 AddHostedService**(중복 없음). WO-6에서 singleton+hosted로 교체(A2). +- DDL(ExperionDbContext.InitializeAsync, line ~1066~1103): `ff_column_config`, `ff_stream_config`(grade, **level_tag** 포함)을 **하나의 `ExecuteSqlRawAsync`에 여러 문장(CREATE;CREATE;ALTER)** 으로 멱등 생성(Npgsql는 다문장 허용 — PhaseI의 "한 호출 한 문장" 주의는 현재 코드에선 무효). try/catch로 "[ExperionDb] 초기화 실패" 경고 후 `return false`. + +### B.5 C-6111 태그 매핑 (브레인스토밍 확정) +| 키 | 태그 | 의미 | 역할(seed) | 전환류 시 | +|:--:|:-----|:-----|:----------|:----------| +| FEED | ficq-6101 | 원료 투입(물질수지 기준) | (입력·외란) | **차단(→0)** | +| R | ficq-6113 | 환류(reflux) | Commanded(RefluxFromProduct) | **최대(전량 환류)** | +| P | ficq-6118 | 측류 제품(PGMEA) | Commanded | **차단(→0)** | +| D | ficq-6114 | 탑정 경비물(저비점) 배출 | LevelDriven | **차단(→0)** | +| B | ficq-6116 | 탑저 중비물(고비점) 배출 | LevelDriven | **차단(→0)** | +| 보조 | tica-6111a(탑저/리보일러), pica-6111·pi-6111(진공 ~48.5torr), lica-6113/li-6111(레벨), ti-6111b/c/d(프로파일) | | | + +> 출처: D/P/B 매핑 = `knowledge/PGMEA_측류추출운전방식_주의점.md §1` + PhaseI §5.8 seed. 태그 계측현황 = `docs/보조운전-브레인스토밍.md §10.2`(ti-6111a.pv=0 고장의심 주의). +> **운전 정석(`PGMEA_측류추출운전방식_주의점.md §4.2·§4.3`)**: 측류 조성이 목표 이탈/외란 시 → ① **측류를 먼저 줄이거나 일시 중단**(오염 제품 방지) → ② **환류비를 높여 탑 내부 재안정화** → ③ 회복되면 측류 재개. "탑을 안정시킨 후 뽑는다"가 원칙. **전환류 모드(WO-6)는 이 §4.3 정석의 극단(드로우 전면 중단·전량 환류)을 상태기계로 형식화한 것.** + +--- + +## §0. 모델 공통 확장 (모든 WO 선행) — `FeedforwardModels.cs` + +WO들이 공유하는 필드를 **한 번에** 추가한다(레코드라 `with` 호환). **camelCase 직렬화는 컨트롤러 Map에서 명시** (PhaseI 규칙). + +```csharp +// enum 추가 +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ColumnMode { Normal, Recovering, Returning } // WO-6 전환류 상태기계 + +// StreamConfig 추가 필드 (DDL ff_stream_config 동반) +// P-1(θ제안)·전환류 reflux 식별용 +public bool IsReflux { get; init; } // 전환류 시 "전량 환류" 대상. (없으면 RefluxFromProduct로 대체식별) +public double RecoverySp { get; init; } = double.NaN; // 전환류 중 이 스트림 권장SP. NaN이면 규칙기본(draw=0, reflux=SpMax) + +// ColumnConfig 추가 필드 (DDL ff_column_config 동반) +// P-2(PCT/차온) +public IReadOnlyList TempTags { get; init; } = Array.Empty(); // 프로파일 온도 base tag (상→하 순서) +public string? SensitiveTrayTag { get; init; } // 민감트레이(프론트 지표). null이면 ΔT(상-하)로 대체 +public double DTdP { get; init; } = 0.0; // dT/dP [°C/압력단위]. 0이면 PCT 미적용(생온도) +public double PRef { get; init; } = double.NaN; // 압력 기준점. NaN이면 첫 정상압력 시드 +// P-1(θ 자동튜닝) +public string? SteamOpTag { get; init; } // TICA-6111A.OP(스팀) — 부분상관 2번째 입력(폐루프 오염 회피) +public bool ThetaAutoTune { get; init; } // θ 식별 가동 여부(제안만, 자동반영 X) +// P-4(느린 바이어스) +public double BiasMaWindowSec { get; init; } = 6*3600; // K_obs/k_V 장기 MA 창(기본 6h) +// WO-6(전환류 복귀) +public bool RecoveryEnabled { get; init; } // 전환류 권장 기능 on/off +public bool RecoveryAutoArm { get; init; } // true=자동권장, false=운전원 1클릭 무장 후에만 +public double ImbalanceTriggerFrac { get; init; } = 0.10; // |V_loss_MA|/F 지속 초과 시 트리거(기본 10%) +public double ImbalanceTriggerSec { get; init; } = 600; // 지속 시간(기본 10분) +public double RecoverySettleSec { get; init; } = 1800; // 전환류 평형 dwell(기본 30분) +public double ReturnRampSec { get; init; } = 600; // 복귀 시 draw/feed 램프(기본 10분) +public double FeedRecoverySp { get; init; } = 0.0; // 전환류 중 FEED 권장값(기본 0=차단) +public string? DeltaPTag { get; init; } // 탑 차압(ΔP) — 플러딩/비산 트리거(주의점§3 4순위). null=미사용 +public double DeltaPFloodLimit { get; init; } = double.MaxValue; // ΔP 상한(초과 지속 시 트리거) + +// StreamAdvisory 추가 필드 +public string? GradeReason { get; init; } // P-5 강등 사유 +public double? ThetaSuggestUpSec { get; init; } // P-1 제안 θ↑ (null=신뢰부족) +public double? ThetaSuggestDnSec { get; init; } // P-1 제안 θ↓ +public double? ThetaSuggestConf { get; init; } // P-1 상관 신뢰 0~1 +public double? KObsSuggest { get; init; } // P-4 관측 K 장기추세 제안 + +// AdvisoryResult 추가 필드 +public ColumnMode Mode { get; init; } = ColumnMode.Normal; // WO-6 +public string? ModeReason { get; init; } +public double? VLossMa { get; init; } // P-4/WO-6 장기 MA V_loss +public IReadOnlyList? Temps { get; init; } // P-2 PCT/차온 모니터 +public string? FrontPositionState { get; init; } // P-3 +public string? FrontTrimAdvice { get; init; } // P-3 + +public sealed record TempPoint(string Tag, double Raw, double Pct, bool Good); +``` + +> **레코드 확장 주의**: `StreamAdvisory`·`AdvisoryResult`는 **위치 인자(positional) record** 다. 새 필드는 **positional 파라미터로 추가하면 기존 `new StreamAdvisory(...)` 호출이 전부 깨진다.** → **새 필드는 위와 같이 `{ get; init; }` 본문 프로퍼티로 추가**(positional 생성자 불변)하고, 생성부에서 `with { ... }` 또는 object initializer로 채운다. 엔진의 기존 `new StreamAdvisory(...)`/`new AdvisoryResult(...)` 호출은 그대로 두고 뒤에 `with { GradeReason = ..., Mode = ... }`를 붙인다. + +**DDL 델타** (ExperionDbContext.InitializeAsync, line ~1102 기존 ff 블록 끝 — 같은 `ExecuteSqlRawAsync` 다문장 블록에 멱등 ALTER 추가; Npgsql 다문장 허용. 위치는 `ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS level_tag TEXT;` 바로 뒤): +```sql +ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS is_reflux BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_stream_config ADD COLUMN IF NOT EXISTS recovery_sp DOUBLE PRECISION; -- NULL=규칙기본 +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_tags TEXT; -- 콤마구분 +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS sensitive_tray_tag TEXT; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS dtdp DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS p_ref DOUBLE PRECISION; -- NULL=시드 +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS steam_op_tag TEXT; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS theta_auto_tune BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS bias_ma_window_sec DOUBLE PRECISION NOT NULL DEFAULT 21600; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_enabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_auto_arm BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_frac DOUBLE PRECISION NOT NULL DEFAULT 0.10; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS imbalance_trigger_sec DOUBLE PRECISION NOT NULL DEFAULT 600; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS recovery_settle_sec DOUBLE PRECISION NOT NULL DEFAULT 1800; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS return_ramp_sec DOUBLE PRECISION NOT NULL DEFAULT 600; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_recovery_sp DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag TEXT; +ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9; +``` +> **ConfigStore 동반 수정**: `LoadAllAsync`의 두 SELECT에 신규 컬럼 추가(읽기 인덱스 시프트 주의 — 새 컬럼은 **항상 SELECT 끝에 append**하고 새 인덱스로 읽을 것), `SaveColumnAsync`의 INSERT/UPDATE에 신규 파라미터 추가, `MapConfig`에 camelCase 필드 추가. **인덱스 시프트가 PhaseI 진단의 단골 버그**이므로 SELECT 컬럼 순서와 `rd.GetXxx(n)` 번호를 1:1 대조 검증. + +--- + +## WO-1 — P-5 confidence 자동강등 (1순위, 노력 小) + +**목적**: config의 정적 `Grade`(A/B/C)를 **실시간 입력 건전성으로 강등**해, 운전원이 "지금 이 권장값을 믿을지"를 색으로 안다. PhaseIII auto-write의 **안전 게이트 전제**(Grade A만 쓰기). +**근거**: spec §14.3(보정 3등급), §14.5(신뢰도 플래그). PhaseII §6 훅("`StreamAdvisory.Grade` ← P-5 연결점"). + +**설계**: config `Grade`는 **상한(best-case)**. 엔진이 아래 사유로 한 단계씩 강등하고 `GradeReason`에 기록. + +| 강등 사유 | 적용 | +|:----------|:-----| +| PV stale/BAD(신선도 초과) | 해당 스트림 → 최소 B, 연속 시 C | +| 과도(transient) 중 | → 한 단계 강등(정착 전 신뢰 낮음) | +| 압력 불안정(pUnstable) | 컬럼 전체 → 한 단계 강등 | +| analyzer/온도지표 부재인데 등급이 C 항목 의존(P-3 연계) | C 유지 | +| 물질수지 불일치(`mbState`≠"정상") | commanded 스트림 → 한 단계 강등 | + +**구현 (`FeedforwardEngine.cs`)** — 순수함수 유지. 헬퍼 추가: +```csharp +private static (Confidence g, string? why) Downgrade(Confidence baseG, params (bool hit,string why)[] rules) +{ + int lvl = (int)baseG; // A=0,B=1,C=2 + string? why = null; + foreach (var (hit, w) in rules) if (hit) { lvl = Math.Min(2, lvl + 1); why = why is null ? w : why + "; " + w; } + return ((Confidence)lvl, why); +} +``` +`BuildAdvisory`에서 스트림별 적용(`pv.Streams[key].Good` 신선도, `transient`, mbState 불일치 플래그를 인자로 받게 시그니처 확장), 결과를 `with { Grade = g, GradeReason = why }`. 컬럼 공통 사유(pUnstable)는 Tick에서 일괄 한 단계 추가 강등. + +**UI(ff.js, 기존 P-6 자산)**: 이미 `.ff-grade-A/B/C` 색 클래스 존재 → `s.gradeReason`을 셀 `title`(툴팁)로 노출만 추가. + +**테스트(xUnit, PhaseI §5.7 프로젝트)**: +- 신선 PV + 정상 → config Grade 그대로. +- stale PV → ≥B. transient → 한 단계. stale+transient+mb불일치 → C(바닥). +- `Downgrade`는 A를 넘어 C에서 더 내려가지 않음(클램프). + +**검증**: 빌드 0/0, `grep`로 쓰기 0건 유지, advisory 응답에 `gradeReason` 등장, 한 스트림 강제 stale 시 해당 카드만 강등. + +--- + +## WO-2 — P-2 PCT/차온 모니터 (2순위, 반쯤 완성) + +**목적**: 죽은 코드 `TempCorrection.PressureCompensated`를 **엔진에 배선** + `DiffTemp` 추가 → 진공노이즈 제거된 PCT·차온을 모니터로 산출. **P-3(프론트 위치)·P-5(C등급 근거)의 입력**. +**근거**: spec §13.3(PCT/ΔT), §13.6(블록), §14.1(dT/dP≈0.5°C/torr — 진공 ±2torr가 구배 절반 → PCT 필수). + +**신규 블록 (`ComputationBlocks.cs`)**: +```csharp +/// 차온/이중차온 — 공통모드(압력·계절) 상쇄, 프론트 이동만 부각. §13.3 +public static class DiffTemp +{ + public static double Delta(double tHi, double tLo) => tHi - tLo; // 두 트레이 차온 + public static double Double(double tA, double tB, double tC) => (tA - tB) - (tB - tC); // 이중차온(곡률) +} +``` +> `TempCorrection.PressureCompensated`는 그대로 사용(추가 코드 없음). PCT = `T_meas − dTdP·(P − P_ref)`. + +**엔진 배선**: +- `ColumnState`에 `FirstOrderLag PRefSeed`(또는 단순 `double _pRef`) 추가 — `cfg.PRef`가 NaN이면 첫 정상 압력으로 시드. +- `BuildSnapshotAsync`(Supervisor)에 `cfg.TempTags` PV 읽기를 추가(이미 feed/pressure/levels/streams 읽는 패턴 재사용). `PvSnapshot`에 `IReadOnlyList Temps`를 추가(positional이므로 **새 record 필드는 init 프로퍼티로** 추가하거나 `PvSnapshot`를 확장 — 본 문서는 `PvSnapshot`에 `Temps` init 프로퍼티 추가 권장). +- Tick에서 각 온도에 PCT 계산 → `AdvisoryResult.Temps`(`TempPoint`)로 저장. `dTdP==0`이면 PCT=raw(생온도, 모니터만). +- **advisory-only**: Temps는 표시·P-3 입력일 뿐 권장SP에 영향 없음(이번 WO 한정). + +**ConfigStore/Controller/DDL**: §0의 `temp_tags`·`dtdp`·`p_ref`·`sensitive_tray_tag` 반영(load/save/map). + +**UI(ff.js)**: 카드 하단에 온도 미니행(태그·raw·PCT) 1줄. 없으면 생략. + +**테스트**: `TempCorrection`(P 상승 시 PCT가 raw보다 낮아짐, dTdP=0이면 PCT=raw), `DiffTemp.Delta/Double` 산술, `PRef` NaN 시드 경로. + +**검증**: dtdp>0 컬럼에서 진공 흔들 때 raw는 출렁이나 PCT는 평탄(공통모드 상쇄) 확인. + +--- + +## WO-3 — P-1 θ 자동튜닝 (3순위, 노력 大·통계) + +**목적**: seed θ/τ가 전부 placeholder(PhaseI §5.8 경고)인 문제를 해소. **정상 운전 중 자연외란**으로 θ를 **passive 식별**해 **제안만**(자동반영 금지·운전원 승인 시 config 반영). +**근거**: spec §13.4(교차상관 θ, 스팀 부분상관으로 폐루프 오염 회피), §13.7(θ는 신뢰도 등급 붙은 추정치). + +> **현실 경고(spec §13.2)**: 단일점 생온도 SNR 낮음 → **WO-2의 PCT/ΔT를 입력으로** 쓰고, **스팀 OP(`SteamOpTag`)를 2번째 입력으로 부분상관**해 TICA 폐루프 동특성을 θ로 오귀속하지 않게 한다. 외란 부족 시 **신뢰도 낮음 → 제안 억제(null)**. + +**신규 블록 (`ComputationBlocks.cs` 또는 신규 `CrossCorrLagEstimator.cs`)** — 계약(시그니처) 고정: +```csharp +/// +/// Passive 전달지연 식별. ΔF(피드 변화)와 ΔS_i(=ΔPCT/Δflow) 의 교차상관 최대 지연 = θ. +/// 스팀 ΔS_steam 을 2번째 입력으로 부분상관(partial corr)해 폐루프 오염 제거(§13.4). +/// 사전백색화(pre-whitening=1차차분) 적용. I/O 없음, 컬럼 루프 단일 소유(락 불필요). +/// +public sealed class CrossCorrLagEstimator +{ + public CrossCorrLagEstimator(int maxLagSamples, int historySamples, double minSignalStd); + /// 매 틱 호출. 반환: 충분한 외란 누적 후에만 (θup,θdn,conf), 아니면 null. + public (double thetaUpSec, double thetaDnSec, double conf)? Push( + double dFeed, double dResponse, double dSteam, double tsSec); +} +``` +**알고리즘(구현 지침)**: +1. 입력은 **1차차분(Δ)** 받음(미분=사전백색화). 링버퍼(historySamples)에 `dFeed`,`dResponse`,`dSteam` 누적. +2. 외란 검정: `std(dFeed) < minSignalStd` → 신뢰 0, null 반환(억제). +3. **부분상관**: `dResponse`에서 `dSteam` 선형회귀 성분 제거(잔차 `r = dResponse − β·dSteam`, β=cov/var). 이후 `ρ(τ)=corr(dFeed[t], r[t+τ])` for τ∈[0,maxLag]. +4. `θ = argmax_τ ρ(τ)·ts`. `conf = max ρ`(0~1, 음수면 0). +5. 상승/하강 비대칭: `dFeed>0` 표본만으로 θup, `dFeed<0` 표본만으로 θdn 별도 추정(표본 부족 시 공통값). +6. `conf < 0.3`면 제안 억제(null). + +**배선**: `StreamState`에 컬럼당 1개 estimator(또는 commanded 스트림별). Tick에서 `cfg.ThetaAutoTune && WO-2 PCT 가용` 일 때만 `Push` → `StreamAdvisory.ThetaSuggestUpSec/DnSec/Conf` 채움. **config θ는 변경하지 않음**(제안 전용). + +**UI**: 카드에 "θ 제안 ↑NNs ↓NNs (conf 0.x)" 보조행. 운전원이 설정 에디터에서 수동 반영. + +**테스트**: 합성 신호(알려진 θ로 지연된 응답 + 노이즈)에 대해 추정 θ가 ±1 샘플 내. 외란 std 미달 시 null. 스팀 상관 주입 시 부분상관이 제거하는지(스팀만 상관된 가짜 지연은 conf 낮게). + +**검증**: 라이브에서 ThetaAutoTune=true 컬럼이 외란 충분 시에만 제안 노출, config 무변경(쓰기 0건) 확인. + +> **현실성**: 데모 시스템 온도는 인위 생성(spec §13.7) → 실플랜트 전 **검증 보류 가능**. 본 WO는 **인터페이스·블록·테스트까지** 턴키로 두되, 가동 스위치(`ThetaAutoTune`)는 기본 false. + +--- + +## WO-4 — P-4 느린 바이어스 적응 (4순위) + +**목적**: 계절 CW 스윙 등 **크지만 느린 외란**(spec §14.4)을 정밀모델 대신 **장기 MA로 K_obs·k_V 추세**를 내어 운전원에게 "계절 보정 제안". 자동 변경 아님. +**근거**: spec §14.4(느린 바이어스/운전원 트림), §14.3 B등급(V_loss는 장기 MA로만 의미). + +**설계**: +- `ColumnState`에 `MovingAverage`(창=`BiasMaWindowSec/ScanSec` 샘플) 2개: `VLossMa`, 그리고 commanded 스트림별 `KObsMa`(=PV/FeedFiltered 의 MA). +- **정상상태에서만 갱신**(transient·BAD 제외) — 과도 표본 오염 방지. +- 산출: `AdvisoryResult.VLossMa`, `StreamAdvisory.KObsSuggest`(= K_obs MA, config TargetCoeff와 비교해 드리프트 표시). +- **advisory-only**: 제안값일 뿐 엔진 K는 config 그대로. + +**테스트**: 일정 비율 입력 스텝 후 MA가 천천히 수렴(창 길이만큼), 과도 표본은 MA에 안 들어감. + +**검증**: 장기 가동 후 KObsSuggest가 config K 부근, 인위적 bias 주입 시 추세 이동. + +> WO-4의 `VLossMa`는 **WO-6 전환류 트리거 입력**으로 재사용(순간 V_loss는 §5.3대로 신뢰불가 → MA로 판정). + +--- + +## WO-5 — P-3 Sweet-Spot / 프론트 위치 지표 (5순위, P-2 의존) + +**목적**: WO-2의 제품존 PCT/ΔT(민감트레이)를 **프론트 위치 프록시**로 삼아 드리프트 시 **환류/boilup 트림 권장**(advisory). analyzer 있으면 우선. +**근거**: spec §13.5(2층 구조: 빠른 에너지=피드포워드, 느린 조성=온도 피드백), §13.2 함정②(제품존 신호 약함 → 차온 필수), §14.3 C등급. + +**신규 블록 (`FrontPositionIndicator.cs`)**: +```csharp +/// 제품존 PCT/ΔT 의 기준대비 드리프트 → sweet-spot 건전성 + 트림 방향 권장. advisory. +public sealed class FrontPositionIndicator +{ + public FrontPositionIndicator(double bandwidth, double refTau); + public (string state, string? trimAdvice, Confidence grade) Update( + double frontMetric, double tsSec); // frontMetric = 민감트레이 PCT 또는 제품존 ΔT +} +``` +- 느린 기준(EMA refTau, 예 30~60min)에서 `frontMetric` 이탈량 산출. +- 밴드 내=「정상」 / 위로 드리프트=「프론트 상승 — 경비물 혼입 위험: 환류↑ 권장」(브레인스토밍 Q2/Q3 정석) / 아래=「프론트 하강 — boilup↑/환류↓ 권장」. +- 등급: 단일 생온도면 C(신호 약함), 차온/analyzer면 B 이상. +- **트림은 권장 문구만**(`AdvisoryResult.FrontPositionState`/`FrontTrimAdvice`) — SP 미변경. + +**배선**: WO-2 Temps에서 `SensitiveTrayTag`(없으면 상-하 ΔT) 추출 → Indicator.Update → AdvisoryResult 필드. + +**UI**: 카드 배너에 프론트 상태/트림 권장 표시(P-6 `ff-note` 자리 활용). + +**테스트**: 기준 대비 상/하 드리프트에 대한 state·trim 분기, 밴드 내 「정상」, 등급 강등(생온도→C). + +--- + +## WO-6 — 전환류(Total Reflux) 평형복귀 모드 ★ 신규 + +**요구(운전원)**: 컬럼 균형이 **심각하게 깨졌다고 판단되면**, **전환류 모드**로 전환 — **제품(P)·원료투입(F)·경비물(D)·중비물(B) 제거를 모두 차단**하고 **환류(R)를 전량 환류**로 두어 **평형 복귀**할 때까지 유지, 회복 후 정상 복귀. + +**공정 근거**: 증류 정석의 *total reflux* 회복기동 = `knowledge/PGMEA_측류추출운전방식_주의점.md §4.3`("외란 시: 측류 먼저 중단 → 환류비↑ 재안정화 → 회복 후 재개")의 극단형. 외란으로 조성 프론트가 무너지면, 드로우·피드를 끊고 내부 환류만 순환시키면 단별 조성 프로파일이 **재평형**된다. 측류추출탑은 측류 조성이 상부 경비물·하부 중비물 **균형에 전적 의존**(동 §2)하므로 균형 붕괴 시 **측류 혼입(off-spec) 직전 회복 카드**. + +### 6.1 아키텍처 결정 (불변식 준수) +- **Phase II 범위 = 권장·상태표시까지**. 전환류는 본질적으로 **쓰기 동작**(드로우/피드 SP=0, 환류=max)이므로, **실제 실행은 PhaseIII(WriteGuard) 경유**. 본 WO는 **모드 판정 상태기계 + 권장 SP 산출 + UI 경보/표시**까지(쓰기 0건). +- **트리거 권위 2모드**: `RecoveryAutoArm=false`(기본) → 엔진은 "전환류 권장(ARMED)"만 띄우고 **운전원 1클릭 확인** 후 Recovering 진입(PhaseIII에서 실제 쓰기). `=true` → 자동 권장 상태기계가 직접 Recovering 진입(여전히 표시/권장, 쓰기는 PhaseIII gating). +- **오발동 비용 큼**(피드 차단=생산중단) → 트리거는 **순간 V_loss 금지**(§5.3). 과도(transient) 중엔 트리거 금지. +- **다신호 트리거(canonical 근거: `PGMEA_측류추출운전방식_주의점.md §3`)** — "균형 심각 붕괴"는 단일 V_loss보다 아래 **지속 조건의 OR**로 판정(어느 하나라도 `ImbalanceTriggerSec` 지속 시 ARM): + - **① 물질수지**: `|VLossMa|/F > ImbalanceTriggerFrac` (WO-4 장기 MA). + - **② 프론트(감도트레이)**: WO-5 `FrontPositionState`=심각 드리프트(주의점 §3 1순위 = 감도트레이 온도. **가장 신뢰도 높은 조기신호**). + - **③ 플러딩/비산**: `DeltaPTag` 가용 시 `ΔP > DeltaPFloodLimit` (주의점 §3 4순위 — 비산이 제품 오염 직결). + - 신호별 가용성은 config로 결정(태그 없으면 해당 조건 비활성). + +### 6.2 상태기계 (`ColumnMode`) +``` + ┌────────────────────────── Normal ──────────────────────────┐ + │ |VLossMa|/F > ImbalanceTriggerFrac 지속> ImbalanceTriggerSec │ + │ AND !transient │ + │ AND (RecoveryAutoArm OR 운전원 ARM 확인) │ + ▼ │ + Recovering(전환류) │ + 권장: F→FeedRecoverySp(0), P/D/B→0(또는 RecoverySp), R→SpMax(전량) │ + dwell: 평형지표 안정(|VLossMa|/F < Frac·0.5 AND 프론트(WO-5) 정상) │ + 을 RecoverySettleSec 동안 연속 만족 │ + ▼ │ + Returning(복귀 램프) │ + ReturnRampSec 동안 F·draw를 정상 권장값으로 RateLimiter 램프 복원 │ + 완료 → Normal ──────────────────────────────────────────────────┘ + (어느 상태든 운전원 수동 Normal 복귀 가능 / FEED BAD 지속 시 Recovering 유지) +``` + +### 6.3 엔진 구현 (`FeedforwardEngine.cs` + `ColumnState`) +- `ColumnState`에 추가: `ColumnMode Mode`, `double ImbalanceTimerSec`, `double RecoverySettleTimerSec`, `double ReturnTimerSec`, `bool OperatorArmed`(컨트롤러가 set). +- **순수성 유지**: 상태 전이는 Tick 내에서 `st`(가변 ColumnState)로 처리 — 기존 SettleTimer와 동일 패턴(I/O 없음). +- 절차(Tick 말미, advisory 산출 후): + 1. `severe = ① || ② || ③` (위 다신호; 가용 신호만 평가). `① frac=|VLossMa|/ff > ImbalanceTriggerFrac`, `② WO-5 FrontPositionState 심각`, `③ ΔP>DeltaPFloodLimit`. + 2. **Normal**: `!transient && severe` → `ImbalanceTimerSec += ts` else `=0`. `ImbalanceTimerSec ≥ ImbalanceTriggerSec` + (AutoArm || OperatorArmed) → `Mode=Recovering`, 타이머 리셋, OperatorArmed=false. (AutoArm=false면 ARM 없이는 "전환류 권장(ARMED 대기)" 표시만.) + 3. **Recovering**: 스트림 권장값을 **오버라이드** — reflux(IsReflux||RefluxFromProduct)=`SpMax`, 그 외 commanded draw=`RecoverySp(NaN→0)`, FEED 권장=`FeedRecoverySp`. 평형조건(`frac < Frac*0.5 && 프론트 정상`) 연속 만족 시 `RecoverySettleTimerSec += ts`, 도달 시 `Mode=Returning`. + 4. **Returning**: `ReturnTimerSec += ts`; 진행률 `α=min(1, ReturnTimerSec/ReturnRampSec)`로 draw/feed 권장을 0→정상값 보간(또는 RateLimiter가 자연 램프). `α>=1` → `Mode=Normal`. + 5. `AdvisoryResult`에 `with { Mode = st.Mode, ModeReason = ..., VLossMa = ... }`. Recovering/Returning에선 각 `StreamAdvisory.RecommendedSp`가 오버라이드값, `Note`에 "전환류 복귀" 표기. **Grade는 강등하지 않되 Valid=false(운전원 인가 필요)**. +- **FEED 권장 노출**: FEED는 스트림이 아니므로 `AdvisoryResult`에 `FeedRecommendedSp`(double?) init 프로퍼티 추가 — Recovering 시 `FeedRecoverySp`, 그 외 null. + +### 6.4 컨트롤러 (`FeedforwardController.cs`) +- `POST api/ff/recovery/{columnId}/arm` — 운전원 ARM(=Supervisor 통해 해당 ColumnState.OperatorArmed=true). **Supervisor를 singleton 주입**(A2 정정으로 가능)해 컬럼 상태 접근. 쓰기 아님(모드 판정용 플래그). +- `POST api/ff/recovery/{columnId}/cancel` — 수동 Normal 복귀. +- (PhaseIII에서) 실제 SP 쓰기는 별도 `apply` 엔드포인트가 WriteGuard 경유. + +### 6.5 UI (ff.js / ff.css) +- 카드 헤더에 모드 뱃지: `Normal`(무표시) / `전환류 복귀중 ●`(주황) / `복귀 램프 ●`(파랑) / `전환류 권장(ARMED)`(점멸 경보 + [확인] 버튼 → arm 호출). +- Recovering/Returning 시 표에 "권장 SP" 열이 0/max 오버라이드로 표시, Valid=false라 흐리게(`ff-stale`)·"운전원 인가" 주석. + +### 6.6 DDL/ConfigStore +§0의 recovery 컬럼들(`recovery_enabled`, `recovery_auto_arm`, `imbalance_trigger_frac`, `imbalance_trigger_sec`, `recovery_settle_sec`, `return_ramp_sec`, `feed_recovery_sp`) + stream `is_reflux`/`recovery_sp` 반영. + +### 6.7 테스트 (xUnit) +- 합성 시퀀스: 정상 → VLossMa 임계 지속 초과(+!transient) → `Recovering` 진입. AutoArm=false면 OperatorArmed 없이는 **진입 안 함**. +- Recovering 권장값: reflux=SpMax, P/D/B=0, FeedRecommendedSp=FeedRecoverySp. +- 평형 회복(frac↓ 지속 RecoverySettleSec) → `Returning` → ReturnRampSec 경과 → `Normal`. +- transient 중엔 트리거 타이머 누적 안 됨. +- 수동 cancel 시 즉시 Normal. +- **쓰기 0건**(엔진/컨트롤러 grep) — 모드 판정·권장만. + +### 6.8 안전 결정 (문서화) +| 항목 | 결정 | +|:-----|:-----| +| 실행 권한 | 권장·표시는 PhaseII, **실제 SP 쓰기는 PhaseIII WriteGuard** 경유(전환류는 대규모 조작이라 운전원 확인 강제 권장) | +| 오발동 방지 | 순간 V_loss 금지, `VLossMa`(장기 MA) 지속 초과 + `!transient` + (AutoArm||운전원 ARM) | +| 복귀 부드�러움 | Returning에서 RateLimiter 램프 — bumpless | +| 트리거 보수성 | 기본 `RecoveryAutoArm=false`(운전원 1클릭). 자동무장은 신뢰 확보 후 | +| point of no return | 프론트(WO-5) "경비물 혼입 위험" 단계에서 **선제 ARM 권장**(정석) | + +--- + +## §C. 통합 검증 (감독자 — diagnosis-checklist.md 8단계) + +1. **빌드**: `dotnet build src/Web/ExperionCrawler.csproj` 경고0/에러0. +2. **쓰기 불변식**(FF 경로 한정): `grep -rE "ExperionOpcWriteClient|Write.*Async" src/Infrastructure/Control src/Web/Controllers/FeedforwardController.cs` → **0건**(범용 `ExperionOpcWriteClient.cs`는 OpcUa 폴더라 미포함 — 정상). WO 전체 advisory. +3. **DI(A2)**: WO-6 적용 시 `FeedforwardSupervisor`가 singleton+hosted로 노출되어 **인스턴스 1개만 가동**(로그 틱 루프 1회) + 컨트롤러 주입 가능. WO-6 전이면 현 단일 AddHostedService 유지. +4. **인덱스 정합(§0)**: ConfigStore SELECT 컬럼 ↔ `rd.GetXxx(n)` 1:1, 저장→재로드 라운드트립 일치(신규 필드 포함). +5. **단위테스트**: WO-1~WO-6 케이스 + PhaseI 기존 4 모두 green. +6. **라이브**: Tab 18에서 등급 강등(WO-1)·PCT(WO-2)·θ제안(WO-3)·KObs추세(WO-4)·프론트 트림(WO-5)·전환류 모드 뱃지/ARM(WO-6) 표시. 폴링 누수 없음. +7. **전환류 시나리오**: 인위적 V_loss bias 주입 → VLossMa 임계 지속 → (ARM 후) Recovering 권장(R=max, F/P/D/B=0) → 회복 → Returning → Normal. 쓰기 0건. + +--- + +## §D. P-7 (PhaseIII auto-write) — 기존 문서 정정 메모 + +PhaseIII 문서는 대체로 유효하나 착수 전 아래 정정: +- **D1**: PhaseIII §6이 지목한 `OpcUaClientService.cs`는 **그 이름으론 부재**. 그러나 **`src/Infrastructure/OpcUa/ExperionOpcWriteClient.cs`(namespace `ExperionCrawler.Infrastructure.Control`, `Opc.Ua.Client` 사용)가 이미 존재** — 쓰기 래퍼를 **신규 작성하지 말고 이 기존 클라이언트를 재사용/확장**(가드는 WriteGuard로 상위에서). 기존 읽기 서비스(Realtime/History/Metadata 등)는 건드리지 말 것. +- **D2**: NodeId `ns=3;s="{tag}.sp"` 는 **미검증 가정** — `ExperionOpcWriteClient`의 실제 노드 지정 방식 + 서버 브라우즈로 **확인 후 확정**. +- **D3**: WriteGuard 게이트는 **WO-1의 동적 Grade**(정적 config Grade 아님) + `!Transient` + SafetyMaxDelta. WO-6 전환류는 별도 `apply` 경로로 **운전원 확인 강제**. +- **D4**: PhaseIII §1.2("B/C 금지")와 §4.3("스트림 단위 A만 대상")은 **per-stream 동적 Grade 기준**으로 통일. +- **D5**: auth 재도입(`IKbAuthService`)은 **쓰기 엔드포인트에만**(advisory/config 읽기는 공개 유지). + +--- + +## §E. 턴키 요약 + +| WO | 항목 | 노력 | 신규 파일 | 변경 파일 | 가동 스위치(기본) | +|:--:|:-----|:----:|:----------|:----------|:------------------| +| A | 문서 드리프트 정정(§B 기준선화) | 小 | — | (WO-6 시 Program.cs DI 교체) | — | +| 0 | 모델·DDL 공통확장 | 中 | — | Models/ConfigStore/Context/Controller | — | +| 1 | P-5 등급 자동강등 | 小 | — | Engine, ff.js | 항상 | +| 2 | P-2 PCT/차온 | 小 | (DiffTemp 블록) | Blocks/Engine/Supervisor/Store/Ctrl | dtdp>0 시 | +| 3 | P-1 θ 자동튜닝 | 大 | CrossCorrLagEstimator | Engine/State/Supervisor | ThetaAutoTune=false | +| 4 | P-4 느린 바이어스 | 中 | — | Engine/State | 항상(제안) | +| 5 | P-3 프론트 위치 | 中 | FrontPositionIndicator | Engine/State | 온도 가용 시 | +| 6 | 전환류 복귀 | 大 | — | Engine/State/Ctrl/ff.js/css | RecoveryEnabled=false, AutoArm=false | +| D | P-7 정정 | — | (PhaseIII) | — | — | + +**구현 순서**: A(정정) → 0(공통확장·DDL·ConfigStore 인덱스검증) → 1 → 2 → 3 → 4 → 5 → 6 → C(검증). 각 WO는 **빌드+해당 테스트 green + 쓰기 0건 grep** 후 다음 진행. + +**불변식 재확인**: 본 문서 전체 범위에서 **제어 레지스터 쓰기 0건**. 전환류 포함 모든 실제 SP 쓰기는 PhaseIII. + + + + +File created successfully: /home/windpacer/projects/ExperionCrawler/docs/측류추출식-통합유량설정공식-구현코딩-PhaseII-분석엔진+전환류복귀.md \ No newline at end of file diff --git a/docs/측류추출식-통합유량설정공식-구현코딩-PhaseII.md b/docs/측류추출식-통합유량설정공식-구현코딩-PhaseII.md new file mode 100644 index 0000000..53e6aee --- /dev/null +++ b/docs/측류추출식-통합유량설정공식-구현코딩-PhaseII.md @@ -0,0 +1,514 @@ +# 측류추출 통합유량 — Phase II UI 구현 코딩 (Tab 18: 설정 + 권장 SP 대시보드) + +> **성격**: Phase I advisory 엔진(`...-PhaseI.md`)의 **Web UI 코딩 명세 + 검증 절차**. +> 감독자가 `diagnosis-checklist.md` 8단계로 진단한 뒤 반영. **advisory 불변식 유지** — 제어 레지스터 쓰기 0건. +> Phase II는 **운전원이 경험상수를 공급**하고 **권장 SP를 화면에서 본다**(수동 인가). 자동 쓰기는 Phase III. + +**Phase II 범위 분리**: +- **본 문서 = UI 코딩**: ① 설정 CRUD API(admin) ② Tab 18 = 설정 에디터 + 권장 SP 대시보드. +- **Phase II-분석(별도)**: θ 자동튜닝·PCT/차온·front-position·confidence 자동강등·느린 바이어스(= PhaseI §6 P-1~P-5). 본 문서 §6에 인터페이스 훅만. + +--- + +## 0. 기존 UI 아키텍처 전제 (확인됨) + +| 요소 | 사실 | +|:-----|:-----| +| 탭 라우터 | `core.js`의 `paneInit` 맵 + `activateTab(tab)` → `data-src`(`/panes/.html`) HTML을 fetch해 주입 후 `paneInit[tab]?.()` 호출 | +| 탭 등록 | ① `index.html` `