feat: 안전 피드램프 Advisor (WP0 Sim Override + WP1 매트릭스 + WP3 계산기)
WP3 — read-only Feed Ramp Advisor (쓰기 없음): - FeedRampCalculator(순수함수): ceiling(밸브포화/flooding)·램프율(valveSlew/dynamic)·예상시간·binding·스팀(FIQ-6115 목표, a안 현열보정) 산출 - FeedRampModels(DTO+ISimOverrideStore), FeedRampAdvisorService(라이브+override), GET /api/ff/ramp-advisor WP0 — Sim Override Layer: - SimOverrideStore(ConcurrentDictionary+volatile), sim/override GET·POST·DELETE, Feedforward:SimOverrideEnabled 게이트 - 한계: ramp-advisor만 통합·엔진 미반영 → S6/S7 라이브는 override 불가(문서화) WP1 — docs/안전피드램프-검증시나리오매트릭스.md (S0~S7) 검증: 단위 31/31, 라이브 스모크 S1~S5 기대치 일치(3.29 dynamic/60.8min/366.7, 31.58 valveSlew, 2105 clamp, 1018.8 flooding, 309 현열) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
109
tests/ExperionCrawler.Tests/FeedRampCalculatorTests.cs
Normal file
109
tests/ExperionCrawler.Tests/FeedRampCalculatorTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
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<string>(),
|
||||
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<TagSample>(),
|
||||
new Dictionary<string, TagSample>
|
||||
{
|
||||
["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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user