feat: WP5 1단계 — 2-point front (상부 C-D / 하부 C-B)

- ColumnState UpperFrontBase/LowerFrontBase, 엔진 ApplyFront2Point(C=pivot temps[n-2], 느린 baseline 편차→중립 상태 정상/상승/하강)
- AdvisoryResult Upper/LowerFrontState·Metric + 컨트롤러 노출 + ff.js/css 표시
- 단위 3건(상/하 격리 perturb, metric 노출). 45/45. 2단계(trim)·3단계(조성base) 미구현

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-01 20:27:52 +09:00
parent 0519547271
commit dae8d7f902
7 changed files with 131 additions and 2 deletions

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
// WP5 1단계: 2-point front (C=제품 pivot=temps[n-2]). 상부=ΔT(CD), 하부=ΔT(CB).
// temp_tags 하단→상단 [A,B,C,D]. dtdp=0 → pct=raw. 한 섹션만 perturb 시 그 front만 반응.
public class FeedforwardFront2PointTests
{
private static ColumnConfig Cfg() => new()
{
Id = 1, Name = "C-F2", 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 } }
};
private static PvSnapshot Snap(double a, double b, double c, double d) => new(
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
new Dictionary<string, TagSample> { ["P"] = new TagSample("ficq-6118", 95, 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)
}
};
[Fact]
public void Lower_front_isolates_from_upper()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
// 정상 단조 A110>B105>C100>D90 으로 baseline 시드
for (int i = 0; i < 5; i++) engine.Tick(Cfg(), Snap(110, 105, 100, 90), st, DateTime.UtcNow);
// 하부만 perturb: B 강하(105→95) → CB 증가 → 하부 "상승". 상부(C,D 불변) "정상"
var res = engine.Tick(Cfg(), Snap(110, 95, 100, 90), st, DateTime.UtcNow);
Assert.Equal("상승", res.LowerFrontState);
Assert.Equal("정상", res.UpperFrontState);
}
[Fact]
public void Upper_front_isolates_from_lower()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
for (int i = 0; i < 5; i++) engine.Tick(Cfg(), Snap(110, 105, 100, 90), st, DateTime.UtcNow);
// 상부만 perturb: D 가열(90→98) → CD 감소 → 상부 "하강". 하부(C,B 불변) "정상"
var res = engine.Tick(Cfg(), Snap(110, 105, 100, 98), st, DateTime.UtcNow);
Assert.Equal("하강", res.UpperFrontState);
Assert.Equal("정상", res.LowerFrontState);
}
[Fact]
public void Metrics_exposed()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
var res = engine.Tick(Cfg(), Snap(110, 105, 100, 90), st, DateTime.UtcNow);
Assert.Equal(10.0, res.UpperFrontMetric!.Value, 6); // CD = 10090
Assert.Equal(-5.0, res.LowerFrontMetric!.Value, 6); // CB = 100105
}
}