Files
ExperionCrawler/tests/ExperionCrawler.Tests/FeedforwardCompositionTrimTests.cs
windpacer 49cf04569e 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>
2026-06-01 21:19:07 +09:00

88 lines
3.8 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}