From 7688757b21725f7f3b9a42a58a9f7e4960c3642e Mon Sep 17 00:00:00 2001 From: windpacer Date: Sun, 31 May 2026 17:31:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Feedforward=20advisory=20engine=20Phase?= =?UTF-8?q?=20I=20=E2=80=94=20core=20C#=20models/blocks/engine/supervisor/?= =?UTF-8?q?store/controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feedforward/FeedforwardModels.cs | 71 +++++++ .../Feedforward/IFeedforwardStores.cs | 15 ++ .../Control/ComputationBlocks.cs | 123 +++++++++++ .../Control/FeedforwardAdvisoryStore.cs | 12 ++ .../Control/FeedforwardConfigStore.cs | 196 ++++++++++++++++++ .../Control/FeedforwardEngine.cs | 163 +++++++++++++++ .../Control/FeedforwardSupervisor.cs | 104 ++++++++++ src/Web/Controllers/FeedforwardController.cs | 96 +++++++++ 8 files changed, 780 insertions(+) create mode 100644 src/Core/Application/Feedforward/FeedforwardModels.cs create mode 100644 src/Core/Application/Feedforward/IFeedforwardStores.cs create mode 100644 src/Infrastructure/Control/ComputationBlocks.cs create mode 100644 src/Infrastructure/Control/FeedforwardAdvisoryStore.cs create mode 100644 src/Infrastructure/Control/FeedforwardConfigStore.cs create mode 100644 src/Infrastructure/Control/FeedforwardEngine.cs create mode 100644 src/Infrastructure/Control/FeedforwardSupervisor.cs create mode 100644 src/Web/Controllers/FeedforwardController.cs diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs new file mode 100644 index 0000000..14390c1 --- /dev/null +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -0,0 +1,71 @@ +using System.Text.Json.Serialization; + +namespace ExperionCrawler.Core.Application.Feedforward; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum StreamRole { Commanded, LevelDriven, Monitor } + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Confidence { A, B, C } + +public sealed record StreamConfig +{ + public string Key { get; init; } = ""; + public string FlowTag { get; init; } = ""; + public StreamRole Role { get; init; } = StreamRole.Monitor; + public string? LevelTag { get; init; } + public double TargetCoeff { get; init; } + public double ThetaUpSec { get; init; } + public double ThetaDnSec { get; init; } + public double TauSec { get; init; } + 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; } + public Confidence Grade { get; init; } = Confidence.A; +} + +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; + public string FeedTag { get; init; } = ""; + 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; + public double FeedMoveThresholdPerMin { get; init; } = 0.0; + public double PressFilterTauSec { get; init; } = 60.0; + public double PressureBand { get; init; } = double.MaxValue; + public double SettleSec { get; init; } = 0.0; + public double StaleSec { get; init; } = 120.0; + public string? ProductKey { get; init; } = "P"; + public IReadOnlyList Streams { get; init; } = Array.Empty(); +} + +public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp); + +public sealed record PvSnapshot( + TagSample Feed, + TagSample? Pressure, + IReadOnlyList Levels, + IReadOnlyDictionary Streams); + +public sealed record StreamAdvisory( + string Key, string FlowTag, StreamRole Role, + double Pv, double? RecommendedSp, double? Gap, + int Trend, + bool Valid, + Confidence Grade, + string? LevelTag, + string Note); + +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); diff --git a/src/Core/Application/Feedforward/IFeedforwardStores.cs b/src/Core/Application/Feedforward/IFeedforwardStores.cs new file mode 100644 index 0000000..1425224 --- /dev/null +++ b/src/Core/Application/Feedforward/IFeedforwardStores.cs @@ -0,0 +1,15 @@ +namespace ExperionCrawler.Core.Application.Feedforward; + +public interface IFeedforwardConfigStore +{ + Task> LoadAllAsync(CancellationToken ct = default); + Task SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default); + Task DeleteColumnAsync(int columnId, CancellationToken ct = default); +} + +public interface IFeedforwardAdvisoryStore +{ + void Set(AdvisoryResult result); + AdvisoryResult? Get(int columnId); + IReadOnlyCollection GetAll(); +} diff --git a/src/Infrastructure/Control/ComputationBlocks.cs b/src/Infrastructure/Control/ComputationBlocks.cs new file mode 100644 index 0000000..d0d7ee9 --- /dev/null +++ b/src/Infrastructure/Control/ComputationBlocks.cs @@ -0,0 +1,123 @@ +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); +} + +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); + _y += (x - _y) * a; + return _y; + } +} + +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) **읽기 오프셋만 가변** — 히스토리를 보존한다. +/// (이전 구현은 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; // θ용량을 need 이상으로 확보(증가 전용). 기존 이력은 논리순서로 보존. + private void EnsureCapacity(int need, double seed) + { + if (_seeded && need <= _cap) return; + int newCap = Math.Max(need, Math.Max(_cap, 1)); + var nb = new double[newCap]; + if (!_seeded) + { + Array.Fill(nb, seed); // 최초: 현재값으로 사전충전(bumpless) + } + else + { + int extra = newCap - _cap; + double oldest = _buf[_head]; + for (int i = 0; i < extra; i++) nb[i] = oldest; // 앞쪽 패딩=가장 오래된 값 + for (int k = 0; k < _cap; k++) nb[extra + k] = _buf[(_head + k) % _cap]; // 오래된→최신 + } + _buf = nb; _cap = newCap; _head = 0; // head=0: 인덱스0이 가장 오래된, cap-1이 최신 + _seeded = true; + } +} + +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; + } +} + +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; + } +} + +public static class TempCorrection +{ + public static double PressureCompensated(double tMeas, double p, double pRef, double dTdP) + => tMeas - dTdP * (p - pRef); +} diff --git a/src/Infrastructure/Control/FeedforwardAdvisoryStore.cs b/src/Infrastructure/Control/FeedforwardAdvisoryStore.cs new file mode 100644 index 0000000..b39b89a --- /dev/null +++ b/src/Infrastructure/Control/FeedforwardAdvisoryStore.cs @@ -0,0 +1,12 @@ +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; + public AdvisoryResult? Get(int id) => _latest.TryGetValue(id, out var r) ? r : null; + public IReadOnlyCollection GetAll() => _latest.Values.ToArray(); +} diff --git a/src/Infrastructure/Control/FeedforwardConfigStore.cs b/src/Infrastructure/Control/FeedforwardConfigStore.cs new file mode 100644 index 0000000..0c9bd69 --- /dev/null +++ b/src/Infrastructure/Control/FeedforwardConfigStore.cs @@ -0,0 +1,196 @@ +using System.Data; +using System.Data.Common; +using ExperionCrawler.Core.Application.Feedforward; +using ExperionCrawler.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +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); + + 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, + 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()); + } + } + + 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, level_tag + 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, + LevelTag = rd.IsDBNull(14) ? null : rd.GetString(14).ToLowerInvariant(), + 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(); + } + + private static DbParameter P(DbCommand cmd, string name, object? val) + { + var p = cmd.CreateParameter(); + p.ParameterName = name; + p.Value = val ?? DBNull.Value; + cmd.Parameters.Add(p); + return p; + } + + public async Task SaveColumnAsync(ColumnConfig cfg, CancellationToken ct = default) + { + var conn = _ctx.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); + await using var tx = await conn.BeginTransactionAsync(ct); + + int id = cfg.Id; + var levelTags = string.Join(',', cfg.LevelTags); + + if (id == 0) + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = """ + INSERT INTO ff_column_config + (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, advisory_only) + VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,TRUE) + RETURNING id + """; + P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); + P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); P(cmd,"@lvl",levelTags.ToLowerInvariant()); + P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); + P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); P(cmd,"@settle",cfg.SettleSec); + P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P"); + id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); + } + else + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = """ + UPDATE ff_column_config SET + name=@name, enabled=@en, feed_tag=@feed, pressure_tag=@pres, level_tags=@lvl, + scan_sec=@scan, feed_filter_tau_sec=@fft, feed_move_thr_per_min=@fmt, + press_filter_tau_sec=@pft, pressure_band=@pb, settle_sec=@settle, + stale_sec=@stale, product_key=@pk, advisory_only=TRUE + WHERE id=@id + """; + P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); + P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@pres",(object?)cfg.PressureTag?.ToLowerInvariant()); + P(cmd,"@lvl",levelTags.ToLowerInvariant()); P(cmd,"@scan",cfg.ScanSec); P(cmd,"@fft",cfg.FeedFilterTauSec); + P(cmd,"@fmt",cfg.FeedMoveThresholdPerMin); P(cmd,"@pft",cfg.PressFilterTauSec); P(cmd,"@pb",cfg.PressureBand); + P(cmd,"@settle",cfg.SettleSec); P(cmd,"@stale",cfg.StaleSec); P(cmd,"@pk",cfg.ProductKey ?? "P"); + await cmd.ExecuteNonQueryAsync(ct); + } + + // 스트림 원자적 교체 + await using (var del = conn.CreateCommand()) + { + del.Transaction = tx; del.CommandText = "DELETE FROM ff_stream_config WHERE column_id=@id"; + P(del,"@id",id); await del.ExecuteNonQueryAsync(ct); + } + foreach (var s in cfg.Streams) + { + await using var ins = conn.CreateCommand(); + ins.Transaction = tx; + ins.CommandText = """ + 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, level_tag) + VALUES (@cid,@key,@flow,@role,@k,@tup,@tdn,@tau,@smin,@smax,@rup,@rdn,@rfp,@grade,@lvlTag) + """; + P(ins,"@cid",id); P(ins,"@key",s.Key); P(ins,"@flow",s.FlowTag.ToLowerInvariant()); + P(ins,"@role",s.Role.ToString()); P(ins,"@lvlTag",(object?)s.LevelTag?.ToLowerInvariant() ?? DBNull.Value); P(ins,"@k",s.TargetCoeff); P(ins,"@tup",s.ThetaUpSec); + P(ins,"@tdn",s.ThetaDnSec); P(ins,"@tau",s.TauSec); P(ins,"@smin",s.SpMin); P(ins,"@smax",s.SpMax); + P(ins,"@rup",s.RateUpPerMin); P(ins,"@rdn",s.RateDnPerMin); P(ins,"@rfp",s.RefluxFromProduct); + P(ins,"@grade",s.Grade.ToString()); + await ins.ExecuteNonQueryAsync(ct); + } + + await tx.CommitAsync(ct); + return id; + } + + public async Task DeleteColumnAsync(int columnId, CancellationToken ct = default) + { + var conn = _ctx.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM ff_column_config WHERE id=@id"; + P(cmd,"@id",columnId); + await cmd.ExecuteNonQueryAsync(ct); + } +} diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs new file mode 100644 index 0000000..08552fb --- /dev/null +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -0,0 +1,163 @@ +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}"); + } +} diff --git a/src/Infrastructure/Control/FeedforwardSupervisor.cs b/src/Infrastructure/Control/FeedforwardSupervisor.cs new file mode 100644 index 0000000..f5edd57 --- /dev/null +++ b/src/Infrastructure/Control/FeedforwardSupervisor.cs @@ -0,0 +1,104 @@ +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) + { + 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) + { + 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.Where(s => s.LevelTag is not null).Select(s => PvTag(s.LevelTag!))); + 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); + } +} diff --git a/src/Web/Controllers/FeedforwardController.cs b/src/Web/Controllers/FeedforwardController.cs new file mode 100644 index 0000000..0ccc1dc --- /dev/null +++ b/src/Web/Controllers/FeedforwardController.cs @@ -0,0 +1,96 @@ +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; + private readonly IFeedforwardConfigStore _config; + public FeedforwardController( + IFeedforwardAdvisoryStore store, + IFeedforwardConfigStore config) + { _store = store; _config = config; } + + // ── 설정 CRUD (Phase I: 인증 없음. 쓰기 API 추가 시 IKbAuthService 재도입) ── + [HttpGet("config")] + public async Task GetConfig(CancellationToken ct) + { + var cols = await _config.LoadAllAsync(ct); + return Ok(new { columns = cols.Select(MapConfig) }); + } + + [HttpPost("config")] + public async Task SaveConfig([FromBody] ColumnConfig body, CancellationToken ct) + { + var id = await _config.SaveColumnAsync(body, ct); + return Ok(new { success = true, id }); + } + + [HttpDelete("config/{id:int}")] + public async Task DeleteConfig(int id, CancellationToken ct) + { + await _config.DeleteColumnAsync(id, ct); + return Ok(new { success = true }); + } + + private static object MapConfig(ColumnConfig c) => new + { + id = c.Id, name = c.Name, enabled = c.Enabled, advisoryOnly = c.AdvisoryOnly, + feedTag = c.FeedTag, pressureTag = c.PressureTag, levelTags = c.LevelTags, + scanSec = c.ScanSec, feedFilterTauSec = c.FeedFilterTauSec, + feedMoveThresholdPerMin = c.FeedMoveThresholdPerMin, pressFilterTauSec = c.PressFilterTauSec, + pressureBand = c.PressureBand, settleSec = c.SettleSec, staleSec = c.StaleSec, productKey = c.ProductKey, + streams = c.Streams.Select(s => new + { + key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff, + thetaUpSec = s.ThetaUpSec, thetaDnSec = s.ThetaDnSec, tauSec = s.TauSec, + spMin = s.SpMin, spMax = s.SpMax, rateUpPerMin = s.RateUpPerMin, rateDnPerMin = s.RateDnPerMin, + refluxFromProduct = s.RefluxFromProduct, grade = s.Grade.ToString() + }) + }; + + // ── Advisory (공개 읽기) ─────────────────────────────────────── + [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)); + } + + 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(), + levelTag = s.LevelTag, + 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 + }) + }; +}