From 49cf04569e9177e68af5149e68da00fc8baf265e Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 1 Jun 2026 21:19:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20WP5=202=C2=B73=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=E2=80=94=20=EC=A1=B0=EC=84=B1=20base=20+=20=EC=9C=A0=EA=B3=84?= =?UTF-8?q?=20trim=20(B=5FSP=3D=EB=B6=84=EC=9C=A8=C3=97feed+=ED=8E=B8?= =?UTF-8?q?=EC=B0=A8,=20advisory)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../안전피드램프-후속작업-작업지시서-WP4-7.md | 8 +- .../Application/Feedforward/FeedRampModels.cs | 9 ++ .../Feedforward/FeedforwardModels.cs | 5 ++ .../Control/CompositionStore.cs | 21 +++++ .../Control/FeedforwardEngine.cs | 37 ++++++++ .../Control/FeedforwardSupervisor.cs | 23 ++++- src/Web/Controllers/FeedforwardController.cs | 34 +++++++- src/Web/Program.cs | 1 + src/Web/wwwroot/css/ff.css | 4 + src/Web/wwwroot/js/ff.js | 8 ++ .../FeedforwardCompositionTrimTests.cs | 87 +++++++++++++++++++ 11 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 src/Infrastructure/Control/CompositionStore.cs create mode 100644 tests/ExperionCrawler.Tests/FeedforwardCompositionTrimTests.cs diff --git a/plans/안전피드램프-후속작업-작업지시서-WP4-7.md b/plans/안전피드램프-후속작업-작업지시서-WP4-7.md index cc51213..4f1adda 100644 --- a/plans/안전피드램프-후속작업-작업지시서-WP4-7.md +++ b/plans/안전피드램프-후속작업-작업지시서-WP4-7.md @@ -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-타임스케일. diff --git a/src/Core/Application/Feedforward/FeedRampModels.cs b/src/Core/Application/Feedforward/FeedRampModels.cs index 21cf950..750f3c4 100644 --- a/src/Core/Application/Feedforward/FeedRampModels.cs +++ b/src/Core/Application/Feedforward/FeedRampModels.cs @@ -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 Snapshot(); +} + // WP0: Sim Override Layer — advisor가 읽는 입력값을 in-memory로 치환(제어 쓰기 아님). public interface ISimOverrideStore { diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index 50002c7..ca0be9c 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -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; } diff --git a/src/Infrastructure/Control/CompositionStore.cs b/src/Infrastructure/Control/CompositionStore.cs new file mode 100644 index 0000000..297dede --- /dev/null +++ b/src/Infrastructure/Control/CompositionStore.cs @@ -0,0 +1,21 @@ +using System.Collections.Concurrent; +using ExperionCrawler.Core.Application.Feedforward; + +namespace ExperionCrawler.Infrastructure.Control; + +/// +/// WP5 3단계: 조성 분율 수동입력 store (랩/원료분석값). 싱글톤 in-memory. +/// key = "{columnId}:{streamKey}" → 중비물/경비물 분율. 미설정 시 호출측이 config K로 폴백. +/// thread-safe(ConcurrentDictionary). +/// +public sealed class CompositionStore : ICompositionStore +{ + private readonly ConcurrentDictionary _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 Snapshot() => new Dictionary(_frac); +} diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs index dba7ac0..5eebecc 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -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 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? temps, ColumnState st, bool transient) { diff --git a/src/Infrastructure/Control/FeedforwardSupervisor.cs b/src/Infrastructure/Control/FeedforwardSupervisor.cs index 6303acc..fd750a9 100644 --- a/src/Infrastructure/Control/FeedforwardSupervisor.cs +++ b/src/Infrastructure/Control/FeedforwardSupervisor.cs @@ -28,8 +28,24 @@ public sealed class FeedforwardSupervisor : BackgroundService IFeedforwardAdvisoryStore store, IFeedforwardWriteGuard writeGuard, ILogger 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); diff --git a/src/Web/Controllers/FeedforwardController.cs b/src/Web/Controllers/FeedforwardController.cs index b20018f..b32ab68 100644 --- a/src/Web/Controllers/FeedforwardController.cs +++ b/src/Web/Controllers/FeedforwardController.cs @@ -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 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? values { get; init; } } + +public sealed record CompositionBody +{ + public int columnId { get; init; } + public string streamKey { get; init; } = ""; + public double fraction { get; init; } +} diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 7301122..ba31827 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -131,6 +131,7 @@ builder.Services.AddSingleton sp.GetRequiredService()); // WP0/WP3: Sim Override(입력 치환) + Feed Ramp Advisor(read-only) builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); // ── P&ID Services ─────────────────────────────────────────────────────────────── diff --git a/src/Web/wwwroot/css/ff.css b/src/Web/wwwroot/css/ff.css index 6d5b351..6c1af99 100644 --- a/src/Web/wwwroot/css/ff.css +++ b/src/Web/wwwroot/css/ff.css @@ -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; } diff --git a/src/Web/wwwroot/js/ff.js b/src/Web/wwwroot/js/ff.js index 5a9bc6d..acf4263 100644 --- a/src/Web/wwwroot/js/ff.js +++ b/src/Web/wwwroot/js/ff.js @@ -191,6 +191,13 @@ function ffCard(c) { 상부(D) ${esc(c.upperFrontState||'–')}${c.upperFrontMetric!=null?` ΔT ${fmtVal(c.upperFrontMetric)}`:''} ` : ''; + // 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} = ${fmtVal(s.recommendedSpComposition)}`; + }).join(' · '); + const comp = compSp ? `
조성 권장SP(base+편차): ${compSp} (advisory · 게인·부호 현장 calibrate)
` : ''; + const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); const modeBadge = c.mode === 'Recovering' ? '전환류 복귀중 ●' @@ -225,6 +232,7 @@ function ffCard(c) { ${front} ${tpBadge} ${front2} + ${comp}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
`; } diff --git a/tests/ExperionCrawler.Tests/FeedforwardCompositionTrimTests.cs b/tests/ExperionCrawler.Tests/FeedforwardCompositionTrimTests.cs new file mode 100644 index 0000000..dcad1c9 --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardCompositionTrimTests.cs @@ -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(), + new Dictionary + { + ["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); + } +}