using System; using System.Collections.Generic; using ExperionCrawler.Core.Application.Feedforward; using ExperionCrawler.Infrastructure.Control; using Xunit; namespace ExperionCrawler.Tests; // WP3-F: docs/안전피드램프-검증시나리오매트릭스.md S0~S6 (S7은 엔진 mbState라 제외). public class FeedRampCalculatorTests { private static ColumnConfig C6111() => new() { Id = 1, Name = "C-6111", Enabled = true, FeedTag = "ficq-6101", ProductKey = "P", ScanSec = 2, StaleSec = 120, PressureTag = "pica-6111", TempTags = Array.Empty(), Streams = new[] { new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, TargetCoeff = 0.95, ThetaUpSec = 60, ThetaDnSec = 60, TauSec = 900, SpMin = 0, SpMax = 2000, RateUpPerMin = 30, RateDnPerMin = 60, Grade = Confidence.A }, new StreamConfig { Key = "R", FlowTag = "ficq-6113", Role = StreamRole.Commanded, TargetCoeff = 0.80, ThetaUpSec = 0, ThetaDnSec = 0, TauSec = 0, SpMin = 0, SpMax = 2000, RateUpPerMin = 30, RateDnPerMin = 30, Grade = Confidence.A }, new StreamConfig { Key = "D", FlowTag = "ficq-6114", Role = StreamRole.LevelDriven, TargetCoeff = 0.02, SpMin = 0, SpMax = 1000, Grade = Confidence.B }, new StreamConfig { Key = "B", FlowTag = "ficq-6116", Role = StreamRole.LevelDriven, TargetCoeff = 0.03, SpMin = 0, SpMax = 500, Grade = Confidence.B }, } }; private static PvSnapshot Snap(double feed, bool good = true) => new( new TagSample("ficq-6101.pv", feed, good, DateTime.UtcNow), null, Array.Empty(), new Dictionary { ["P"] = new("ficq-6118.pv", 0.95 * feed, true, DateTime.UtcNow), ["R"] = new("ficq-6113.pv", 0.80 * feed, true, DateTime.UtcNow), ["D"] = new("ficq-6114.pv", 0.02 * feed, true, DateTime.UtcNow), ["B"] = new("ficq-6116.pv", 0.03 * feed, true, DateTime.UtcNow), }); // anchor extras: pica40, pi80(ΔP40), ti6103=150, fiq6115=300 private static RampExtraInputs Extra(double? pi6111b = 80, double ti6103 = 150, double floodLimit = double.NaN, double n = 1.8) => new(Pica: 40, Pi6111b: pi6111b, Ti6103: ti6103, Fiq6115: 300, Op: null, FloodLimit: floodLimit, FloodExponentN: n); private static void Near(double expected, double actual, double tolFrac = 0.02) => Assert.True(Math.Abs(actual - expected) <= Math.Abs(expected) * tolFrac + 1e-6, $"expected≈{expected}, actual={actual}"); [Fact] // S0 평형 public void S0_baseline_no_change() { var a = FeedRampCalculator.Compute(C6111(), Snap(900), 900, 50, 0.001, 150, Extra(), false); Assert.False(a.Hold); Near(0, a.RampTimeMin, 0); Assert.Equal(0, a.RampTimeMin); Near(2105.26, a.Ceiling.Value); Assert.Equal("P sp_max", a.Ceiling.Binding); Near(300, a.Steam.Fiq6115To!.Value); } [Fact] // S1 동특성 binding public void S1_dynamic_binding() { var a = FeedRampCalculator.Compute(C6111(), Snap(900), 1100, 50, 0.001, 150, Extra(), false); Assert.Equal("dynamic", a.RampRate.Binding); Near(3.289, a.RampRate.Value); Near(60.8, a.RampTimeMin); Near(366.7, a.Steam.Fiq6115To!.Value); Near(2105.26, a.Ceiling.Value); } [Fact] // S2 밸브 슬루 binding (ΔI 크게) public void S2_valveslew_binding() { var a = FeedRampCalculator.Compute(C6111(), Snap(900), 1100, 600, 0.001, 150, Extra(), false); Assert.Equal("valveSlew@P", a.RampRate.Binding); Near(31.58, a.RampRate.Value); Near(6.33, a.RampTimeMin); } [Fact] // S3 ceiling 초과 → 클램프 public void S3_ceiling_clamp() { var a = FeedRampCalculator.Compute(C6111(), Snap(900), 2200, 50, 0.001, 150, Extra(), false); Near(2105.26, a.ClampedTarget); Assert.Equal("P sp_max", a.Ceiling.Binding); Assert.Equal(2200, a.TargetFeed); } [Fact] // S4 flooding ceiling public void S4_flooding_ceiling() { var a = FeedRampCalculator.Compute(C6111(), Snap(900), 1100, 50, 0.001, 150, Extra(pi6111b: 120, floodLimit: 100, n: 1.8), false); // ΔPnow=80 Assert.Equal("flooding", a.Ceiling.Binding); Near(1018.8, a.Ceiling.Value); Near(1018.8, a.ClampedTarget); } [Fact] // S5 TI-6103 하강 현열 FF (피드 무변에도 steam↑) public void S5_sensible_correction() { var a = FeedRampCalculator.Compute(C6111(), Snap(900), 900, 50, 0.001, 150, Extra(ti6103: 140), false); // 150→140, 피드 고정 Near(309.0, a.Steam.Fiq6115To!.Value); // 300 + 0.001*900*10 Assert.Equal(0, a.RampTimeMin); } [Fact] // S6 stale/HOLD public void S6_feed_bad_holds() { var a = FeedRampCalculator.Compute(C6111(), Snap(900, good: false), 1100, 50, 0.001, 150, Extra(), false); Assert.True(a.Hold); Assert.Contains(a.Warnings, w => w.Contains("feed-bad")); } }