Compare commits

...

2 Commits

Author SHA1 Message Date
windpacer
06b1ecc6c0 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>
2026-06-01 16:52:32 +09:00
windpacer
a35c1722e9 docs: front 부호수정 라이브 물리검증 기록 (D↑→상승/환류↑, D↓→하강/boilup)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:50:27 +09:00
7 changed files with 146 additions and 6 deletions

View File

@@ -434,9 +434,9 @@ ti-6111d≈pica-6111 / tica-6111a≈pi-6111b / **ti-6111c·ti-6111b=보간**.
### 10.2 발견된 버그/문제 (코드 확인)
| # | 문제 | 영향 | 등급 |
|---|------|------|------|
| A | **부호 역전 버그**`DiffTemp.Delta(tHi,tLo)=tHitLo` 주석="상단−하단", 그러나 `ApplyFront`(engine.cs:255) 호출=`Delta(temps[0]=A=하단, temps[^1]=D=상단)`=AD=**하단−상단** → 규약 반대부호 | FrontPositionIndicator `dev>0→프론트상승→환류↑` 매핑이 "상단−하단" 가정 → **트림 권고 반전 + ApplyRecovery sigFront 오판** | 🔴 |
| 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 사용) 약화 | 🔴 |
| 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])`(DA) 또는 부호 반전 → FrontPositionIndicator 매핑과 정합.

View File

@@ -22,7 +22,7 @@ streams: P rec=833.5(pv817) D rec=18.02(pv15.8) B rec=27.03(pv24.8) R rec=666.8(
| **P3** | pi-6111b 미사용 | 엔진 태그목록(BuildSnapshotAsync)에 pi-6111b 없음 — 코드 사실(관찰 대상 아님) | ✅ 코드 확인 |
| **P4** | feed 급변 시 transient/valid=false | **자연 variation(feed ±0.1)으론 transient 미발화**(FeedMoveThresholdPerMin=0이라 moving 조건 자체 off) | ⚠ 운전원 step 주입 필요 |
| **P5** | D+P+B≠feed → mbState 불일치 | vLoss=43.3(>3%·feed=27) → "불일치", Bpv noise로 "정상"↔"불일치" 토글 | ✅ 단, **데모 PV 비보존 아티팩트**(실제 과추출 아님) |
| **P6** | front 부호 수정 동작 | front는 tica-6111a(A)↔ti-6111d(D)로 산출, "하강↔안정" 변동. 부호픽스(76fdce8) 머지·단위검증 완료 | ⚠ 데모 temp 비물리라 **물리 방향 라이브 검증 불가** |
| **P6** | front 부호 수정 동작 | **override 엔진확장 후 라이브 검증(2026-06-01)**: A=90 고정, D sweep → D↑(94/98)=**프론트 상승→환류↑**, D↓(82/78)=**프론트 하강→boilup↑**, 전환점 D≈A. 구버그면 반대 | ✅ 물리방향 확정 |
## ⚠ 핵심 발견 — 데모 temps가 물리 프로파일 위반
운전원 확정 정상 프로파일은 **A>B>C>D 단조감소**(§10.1). 그러나 데모 실측은:

View File

@@ -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; }

View File

@@ -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,

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,
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,

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);
}
}