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:
@@ -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 매핑과 정합.
|
||||
|
||||
@@ -114,6 +114,11 @@ public sealed record AdvisoryResult(
|
||||
public IReadOnlyList<TempPoint>? 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; }
|
||||
|
||||
@@ -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<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 — 권장값 오버라이드만, 쓰기 없음) ──
|
||||
private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery(
|
||||
ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff,
|
||||
|
||||
38
src/Infrastructure/Control/TempProfileJudge.cs
Normal file
38
src/Infrastructure/Control/TempProfileJudge.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
63
tests/ExperionCrawler.Tests/TempProfileJudgeTests.cs
Normal file
63
tests/ExperionCrawler.Tests/TempProfileJudgeTests.cs
Normal 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); // 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user