diff --git a/docs/안전피드램프-한계치-브레인스토밍.md b/docs/안전피드램프-한계치-브레인스토밍.md index dca7b8b..dc9c871 100644 --- a/docs/안전피드램프-한계치-브레인스토밍.md +++ b/docs/안전피드램프-한계치-브레인스토밍.md @@ -436,7 +436,7 @@ ti-6111d≈pica-6111 / tica-6111a≈pi-6111b / **ti-6111c·ti-6111b=보간**. |---|------|------|------| | A | **부호 역전 버그** — `DiffTemp.Delta(tHi,tLo)=tHi−tLo` 주석="상단−하단", 그러나 `ApplyFront`(engine.cs:255) 호출=`Delta(temps[0]=A=하단, temps[^1]=D=상단)`=A−D=**하단−상단** → 규약 반대부호 | FrontPositionIndicator `dev>0→프론트상승→환류↑` 매핑이 "상단−하단" 가정 → **트림 권고 반전 + ApplyRecovery sigFront 오판**. **수정완료(76fdce8)+라이브검증(D↑→상승/환류↑, D↓→하강/boilup)** | 🔴→✅ | | B | **D 환류 서브쿨링 오염** — ti-6111d=리플럭스 직하라 조성 아닌 환류온도 추종 | A−D·ΔT(C−D)가 분리 아닌 환류에 끌림 → §8.8 상부 front(D 사용) 약화 | 🔴 | -| C | **역전 가드 부재** — 단조성 검증 없음, 순서 가정 silent | 실제 이상역전(플러딩 등) 미검출 | 🟡 | +| C | **역전 가드 부재** — 단조성 검증 없음, 순서 가정 silent | 실제 이상역전(플러딩 등) 미검출. **구현완료(TempProfileJudge, §10.3)** | 🟡→✅ | ### 10.3 역전(inversion) 판정 — 구체 spec (설계, 미구현) @@ -476,8 +476,10 @@ span = T(A) − T(C) // 정상 양수 (분리 강 `tempProfileState`(정상/약화/붕괴/역전) + `inversionPair`(예 "B-C") + `span`/`spanRef`. #### 구현 메모 -- 신규 순수함수 `TempProfileJudge.Evaluate(temps[A,B,C], spanRef, params)` → 단위테스트(정상/역전/붕괴 각 케이스). `FeedforwardEngine`은 호출만(엔진 로직 최소 변경). -- §10.2-A(부호) 버그픽스와 독립. WP2 P6에서 정상 프로파일·역전 재현으로 검증. +- 신규 순수함수 `TempProfileJudge.Evaluate(trays, spanRef, params)` → 단위테스트(정상/역전/붕괴/약화/입력부족). +- **구현완료(2026-06-01)**: `TempProfileJudge` + 엔진 `JudgeTempProfile`(temp_tags 마지막=D 제외, spanRef 최초정상 시드) + `AdvisoryResult.{TempProfileState,InversionPair,TempSpan,TempSpanRef}` + 컨트롤러 노출 + **역전/붕괴 시 front trim HOLD** + 단위 6건(37/37). 파라미터(tolInv 0.5/warn 0.5/collapse 0.3)는 엔진 const(후속 config 편입). +- **미적용(후속)**: ApplyRecovery `sigInv`/`sigCollapse` 연동 + **코러보레이션**(ΔP/vloss 동반=공정 / 단발=센서) — 센서이상이 곧장 recovery 트리거하지 않도록 보류. +- §10.2-A(부호) 버그픽스와 독립. 라이브 역전 검증은 override로 C>B 주입. ### 10.4 제안 — 프론트 부호 정정 - metric을 명시 "상단−하단"으로: `Delta(temps[^1], temps[0])`(D−A) 또는 부호 반전 → FrontPositionIndicator 매핑과 정합. diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index fcb584c..232e927 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -114,6 +114,11 @@ public sealed record AdvisoryResult( public IReadOnlyList? Temps { get; init; } public string? FrontPositionState { get; init; } public string? FrontTrimAdvice { get; init; } + // §10 온도 역전 판정 + public string? TempProfileState { get; init; } // 정상/약화/프로파일붕괴/온도역전/정착대기/입력부족 + public string? InversionPair { get; init; } + public double? TempSpan { get; init; } + public double? TempSpanRef { 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 79b244f..312e0d3 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -20,6 +20,9 @@ public sealed class ColumnState // WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드) public bool PRefSeeded { get; set; } public double PRefValue { get; set; } = double.NaN; + // §10: 온도 프로파일 스팬 기준점(최초 정상에서 시드) + public bool SpanRefSeeded { get; set; } + public double SpanRefValue { get; set; } = double.NaN; // WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존) public CrossCorrLagEstimator? ThetaEst { get; set; } public double PrevFeedFiltered { get; set; } = double.NaN; @@ -130,12 +133,16 @@ public sealed class FeedforwardEngine ApplyThetaSuggestion(cfg, pv, st, ts, temps, ref outs); // WO-3 θ 제안(advisory) 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 역전 판정 + // 역전/붕괴 시 front 트림 보류(방향 신뢰 불가) + if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null; var (mode, modeReason, feedRecSp) = ApplyRecovery( cfg, pv, st, ts, ff, vLossMa, frontState, transient, ref outs); // WO-6 전환류 복귀 return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, transient, treason, ff, outs, vloss, yield, mbState) { Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim, - Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp }; + Mode = mode, ModeReason = modeReason, FeedRecommendedSp = feedRecSp, + TempProfileState = tp.State, InversionPair = tp.InversionPair, TempSpan = tp.Span, TempSpanRef = tp.SpanRef }; } // ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ─────────── @@ -267,6 +274,27 @@ public sealed class FeedforwardEngine return (state, trim); } + // ── §10: 온도 프로파일 역전 판정 (조성 트레이 A,B,C — 환류오염 탑정 D는 제외) ────────── + private static TempProfileResult JudgeTempProfile(IReadOnlyList? temps, ColumnState st, bool transient) + { + double? refOut = st.SpanRefSeeded ? st.SpanRefValue : (double?)null; + if (temps is null || temps.Count < 3) return new("입력부족", null, double.NaN, refOut); + if (transient) return new("정착대기", null, double.NaN, refOut); + + // temp_tags 하단→상단 순서. 마지막(=탑정 D, 환류 서브쿨 오염)을 제외한 조성 트레이만. + var trays = temps.Take(temps.Count - 1).Select(t => (t.Tag, t.Pct, t.Good)).ToList(); + + // 파라미터(기본값, 운전 데이터로 튜닝 — 추후 config 편입). §10.3. + const double tolInv = 0.5, spanWarnFrac = 0.5, spanCollapseFrac = 0.3; + double spanRef = st.SpanRefSeeded ? st.SpanRefValue : double.NaN; + var r = TempProfileJudge.Evaluate(trays, spanRef, tolInv, spanWarnFrac, spanCollapseFrac); + + // 최초 정상에서 spanRef 시드 + if (!st.SpanRefSeeded && r.State == "정상" && Num.IsFinite(r.Span) && r.Span > 0) + { st.SpanRefValue = r.Span; st.SpanRefSeeded = true; } + return r; + } + // ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ── private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery( ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff, diff --git a/src/Infrastructure/Control/TempProfileJudge.cs b/src/Infrastructure/Control/TempProfileJudge.cs new file mode 100644 index 0000000..4c9dfab --- /dev/null +++ b/src/Infrastructure/Control/TempProfileJudge.cs @@ -0,0 +1,38 @@ +namespace ExperionCrawler.Infrastructure.Control; + +public sealed record TempProfileResult(string State, string? InversionPair, double Span, double? SpanRef); + +/// +/// §10 온도 프로파일 역전 판정 (순수함수). 입력=하단→상단 순서의 **조성 트레이** PCT +/// (환류 서브쿨 오염 탑정 D는 호출측에서 제외하고 전달). 정상=단조 감소(A≥B≥C). +/// 상태: 정상 / 약화 / 프로파일붕괴 / 온도역전 / 입력부족. +/// +public static class TempProfileJudge +{ + public static TempProfileResult Evaluate( + IReadOnlyList<(string tag, double pct, bool good)> trays, + double spanRef, double tolInv, double spanWarnFrac, double spanCollapseFrac) + { + double? refOut = Num.IsFinite(spanRef) ? spanRef : (double?)null; + + if (trays.Count < 2 || trays.Any(t => !t.good || !Num.IsFinite(t.pct))) + return new("입력부족", null, double.NaN, refOut); + + // 인접쌍 단조감소 검증: dDown = 하단 − 상단, 정상 > 0. 상단이 tolInv 이상 뜨거우면 역전. + for (int i = 0; i + 1 < trays.Count; i++) + { + double dDown = trays[i].pct - trays[i + 1].pct; + if (dDown < -tolInv) + return new("온도역전", $"{trays[i].tag}-{trays[i + 1].tag}", + trays[0].pct - trays[^1].pct, refOut); + } + + double span = trays[0].pct - trays[^1].pct; // A − C (조성 분리 스팬) + if (Num.IsFinite(spanRef) && spanRef > 0) + { + if (span < spanCollapseFrac * spanRef) return new("프로파일붕괴", null, span, spanRef); + if (span < spanWarnFrac * spanRef) return new("약화", null, span, spanRef); + } + return new("정상", null, span, refOut); + } +} diff --git a/src/Web/Controllers/FeedforwardController.cs b/src/Web/Controllers/FeedforwardController.cs index 35a5f20..3c01410 100644 --- a/src/Web/Controllers/FeedforwardController.cs +++ b/src/Web/Controllers/FeedforwardController.cs @@ -273,6 +273,10 @@ public sealed class FeedforwardController : ControllerBase vLossMa = r.VLossMa, frontPositionState = r.FrontPositionState, frontTrimAdvice = r.FrontTrimAdvice, + tempProfileState = r.TempProfileState, + inversionPair = r.InversionPair, + tempSpan = (r.TempSpan is double ts2 && !double.IsNaN(ts2)) ? ts2 : (double?)null, + tempSpanRef = r.TempSpanRef, autoWriteActive = r.AutoWriteActive, writeGuardBlockedSp = r.WriteGuardBlockedSp, writeGuardReason = r.WriteGuardReason, diff --git a/tests/ExperionCrawler.Tests/TempProfileJudgeTests.cs b/tests/ExperionCrawler.Tests/TempProfileJudgeTests.cs new file mode 100644 index 0000000..24c46c0 --- /dev/null +++ b/tests/ExperionCrawler.Tests/TempProfileJudgeTests.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using ExperionCrawler.Infrastructure.Control; +using Xunit; + +namespace ExperionCrawler.Tests; + +// §10.3 온도 역전 판정. trays = 하단→상단 조성 트레이(A,B,C), 정상 A≥B≥C. +public class TempProfileJudgeTests +{ + private static List<(string, double, bool)> T(double a, double b, double c, bool good = true) + => new() { ("A", a, good), ("B", b, good), ("C", c, good) }; + + const double Tol = 0.5, Warn = 0.5, Coll = 0.3; + + [Fact] + public void Normal_monotonic_seeds_span() + { + var r = TempProfileJudge.Evaluate(T(90, 85, 82), spanRef: double.NaN, Tol, Warn, Coll); + Assert.Equal("정상", r.State); + Assert.Equal(8, r.Span, 6); // A−C = 90−82 + Assert.Null(r.InversionPair); + } + + [Fact] + public void Inversion_when_upper_hotter() + { + // C(상단)가 B(하단)보다 tolInv 이상 뜨거움 → 역전 + var r = TempProfileJudge.Evaluate(T(90, 80, 85), spanRef: 8, Tol, Warn, Coll); + Assert.Equal("온도역전", r.State); + Assert.Equal("B-C", r.InversionPair); + } + + [Fact] + public void Small_nonmonotonic_within_tol_is_not_inversion() + { + // B−C = -0.2 (데모 노이즈 수준) → tolInv 0.5 내라 역전 아님 + var r = TempProfileJudge.Evaluate(T(79.2, 78.47, 78.69), spanRef: double.NaN, Tol, Warn, Coll); + Assert.NotEqual("온도역전", r.State); + } + + [Fact] + public void Collapse_when_span_below_fraction() + { + // spanRef=10, span = 90−88 = 2 < 0.3*10=3 → 붕괴 + var r = TempProfileJudge.Evaluate(T(90, 89, 88), spanRef: 10, Tol, Warn, Coll); + Assert.Equal("프로파일붕괴", r.State); + } + + [Fact] + public void Weakening_between_warn_and_collapse() + { + // spanRef=10, span = 90−86 = 4 (3<4<5) → 약화 + var r = TempProfileJudge.Evaluate(T(90, 88, 86), spanRef: 10, Tol, Warn, Coll); + Assert.Equal("약화", r.State); + } + + [Fact] + public void Bad_tray_is_input_insufficient() + { + var r = TempProfileJudge.Evaluate(T(90, 85, 82, good: false), spanRef: 8, Tol, Warn, Coll); + Assert.Equal("입력부족", r.State); + } +}