using System; using System.Collections.Generic; using System.Linq; using ExperionCrawler.Core.Application.Feedforward; using ExperionCrawler.Infrastructure.Control; using Xunit; namespace ExperionCrawler.Tests; public class FeedforwardRecoveryTests { private static ColumnConfig Cfg(bool autoArm) => new() { Id = 1, Name = "C-REC", Enabled = true, FeedTag = "f", ProductKey = "P", ScanSec = 2, BiasMaWindowSec = 4, RecoveryEnabled = true, RecoveryAutoArm = autoArm, ImbalanceTriggerFrac = 0.10, ImbalanceTriggerSec = 4, RecoverySettleSec = 4, ReturnRampSec = 4, FeedRecoverySp = 0, Streams = new[] { new StreamConfig { Key="P", FlowTag="p", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.95, SpMax=950 }, new StreamConfig { Key="R", FlowTag="r", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.80, SpMax=1100, RefluxFromProduct=true }, new StreamConfig { Key="D", FlowTag="d", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.02, SpMax=60 }, new StreamConfig { Key="B", FlowTag="b", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.03, SpMax=80 }, } }; private static PvSnapshot Imbalanced() => new( new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), new Dictionary { ["P"]=new("p",30,true,DateTime.UtcNow), ["R"]=new("r",50,true,DateTime.UtcNow), ["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }); private static PvSnapshot Balanced() => new( new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), new Dictionary { ["P"]=new("p",95,true,DateTime.UtcNow), ["R"]=new("r",760,true,DateTime.UtcNow), ["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }); [Fact] public void AutoArm_enters_recovering_on_sustained_imbalance() { var engine = new FeedforwardEngine(); var st = new ColumnState(); AdvisoryResult res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow); for (int i = 0; i < 6; i++) res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Recovering, res.Mode); Assert.Equal(0.0, res.FeedRecommendedSp); var r = res.Streams.First(s => s.Key == "R"); var p = res.Streams.First(s => s.Key == "P"); Assert.Equal(1100.0, r.RecommendedSp); Assert.Equal(0.0, p.RecommendedSp); Assert.False(p.Valid); } [Fact] public void ManualArm_required_when_autoArm_false() { var engine = new FeedforwardEngine(); var st = new ColumnState(); for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Normal, st.Mode); st.OperatorArmed = true; var res = engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Recovering, res.Mode); } [Fact] public void Recovers_then_returns_to_normal() { var engine = new FeedforwardEngine(); var st = new ColumnState(); for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Recovering, st.Mode); AdvisoryResult res = null!; for (int i = 0; i < 10; i++) res = engine.Tick(Cfg(autoArm:true), Balanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Normal, res.Mode); } [Fact] public void Cancel_returns_to_normal_immediately() { var engine = new FeedforwardEngine(); var st = new ColumnState(); for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Recovering, st.Mode); st.OperatorCancel = true; var res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Normal, res.Mode); } }