feat: Feedforward advisory engine Phase I — core C# models/blocks/engine/supervisor/store/controller
This commit is contained in:
71
src/Core/Application/Feedforward/FeedforwardModels.cs
Normal file
71
src/Core/Application/Feedforward/FeedforwardModels.cs
Normal 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);
|
||||
15
src/Core/Application/Feedforward/IFeedforwardStores.cs
Normal file
15
src/Core/Application/Feedforward/IFeedforwardStores.cs
Normal 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();
|
||||
}
|
||||
123
src/Infrastructure/Control/ComputationBlocks.cs
Normal file
123
src/Infrastructure/Control/ComputationBlocks.cs
Normal 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);
|
||||
}
|
||||
12
src/Infrastructure/Control/FeedforwardAdvisoryStore.cs
Normal file
12
src/Infrastructure/Control/FeedforwardAdvisoryStore.cs
Normal 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();
|
||||
}
|
||||
196
src/Infrastructure/Control/FeedforwardConfigStore.cs
Normal file
196
src/Infrastructure/Control/FeedforwardConfigStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
163
src/Infrastructure/Control/FeedforwardEngine.cs
Normal file
163
src/Infrastructure/Control/FeedforwardEngine.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
104
src/Infrastructure/Control/FeedforwardSupervisor.cs
Normal file
104
src/Infrastructure/Control/FeedforwardSupervisor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
96
src/Web/Controllers/FeedforwardController.cs
Normal file
96
src/Web/Controllers/FeedforwardController.cs
Normal 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
|
||||
})
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user