feat: WP4-7 안전피드램프 후속 — 역전코러보·ΔP합성·국소PCT·프론트UI
This commit is contained in:
@@ -79,6 +79,7 @@ public sealed record PvSnapshot(
|
||||
public IReadOnlyList<TagSample>? 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(
|
||||
|
||||
@@ -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<TempPoint>? 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<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);
|
||||
double raw = good ? t.Value : double.NaN;
|
||||
|
||||
// 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 && havePress && Num.IsFinite(pRef))
|
||||
pct = TempCorrection.PressureCompensated(raw, pNow, pRef, cfg.DTdP);
|
||||
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<StreamAdvisory> outs)
|
||||
double? vLossMa, string? frontState, string? tempProfileState, bool transient, ref List<StreamAdvisory> 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:
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = `<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
|
||||
? `<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 modeBadge =
|
||||
c.mode === 'Recovering' ? '<span class="ff-mode ff-mode-rec">전환류 복귀중 ●</span>'
|
||||
@@ -116,6 +216,7 @@ function ffCard(c) {
|
||||
${temps}
|
||||
${theta}
|
||||
${front}
|
||||
${tpBadge}
|
||||
<div class="ff-note">LevelDriven(D·B)은 레벨 제어(LIC)가 SP를 결정. 권장값은 참고 — 인가는 운전원.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
<h2>측류추출 권장 유량 설정값 (Advisory · 보조지표)</h2>
|
||||
<span class="ff-badge">읽기 전용 — 권장값. 인가는 운전원</span>
|
||||
<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>
|
||||
|
||||
<!-- 권장 SP 대시보드 (공개 읽기) -->
|
||||
@@ -16,4 +34,18 @@
|
||||
</div>
|
||||
<div id="ff-cfg-list"></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>
|
||||
|
||||
@@ -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<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 ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user