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

@@ -87,9 +87,13 @@ pi-6111b를 활용해 **전탑 ΔP**(flooding) + **압력 프로파일 기반 PC
--- ---
## WP5 — 편차 trim + 2-point sensitive tray (§8·§8.8) (난이도 高) — **1단계 완료 ✅ 2026-06-01** ## WP5 — 편차 trim + 2-point sensitive tray (§8·§8.8) (난이도 高) — **완료 ✅ 2026-06-01 (1·2·3단계)**
> **1단계 완료**: 2-point front 인프라(ColumnState `UpperFrontBase`/`LowerFrontBase`, 엔진 `ApplyFront2Point`, AdvisoryResult `Upper/LowerFrontState`·`Metric`, 컨트롤러·UI 노출, 단위 3건). 상부=ΔT(CD)·하부=ΔT(CB), C=pivot(temps[n-2]), 중립 상태(정상/상승/하강). **2단계(trim ±5%+귀속)·3단계(조성base SP)는 미구현.** > **1단계**: 2-point front(`ApplyFront2Point`, 상부 ΔT(CD)·하부 ΔT(CB), C=pivot temps[n-2], 중립 상태).
> **2단계**: `ApplyCompositionTrim` — LevelDriven 드로우에 유계 trim(±5% clamp, B=하부dev·D=상부dev). 과도/역전 시 trim 0. `StreamAdvisory.{CompositionBase,Trim,RecommendedSpComposition,TrimSource}`.
> **3단계**: `CompositionStore`(랩 분율 수동입력, `/api/ff/composition` GET·POST·DELETE) → supervisor가 LevelDriven TargetCoeff 치환(없으면 config K). `B_SP = 분율×feed + trim`.
> 단위 4건 추가(49/49 통과). **advisory(쓰기 없음)**.
> **현장 calibrate 필요(데모 검증 불가)**: trim 게인(현 1%/°C placeholder)·부호·조성 분율값. rate제한(듀티 대역폭)은 후속(현재 clamp만).
### 목적 ### 목적
LevelDriven 드로우 권장을 `K×feed`(무한상승)에서 **[조성목표(분율×feed)] + [bounded 편차 trim]** 구조로. 편차는 §8.4 2-타임스케일. LevelDriven 드로우 권장을 `K×feed`(무한상승)에서 **[조성목표(분율×feed)] + [bounded 편차 trim]** 구조로. 편차는 §8.4 2-타임스케일.

View File

@@ -28,6 +28,15 @@ public sealed record FeedRampAdvisory(
bool Hold, bool Hold,
string[] Warnings); string[] Warnings);
// WP5 3단계: 조성 분율 수동입력(랩/원료분석). key=(columnId,streamKey) → 분율. 미설정 시 config K 사용.
public interface ICompositionStore
{
void Set(int columnId, string streamKey, double fraction);
void Clear(int columnId, string streamKey);
bool TryGet(int columnId, string streamKey, out double fraction);
IReadOnlyDictionary<string, double> Snapshot();
}
// WP0: Sim Override Layer — advisor가 읽는 입력값을 in-memory로 치환(제어 쓰기 아님). // WP0: Sim Override Layer — advisor가 읽는 입력값을 in-memory로 치환(제어 쓰기 아님).
public interface ISimOverrideStore public interface ISimOverrideStore
{ {

View File

@@ -96,6 +96,11 @@ public sealed record StreamAdvisory(
public double? ThetaSuggestDnSec { get; init; } public double? ThetaSuggestDnSec { get; init; }
public double? ThetaSuggestConf { get; init; } public double? ThetaSuggestConf { get; init; }
public double? KObsSuggest { get; init; } public double? KObsSuggest { get; init; }
// WP5 2·3단계: 조성목표 base + 유계 trim (advisory; LevelDriven 드로우용)
public double? CompositionBase { get; init; } // 분율×feed (분율=랩수동입력 또는 config K)
public double? Trim { get; init; } // 유계 편차 trim(±clamp, rate제한)
public double? RecommendedSpComposition { get; init; } // base + trim
public string? TrimSource { get; init; } // 어느 front에서(하부B/상부D) 유래 + calibrate 표기
// Phase II: auto-write 결과 // Phase II: auto-write 결과
public double? LastWriteSp { get; init; } public double? LastWriteSp { get; init; }
public string? LastWriteError { get; init; } public string? LastWriteError { get; init; }

View File

@@ -0,0 +1,21 @@
using System.Collections.Concurrent;
using ExperionCrawler.Core.Application.Feedforward;
namespace ExperionCrawler.Infrastructure.Control;
/// <summary>
/// WP5 3단계: 조성 분율 수동입력 store (랩/원료분석값). 싱글톤 in-memory.
/// key = "{columnId}:{streamKey}" → 중비물/경비물 분율. 미설정 시 호출측이 config K로 폴백.
/// thread-safe(ConcurrentDictionary).
/// </summary>
public sealed class CompositionStore : ICompositionStore
{
private readonly ConcurrentDictionary<string, double> _frac = new();
private static string K(int columnId, string streamKey) => $"{columnId}:{streamKey.ToUpperInvariant()}";
public void Set(int columnId, string streamKey, double fraction) => _frac[K(columnId, streamKey)] = fraction;
public void Clear(int columnId, string streamKey) => _frac.TryRemove(K(columnId, streamKey), out _);
public bool TryGet(int columnId, string streamKey, out double fraction) => _frac.TryGetValue(K(columnId, streamKey), out fraction);
public IReadOnlyDictionary<string, double> Snapshot() => new Dictionary<string, double>(_frac);
}

View File

@@ -138,6 +138,7 @@ public sealed class FeedforwardEngine
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치 var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
var tp = JudgeTempProfile(temps, st, transient); // §10 역전 판정 var tp = JudgeTempProfile(temps, st, transient); // §10 역전 판정
var f2 = ApplyFront2Point(st, ts, temps, transient); // WP5 1단계 2-point front var f2 = ApplyFront2Point(st, ts, temps, transient); // WP5 1단계 2-point front
ApplyCompositionTrim(cfg, ff, st, f2, transient, tp.State, ref outs); // WP5 2·3단계 조성base+trim
// 역전/붕괴 시 front 트림 보류(방향 신뢰 불가) // 역전/붕괴 시 front 트림 보류(방향 신뢰 불가)
if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null; if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null;
var (mode, modeReason, feedRecSp) = ApplyRecovery( var (mode, modeReason, feedRecSp) = ApplyRecovery(
@@ -337,6 +338,42 @@ public sealed class FeedforwardEngine
return (upState, upMetric, loState, loMetric); return (upState, upMetric, loState, loMetric);
} }
// ── WP5 2·3단계: 조성목표 base(분율×feed) + 유계 trim(±5% clamp). LevelDriven 드로우 advisory(쓰기 없음). ──
// 분율 = sc.TargetCoeff(수동입력 시 supervisor가 치환). trim 게인·부호는 현장 calibrate 필요(데모 검증 불가).
private static void ApplyCompositionTrim(ColumnConfig cfg, double ff, ColumnState st,
(string? upState, double? upMetric, string? loState, double? loMetric) f2, bool transient,
string? tempProfileState, ref List<StreamAdvisory> outs)
{
const double clampFrac = 0.05; // ±5%(보수, 결정)
const double gainPerDeg = 0.01; // 1%/°C placeholder — 현장 calibrate
bool block = transient || tempProfileState is "온도역전" or "프로파일붕괴"; // 과도/역전 시 trim 0
double devUp = f2.upMetric.HasValue ? f2.upMetric.Value - st.UpperFrontBase.Value : double.NaN;
double devLo = f2.loMetric.HasValue ? f2.loMetric.Value - st.LowerFrontBase.Value : double.NaN;
outs = outs.Select(a =>
{
if (a.Role != StreamRole.LevelDriven) return a;
var sc = cfg.Streams.FirstOrDefault(x => x.Key == a.Key);
if (sc is null || ff <= 1e-6) return a;
double baseSp = sc.TargetCoeff * ff; // 분율×feed
double dev; string src;
if (a.Key.Equals("B", StringComparison.OrdinalIgnoreCase)) { dev = devLo; src = "하부front(CB)"; }
else if (a.Key.Equals("D", StringComparison.OrdinalIgnoreCase)) { dev = -devUp; src = "상부front(CD)"; }
else return a with { CompositionBase = baseSp, RecommendedSpComposition = baseSp, TrimSource = "trim 미적용" };
if (block || !Num.IsFinite(dev))
return a with { CompositionBase = baseSp, Trim = 0.0, RecommendedSpComposition = baseSp,
TrimSource = $"{src} (과도/역전/무신호 — trim 0)" };
double trim = Num.Clamp(gainPerDeg * dev, -clampFrac, clampFrac) * baseSp;
return a with {
CompositionBase = baseSp, Trim = trim, RecommendedSpComposition = baseSp + trim,
TrimSource = $"{src} dev={dev:F2}°C (±{clampFrac:P0} clamp · 게인·부호 현장 calibrate · rate제한 후속)"
};
}).ToList();
}
// ── §10: 온도 프로파일 역전 판정 (조성 트레이 A,B,C — 환류오염 탑정 D는 제외) ────────── // ── §10: 온도 프로파일 역전 판정 (조성 트레이 A,B,C — 환류오염 탑정 D는 제외) ──────────
private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? temps, ColumnState st, bool transient) private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? temps, ColumnState st, bool transient)
{ {

View File

@@ -28,8 +28,24 @@ public sealed class FeedforwardSupervisor : BackgroundService
IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard, IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard,
ILogger<FeedforwardSupervisor> logger, ILogger<FeedforwardSupervisor> logger,
Microsoft.Extensions.Configuration.IConfiguration appConfig, Microsoft.Extensions.Configuration.IConfiguration appConfig,
ISimOverrideStore sim) ISimOverrideStore sim, ICompositionStore composition)
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; _sim = sim; } { _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; _sim = sim; _composition = composition; }
private readonly ICompositionStore _composition;
// WP5 3단계: LevelDriven 스트림의 분율을 수동입력(랩)값으로 치환(있으면). 없으면 config K.
private ColumnConfig ApplyManualFractions(ColumnConfig cfg)
{
if (cfg.Streams.All(s => s.Role != StreamRole.LevelDriven)) return cfg;
bool any = false;
var streams = cfg.Streams.Select(s =>
{
if (s.Role == StreamRole.LevelDriven && _composition.TryGet(cfg.Id, s.Key, out var frac))
{ any = true; return s with { TargetCoeff = frac }; }
return s;
}).ToList();
return any ? cfg with { Streams = streams } : cfg;
}
// Phase II: 쓰기 결과 조회 (Controller에서 사용) // Phase II: 쓰기 결과 조회 (Controller에서 사용)
public (double? sp, string? error, DateTime? at) GetLastWrite(int colId, string streamKey) public (double? sp, string? error, DateTime? at) GetLastWrite(int colId, string streamKey)
@@ -53,8 +69,9 @@ public sealed class FeedforwardSupervisor : BackgroundService
var enabled = columns.Where(c => c.Enabled).ToList(); var enabled = columns.Where(c => c.Enabled).ToList();
if (enabled.Count > 0) minScan = enabled.Min(c => c.ScanSec); if (enabled.Count > 0) minScan = enabled.Min(c => c.ScanSec);
foreach (var cfg in enabled) foreach (var cfg0 in enabled)
{ {
var cfg = ApplyManualFractions(cfg0); // WP5 3단계: 랩 수동입력 분율 치환
try try
{ {
var snap = await BuildSnapshotAsync(db, cfg); var snap = await BuildSnapshotAsync(db, cfg);

View File

@@ -29,13 +29,15 @@ public sealed class FeedforwardController : ControllerBase
Microsoft.Extensions.Configuration.IConfiguration appConfig, Microsoft.Extensions.Configuration.IConfiguration appConfig,
ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor, ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor,
ISimOverrideStore sim, ISimOverrideStore sim,
ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService ramp) ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService ramp,
ICompositionStore composition)
{ _store = store; _config = config; _audit = audit; _writeGuard = writeGuard; { _store = store; _config = config; _audit = audit; _writeGuard = writeGuard;
_writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor; _writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor;
_sim = sim; _ramp = ramp; } _sim = sim; _ramp = ramp; _composition = composition; }
private readonly ISimOverrideStore _sim; private readonly ISimOverrideStore _sim;
private readonly ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService _ramp; private readonly ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService _ramp;
private readonly ICompositionStore _composition;
private async Task<bool> AuthAsync(CancellationToken ct) private async Task<bool> AuthAsync(CancellationToken ct)
=> await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct); => await _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
@@ -199,6 +201,22 @@ public sealed class FeedforwardController : ControllerBase
return Ok(new { enabled = _sim.Enabled }); return Ok(new { enabled = _sim.Enabled });
} }
// ── WP5 3단계: 조성 분율 수동입력(랩) ──
[HttpGet("composition")]
public IActionResult GetComposition() => Ok(new { fractions = _composition.Snapshot() });
[HttpPost("composition")]
public IActionResult SetComposition([FromBody] CompositionBody b)
{
if (b is null || string.IsNullOrWhiteSpace(b.streamKey)) return BadRequest(new { error = "streamKey 필요" });
_composition.Set(b.columnId, b.streamKey, b.fraction);
return Ok(new { fractions = _composition.Snapshot() });
}
[HttpDelete("composition/{columnId:int}/{streamKey}")]
public IActionResult ClearComposition(int columnId, string streamKey)
{ _composition.Clear(columnId, streamKey); return Ok(new { fractions = _composition.Snapshot() }); }
private static double? Fin(double v) => (double.IsNaN(v) || double.IsInfinity(v)) ? (double?)null : v; private static double? Fin(double v) => (double.IsNaN(v) || double.IsInfinity(v)) ? (double?)null : v;
private static object MapRamp(FeedRampAdvisory a) => new private static object MapRamp(FeedRampAdvisory a) => new
{ {
@@ -248,6 +266,11 @@ public sealed class FeedforwardController : ControllerBase
thetaSuggestDnSec = s.ThetaSuggestDnSec, thetaSuggestDnSec = s.ThetaSuggestDnSec,
thetaSuggestConf = s.ThetaSuggestConf, thetaSuggestConf = s.ThetaSuggestConf,
kObsSuggest = s.KObsSuggest, kObsSuggest = s.KObsSuggest,
// WP5 2·3단계: 조성 base + trim
compositionBase = s.CompositionBase,
trim = s.Trim,
recommendedSpComposition = s.RecommendedSpComposition,
trimSource = s.TrimSource,
// Phase II: auto-write 결과 // Phase II: auto-write 결과
lastWriteSp = lastSp, lastWriteSp = lastSp,
lastWriteError = lastErr, lastWriteError = lastErr,
@@ -303,3 +326,10 @@ public sealed record SimOverrideBody
public bool enabled { get; init; } public bool enabled { get; init; }
public Dictionary<string, double>? values { get; init; } public Dictionary<string, double>? values { get; init; }
} }
public sealed record CompositionBody
{
public int columnId { get; init; }
public string streamKey { get; init; } = "";
public double fraction { get; init; }
}

View File

@@ -131,6 +131,7 @@ builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.Feedforward
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>());
// WP0/WP3: Sim Override(입력 치환) + Feed Ramp Advisor(read-only) // WP0/WP3: Sim Override(입력 치환) + Feed Ramp Advisor(read-only)
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.ISimOverrideStore, ExperionCrawler.Infrastructure.Control.SimOverrideStore>(); builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.ISimOverrideStore, ExperionCrawler.Infrastructure.Control.SimOverrideStore>();
builder.Services.AddSingleton<ExperionCrawler.Core.Application.Feedforward.ICompositionStore, ExperionCrawler.Infrastructure.Control.CompositionStore>();
builder.Services.AddScoped<ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService>(); builder.Services.AddScoped<ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService>();
// ── P&ID Services ─────────────────────────────────────────────────────────────── // ── P&ID Services ───────────────────────────────────────────────────────────────

View File

@@ -116,3 +116,7 @@
.ff-f2-up { background:#7c2d12; color:#fed7aa; } .ff-f2-up { background:#7c2d12; color:#fed7aa; }
.ff-f2-dn { background:#1e3a5f; color:#bfdbfe; } .ff-f2-dn { background:#1e3a5f; color:#bfdbfe; }
.ff-f2-ok { background:#14532d; color:#bbf7d0; } .ff-f2-ok { background:#14532d; color:#bbf7d0; }
/* WP5 2·3단계: 조성 권장SP */
.ff-comp { margin-top:4px; font-size:12px; color:#cbd5e1; background:#172033; padding:3px 8px; border-radius:4px; }
.ff-comp small { color:#94a3b8; }

View File

@@ -191,6 +191,13 @@ function ffCard(c) {
<span class="ff-f2 ${f2cls(c.upperFrontState)}">상부(D) ${esc(c.upperFrontState||'')}${c.upperFrontMetric!=null?` <small>ΔT ${fmtVal(c.upperFrontMetric)}</small>`:''}</span> <span class="ff-f2 ${f2cls(c.upperFrontState)}">상부(D) ${esc(c.upperFrontState||'')}${c.upperFrontMetric!=null?` <small>ΔT ${fmtVal(c.upperFrontMetric)}</small>`:''}</span>
</div>` : ''; </div>` : '';
// WP5 2·3단계: 조성 권장SP(base+trim) — LevelDriven
const compSp = (c.streams||[]).filter(s=>s.recommendedSpComposition!=null).map(s=>{
const tr = s.trim!=null && s.trim!==0 ? `${s.trim>=0?'+':''}${fmtVal(s.trim)}` : '';
return `${esc(s.key)}: ${fmtVal(s.compositionBase)}${tr} = <b>${fmtVal(s.recommendedSpComposition)}</b>`;
}).join(' · ');
const comp = compSp ? `<div class="ff-comp">조성 권장SP(base+편차): ${compSp} <small>(advisory · 게인·부호 현장 calibrate)</small></div>` : '';
const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0);
const modeBadge = const modeBadge =
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>' c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
@@ -225,6 +232,7 @@ function ffCard(c) {
${front} ${front}
${tpBadge} ${tpBadge}
${front2} ${front2}
${comp}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div> <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
</div>`; </div>`;
} }

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);
}
}