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; if (!pv.Feed.Good || !Num.IsFinite(pv.Feed.Value)) return Hold(cfg, st, now, "FEED BAD"); var ff = st.FeedFilter.Step(pv.Feed.Value, cfg.FeedFilterTauSec, ts); if (!st.Initialized) { SeedAll(cfg, pv, st, ff); st.Initialized = true; } var dF = st.FeedDeriv.Update(ff, ts); 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" : ""; var outs = new List(cfg.Streams.Count); double? prodRec = null; foreach (var s in cfg.Streams) { if (s.RefluxFromProduct) continue; 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)); } 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); } 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; 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 미적용)"); default: 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, !transient && s.Role != StreamRole.Monitor, s.Grade, s.LevelTag, 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, false, s.Grade, s.LevelTag, $"홀드: {reason}"); }).ToList(); return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, true, reason, st.FeedFilter.Value, outs, null, null, $"홀드: {reason}"); } }