diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index 232e927..976e569 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -79,6 +79,7 @@ public sealed record PvSnapshot( public IReadOnlyList? Temps { get; init; } public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력) public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거) + public TagSample? BottomPressure { get; init; } // WP6: 탑저 압력(ΔP 합성·국소 PCT용) } public sealed record StreamAdvisory( diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs index 312e0d3..b295269 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -137,7 +137,7 @@ public sealed class FeedforwardEngine // 역전/붕괴 시 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 전환류 복귀 + cfg, pv, st, ts, ff, vLossMa, frontState, tp.State, 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, @@ -145,29 +145,55 @@ public sealed class FeedforwardEngine TempProfileState = tp.State, InversionPair = tp.InversionPair, TempSpan = tp.Span, TempSpanRef = tp.SpanRef }; } - // ── WO-2 P-2: 압력보정온도(PCT) 모니터 (advisory, 권장SP 무관) ─────────── + // ── WO-2 P-2 + WP6: 압력보정온도(PCT) 모니터 — 국소 압력 프로파일 적용 ── private static IReadOnlyList? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st) { if (pv.Temps is null || pv.Temps.Count == 0) return null; bool havePress = pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value); - double pNow = havePress ? pv.Pressure!.Value : double.NaN; + double pTop = havePress ? pv.Pressure!.Value : double.NaN; + + // WP6: 탑저압(합성 또는 직접 측정) — 국소 압력 보간용 + bool haveBottom = pv.BottomPressure is { Good: true } bp && Num.IsFinite(bp.Value); + double pBottom = haveBottom ? pv.BottomPressure!.Value : double.NaN; double pRef = cfg.PRef; if (double.IsNaN(pRef)) { - if (!st.PRefSeeded && havePress) { st.PRefValue = pNow; st.PRefSeeded = true; } + if (!st.PRefSeeded && havePress) { st.PRefValue = pTop; st.PRefSeeded = true; } pRef = st.PRefSeeded ? st.PRefValue : double.NaN; } + // WP6: 국소 압력 보간 비율(하단→상단). 탑저=1.0, 탑정=0.0. C-6111 레이아웃 기준. + // 향후 config 확장 가능. temp_tags 순서 = 하단→상단 = tica-6111a,ti-6111b,ti-6111c,ti-6111d var list = new List(pv.Temps.Count); - foreach (var t in pv.Temps) + int n = pv.Temps.Count; + for (int i = 0; i < n; i++) { + var t = pv.Temps[i]; bool good = t.Good && Num.IsFinite(t.Value); double raw = good ? t.Value : double.NaN; - double pct = raw; - if (good && cfg.DTdP != 0.0 && havePress && Num.IsFinite(pRef)) - pct = TempCorrection.PressureCompensated(raw, pNow, pRef, cfg.DTdP); + + // WP6: 국소 압력 = 선형 보간 bottom→top (i=0→bottom, i=n-1→top) + double pLocal = double.NaN, pRefLocal = double.NaN; + if (havePress && haveBottom) + { + double w = n > 1 ? i / (double)(n - 1) : 0.0; // 0.0=bottom ~ 1.0=top + pLocal = pBottom * (1.0 - w) + pTop * w; + if (Num.IsFinite(pRef)) + pRefLocal = pRef; // 단일 pRef → 프로파일 없음(dTdP만 적용) + else + pRefLocal = pRef; + } + else if (havePress) + { + pLocal = pTop; + pRefLocal = pRef; + } + + double pct = raw; + if (good && cfg.DTdP != 0.0 && Num.IsFinite(pLocal) && Num.IsFinite(pRefLocal)) + pct = TempCorrection.PressureCompensated(raw, pLocal, pRefLocal, cfg.DTdP); list.Add(new TempPoint(t.Tag, raw, pct, good)); } return list; @@ -298,7 +324,7 @@ public sealed class FeedforwardEngine // ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ── private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery( ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff, - double? vLossMa, string? frontState, bool transient, ref List outs) + double? vLossMa, string? frontState, string? tempProfileState, bool transient, ref List outs) { if (!cfg.RecoveryEnabled) { @@ -316,10 +342,22 @@ public sealed class FeedforwardEngine bool sigVloss = vLossMa.HasValue && frac > cfg.ImbalanceTriggerFrac; bool sigFront = frontState is not null && (frontState.Contains("상승") || frontState.Contains("하강")); bool sigDp = pv.DeltaP is { Good: true } dp && Num.IsFinite(dp.Value) && dp.Value > cfg.DeltaPFloodLimit; - bool severe = sigVloss || sigFront || sigDp; + // WP4: 온도 프로파일 역전/붕괴 코러보레이션 — ΔP 또는 vloss 동반 시에만 공정으로 인정 + bool sigInv = tempProfileState == "온도역전"; + bool sigCollapse = tempProfileState == "프로파일붕괴"; + bool corroborated = sigVloss || sigDp; + bool tempSevere = (sigInv || sigCollapse) && corroborated; + bool severe = sigVloss || sigFront || sigDp || tempSevere; + + // 비코러보 온도역전 — 센서 점검 메시지(severe 아님) + string? sensorCheck = (sigInv && !corroborated) + ? "온도역전(센서 점검 권고 — ΔP/물질수지 정상)" : null; string SeverityText() => - (sigVloss ? $"물질수지({frac:P0}) " : "") + (sigFront ? "프론트드리프트 " : "") + (sigDp ? "ΔP플러딩" : ""); + (sigVloss ? $"물질수지({frac:P0}) " : "") + + (sigFront ? "프론트드리프트 " : "") + + (sigDp ? "ΔP플러딩 " : "") + + (tempSevere ? "온도역전/붕괴 " : ""); switch (st.Mode) { @@ -334,7 +372,7 @@ public sealed class FeedforwardEngine } if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec) return (ColumnMode.Normal, $"전환류 권장(ARM 대기): {SeverityText()}", null); - return (ColumnMode.Normal, null, null); + return (ColumnMode.Normal, sensorCheck, null); case ColumnMode.Recovering: { diff --git a/src/Infrastructure/Control/FeedforwardSupervisor.cs b/src/Infrastructure/Control/FeedforwardSupervisor.cs index ca00a81..6303acc 100644 --- a/src/Infrastructure/Control/FeedforwardSupervisor.cs +++ b/src/Infrastructure/Control/FeedforwardSupervisor.cs @@ -183,6 +183,20 @@ public sealed class FeedforwardSupervisor : BackgroundService if (cfg.SteamOpTag is not null) tags.Add(cfg.SteamOpTag.ToLowerInvariant()); // WO-3 스팀 OP(.op 그대로) if (cfg.DeltaPTag is not null) tags.Add(PvTag(cfg.DeltaPTag)); // WO-6 차압(.pv) + // WP6: DeltaPTag 미지정 시 PressureTag로부터 탑저압 파생 + string? derivedBottomTag = null; + if (cfg.DeltaPTag is null && cfg.PressureTag is not null) + { + // pica-{n} → pi-{n}b (C-6111 명명 규약) + var p = cfg.PressureTag.ToLowerInvariant(); + if (p.StartsWith("pica-")) + { + var num = p["pica-".Length..]; + derivedBottomTag = "pi-" + num + "b"; + tags.Add(PvTag(derivedBottomTag)); + } + } + var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags)) .ToDictionary(r => r.TagName.ToLowerInvariant(), r => r); @@ -221,7 +235,27 @@ public sealed class FeedforwardSupervisor : BackgroundService var streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag)); var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null; var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : null; - var deltaP = cfg.DeltaPTag is not null ? Sample(cfg.DeltaPTag) : null; - return new PvSnapshot(feed, press, levels, streams) { Temps = temps, SteamOp = steam, DeltaP = deltaP }; + + // WP6: ΔP 합성 — delta_p_tag 직접값 또는 {bottom - top} 파생 + TagSample? deltaP; + TagSample? bottomPress = null; + if (cfg.DeltaPTag is not null) + { + deltaP = Sample(cfg.DeltaPTag); + } + else if (press is { Good: true } && derivedBottomTag is not null) + { + bottomPress = Sample(derivedBottomTag); + if (bottomPress.Good && Num.IsFinite(bottomPress.Value) && Num.IsFinite(press.Value)) + { + double dpVal = bottomPress.Value - press.Value; + deltaP = new TagSample("_synthetic_dp", dpVal, Good: true, DateTime.UtcNow); + } + else deltaP = null; + } + else deltaP = null; + + return new PvSnapshot(feed, press, levels, streams) + { Temps = temps, SteamOp = steam, DeltaP = deltaP, BottomPressure = bottomPress }; } } diff --git a/src/Web/wwwroot/css/ff.css b/src/Web/wwwroot/css/ff.css index a6fd71a..2bb6171 100644 --- a/src/Web/wwwroot/css/ff.css +++ b/src/Web/wwwroot/css/ff.css @@ -79,3 +79,33 @@ .ff-write-err{color:#ff8a80} .ff-wg-blocked{font-size:12px;color:#ff8a80;background:#3a0000;padding:4px 8px;border-radius:4px;margin:4px 0} .ff-wg-blocked b{color:#ff5252} + +/* ── WP7: 램프 계산기 ────────────────────────────────── */ +.ff-ramp{border:1px solid var(--bd);border-radius:8px;padding:12px;margin-bottom:12px;background:var(--bg2)} +.ff-ramp-body{display:flex;gap:16px;flex-wrap:wrap} +.ff-ramp-inputs{display:flex;flex-wrap:wrap;gap:8px;align-items:end} +.ff-ramp-inputs label{font-size:12px;color:var(--t2);display:flex;flex-direction:column;gap:2px} +.ff-ramp-inputs .inp{width:80px} +.ff-ramp-result{flex:1;min-width:200px} +.ff-ramp-table{font-size:13px;border-collapse:collapse;width:100%} +.ff-ramp-table td{padding:3px 8px;border-bottom:1px solid var(--bd)} +.ff-ramp-table td:first-child{color:var(--t2)} +.ff-ramp-warn{font-size:11px;color:#ffb74d;margin-top:6px;line-height:1.5} +.ff-ramp-hold{color:#ff8a80;font-weight:600} +.ff-ramp-err{color:#ff5252} +/* ── WP7: 온도 프로파일 상태 뱃지 ────────────────────── */ +.ff-tp-badge{font-size:12px;padding:2px 8px;border-radius:10px;display:inline-block;margin-top:4px} +.ff-tp-ok{background:#003a1a;color:#69f0ae} +.ff-tp-warn{background:#3a2e00;color:#ffd24d} +.ff-tp-collapse{background:#5a0000;color:#ff8a80} +.ff-tp-inv{background:#5a0000;color:#ff5252;font-weight:700;animation:fftpblink 0.8s step-start infinite} +.ff-tp-na{background:#222;color:var(--t3)} +.ff-tp-badge small{font-weight:400;opacity:.7} +@keyframes fftpblink{50%{opacity:.5}} +/* ── WP7: Sim Override 패널 ──────────────────────────── */ +.ff-sim{border:1px solid #ffb74d;border-radius:8px;padding:12px;margin-top:12px;background:rgba(255,183,77,.04)} +.ff-sim-head{font-weight:700;font-size:13px;margin-bottom:8px;color:#ffb74d} +.ff-sim-head small{font-weight:400;color:var(--t2)} +.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} diff --git a/src/Web/wwwroot/js/ff.js b/src/Web/wwwroot/js/ff.js index 1b318ab..1141265 100644 --- a/src/Web/wwwroot/js/ff.js +++ b/src/Web/wwwroot/js/ff.js @@ -25,8 +25,94 @@ async function ffInit() { const c = document.getElementById('ff-cfg'); c.style.display = c.style.display === 'none' ? 'block' : 'none'; }; + document.getElementById('ff-ramp-toggle').onclick = () => { + const r = document.getElementById('ff-ramp'); + r.style.display = r.style.display === 'none' ? 'block' : 'none'; + }; + document.getElementById('ff-ramp-go').onclick = ffRampCompute; + document.getElementById('ff-new').onclick = () => ffEditColumn(null); ffLoadConfig().catch(()=>{}); + + // WP7: sim override — 가능하면 로드(403 무시) + ffSimLoad(); +} + +// ── WP7: 램프 계산기 ────────────────────────────────────────── +async function ffRampCompute() { + const g = id => document.getElementById(id); + const params = new URLSearchParams({ + columnId: g('ff-ramp-colId').value, + targetFeed: g('ff-ramp-targetFeed').value, + deltaIAllow: g('ff-ramp-deltaI').value, + sensibleGain: g('ff-ramp-sensibleGain').value || '', + feedTempRef: g('ff-ramp-feedTempRef').value || '', + floodLimit: g('ff-ramp-floodLimit').value || '', + n: g('ff-ramp-n').value + }); + const host = g('ff-ramp-result'); + host.innerHTML = '계산 중…'; + try { + const data = await api('GET', '/api/ff/ramp-advisor?' + params.toString()); + if (data.hold) { + host.innerHTML = `
HOLD: ${esc(data.warnings?.join(', ') || '피드 불량')}
`; + return; + } + host.innerHTML = ` + + + + + + + + + +
현재 피드${fmtVal(data.currentFeed)}
목표 피드${fmtVal(data.targetFeed)}
클램프 목표${fmtVal(data.clampedTarget)}
Ceiling${fmtVal(data.ceiling?.value)} (${esc(data.ceiling?.binding)})
램프율${fmtVal(data.rampRate?.value)} kg/hr·min (${esc(data.rampRate?.binding)})
예상 시간${data.rampTimeMin != null ? Math.round(data.rampTimeMin) + '분' : '–'}
스팀(현재)${fmtVal(data.steam?.fiq6115From)}
스팀(목표)${fmtVal(data.steam?.fiq6115To)}
+
${(data.warnings||[]).map(w => esc(w)).join('
')}
`; + } catch (e) { + host.innerHTML = `
오류: ${esc(e.message)}
`; + } +} + +// ── WP7: Sim Override ───────────────────────────────────────── +async function ffSimLoad() { + try { + const data = await api('GET', '/api/ff/sim/override'); + document.getElementById('ff-sim').style.display = 'block'; + document.getElementById('ff-sim-enabled').checked = data.enabled; + document.getElementById('ff-sim-values').value = JSON.stringify(data.values || {}, null, 2); + } catch (e) { + // 403 또는 미구현 → 패널 숨김 + } + document.getElementById('ff-sim-apply').onclick = ffSimApply; + document.getElementById('ff-sim-clear').onclick = ffSimClear; +} +async function ffSimApply() { + const enabled = document.getElementById('ff-sim-enabled').checked; + let values = {}; + try { values = JSON.parse(document.getElementById('ff-sim-values').value || '{}'); } catch (e) {} + try { + const data = await ffApi('POST', '/api/ff/sim/override', { enabled, values }); + document.getElementById('ff-sim-status').textContent = '적용됨'; + document.getElementById('ff-sim-status').className = 'ff-msg'; + document.getElementById('ff-sim-values').value = JSON.stringify(data.values || {}, null, 2); + } catch (e) { + document.getElementById('ff-sim-status').textContent = '실패: ' + e.message; + document.getElementById('ff-sim-status').className = 'ff-msg err'; + } +} +async function ffSimClear() { + try { + const data = await ffApi('DELETE', '/api/ff/sim/override'); + document.getElementById('ff-sim-enabled').checked = false; + document.getElementById('ff-sim-values').value = '{}'; + document.getElementById('ff-sim-status').textContent = '해제됨'; + document.getElementById('ff-sim-status').className = 'ff-msg'; + } catch (e) { + document.getElementById('ff-sim-status').textContent = '실패: ' + e.message; + document.getElementById('ff-sim-status').className = 'ff-msg err'; + } } // ── 대시보드 (공개) ────────────────────────────────────────────── @@ -84,6 +170,20 @@ function ffCard(c) { const front = c.frontPositionState ? `
프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → ${esc(c.frontTrimAdvice)}`:''}
` : ''; + + // WP7: 온도 프로파일 상태 뱃지 + const tpBadge = c.tempProfileState ? (() => { + const tpClass = c.tempProfileState === '온도역전' ? 'ff-tp-inv' + : c.tempProfileState === '프로파일붕괴' ? 'ff-tp-collapse' + : c.tempProfileState === '약화' ? 'ff-tp-warn' + : c.tempProfileState === '정상' ? 'ff-tp-ok' + : 'ff-tp-na'; + const spanStr = (c.tempSpan != null && c.tempSpanRef != null) + ? ` span ${fmtVal(c.tempSpan)}/${fmtVal(c.tempSpanRef)}` : ''; + const invStr = c.inversionPair ? ` (${esc(c.inversionPair)})` : ''; + return `
${esc(c.tempProfileState)}${invStr}${spanStr}
`; + })() : ''; + const armWait = (c.mode === 'Normal' && c.modeReason && c.modeReason.indexOf('ARM 대기') >= 0); const modeBadge = c.mode === 'Recovering' ? '전환류 복귀중 ●' @@ -116,6 +216,7 @@ function ffCard(c) { ${temps} ${theta} ${front} + ${tpBadge}
LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.
`; } diff --git a/src/Web/wwwroot/panes/ff.html b/src/Web/wwwroot/panes/ff.html index acfcae1..2524efb 100644 --- a/src/Web/wwwroot/panes/ff.html +++ b/src/Web/wwwroot/panes/ff.html @@ -3,6 +3,24 @@

측류추출 권장 유량 설정값 (Advisory · 보조지표)

읽기 전용 — 권장값. 인가는 운전원 + + + + + @@ -16,4 +34,18 @@
+ + + diff --git a/tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs b/tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs index c8b3cd9..7543c03 100644 --- a/tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs +++ b/tests/ExperionCrawler.Tests/FeedforwardRecoveryTests.cs @@ -84,4 +84,99 @@ public class FeedforwardRecoveryTests var res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow); Assert.Equal(ColumnMode.Normal, res.Mode); } + + // ── WP4: 온도역전 + vloss 코러보레이션 ──────────────────────────── + // temp_tags 하단→상단: 정상 단조감소(110>105>100>90). 역전은 하단이 상단보다 차가워야 하므로 + // 하단(110) > 중간(100) > 상단(105 → 역전: 100-105 < -tolInv) + // 열순서: A(tica-6111a)=리보일러→B(ti-6111b)=중간→C(ti-6111c)=제품→D(ti-6111d,제외) + + // 조성트레이(A,B,C) 역전: 태그 D(ti-6111d)는 제외되므로, B(ti-6111b)가 C(ti-6111c)보다 + // 차가우면 역전(B-C): B=95, C=100 → 95-100=-5 < -0.5 → 온도역전 + private static PvSnapshot ImbalancedWithInversion() => new( + new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), + new Dictionary { + ["P"]=new("p",30,true,DateTime.UtcNow), ["R"]=new("r",50,true,DateTime.UtcNow), + ["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }) + { Temps = new[] { + new TagSample("tica-6111a", 110, true, DateTime.UtcNow), // 하단 A + new TagSample("ti-6111b", 95, true, DateTime.UtcNow), // B (C보다 차가움 → B-C 역전) + new TagSample("ti-6111c", 100, true, DateTime.UtcNow), // C + new TagSample("ti-6111d", 90, true, DateTime.UtcNow) }};// D(제외) + + private static PvSnapshot BalancedWithInversion() => new( + new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), + new Dictionary { + ["P"]=new("p",95,true,DateTime.UtcNow), ["R"]=new("r",760,true,DateTime.UtcNow), + ["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }) + { Temps = new[] { + new TagSample("tica-6111a", 110, true, DateTime.UtcNow), // A + new TagSample("ti-6111b", 95, true, DateTime.UtcNow), // B (C보다 차가움 → B-C 역전) + new TagSample("ti-6111c", 100, true, DateTime.UtcNow), // C + new TagSample("ti-6111d", 90, true, DateTime.UtcNow) }};// D(제외) + + // D(ti-6111d) 제외 후 A=110, B=95, C=100 → B-C = 95-100 = -5 < -0.5 → 온도역전(B-C) + // + vloss 불일치(Balanced=0) → corroborated=false → severe 아님 + + private static PvSnapshot BalancedNormalTemps() => new( + new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty(), + new Dictionary { + ["P"]=new("p",95,true,DateTime.UtcNow), ["R"]=new("r",760,true,DateTime.UtcNow), + ["D"]=new("d",2,true,DateTime.UtcNow), ["B"]=new("b",3,true,DateTime.UtcNow) }) + { Temps = new[] { + new TagSample("tica-6111a", 110, true, DateTime.UtcNow), // 하단 + new TagSample("ti-6111b", 105, true, DateTime.UtcNow), // 중간 + new TagSample("ti-6111c", 100, true, DateTime.UtcNow), // 제품 pivot + new TagSample("ti-6111d", 90, true, DateTime.UtcNow) }};// 탑정 + + [Fact] + public void Inversion_with_vloss_triggers_recovery() + { + // WP4 역전(vloss 코러보) → severe → 전환류 진입 + var cfg = Cfg(autoArm: true) with + { + TempTags = new[] { "tica-6111a", "ti-6111b", "ti-6111c", "ti-6111d" }, + DTdP = 0.0, PRef = double.NaN, PressureTag = null + }; + var engine = new FeedforwardEngine(); var st = new ColumnState(); + // 먼저 정상 온도로 시드(spanRef) + var s0 = engine.Tick(cfg, BalancedNormalTemps(), st, DateTime.UtcNow); + // 역전 + vloss 상태 지속 → timer 누적 → Recovering + string? entryReason = null; + string? firstTp = null; + AdvisoryResult res = null!; + for (int i = 0; i < 6; i++) + { + res = engine.Tick(cfg, ImbalancedWithInversion(), st, DateTime.UtcNow); + if (i == 0) firstTp = res.TempProfileState; + if (res.Mode == ColumnMode.Recovering && entryReason is null) + entryReason = res.ModeReason; + } + Assert.Equal("온도역전", firstTp); + Assert.Equal(ColumnMode.Recovering, res.Mode); + Assert.Contains("온도역전", entryReason ?? ""); + } + + [Fact] + public void Inversion_alone_not_severe_sensor_check() + { + // WP4 역전만(balanced, vloss=0, ΔP 없음) → severe 아님, 센서 점검 메시지 + var cfg = Cfg(autoArm: true) with + { + TempTags = new[] { "tica-6111a", "ti-6111b", "ti-6111c", "ti-6111d" }, + DTdP = 0.0, PRef = double.NaN, PressureTag = null + }; + var engine = new FeedforwardEngine(); var st = new ColumnState(); + // 정상 온도로 시드 + engine.Tick(cfg, BalancedNormalTemps(), st, DateTime.UtcNow); + // 역전 + balanced(no vloss) → timer 누적 안 됨 → Normal + 센서 점검 + for (int i = 0; i < 6; i++) + { + var res = engine.Tick(cfg, BalancedWithInversion(), st, DateTime.UtcNow); + if (i == 5) + { + Assert.Equal(ColumnMode.Normal, res.Mode); + Assert.Contains("센서 점검", res.ModeReason ?? ""); + } + } + } } diff --git a/tests/ExperionCrawler.Tests/FeedforwardTempTests.cs b/tests/ExperionCrawler.Tests/FeedforwardTempTests.cs index cc60664..b276cee 100644 --- a/tests/ExperionCrawler.Tests/FeedforwardTempTests.cs +++ b/tests/ExperionCrawler.Tests/FeedforwardTempTests.cs @@ -70,4 +70,97 @@ public class FeedforwardTempTests new ColumnState(), DateTime.UtcNow); Assert.Equal(100.0, res.Temps![0].Pct, 6); } + + // ── WP6: 압력 프로파일 PCT + ΔP 합성 ────────────────────────── + private static ColumnConfig CfgProfile(double dtdp, double pRef) => new() + { + Id = 2, Name = "C-PROFILE", Enabled = true, FeedTag = "f", ProductKey = "P", + ScanSec = 2, DTdP = dtdp, PRef = pRef, PressureTag = "pica", + TempTags = new[] { "ta", "tb", "tc", "td" }, + Streams = new[] { new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 } } + }; + + // 4 temps bottom→top, with explicit bottom pressure + private static PvSnapshot SnapProfile(double pTop, double pBottom, double ta, double tb, double tc, double td) => new( + new TagSample("f", 100, true, DateTime.UtcNow), + new TagSample("pica", pTop, true, DateTime.UtcNow), + Array.Empty(), + new Dictionary { ["P"] = new TagSample("ficq-6118", 95, true, DateTime.UtcNow) }) + { + Temps = new[] { + new TagSample("ta", ta, true, DateTime.UtcNow), + new TagSample("tb", tb, true, DateTime.UtcNow), + new TagSample("tc", tc, true, DateTime.UtcNow), + new TagSample("td", td, true, DateTime.UtcNow) + }, + BottomPressure = new TagSample("pi-b", pBottom, true, DateTime.UtcNow) + }; + + [Fact] + public void Local_pressure_gives_different_pct_per_temp() + { + // dTdP≠0 + 프로파일 압력 → 각 temp가 다른 국소압으로 보정 + // pTop=50, pBottom=60 → 국소압: ta(하단)=60, tb=56.7, tc=53.3, td(상단)=50 + // pRef=50 → pRefLocal=50(단일), 보정: pct=raw - dTdP*(pLocal-pRef) + // ta: 100 - 0.5*(60-50) = 100 - 5 = 95 + // tb: 100 - 0.5*(56.7-50) = 100 - 3.3 = 96.7 + // tc: 100 - 0.5*(53.3-50) = 100 - 1.7 = 98.3 + // td: 100 - 0.5*(50-50) = 100 - 0 = 100 + var engine = new FeedforwardEngine(); + var st = new ColumnState(); + var res = engine.Tick(CfgProfile(dtdp: 0.5, pRef: 50), + SnapProfile(pTop: 50, pBottom: 60, ta: 100, tb: 100, tc: 100, td: 100), st, DateTime.UtcNow); + Assert.NotNull(res.Temps); + Assert.Equal(4, res.Temps!.Count); + Assert.Equal(95.0, res.Temps[0].Pct, 1); // ta(하단), bottom=60 + Assert.Equal(96.7, res.Temps[1].Pct, 1); // tb + Assert.Equal(98.3, res.Temps[2].Pct, 1); // tc + Assert.Equal(100.0, res.Temps[3].Pct, 1); // td(상단), top=50 + } + + [Fact] + public void Synthetic_deltaP_feeds_applyRecovery_sigDp() + { + // ΔP > DeltaPFloodLimit (+ vloss) → sigDp로 recovery 진입 + var cfg = CfgProfile(dtdp: 0.0, pRef: double.NaN) with + { + RecoveryEnabled = true, RecoveryAutoArm = true, + ImbalanceTriggerFrac = 0.10, ImbalanceTriggerSec = 4, + DeltaPFloodLimit = 5.0 // ΔP > 5 → flooding + }; + // vloss: P=30 out of feed=100 → sigVloss 확보 + var snap = new PvSnapshot( + new TagSample("f", 100, true, DateTime.UtcNow), + new TagSample("pica", 50, true, DateTime.UtcNow), + Array.Empty(), + new Dictionary { + ["P"] = new("ficq-6118", 30, true, DateTime.UtcNow) }) + { + Temps = new[] { new TagSample("ta", 100, true, DateTime.UtcNow) }, + BottomPressure = new TagSample("pi-b", 60, true, DateTime.UtcNow), + DeltaP = new TagSample("_syn", 10, true, DateTime.UtcNow) // ΔP = 60-50 = 10 > 5 + }; + var engine = new FeedforwardEngine(); + var st = new ColumnState(); + for (int i = 0; i < 6; i++) + engine.Tick(cfg, snap, st, DateTime.UtcNow); + Assert.Equal(ColumnMode.Recovering, st.Mode); + } + + [Fact] + public void Local_pressure_off_when_bottom_missing() + { + // BottomPressure 없음 → 국소압 없이 단일 pTop 사용 → dTdP 적용 시 모두 동일 PCT + var noBottom = SnapProfile(pTop: 50, pBottom: 60, ta: 100, tb: 100, tc: 100, td: 100) with + { + BottomPressure = null + }; + var engine = new FeedforwardEngine(); + var st = new ColumnState(); + var res = engine.Tick(CfgProfile(dtdp: 0.5, pRef: 50), noBottom, st, DateTime.UtcNow); + Assert.NotNull(res.Temps); + // 모두 단일 pTop=50으로 보정 → pct=100 (pLocal=pRef=50) + foreach (var tp in res.Temps!) + Assert.Equal(100.0, tp.Pct, 6); + } }