feat: WP5 1단계 — 2-point front (상부 C-D / 하부 C-B)

- ColumnState UpperFrontBase/LowerFrontBase, 엔진 ApplyFront2Point(C=pivot temps[n-2], 느린 baseline 편차→중립 상태 정상/상승/하강)
- AdvisoryResult Upper/LowerFrontState·Metric + 컨트롤러 노출 + ff.js/css 표시
- 단위 3건(상/하 격리 perturb, metric 노출). 45/45. 2단계(trim)·3단계(조성base) 미구현

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-01 20:27:52 +09:00
parent 0519547271
commit dae8d7f902
7 changed files with 131 additions and 2 deletions

View File

@@ -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(CD)·하부=ΔT(CB), C=pivot(temps[n-2]), 중립 상태(정상/상승/하강). **2단계(trim ±5%+귀속)·3단계(조성base SP)는 미구현.**
### 목적 ### 목적
LevelDriven 드로우 권장을 `K×feed`(무한상승)에서 **[조성목표(분율×feed)] + [bounded 편차 trim]** 구조로. 편차는 §8.4 2-타임스케일. LevelDriven 드로우 권장을 `K×feed`(무한상승)에서 **[조성목표(분율×feed)] + [bounded 편차 trim]** 구조로. 편차는 §8.4 2-타임스케일.

View File

@@ -120,6 +120,11 @@ public sealed record AdvisoryResult(
public string? InversionPair { get; init; } public string? InversionPair { get; init; }
public double? TempSpan { get; init; } public double? TempSpan { get; init; }
public double? TempSpanRef { get; init; } public double? TempSpanRef { get; init; }
// WP5 1단계: 2-point front (C pivot 기준 상/하 분리)
public string? UpperFrontState { get; init; } // 상부 ΔT(CD): 정상/상승/하강/정착대기
public string? LowerFrontState { get; init; } // 하부 ΔT(CB): 정상/상승/하강/정착대기
public double? UpperFrontMetric { get; init; }
public double? LowerFrontMetric { 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

@@ -32,6 +32,9 @@ public sealed class ColumnState
public MovingAverage? VLossMaBlock { get; set; } public MovingAverage? VLossMaBlock { get; set; }
public Dictionary<string, MovingAverage> KObsMa { get; } = new(); public Dictionary<string, MovingAverage> KObsMa { get; } = new();
public FrontPositionIndicator? FrontInd { get; set; } // WO-5 public FrontPositionIndicator? FrontInd { get; set; } // WO-5
// WP5 1단계: 2-point front baseline (C pivot 기준 상/하 차온의 느린 baseline)
public FirstOrderLag UpperFrontBase { get; } = new(); // ΔT(CD) 상부
public FirstOrderLag LowerFrontBase { get; } = new(); // ΔT(CB) 하부
// WO-6 전환류 상태기계 // WO-6 전환류 상태기계
public ColumnMode Mode { get; set; } = ColumnMode.Normal; public ColumnMode Mode { get; set; } = ColumnMode.Normal;
public double ImbalanceTimerSec { get; set; } 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 느린 바이어스 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 역전 판정 var tp = JudgeTempProfile(temps, st, transient); // §10 역전 판정
var f2 = ApplyFront2Point(st, ts, temps, transient); // WP5 1단계 2-point front
// 역전/붕괴 시 front 트림 보류(방향 신뢰 불가) // 역전/붕괴 시 front 트림 보류(방향 신뢰 불가)
if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null; if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null;
var (mode, modeReason, feedRecSp) = ApplyRecovery( var (mode, modeReason, feedRecSp) = ApplyRecovery(
@@ -142,7 +146,9 @@ public sealed class FeedforwardEngine
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 }; 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) 모니터 — 국소 압력 프로파일 적용 ── // ── WO-2 P-2 + WP6: 압력보정온도(PCT) 모니터 — 국소 압력 프로파일 적용 ──
@@ -297,6 +303,40 @@ public sealed class FeedforwardEngine
return (state, trim); return (state, trim);
} }
// ── WP5 1단계: 2-point front (C=제품 pivot=temps[n-2]). 상부=ΔT(CD), 하부=ΔT(CB). 느린 baseline 편차로 판정. ──
// 상태는 중립(정상/상승/하강) — 섹션별 액션(B↑/환류↑) 매핑은 2단계(trim). D는 유효온도라 ΔT(CD) 정상 사용.
private static (string? upState, double? upMetric, string? loState, double? loMetric)
ApplyFront2Point(ColumnState st, double ts, IReadOnlyList<TempPoint>? 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(CD)
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(CB)
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는 제외) ────────── // ── §10: 온도 프로파일 역전 판정 (조성 트레이 A,B,C — 환류오염 탑정 D는 제외) ──────────
private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? temps, ColumnState st, bool transient) private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? temps, ColumnState st, bool transient)
{ {

View File

@@ -277,6 +277,10 @@ public sealed class FeedforwardController : ControllerBase
inversionPair = r.InversionPair, inversionPair = r.InversionPair,
tempSpan = (r.TempSpan is double ts2 && !double.IsNaN(ts2)) ? ts2 : (double?)null, tempSpan = (r.TempSpan is double ts2 && !double.IsNaN(ts2)) ? ts2 : (double?)null,
tempSpanRef = r.TempSpanRef, tempSpanRef = r.TempSpanRef,
upperFrontState = r.UpperFrontState,
lowerFrontState = r.LowerFrontState,
upperFrontMetric = r.UpperFrontMetric,
lowerFrontMetric = r.LowerFrontMetric,
autoWriteActive = r.AutoWriteActive, autoWriteActive = r.AutoWriteActive,
writeGuardBlockedSp = r.WriteGuardBlockedSp, writeGuardBlockedSp = r.WriteGuardBlockedSp,
writeGuardReason = r.WriteGuardReason, writeGuardReason = r.WriteGuardReason,

View File

@@ -109,3 +109,10 @@
.ff-sim-body{display:flex;flex-direction:column;gap:8px} .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-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} .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; }

View File

@@ -184,6 +184,13 @@ function ffCard(c) {
return `<div class="ff-tp-badge ${tpClass}">${esc(c.tempProfileState)}${invStr}${spanStr}</div>`; return `<div class="ff-tp-badge ${tpClass}">${esc(c.tempProfileState)}${invStr}${spanStr}</div>`;
})() : ''; })() : '';
// WP5 1단계: 2-point front (상부 CD / 하부 CB)
const f2cls = s => s === '상승' ? 'ff-f2-up' : s === '하강' ? 'ff-f2-dn' : 'ff-f2-ok';
const front2 = (c.upperFrontState || c.lowerFrontState) ? `<div class="ff-front2">
<span class="ff-f2 ${f2cls(c.lowerFrontState)}">하부(B) ${esc(c.lowerFrontState||'')}${c.lowerFrontMetric!=null?` <small>ΔT ${fmtVal(c.lowerFrontMetric)}</small>`:''}</span>
<span class="ff-f2 ${f2cls(c.upperFrontState)}">상부(D) ${esc(c.upperFrontState||'')}${c.upperFrontMetric!=null?` <small>ΔT ${fmtVal(c.upperFrontMetric)}</small>`:''}</span>
</div>` : '';
const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0);
const modeBadge = const modeBadge =
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>' c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
@@ -217,6 +224,7 @@ function ffCard(c) {
${theta} ${theta}
${front} ${front}
${tpBadge} ${tpBadge}
${front2}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div> <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
</div>`; </div>`;
} }

View File

@@ -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(CD), 하부=ΔT(CB).
// 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<TagSample>(),
new Dictionary<string, TagSample> { ["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) → CB 증가 → 하부 "상승". 상부(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) → CD 감소 → 상부 "하강". 하부(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); // CD = 10090
Assert.Equal(-5.0, res.LowerFrontMetric!.Value, 6); // CB = 100105
}
}