diff --git a/plans/안전피드램프-후속작업-작업지시서-WP4-7.md b/plans/안전피드램프-후속작업-작업지시서-WP4-7.md index aacd17d..cc51213 100644 --- a/plans/안전피드램프-후속작업-작업지시서-WP4-7.md +++ b/plans/안전피드램프-후속작업-작업지시서-WP4-7.md @@ -87,7 +87,9 @@ pi-6111b를 활용해 **전탑 ΔP**(flooding) + **압력 프로파일 기반 PC --- -## WP5 — 편차 trim + 2-point sensitive tray (§8·§8.8) (난이도 高, 설계결정 필요) +## WP5 — 편차 trim + 2-point sensitive tray (§8·§8.8) (난이도 高) — **1단계 완료 ✅ 2026-06-01** + +> **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)는 미구현.** ### 목적 LevelDriven 드로우 권장을 `K×feed`(무한상승)에서 **[조성목표(분율×feed)] + [bounded 편차 trim]** 구조로. 편차는 §8.4 2-타임스케일. diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index 976e569..50002c7 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -120,6 +120,11 @@ public sealed record AdvisoryResult( public string? InversionPair { get; init; } public double? TempSpan { get; init; } public double? TempSpanRef { get; init; } + // WP5 1단계: 2-point front (C pivot 기준 상/하 분리) + public string? UpperFrontState { get; init; } // 상부 ΔT(C−D): 정상/상승/하강/정착대기 + public string? LowerFrontState { get; init; } // 하부 ΔT(C−B): 정상/상승/하강/정착대기 + public double? UpperFrontMetric { get; init; } + public double? LowerFrontMetric { get; init; } public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null // Phase II: auto-write 상태 public bool AutoWriteActive { get; init; } diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs index 2565772..dba7ac0 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -32,6 +32,9 @@ public sealed class ColumnState public MovingAverage? VLossMaBlock { get; set; } public Dictionary KObsMa { get; } = new(); public FrontPositionIndicator? FrontInd { get; set; } // WO-5 + // WP5 1단계: 2-point front baseline (C pivot 기준 상/하 차온의 느린 baseline) + public FirstOrderLag UpperFrontBase { get; } = new(); // ΔT(C−D) 상부 + public FirstOrderLag LowerFrontBase { get; } = new(); // ΔT(C−B) 하부 // WO-6 전환류 상태기계 public ColumnMode Mode { get; set; } = ColumnMode.Normal; public double ImbalanceTimerSec { get; set; } @@ -134,6 +137,7 @@ public sealed class FeedforwardEngine ApplyBias(cfg, pv, st, ff, vloss, transient, ref outs, out var vLossMa); // WO-4 느린 바이어스 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 // 역전/붕괴 시 front 트림 보류(방향 신뢰 불가) if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null; var (mode, modeReason, feedRecSp) = ApplyRecovery( @@ -142,7 +146,9 @@ public sealed class FeedforwardEngine transient, treason, ff, outs, vloss, yield, mbState) { Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim, Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp, - TempProfileState = tp.State, InversionPair = tp.InversionPair, TempSpan = tp.Span, TempSpanRef = tp.SpanRef }; + TempProfileState = tp.State, InversionPair = tp.InversionPair, TempSpan = tp.Span, TempSpanRef = tp.SpanRef, + UpperFrontState = f2.upState, LowerFrontState = f2.loState, + UpperFrontMetric = f2.upMetric, LowerFrontMetric = f2.loMetric }; } // ── WO-2 P-2 + WP6: 압력보정온도(PCT) 모니터 — 국소 압력 프로파일 적용 ── @@ -297,6 +303,40 @@ public sealed class FeedforwardEngine return (state, trim); } + // ── WP5 1단계: 2-point front (C=제품 pivot=temps[n-2]). 상부=ΔT(C−D), 하부=ΔT(C−B). 느린 baseline 편차로 판정. ── + // 상태는 중립(정상/상승/하강) — 섹션별 액션(B↑/환류↑) 매핑은 2단계(trim). D는 유효온도라 ΔT(C−D) 정상 사용. + private static (string? upState, double? upMetric, string? loState, double? loMetric) + ApplyFront2Point(ColumnState st, double ts, IReadOnlyList? temps, bool transient) + { + if (temps is null || temps.Count < 3) return (null, null, null, null); + if (transient) return ("정착대기", null, "정착대기", null); + + const double bw = 0.3, refTau = 1800.0; + int n = temps.Count; + var C = temps[n - 2]; // 제품 pivot + var D = temps[n - 1]; // 상단 + var B = temps[n - 3]; // 하부 + + static string Classify(double dev, double band) + => Math.Abs(dev) <= band ? "정상" : dev > 0 ? "상승" : "하강"; + + string? upState = null; double? upMetric = null; + if (C.Good && D.Good && Num.IsFinite(C.Pct) && Num.IsFinite(D.Pct)) + { + double m = C.Pct - D.Pct; // 상부 ΔT(C−D) + double bl = st.UpperFrontBase.Step(m, refTau, ts); + upState = Classify(m - bl, bw); upMetric = m; + } + string? loState = null; double? loMetric = null; + if (C.Good && B.Good && Num.IsFinite(C.Pct) && Num.IsFinite(B.Pct)) + { + double m = C.Pct - B.Pct; // 하부 ΔT(C−B) + double bl = st.LowerFrontBase.Step(m, refTau, ts); + loState = Classify(m - bl, bw); loMetric = m; + } + return (upState, upMetric, loState, loMetric); + } + // ── §10: 온도 프로파일 역전 판정 (조성 트레이 A,B,C — 환류오염 탑정 D는 제외) ────────── private static TempProfileResult JudgeTempProfile(IReadOnlyList? temps, ColumnState st, bool transient) { diff --git a/src/Web/Controllers/FeedforwardController.cs b/src/Web/Controllers/FeedforwardController.cs index 3c01410..b20018f 100644 --- a/src/Web/Controllers/FeedforwardController.cs +++ b/src/Web/Controllers/FeedforwardController.cs @@ -277,6 +277,10 @@ public sealed class FeedforwardController : ControllerBase inversionPair = r.InversionPair, tempSpan = (r.TempSpan is double ts2 && !double.IsNaN(ts2)) ? ts2 : (double?)null, tempSpanRef = r.TempSpanRef, + upperFrontState = r.UpperFrontState, + lowerFrontState = r.LowerFrontState, + upperFrontMetric = r.UpperFrontMetric, + lowerFrontMetric = r.LowerFrontMetric, autoWriteActive = r.AutoWriteActive, writeGuardBlockedSp = r.WriteGuardBlockedSp, writeGuardReason = r.WriteGuardReason, diff --git a/src/Web/wwwroot/css/ff.css b/src/Web/wwwroot/css/ff.css index 2bb6171..6d5b351 100644 --- a/src/Web/wwwroot/css/ff.css +++ b/src/Web/wwwroot/css/ff.css @@ -109,3 +109,10 @@ .ff-sim-body{display:flex;flex-direction:column;gap:8px} .ff-sim-body textarea{background:var(--bg1);color:var(--t0);border:1px solid var(--bd);border-radius:4px;padding:6px;font-size:12px;font-family:monospace;width:100%} .ff-sim-actions{display:flex;gap:8px;align-items:center} + +/* WP5 1단계: 2-point front */ +.ff-front2 { display:flex; gap:8px; margin-top:4px; flex-wrap:wrap; } +.ff-f2 { font-size:12px; padding:2px 8px; border-radius:4px; background:#1e2733; color:#cbd5e1; } +.ff-f2-up { background:#7c2d12; color:#fed7aa; } +.ff-f2-dn { background:#1e3a5f; color:#bfdbfe; } +.ff-f2-ok { background:#14532d; color:#bbf7d0; } diff --git a/src/Web/wwwroot/js/ff.js b/src/Web/wwwroot/js/ff.js index 1141265..5a9bc6d 100644 --- a/src/Web/wwwroot/js/ff.js +++ b/src/Web/wwwroot/js/ff.js @@ -184,6 +184,13 @@ function ffCard(c) { return `
${esc(c.tempProfileState)}${invStr}${spanStr}
`; })() : ''; + // WP5 1단계: 2-point front (상부 C−D / 하부 C−B) + const f2cls = s => s === '상승' ? 'ff-f2-up' : s === '하강' ? 'ff-f2-dn' : 'ff-f2-ok'; + const front2 = (c.upperFrontState || c.lowerFrontState) ? `
+ 하부(B) ${esc(c.lowerFrontState||'–')}${c.lowerFrontMetric!=null?` ΔT ${fmtVal(c.lowerFrontMetric)}`:''} + 상부(D) ${esc(c.upperFrontState||'–')}${c.upperFrontMetric!=null?` ΔT ${fmtVal(c.upperFrontMetric)}`:''} +
` : ''; + const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); const modeBadge = c.mode === 'Recovering' ? '전환류 복귀중 ●' @@ -217,6 +224,7 @@ function ffCard(c) { ${theta} ${front} ${tpBadge} + ${front2}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
`; } diff --git a/tests/ExperionCrawler.Tests/FeedforwardFront2PointTests.cs b/tests/ExperionCrawler.Tests/FeedforwardFront2PointTests.cs new file mode 100644 index 0000000..30af060 --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardFront2PointTests.cs @@ -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(C−D), 하부=ΔT(C−B). +// 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(), + new Dictionary { ["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) → C−B 증가 → 하부 "상승". 상부(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) → C−D 감소 → 상부 "하강". 하부(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); // C−D = 100−90 + Assert.Equal(-5.0, res.LowerFrontMetric!.Value, 6); // C−B = 100−105 + } +}