feat: WP4-7 안전피드램프 후속 — 역전코러보·ΔP합성·국소PCT·프론트UI

This commit is contained in:
windpacer
2026-06-01 17:29:38 +09:00
parent 25fd969276
commit 89187aff19
8 changed files with 438 additions and 14 deletions

View File

@@ -79,6 +79,7 @@ public sealed record PvSnapshot(
public IReadOnlyList<TagSample>? Temps { get; init; } public IReadOnlyList<TagSample>? Temps { get; init; }
public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력) public TagSample? SteamOp { get; init; } // WO-3: TICA.OP (스팀, 부분상관 2번째 입력)
public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거) public TagSample? DeltaP { get; init; } // WO-6: 탑 차압(플러딩 트리거)
public TagSample? BottomPressure { get; init; } // WP6: 탑저 압력(ΔP 합성·국소 PCT용)
} }
public sealed record StreamAdvisory( public sealed record StreamAdvisory(

View File

@@ -137,7 +137,7 @@ public sealed class FeedforwardEngine
// 역전/붕괴 시 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(
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, return new AdvisoryResult(cfg.Id, cfg.Name, now, cfg.Enabled,
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,
@@ -145,29 +145,55 @@ public sealed class FeedforwardEngine
TempProfileState = tp.State, InversionPair = tp.InversionPair, TempSpan = tp.Span, TempSpanRef = tp.SpanRef }; 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<TempPoint>? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st) private static IReadOnlyList<TempPoint>? BuildTemps(ColumnConfig cfg, PvSnapshot pv, ColumnState st)
{ {
if (pv.Temps is null || pv.Temps.Count == 0) return null; if (pv.Temps is null || pv.Temps.Count == 0) return null;
bool havePress = pv.Pressure is { Good: true } pp && Num.IsFinite(pp.Value); 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; double pRef = cfg.PRef;
if (double.IsNaN(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; 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<TempPoint>(pv.Temps.Count); var list = new List<TempPoint>(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); bool good = t.Good && Num.IsFinite(t.Value);
double raw = good ? t.Value : double.NaN; double raw = good ? t.Value : double.NaN;
double pct = raw;
if (good && cfg.DTdP != 0.0 && havePress && Num.IsFinite(pRef)) // WP6: 국소 압력 = 선형 보간 bottom→top (i=0→bottom, i=n-1→top)
pct = TempCorrection.PressureCompensated(raw, pNow, pRef, cfg.DTdP); 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)); list.Add(new TempPoint(t.Tag, raw, pct, good));
} }
return list; return list;
@@ -298,7 +324,7 @@ public sealed class FeedforwardEngine
// ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ── // ── WO-6: 전환류(Total Reflux) 평형복귀 상태기계 (advisory — 권장값 오버라이드만, 쓰기 없음) ──
private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery( private static (ColumnMode mode, string? reason, double? feedRecSp) ApplyRecovery(
ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff, ColumnConfig cfg, PvSnapshot pv, ColumnState st, double ts, double ff,
double? vLossMa, string? frontState, bool transient, ref List<StreamAdvisory> outs) double? vLossMa, string? frontState, string? tempProfileState, bool transient, ref List<StreamAdvisory> outs)
{ {
if (!cfg.RecoveryEnabled) if (!cfg.RecoveryEnabled)
{ {
@@ -316,10 +342,22 @@ public sealed class FeedforwardEngine
bool sigVloss = vLossMa.HasValue && frac > cfg.ImbalanceTriggerFrac; bool sigVloss = vLossMa.HasValue && frac > cfg.ImbalanceTriggerFrac;
bool sigFront = frontState is not null && (frontState.Contains("상승") || frontState.Contains("하강")); 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 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() => string SeverityText() =>
(sigVloss ? $"물질수지({frac:P0}) " : "") + (sigFront ? "프론트드리프트 " : "") + (sigDp ? "ΔP플러딩" : ""); (sigVloss ? $"물질수지({frac:P0}) " : "")
+ (sigFront ? "프론트드리프트 " : "")
+ (sigDp ? "ΔP플러딩 " : "")
+ (tempSevere ? "온도역전/붕괴 " : "");
switch (st.Mode) switch (st.Mode)
{ {
@@ -334,7 +372,7 @@ public sealed class FeedforwardEngine
} }
if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec) if (st.ImbalanceTimerSec >= cfg.ImbalanceTriggerSec)
return (ColumnMode.Normal, $"전환류 권장(ARM 대기): {SeverityText()}", null); return (ColumnMode.Normal, $"전환류 권장(ARM 대기): {SeverityText()}", null);
return (ColumnMode.Normal, null, null); return (ColumnMode.Normal, sensorCheck, null);
case ColumnMode.Recovering: case ColumnMode.Recovering:
{ {

View File

@@ -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.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) 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)) var rows = (await db.GetRealtimeRecordsByTagNamesAsync(tags))
.ToDictionary(r => r.TagName.ToLowerInvariant(), r => r); .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 streams = cfg.Streams.ToDictionary(s => s.Key, s => Sample(s.FlowTag));
var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null; var temps = cfg.TempTags.Count > 0 ? cfg.TempTags.Select(Sample).ToList() : null;
var steam = cfg.SteamOpTag is not null ? SampleExact(cfg.SteamOpTag) : 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 };
} }
} }

View File

@@ -79,3 +79,33 @@
.ff-write-err{color:#ff8a80} .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{font-size:12px;color:#ff8a80;background:#3a0000;padding:4px 8px;border-radius:4px;margin:4px 0}
.ff-wg-blocked b{color:#ff5252} .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}

View File

@@ -25,8 +25,94 @@ async function ffInit() {
const c = document.getElementById('ff-cfg'); const c = document.getElementById('ff-cfg');
c.style.display = c.style.display === 'none' ? 'block' : 'none'; 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); document.getElementById('ff-new').onclick = () => ffEditColumn(null);
ffLoadConfig().catch(()=>{}); 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 = `<div class="ff-ramp-hold">HOLD: ${esc(data.warnings?.join(', ') || '피드 불량')}</div>`;
return;
}
host.innerHTML = `
<table class="ff-ramp-table">
<tr><td>현재 피드</td><td class="ff-num">${fmtVal(data.currentFeed)}</td></tr>
<tr><td>목표 피드</td><td class="ff-num">${fmtVal(data.targetFeed)}</td></tr>
<tr><td>클램프 목표</td><td class="ff-num ff-rec">${fmtVal(data.clampedTarget)}</td></tr>
<tr><td>Ceiling</td><td class="ff-num">${fmtVal(data.ceiling?.value)} <small>(${esc(data.ceiling?.binding)})</small></td></tr>
<tr><td>램프율</td><td class="ff-num">${fmtVal(data.rampRate?.value)} kg/hr·min <small>(${esc(data.rampRate?.binding)})</small></td></tr>
<tr><td>예상 시간</td><td class="ff-num">${data.rampTimeMin != null ? Math.round(data.rampTimeMin) + '분' : ''}</td></tr>
<tr><td>스팀(현재)</td><td class="ff-num">${fmtVal(data.steam?.fiq6115From)}</td></tr>
<tr><td>스팀(목표)</td><td class="ff-num">${fmtVal(data.steam?.fiq6115To)}</td></tr>
</table>
<div class="ff-ramp-warn">${(data.warnings||[]).map(w => esc(w)).join('<br>')}</div>`;
} catch (e) {
host.innerHTML = `<div class="ff-ramp-err">오류: ${esc(e.message)}</div>`;
}
}
// ── 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 const front = c.frontPositionState
? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>` ? `<div class="ff-front${c.frontTrimAdvice?' ff-front-warn':''}">프론트: ${esc(c.frontPositionState)}${c.frontTrimAdvice?` → <b>${esc(c.frontTrimAdvice)}</b>`:''}</div>`
: ''; : '';
// 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)
? ` <small>span ${fmtVal(c.tempSpan)}/${fmtVal(c.tempSpanRef)}</small>` : '';
const invStr = c.inversionPair ? ` <small>(${esc(c.inversionPair)})</small>` : '';
return `<div class="ff-tp-badge ${tpClass}">${esc(c.tempProfileState)}${invStr}${spanStr}</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>'
@@ -116,6 +216,7 @@ function ffCard(c) {
${temps} ${temps}
${theta} ${theta}
${front} ${front}
${tpBadge}
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div> <div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
</div>`; </div>`;
} }

View File

@@ -3,6 +3,24 @@
<h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2> <h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span> <span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
<button id="ff-cfg-toggle" class="btn">설정 ▾</button> <button id="ff-cfg-toggle" class="btn">설정 ▾</button>
<button id="ff-ramp-toggle" class="btn">램프 계산기 ▾</button>
</div>
<!-- WP7: 램프 계산기 패널 -->
<div id="ff-ramp" class="ff-ramp" style="display:none">
<div class="ff-ramp-body">
<div class="ff-ramp-inputs">
<label>columnId <input class="inp" id="ff-ramp-colId" value="1" size="4"></label>
<label>targetFeed <input class="inp" id="ff-ramp-targetFeed" type="number" value="1100"></label>
<label>ΔI_allow <input class="inp" id="ff-ramp-deltaI" type="number" value="50"></label>
<label>sensibleGain <input class="inp" id="ff-ramp-sensibleGain" type="number" step="any" value=""></label>
<label>feedTempRef <input class="inp" id="ff-ramp-feedTempRef" type="number" step="any" value=""></label>
<label>floodLimit <input class="inp" id="ff-ramp-floodLimit" type="number" step="any" value=""></label>
<label>n(지수) <input class="inp" id="ff-ramp-n" type="number" step="any" value="1.8"></label>
<button class="btn" id="ff-ramp-go">계산</button>
</div>
<div id="ff-ramp-result" class="ff-ramp-result"></div>
</div>
</div> </div>
<!-- 권장 SP 대시보드 (공개 읽기) --> <!-- 권장 SP 대시보드 (공개 읽기) -->
@@ -16,4 +34,18 @@
</div> </div>
<div id="ff-cfg-list"></div> <div id="ff-cfg-list"></div>
</div> </div>
<!-- WP7: Sim Override 패널 (개발/데모 전용) -->
<div id="ff-sim" class="ff-sim" style="display:none">
<div class="ff-sim-head">Sim Override <small>개발/데모 전용 — 입력 치환</small></div>
<div class="ff-sim-body">
<div><label><input type="checkbox" id="ff-sim-enabled"> 활성</label></div>
<div><textarea id="ff-sim-values" rows="4" cols="60" placeholder='{"ficq-6101.pv":1100, "tica-6111a.pv":120}'></textarea></div>
<div class="ff-sim-actions">
<button class="btn" id="ff-sim-apply">적용</button>
<button class="btn" id="ff-sim-clear">해제</button>
<span id="ff-sim-status" class="ff-msg"></span>
</div>
</div>
</div>
</div> </div>

View File

@@ -84,4 +84,99 @@ public class FeedforwardRecoveryTests
var res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow); var res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Normal, res.Mode); 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<TagSample>(),
new Dictionary<string, TagSample> {
["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<TagSample>(),
new Dictionary<string, TagSample> {
["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<TagSample>(),
new Dictionary<string, TagSample> {
["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 ?? "");
}
}
}
} }

View File

@@ -70,4 +70,97 @@ public class FeedforwardTempTests
new ColumnState(), DateTime.UtcNow); new ColumnState(), DateTime.UtcNow);
Assert.Equal(100.0, res.Temps![0].Pct, 6); 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<TagSample>(),
new Dictionary<string, TagSample> { ["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<TagSample>(),
new Dictionary<string, TagSample> {
["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);
}
} }