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:
@@ -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(C−D)·하부=ΔT(C−B), C=pivot(temps[n-2]), 중립 상태(정상/상승/하강). **2단계(trim ±5%+귀속)·3단계(조성base SP)는 미구현.**
|
||||
|
||||
### 목적
|
||||
LevelDriven 드로우 권장을 `K×feed`(무한상승)에서 **[조성목표(분율×feed)] + [bounded 편차 trim]** 구조로. 편차는 §8.4 2-타임스케일.
|
||||
|
||||
@@ -120,6 +120,11 @@ public sealed record AdvisoryResult(
|
||||
public string? InversionPair { get; init; }
|
||||
public double? TempSpan { get; init; }
|
||||
public double? TempSpanRef { get; init; }
|
||||
// WP5 1단계: 2-point front (C pivot 기준 상/하 분리)
|
||||
public string? UpperFrontState { get; init; } // 상부 ΔT(C−D): 정상/상승/하강/정착대기
|
||||
public string? LowerFrontState { get; init; } // 하부 ΔT(C−B): 정상/상승/하강/정착대기
|
||||
public double? UpperFrontMetric { get; init; }
|
||||
public double? LowerFrontMetric { get; init; }
|
||||
public double? FeedRecommendedSp { get; init; } // WO-6 전환류 시 FEED 권장(0=차단), 그 외 null
|
||||
// Phase II: auto-write 상태
|
||||
public bool AutoWriteActive { get; init; }
|
||||
|
||||
@@ -32,6 +32,9 @@ public sealed class ColumnState
|
||||
public MovingAverage? VLossMaBlock { get; set; }
|
||||
public Dictionary<string, MovingAverage> KObsMa { get; } = new();
|
||||
public FrontPositionIndicator? FrontInd { get; set; } // WO-5
|
||||
// WP5 1단계: 2-point front baseline (C pivot 기준 상/하 차온의 느린 baseline)
|
||||
public FirstOrderLag UpperFrontBase { get; } = new(); // ΔT(C−D) 상부
|
||||
public FirstOrderLag LowerFrontBase { get; } = new(); // ΔT(C−B) 하부
|
||||
// WO-6 전환류 상태기계
|
||||
public ColumnMode Mode { get; set; } = ColumnMode.Normal;
|
||||
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 느린 바이어스
|
||||
var (frontState, frontTrim) = ApplyFront(cfg, st, ts, temps, transient); // WO-5 프론트 위치
|
||||
var tp = JudgeTempProfile(temps, st, transient); // §10 역전 판정
|
||||
var f2 = ApplyFront2Point(st, ts, temps, transient); // WP5 1단계 2-point front
|
||||
// 역전/붕괴 시 front 트림 보류(방향 신뢰 불가)
|
||||
if (tp.State is "온도역전" or "프로파일붕괴") frontTrim = null;
|
||||
var (mode, modeReason, feedRecSp) = ApplyRecovery(
|
||||
@@ -142,7 +146,9 @@ public sealed class FeedforwardEngine
|
||||
transient, treason, ff, outs, vloss, yield, mbState)
|
||||
{ Temps = temps, VLossMa = vLossMa, FrontPositionState = frontState, FrontTrimAdvice = frontTrim,
|
||||
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) 모니터 — 국소 압력 프로파일 적용 ──
|
||||
@@ -297,6 +303,40 @@ public sealed class FeedforwardEngine
|
||||
return (state, trim);
|
||||
}
|
||||
|
||||
// ── WP5 1단계: 2-point front (C=제품 pivot=temps[n-2]). 상부=ΔT(C−D), 하부=ΔT(C−B). 느린 baseline 편차로 판정. ──
|
||||
// 상태는 중립(정상/상승/하강) — 섹션별 액션(B↑/환류↑) 매핑은 2단계(trim). D는 유효온도라 ΔT(C−D) 정상 사용.
|
||||
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(C−D)
|
||||
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(C−B)
|
||||
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는 제외) ──────────
|
||||
private static TempProfileResult JudgeTempProfile(IReadOnlyList<TempPoint>? temps, ColumnState st, bool transient)
|
||||
{
|
||||
|
||||
@@ -277,6 +277,10 @@ public sealed class FeedforwardController : ControllerBase
|
||||
inversionPair = r.InversionPair,
|
||||
tempSpan = (r.TempSpan is double ts2 && !double.IsNaN(ts2)) ? ts2 : (double?)null,
|
||||
tempSpanRef = r.TempSpanRef,
|
||||
upperFrontState = r.UpperFrontState,
|
||||
lowerFrontState = r.LowerFrontState,
|
||||
upperFrontMetric = r.UpperFrontMetric,
|
||||
lowerFrontMetric = r.LowerFrontMetric,
|
||||
autoWriteActive = r.AutoWriteActive,
|
||||
writeGuardBlockedSp = r.WriteGuardBlockedSp,
|
||||
writeGuardReason = r.WriteGuardReason,
|
||||
|
||||
@@ -109,3 +109,10 @@
|
||||
.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-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; }
|
||||
|
||||
@@ -184,6 +184,13 @@ function ffCard(c) {
|
||||
return `<div class="ff-tp-badge ${tpClass}">${esc(c.tempProfileState)}${invStr}${spanStr}</div>`;
|
||||
})() : '';
|
||||
|
||||
// WP5 1단계: 2-point front (상부 C−D / 하부 C−B)
|
||||
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 modeBadge =
|
||||
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
|
||||
@@ -217,6 +224,7 @@ function ffCard(c) {
|
||||
${theta}
|
||||
${front}
|
||||
${tpBadge}
|
||||
${front2}
|
||||
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
63
tests/ExperionCrawler.Tests/FeedforwardFront2PointTests.cs
Normal file
63
tests/ExperionCrawler.Tests/FeedforwardFront2PointTests.cs
Normal 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(C−D), 하부=ΔT(C−B).
|
||||
// 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) → C−B 증가 → 하부 "상승". 상부(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) → C−D 감소 → 상부 "하강". 하부(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); // C−D = 100−90
|
||||
Assert.Equal(-5.0, res.LowerFrontMetric!.Value, 6); // C−B = 100−105
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user