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:
@@ -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(C−D)·하부=ΔT(C−B), C=pivot(temps[n-2]), 중립 상태(정상/상승/하강). **2단계(trim ±5%+귀속)·3단계(조성base SP)는 미구현.**
|
||||
> **1단계**: 2-point front(`ApplyFront2Point`, 상부 ΔT(C−D)·하부 ΔT(C−B), 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-타임스케일.
|
||||
|
||||
@@ -28,6 +28,15 @@ public sealed record FeedRampAdvisory(
|
||||
bool Hold,
|
||||
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로 치환(제어 쓰기 아님).
|
||||
public interface ISimOverrideStore
|
||||
{
|
||||
|
||||
@@ -96,6 +96,11 @@ public sealed record StreamAdvisory(
|
||||
public double? ThetaSuggestDnSec { get; init; }
|
||||
public double? ThetaSuggestConf { 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 결과
|
||||
public double? LastWriteSp { get; init; }
|
||||
public string? LastWriteError { get; init; }
|
||||
|
||||
21
src/Infrastructure/Control/CompositionStore.cs
Normal file
21
src/Infrastructure/Control/CompositionStore.cs
Normal 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);
|
||||
}
|
||||
@@ -138,6 +138,7 @@ public sealed class FeedforwardEngine
|
||||
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
|
||||
var tp = JudgeTempProfile(temps, st, transient); // §10 역전 판정
|
||||
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 트림 보류(방향 신뢰 불가)
|
||||
if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null;
|
||||
var (mode, modeReason, feedRecSp) = ApplyRecovery(
|
||||
@@ -337,6 +338,42 @@ public sealed class FeedforwardEngine
|
||||
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(C−B)"; }
|
||||
else if (a.Key.Equals("D", StringComparison.OrdinalIgnoreCase)) { dev = -devUp; src = "상부front(C−D)"; }
|
||||
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는 제외) ──────────
|
||||
private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? temps, ColumnState st, bool transient)
|
||||
{
|
||||
|
||||
@@ -28,8 +28,24 @@ public sealed class FeedforwardSupervisor : BackgroundService
|
||||
IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard,
|
||||
ILogger<FeedforwardSupervisor> logger,
|
||||
Microsoft.Extensions.Configuration.IConfiguration appConfig,
|
||||
ISimOverrideStore sim)
|
||||
{ _scopeFactory = scopeFactory; _engine = engine; _store = store; _writeGuard = writeGuard; _logger = logger; _appConfig = appConfig; _sim = sim; }
|
||||
ISimOverrideStore sim, ICompositionStore composition)
|
||||
{ _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에서 사용)
|
||||
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();
|
||||
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
|
||||
{
|
||||
var snap = await BuildSnapshotAsync(db, cfg);
|
||||
|
||||
@@ -29,13 +29,15 @@ public sealed class FeedforwardController : ControllerBase
|
||||
Microsoft.Extensions.Configuration.IConfiguration appConfig,
|
||||
ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor supervisor,
|
||||
ISimOverrideStore sim,
|
||||
ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService ramp)
|
||||
ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService ramp,
|
||||
ICompositionStore composition)
|
||||
{ _store = store; _config = config; _audit = audit; _writeGuard = writeGuard;
|
||||
_writeClient = writeClient; _auth = auth; _appConfig = appConfig; _supervisor = supervisor;
|
||||
_sim = sim; _ramp = ramp; }
|
||||
_sim = sim; _ramp = ramp; _composition = composition; }
|
||||
|
||||
private readonly ISimOverrideStore _sim;
|
||||
private readonly ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService _ramp;
|
||||
private readonly ICompositionStore _composition;
|
||||
|
||||
private async Task<bool> AuthAsync(CancellationToken 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 });
|
||||
}
|
||||
|
||||
// ── 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 object MapRamp(FeedRampAdvisory a) => new
|
||||
{
|
||||
@@ -248,6 +266,11 @@ public sealed class FeedforwardController : ControllerBase
|
||||
thetaSuggestDnSec = s.ThetaSuggestDnSec,
|
||||
thetaSuggestConf = s.ThetaSuggestConf,
|
||||
kObsSuggest = s.KObsSuggest,
|
||||
// WP5 2·3단계: 조성 base + trim
|
||||
compositionBase = s.CompositionBase,
|
||||
trim = s.Trim,
|
||||
recommendedSpComposition = s.RecommendedSpComposition,
|
||||
trimSource = s.TrimSource,
|
||||
// Phase II: auto-write 결과
|
||||
lastWriteSp = lastSp,
|
||||
lastWriteError = lastErr,
|
||||
@@ -303,3 +326,10 @@ public sealed record SimOverrideBody
|
||||
public bool enabled { 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; }
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Control.Feedforward
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionCrawler.Infrastructure.Control.FeedforwardSupervisor>());
|
||||
// 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.ICompositionStore, ExperionCrawler.Infrastructure.Control.CompositionStore>();
|
||||
builder.Services.AddScoped<ExperionCrawler.Infrastructure.Control.FeedRampAdvisorService>();
|
||||
|
||||
// ── P&ID Services ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -116,3 +116,7 @@
|
||||
.ff-f2-up { background:#7c2d12; color:#fed7aa; }
|
||||
.ff-f2-dn { background:#1e3a5f; color:#bfdbfe; }
|
||||
.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; }
|
||||
|
||||
@@ -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>
|
||||
</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 modeBadge =
|
||||
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
|
||||
@@ -225,6 +232,7 @@ function ffCard(c) {
|
||||
${front}
|
||||
${tpBadge}
|
||||
${front2}
|
||||
${comp}
|
||||
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -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