164 lines
6.7 KiB
C#
164 lines
6.7 KiB
C#
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}");
|
||
}
|
||
}
|