using System; using System.Collections.Generic; using System.Linq; using ExperionCrawler.Core.Application.Feedforward; using ExperionCrawler.Infrastructure.Control; using Xunit; namespace ExperionCrawler.Tests; // WP5 2·3단계: 조성 base(분율×feed) + 유계 trim(±5%). B=하부front, D=상부front. public class FeedforwardCompositionTrimTests { private static ColumnConfig Cfg() => new() { Id = 1, Name = "C-CT", Enabled = true, FeedTag = "f", ProductKey = "P", ScanSec = 2, DTdP = 0.0, PRef = double.NaN, PressureTag = null, FeedMoveThresholdPerMin = 0.0, TempTags = new[] { "a", "b", "c", "d" }, Streams = new[] { new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 }, new StreamConfig { Key = "B", FlowTag = "ficq-6116", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.03 }, } }; private static PvSnapshot Snap(double feed, double a, double b, double c, double d) => new( new TagSample("f", feed, true, DateTime.UtcNow), null, Array.Empty(), new Dictionary { ["P"] = new TagSample("ficq-6118", 0.95 * feed, true, DateTime.UtcNow), ["B"] = new TagSample("ficq-6116", 0.03 * feed, true, DateTime.UtcNow), }) { Temps = new[] { new TagSample("a", a, true, DateTime.UtcNow), new TagSample("b", b, true, DateTime.UtcNow), new TagSample("c", c, true, DateTime.UtcNow), new TagSample("d", d, true, DateTime.UtcNow) } }; private static StreamAdvisory B(AdvisoryResult r) => r.Streams.First(s => s.Key == "B"); [Fact] public void Base_is_fraction_times_feed_no_trim_when_steady() { var engine = new FeedforwardEngine(); var st = new ColumnState(); var r = engine.Tick(Cfg(), Snap(900, 110, 105, 100, 90), st, DateTime.UtcNow); var b = B(r); Assert.Equal(27.0, b.CompositionBase!.Value, 3); // 0.03×900 // 첫 tick: baseline 시드 → dev≈0 → trim≈0 Assert.Equal(27.0, b.RecommendedSpComposition!.Value, 1); } [Fact] public void Lower_front_rise_adds_positive_trim_clamped() { var engine = new FeedforwardEngine(); var st = new ColumnState(); for (int i = 0; i < 5; i++) engine.Tick(Cfg(), Snap(900, 110, 105, 100, 90), st, DateTime.UtcNow); // 하부 front 상승: 중질이 제품(C)쪽 침투 → C 가열(100→104) → C−B↑(devLo>0) → B trim>0. // (단조 A110≥B105≥C104 유지 → 역전 미트리거) var r = engine.Tick(Cfg(), Snap(900, 110, 105, 104, 90), st, DateTime.UtcNow); var b = B(r); Assert.True(b.Trim!.Value > 0, $"trim should be >0, got {b.Trim}"); Assert.True(b.Trim!.Value <= 0.05 * 27.0 + 1e-9, "±5% clamp"); // ≤ 1.35 Assert.Equal(27.0 + b.Trim.Value, b.RecommendedSpComposition!.Value, 6); } [Fact] public void Transient_blocks_trim() { var engine = new FeedforwardEngine(); var cfg = Cfg() with { FeedMoveThresholdPerMin = 1.0 }; // 이동 감지 활성 var st = new ColumnState(); engine.Tick(cfg, Snap(900, 110, 105, 100, 90), st, DateTime.UtcNow); var r = engine.Tick(cfg, Snap(1100, 110, 90, 100, 90), st, DateTime.UtcNow); // 큰 feed 이동→transient var b = B(r); Assert.Equal(0.0, b.Trim ?? 0.0, 6); // 과도 시 trim 0 } [Fact] public void Composition_store_fraction_overrides_default() { var store = new CompositionStore(); store.Set(1, "B", 0.05); Assert.True(store.TryGet(1, "b", out var f)); // 대소문자 무시 Assert.Equal(0.05, f, 6); } }