feat: §10 온도 역전 판정 (TempProfileJudge)

- TempProfileJudge(순수함수): 조성 트레이 A,B,C 단조검증 → 정상/약화/프로파일붕괴/온도역전/입력부족 (tolInv 0.5, warn 0.5, collapse 0.3)
- 엔진 JudgeTempProfile: temp_tags 마지막(탑정 D=환류 서브쿨 오염) 제외, spanRef 최초정상 시드, 역전/붕괴 시 front trim HOLD
- AdvisoryResult.{TempProfileState,InversionPair,TempSpan,TempSpanRef} + 컨트롤러 노출
- 단위 6건(37/37). 후속: ApplyRecovery sigInv 연동 + 코러보레이션(센서vs공정)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-01 16:52:32 +09:00
parent a35c1722e9
commit 06b1ecc6c0
6 changed files with 144 additions and 4 deletions

View File

@@ -436,7 +436,7 @@ ti-6111d≈pica-6111 / tica-6111a≈pi-6111b / **ti-6111c·ti-6111b=보간**.
|---|------|------|------| |---|------|------|------|
| A | **부호 역전 버그**`DiffTemp.Delta(tHi,tLo)=tHitLo` 주석="상단−하단", 그러나 `ApplyFront`(engine.cs:255) 호출=`Delta(temps[0]=A=하단, temps[^1]=D=상단)`=AD=**하단−상단** → 규약 반대부호 | FrontPositionIndicator `dev>0→프론트상승→환류↑` 매핑이 "상단−하단" 가정 → **트림 권고 반전 + ApplyRecovery sigFront 오판**. **수정완료(76fdce8)+라이브검증(D↑→상승/환류↑, D↓→하강/boilup)** | 🔴→✅ | | A | **부호 역전 버그**`DiffTemp.Delta(tHi,tLo)=tHitLo` 주석="상단−하단", 그러나 `ApplyFront`(engine.cs:255) 호출=`Delta(temps[0]=A=하단, temps[^1]=D=상단)`=AD=**하단−상단** → 규약 반대부호 | FrontPositionIndicator `dev>0→프론트상승→환류↑` 매핑이 "상단−하단" 가정 → **트림 권고 반전 + ApplyRecovery sigFront 오판**. **수정완료(76fdce8)+라이브검증(D↑→상승/환류↑, D↓→하강/boilup)** | 🔴→✅ |
| B | **D 환류 서브쿨링 오염** — ti-6111d=리플럭스 직하라 조성 아닌 환류온도 추종 | AD·ΔT(CD)가 분리 아닌 환류에 끌림 → §8.8 상부 front(D 사용) 약화 | 🔴 | | B | **D 환류 서브쿨링 오염** — ti-6111d=리플럭스 직하라 조성 아닌 환류온도 추종 | AD·ΔT(CD)가 분리 아닌 환류에 끌림 → §8.8 상부 front(D 사용) 약화 | 🔴 |
| C | **역전 가드 부재** — 단조성 검증 없음, 순서 가정 silent | 실제 이상역전(플러딩 등) 미검출 | 🟡 | | C | **역전 가드 부재** — 단조성 검증 없음, 순서 가정 silent | 실제 이상역전(플러딩 등) 미검출. **구현완료(TempProfileJudge, §10.3)** | 🟡→✅ |
### 10.3 역전(inversion) 판정 — 구체 spec (설계, 미구현) ### 10.3 역전(inversion) 판정 — 구체 spec (설계, 미구현)
@@ -476,8 +476,10 @@ span = T(A) T(C) // 정상 양수 (분리 강
`tempProfileState`(정상/약화/붕괴/역전) + `inversionPair`(예 "B-C") + `span`/`spanRef`. `tempProfileState`(정상/약화/붕괴/역전) + `inversionPair`(예 "B-C") + `span`/`spanRef`.
#### 구현 메모 #### 구현 메모
- 신규 순수함수 `TempProfileJudge.Evaluate(temps[A,B,C], spanRef, params)` → 단위테스트(정상/역전/붕괴 각 케이스). `FeedforwardEngine`은 호출만(엔진 로직 최소 변경). - 신규 순수함수 `TempProfileJudge.Evaluate(trays, spanRef, params)` → 단위테스트(정상/역전/붕괴/약화/입력부족).
- §10.2-A(부호) 버그픽스와 독립. WP2 P6에서 정상 프로파일·역전 재현으로 검증. - **구현완료(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 제안 — 프론트 부호 정정 ### 10.4 제안 — 프론트 부호 정정
- metric을 명시 "상단−하단"으로: `Delta(temps[^1], temps[0])`(DA) 또는 부호 반전 → FrontPositionIndicator 매핑과 정합. - metric을 명시 "상단−하단"으로: `Delta(temps[^1], temps[0])`(DA) 또는 부호 반전 → FrontPositionIndicator 매핑과 정합.

View File

@@ -114,6 +114,11 @@ public sealed record AdvisoryResult(
public IReadOnlyList<TempPoint>? Temps { get; init; } public IReadOnlyList<TempPoint>? Temps { get; init; }
public string? FrontPositionState { get; init; } public string? FrontPositionState { get; init; }
public string? FrontTrimAdvice { 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 public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null
// Phase II: auto-write 상태 // Phase II: auto-write 상태
public bool AutoWriteActive { get; init; } public bool AutoWriteActive { get; init; }

View File

@@ -20,6 +20,9 @@ public sealed class ColumnState
// WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드) // WO-2: 압력 기준점(cfg.PRef가 NaN이면 최초 정상 압력으로 시드)
public bool PRefSeeded { get; set; } public bool PRefSeeded { get; set; }
public double PRefValue { get; set; } = double.NaN; public double PRefValue { get; set; } = double.NaN;
// §10: 온도 프로파일 스팬 기준점(최초 정상에서 시드)
public bool SpanRefSeeded { get; set; }
public double SpanRefValue { get; set; } = double.NaN;
// WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존) // WO-3: θ 자동튜닝(컬럼 1개 추정기 + 이전값 보존)
public CrossCorrLagEstimator? ThetaEst { get; set; } public CrossCorrLagEstimator? ThetaEst { get; set; }
public double PrevFeedFiltered { get; set; } = double.NaN; 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) 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 느린 바이어스 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 (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( var (mode, modeReason, feedRecSp) = ApplyRecovery(
cfg, pv, st, ts, ff, vLossMa, frontState, transient, ref outs); // WO-6 전환류 복귀 cfg, pv, st, ts, ff, vLossMa, frontState, transient, ref outs); // WO-6 전환류 복귀
return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled, return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
transient, treason, ff, outs, vloss, yield, mbState) transient, treason, ff, outs, vloss, yield, mbState)
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim, { 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 무관) ─────────── // ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ───────────
@@ -267,6 +274,27 @@ public sealed class FeedforwardEngine
return (state, trim); return (state, trim);
} }
// ── §10: 온도 프로파일 역전 판정 (조성 트레이 A,B,C — 환류오염 탑정 D는 제외) ──────────
private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? 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 — 권장값 오버라이드만, 쓰기 없음) ── // ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ──
private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery( private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery(
ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff, ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff,

View File

@@ -0,0 +1,38 @@
namespace ExperionCrawler.Infrastructure.Control;
public sealed record TempProfileResult(string State, string? InversionPair, double Span, double? SpanRef);
/// <summary>
/// §10 온도 프로파일 역전 판정 (순수함수). 입력=하단→상단 순서의 **조성 트레이** PCT
/// (환류 서브쿨 오염 탑정 D는 호출측에서 제외하고 전달). 정상=단조 감소(A≥B≥C).
/// 상태: 정상 / 약화 / 프로파일붕괴 / 온도역전 / 입력부족.
/// </summary>
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);
}
}

View File

@@ -273,6 +273,10 @@ public sealed class FeedforwardController : ControllerBase
vLossMa = r.VLossMa, vLossMa = r.VLossMa,
frontPositionState = r.FrontPositionState, frontPositionState = r.FrontPositionState,
frontTrimAdvice = r.FrontTrimAdvice, 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, autoWriteActive = r.AutoWriteActive,
writeGuardBlockedSp = r.WriteGuardBlockedSp, writeGuardBlockedSp = r.WriteGuardBlockedSp,
writeGuardReason = r.WriteGuardReason, writeGuardReason = r.WriteGuardReason,

View File

@@ -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); // AC = 9082
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()
{
// BC = -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 = 9088 = 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 = 9086 = 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);
}
}