feat: Feedforward advisory engine Phase I — core C# models/blocks/engine/supervisor/store/controller

This commit is contained in:
windpacer
2026-05-31 17:31:39 +09:00
parent b53a34c9db
commit 7688757b21
8 changed files with 780 additions and 0 deletions

View File

@@ -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<string> LevelTags { get; init; } = Array.Empty<string>();
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<StreamConfig> Streams { get; init; } = Array.Empty<StreamConfig>();
}
public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp);
public sealed record PvSnapshot(
TagSample Feed,
TagSample? Pressure,
IReadOnlyList<TagSample> Levels,
IReadOnlyDictionary<string, TagSample> 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<StreamAdvisory> Streams,
double? VLoss, double? Yield, string MassBalanceState);

View File

@@ -0,0 +1,15 @@
namespace ExperionCrawler.Core.Application.Feedforward;
public interface IFeedforwardConfigStore
{
Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default);
Task<int> 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<AdvisoryResult> GetAll();
}

View File

@@ -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<double> _buf = new();
private readonly int _window;
private double _sum;
public MovingAverage(int windowSamples) => _window = Math.Max(1, windowSamples);
public double Push(double x)
{
_buf.Enqueue(x); _sum += x;
while (_buf.Count > _window) _sum -= _buf.Dequeue();
return _sum / _buf.Count;
}
}
/// <summary>
/// 가변 전달지연(데드타임) 링버퍼. 용량은 요청된 최대 n 으로 **증가만**(축소 금지)하며
/// θ(=n)가 스캔마다 바뀌어도(비대칭 θup/θdn, D7) **읽기 오프셋만 가변** — 히스토리를 보존한다.
/// (이전 구현은 n 변경 시 Resize 로 버퍼를 재시드해 비대칭 θ에서 지연선이 매 부호반전마다 소실됨)
/// </summary>
public sealed class DeadTimeBuffer
{
private double[] _buf = Array.Empty<double>(); // 항상 가득 찬 링(시드로 사전충전)
private int _cap; // 용량(보존 샘플 수). 증가만.
private int _head; // 다음 덮어쓸 위치(=가장 오래된 값)
private bool _seeded;
public double Through(double x, double thetaSec, double tsSec)
{
int n = (int)Math.Round(thetaSec / Math.Max(1e-6, tsSec));
if (n <= 0) return x; // θ<ts → 지연 없음
EnsureCapacity(n + 1, x); // n 샘플 지연엔 n+1 슬롯 필요
_buf[_head] = x; // 가장 오래된 자리에 현재값 기록
int newest = _head;
_head = (_head + 1) % _cap;
int idx = ((newest - n) % _cap + _cap) % _cap; // n 스캔 전 값
return _buf[idx];
}
/// <summary>용량을 need 이상으로 확보(증가 전용). 기존 이력은 논리순서로 보존.</summary>
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);
}

View File

@@ -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<int, AdvisoryResult> _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<AdvisoryResult> GetAll() => _latest.Values.ToArray();
}

View File

@@ -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<FeedforwardConfigStore> _logger;
public FeedforwardConfigStore(ExperionDbContext ctx, ILogger<FeedforwardConfigStore> logger)
{ _ctx = ctx; _logger = logger; }
public async Task<IReadOnlyList<ColumnConfig>> LoadAllAsync(CancellationToken ct = default)
{
var conn = _ctx.Database.GetDbConnection();
if (conn.State != ConnectionState.Open) await conn.OpenAsync(ct);
var cols = new Dictionary<int, (ColumnConfig cfg, List<StreamConfig> streams)>();
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = """
SELECT id, name, enabled, feed_tag, pressure_tag, level_tags, scan_sec,
feed_filter_tau_sec, feed_move_thr_per_min,
press_filter_tau_sec, pressure_band, settle_sec,
stale_sec, product_key
FROM ff_column_config
""";
await using var rd = await cmd.ExecuteReaderAsync(ct);
while (await rd.ReadAsync(ct))
{
var levelTags = rd.IsDBNull(5)
? Array.Empty<string>()
: rd.GetString(5)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => t.ToLowerInvariant()).ToArray();
var cfg = new ColumnConfig
{
Id = rd.GetInt32(0),
Name = rd.GetString(1),
Enabled = rd.GetBoolean(2),
AdvisoryOnly = true,
FeedTag = rd.GetString(3).ToLowerInvariant(),
PressureTag = rd.IsDBNull(4) ? null : rd.GetString(4).ToLowerInvariant(),
LevelTags = levelTags,
ScanSec = rd.GetDouble(6),
FeedFilterTauSec = rd.GetDouble(7),
FeedMoveThresholdPerMin = rd.GetDouble(8),
PressFilterTauSec = rd.GetDouble(9),
PressureBand = rd.GetDouble(10),
SettleSec = rd.GetDouble(11),
StaleSec = rd.GetDouble(12),
ProductKey = rd.GetString(13),
Streams = Array.Empty<StreamConfig>()
};
cols[cfg.Id] = (cfg, new List<StreamConfig>());
}
}
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<StreamRole>(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<Confidence>(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<int> 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);
}
}

View File

@@ -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<string, StreamState> Streams { get; } = new();
public StreamState Stream(string key)
{
if (!Streams.TryGetValue(key, out var s)) { s = new StreamState(); Streams[key] = s; }
return s;
}
}
public sealed class FeedforwardEngine
{
public AdvisoryResult Tick(ColumnConfig cfg, PvSnapshot pv, ColumnState st, DateTime now)
{
var ts = cfg.ScanSec;
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<StreamAdvisory>(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}");
}
}

View File

@@ -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<FeedforwardSupervisor> _logger;
private readonly Dictionary<int, ColumnState> _states = new();
public FeedforwardSupervisor(
IServiceScopeFactory scopeFactory, FeedforwardEngine engine,
IFeedforwardAdvisoryStore store, ILogger<FeedforwardSupervisor> logger)
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _logger = logger; }
protected override async Task ExecuteAsync(CancellationToken ct)
{
await Task.Yield();
while (!ct.IsCancellationRequested)
{
double minScan = 2.0;
try
{
using var scope = _scopeFactory.CreateScope();
var cfgStore = scope.ServiceProvider.GetRequiredService<IFeedforwardConfigStore>();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var columns = await cfgStore.LoadAllAsync(ct);
var enabled = columns.Where(c => c.Enabled).ToList();
if (enabled.Count > 0) minScan = enabled.Min(c => c.ScanSec);
foreach (var cfg in enabled)
{
try
{
var snap = await BuildSnapshotAsync(db, cfg);
var st = GetState(cfg.Id);
var res = _engine.Tick(cfg, snap, st, DateTime.UtcNow);
_store.Set(res);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "FF tick 실패: column {Id}", cfg.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "FF supervisor 루프 오류");
}
await Task.Delay(TimeSpan.FromSeconds(Math.Clamp(minScan, 1.0, 10.0)), ct);
}
}
private ColumnState GetState(int id)
{
if (!_states.TryGetValue(id, out var s)) { s = new ColumnState(); _states[id] = s; }
return s;
}
private async Task<PvSnapshot> BuildSnapshotAsync(IExperionDbService db, ColumnConfig cfg)
{
string PvTag(string baseTag)
{
var t = baseTag.ToLowerInvariant();
return t.EndsWith(".pv") ? t : t + ".pv";
}
var feedTag = PvTag(cfg.FeedTag);
var tags = new List<string> { feedTag };
if (cfg.PressureTag is not null) tags.Add(PvTag(cfg.PressureTag));
tags.AddRange(cfg.LevelTags.Select(PvTag));
tags.AddRange(cfg.Streams.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);
}
}

View File

@@ -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<IActionResult> GetConfig(CancellationToken ct)
{
var cols = await _config.LoadAllAsync(ct);
return Ok(new { columns = cols.Select(MapConfig) });
}
[HttpPost("config")]
public async Task<IActionResult> 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<IActionResult> 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
})
};
}