feat: WP5 2·3단계 — 조성 base + 유계 trim (B_SP=분율×feed+편차, advisory)
- ApplyCompositionTrim: LevelDriven 드로우 유계 trim(±5% clamp, B=하부front dev·D=상부front dev, 과도/역전 시 0)
- CompositionStore + /api/ff/composition(랩 분율 수동입력) → supervisor가 TargetCoeff 치환(없으면 config K)
- StreamAdvisory.{CompositionBase,Trim,RecommendedSpComposition,TrimSource} + 컨트롤러·ff.js 표시
- 단위 4건(49/49). advisory·쓰기없음. 게인·부호·분율·rate는 현장 calibrate(데모 검증불가)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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<TagSample>(),
|
||||
new Dictionary<string, TagSample>
|
||||
{
|
||||
["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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user