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:
windpacer
2026-06-01 21:19:07 +09:00
parent dae8d7f902
commit 49cf04569e
11 changed files with 230 additions and 7 deletions

View File

@@ -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) → CB↑(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);
}
}