feat: 온도 HIGH LIMIT 전환류 트리거 (컬럼별 UI 설정)

- ColumnConfig.TempHighLimit + ff_column_config.temp_high_limit(DDL/ConfigStore/MapConfig) + ff.js 설정폼
- ApplyRecovery sigTHigh: 온도태그 최고값(raw) > TempHighLimit → 단독 severe(운전원 명시 안전한계라 코러보 불요). 기본 1e9=비활성
- 컬럼별 설정이라 타 물질/컬럼 동일 루틴 재사용. 단위 3건(52/52). 매뉴얼 §12.2·§13.3 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-01 21:54:37 +09:00
parent d5bdf40523
commit 90b15f8b34
8 changed files with 95 additions and 8 deletions

View File

@@ -197,7 +197,8 @@ LevelDriven 드로우(D 경비물·B 중비물)에 대해 **"제거 목표량 +
1. **물질수지**: |V_loss(MA)|/FEED > 임계(기본 10%) 1. **물질수지**: |V_loss(MA)|/FEED > 임계(기본 10%)
2. **프론트 드리프트** 2. **프론트 드리프트**
3. **차압(ΔP) 플러딩**: 탑 차압 > 상한 *(신규: ΔP 태그 없으면 **pi-6111b pica-6111**로 자동 합성)* 3. **차압(ΔP) 플러딩**: 탑 차압 > 상한 *(신규: ΔP 태그 없으면 **pi-6111b pica-6111**로 자동 합성)*
4. **온도역전/붕괴** *(신규)***단, ②③④ 중 하나와 동반(코러보)될 때만 발동**. 온도만 이상하면 "센서 점검 권고"에 그침(오발동 방지). 4. **온도역전/붕괴** *(신규)***단, 1·3·5 중 하나와 동반(코러보)될 때만 발동**. 온도만 이상하면 "센서 점검 권고"에 그침(오발동 방지).
5. **온도 HIGH LIMIT** *(신규)* — 온도 태그 중 **최고값(raw)이 운전원이 설정한 한계 초과**. 운전원이 명시한 안전 한계라 **단독 발동**(코러보 불요). 컬럼별 UI 설정값이라 다른 물질·컬럼에도 동일 루틴 적용. 미설정 시 비활성(1e9).
### 12.3 운전원 절차 ### 12.3 운전원 절차
``` ```
@@ -228,6 +229,7 @@ LevelDriven 드로우(D 경비물·B 중비물)에 대해 **"제거 목표량 +
| **트리거 지속(초)**(기본 600) | 민감도 | | **트리거 지속(초)**(기본 600) | 민감도 |
| 평형 대기/복귀 램프(초) | | | 평형 대기/복귀 램프(초) | |
| 차압(ΔP) 태그 / 플러딩 상한 | 설정 시 ΔP 트리거+코러보 활성 | | 차압(ΔP) 태그 / 플러딩 상한 | 설정 시 ΔP 트리거+코러보 활성 |
| **온도 HIGH LIMIT(raw)** | 온도 최고값 초과 시 단독 전환류 트리거. 컬럼별 설정(다른 물질 재사용) |
### 13.4 스트림 ### 13.4 스트림
역할·K·θ·τ·SP한계·Rate·환류 / 전환류R / 복귀SP. 역할·K·θ·τ·SP한계·Rate·환류 / 전환류R / 복귀SP.

View File

@@ -66,6 +66,7 @@ public sealed record ColumnConfig
public double FeedRecoverySp { get; init; } = 0.0; public double FeedRecoverySp { get; init; } = 0.0;
public string? DeltaPTag { get; init; } public string? DeltaPTag { get; init; }
public double DeltaPFloodLimit { get; init; } = double.MaxValue; public double DeltaPFloodLimit { get; init; } = double.MaxValue;
public double TempHighLimit { get; init; } = double.MaxValue; // 온도 HIGH LIMIT(raw) — 전환류 트리거. UI 설정. 1e9=비활성
} }
public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp); public sealed record TagSample(string Tag, double Value, bool Good, DateTime Timestamp);

View File

@@ -34,7 +34,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
imbalance_trigger_frac, imbalance_trigger_sec, imbalance_trigger_frac, imbalance_trigger_sec,
recovery_settle_sec, return_ramp_sec, feed_recovery_sp, recovery_settle_sec, return_ramp_sec, feed_recovery_sp,
delta_p_tag, delta_p_flood_limit, delta_p_tag, delta_p_flood_limit,
advisory_only advisory_only,
temp_high_limit
FROM ff_column_config FROM ff_column_config
"""; """;
await using var rd = await cmd.ExecuteReaderAsync(ct); await using var rd = await cmd.ExecuteReaderAsync(ct);
@@ -86,6 +87,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
FeedRecoverySp = rd.GetDouble(27), FeedRecoverySp = rd.GetDouble(27),
DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28).ToLowerInvariant(), DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28).ToLowerInvariant(),
DeltaPFloodLimit = rd.GetDouble(29), DeltaPFloodLimit = rd.GetDouble(29),
TempHighLimit = rd.GetDouble(31),
}; };
cols[cfg.Id] = (cfg, new List<StreamConfig>()); cols[cfg.Id] = (cfg, new List<StreamConfig>());
} }
@@ -164,14 +166,14 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
recovery_enabled, recovery_auto_arm, recovery_enabled, recovery_auto_arm,
imbalance_trigger_frac, imbalance_trigger_sec, imbalance_trigger_frac, imbalance_trigger_sec,
recovery_settle_sec, return_ramp_sec, feed_recovery_sp, recovery_settle_sec, return_ramp_sec, feed_recovery_sp,
delta_p_tag, delta_p_flood_limit) delta_p_tag, delta_p_flood_limit, temp_high_limit)
VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,@advisory, VALUES (@name,@en,@feed,@pres,@lvl,@scan,@fft,@fmt,@pft,@pb,@settle,@stale,@pk,@advisory,
@tempTags,@sensTray,@dtdp,@pRef,@steamOp, @tempTags,@sensTray,@dtdp,@pRef,@steamOp,
@thetaAuto,@biasMaWin, @thetaAuto,@biasMaWin,
@recEn,@recAutoArm, @recEn,@recAutoArm,
@imbFrac,@imbSec, @imbFrac,@imbSec,
@recSettle,@retRamp,@feedRecSp, @recSettle,@retRamp,@feedRecSp,
@deltaPTag,@deltaPFlood) @deltaPTag,@deltaPFlood,@tempHigh)
RETURNING id RETURNING id
"""; """;
P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant()); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@feed",cfg.FeedTag.ToLowerInvariant());
@@ -191,6 +193,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
P(cmd,"@feedRecSp",cfg.FeedRecoverySp); P(cmd,"@feedRecSp",cfg.FeedRecoverySp);
P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value); P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value);
P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit);
P(cmd,"@tempHigh",cfg.TempHighLimit);
id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
} }
else else
@@ -208,7 +211,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
recovery_enabled=@recEn, recovery_auto_arm=@recAutoArm, recovery_enabled=@recEn, recovery_auto_arm=@recAutoArm,
imbalance_trigger_frac=@imbFrac, imbalance_trigger_sec=@imbSec, imbalance_trigger_frac=@imbFrac, imbalance_trigger_sec=@imbSec,
recovery_settle_sec=@recSettle, return_ramp_sec=@retRamp, feed_recovery_sp=@feedRecSp, recovery_settle_sec=@recSettle, return_ramp_sec=@retRamp, feed_recovery_sp=@feedRecSp,
delta_p_tag=@deltaPTag, delta_p_flood_limit=@deltaPFlood delta_p_tag=@deltaPTag, delta_p_flood_limit=@deltaPFlood, temp_high_limit=@tempHigh
WHERE id=@id WHERE id=@id
"""; """;
P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled); P(cmd,"@id",id); P(cmd,"@name",cfg.Name); P(cmd,"@en",cfg.Enabled);
@@ -228,6 +231,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore
P(cmd,"@feedRecSp",cfg.FeedRecoverySp); P(cmd,"@feedRecSp",cfg.FeedRecoverySp);
P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value); P(cmd,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value);
P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit);
P(cmd,"@tempHigh",cfg.TempHighLimit);
await cmd.ExecuteNonQueryAsync(ct); await cmd.ExecuteNonQueryAsync(ct);
} }

View File

@@ -422,7 +422,12 @@ public sealed class FeedforwardEngine
bool sigCollapse = tempProfileState == "프로파일붕괴"; bool sigCollapse = tempProfileState == "프로파일붕괴";
bool corroborated = sigVloss || sigDp; bool corroborated = sigVloss || sigDp;
bool tempSevere = (sigInv || sigCollapse) && corroborated; bool tempSevere = (sigInv || sigCollapse) && corroborated;
bool severe = sigVloss || sigFront || sigDp || tempSevere; // 온도 HIGH LIMIT(raw, UI 설정) — 운전원이 명시한 안전 한계라 단독 severe(코러보 불요). 1e9 기본=비활성.
double maxTemp = (pv.Temps is not null && pv.Temps.Count > 0)
? pv.Temps.Where(t => t.Good && Num.IsFinite(t.Value)).Select(t => t.Value).DefaultIfEmpty(double.NaN).Max()
: double.NaN;
bool sigTHigh = Num.IsFinite(maxTemp) && Num.IsFinite(cfg.TempHighLimit) && maxTemp > cfg.TempHighLimit;
bool severe = sigVloss || sigFront || sigDp || tempSevere || sigTHigh;
// 비코러보 온도역전 — 센서 점검 메시지(severe 아님) // 비코러보 온도역전 — 센서 점검 메시지(severe 아님)
string? sensorCheck = (sigInv && !corroborated) string? sensorCheck = (sigInv && !corroborated)
@@ -432,7 +437,8 @@ public sealed class FeedforwardEngine
(sigVloss ? $"물질수지({frac:P0}) " : "") (sigVloss ? $"물질수지({frac:P0}) " : "")
+ (sigFront ? "프론트드리프트 " : "") + (sigFront ? "프론트드리프트 " : "")
+ (sigDp ? "ΔP플러딩 " : "") + (sigDp ? "ΔP플러딩 " : "")
+ (tempSevere ? "온도역전/붕괴 " : ""); + (tempSevere ? "온도역전/붕괴 " : "")
+ (sigTHigh ? $"온도HIGH({maxTemp:F1}>{cfg.TempHighLimit:F0}) " : "");
switch (st.Mode) switch (st.Mode)
{ {

View File

@@ -1136,6 +1136,7 @@ public class ExperionDbService : IExperionDbService
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_recovery_sp DOUBLE PRECISION NOT NULL DEFAULT 0; ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS feed_recovery_sp DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag TEXT; ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_tag TEXT;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9; ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS delta_p_flood_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9;
ALTER TABLE ff_column_config ADD COLUMN IF NOT EXISTS temp_high_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9;
"""); """);
// ── FF operator action audit log ──────────────────────────────── // ── FF operator action audit log ────────────────────────────────

View File

@@ -151,6 +151,7 @@ public sealed class FeedforwardController : ControllerBase
recoverySettleSec = c.RecoverySettleSec, returnRampSec = c.ReturnRampSec, recoverySettleSec = c.RecoverySettleSec, returnRampSec = c.ReturnRampSec,
feedRecoverySp = c.FeedRecoverySp, feedRecoverySp = c.FeedRecoverySp,
deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit, deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit,
tempHighLimit = c.TempHighLimit,
streams = c.Streams.Select(s => new streams = c.Streams.Select(s => new
{ {
key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff, key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff,

View File

@@ -308,7 +308,7 @@ function ffEditColumn(c) {
tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600, tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600,
// WO-6 전환류 복귀 // WO-6 전환류 복귀
recoveryEnabled:false, recoveryAutoArm:false, imbalanceTriggerFrac:0.10, imbalanceTriggerSec:600, recoveryEnabled:false, recoveryAutoArm:false, imbalanceTriggerFrac:0.10, imbalanceTriggerSec:600,
recoverySettleSec:1800, returnRampSec:600, feedRecoverySp:0, deltaPTag:'', deltaPFloodLimit:1e9, recoverySettleSec:1800, returnRampSec:600, feedRecoverySp:0, deltaPTag:'', deltaPFloodLimit:1e9, tempHighLimit:1e9,
streams:[ streams:[
{key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:0,spNodeId:''}, {key:'P',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.95,thetaUpSec:60,thetaDnSec:60,tauSec:900,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:60,refluxFromProduct:false,grade:'A',isReflux:false,recoverySp:0,spNodeId:''},
{key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A',isReflux:true,recoverySp:null,spNodeId:''}, {key:'R',flowTag:'',role:'Commanded',levelTag:'',targetCoeff:0.80,thetaUpSec:0,thetaDnSec:0,tauSec:0,spMin:0,spMax:9999,rateUpPerMin:30,rateDnPerMin:30,refluxFromProduct:true,grade:'A',isReflux:true,recoverySp:null,spNodeId:''},
@@ -357,6 +357,7 @@ function ffEditColumn(c) {
<label><span class="ff-desc">전환류 중 Feed 권장값: 보통 0(차단)</span><input class="inp" type="number" step="any" id="ff-f-feedRecoverySp" value="${def.feedRecoverySp}"></label> <label><span class="ff-desc">전환류 중 Feed 권장값: 보통 0(차단)</span><input class="inp" type="number" step="any" id="ff-f-feedRecoverySp" value="${def.feedRecoverySp}"></label>
<label><span class="ff-desc">차압(ΔP) 태그: 플러딩 트리거용(선택). 비우면 미사용</span><input class="inp" id="ff-f-deltaPTag" value="${esc(def.deltaPTag||'')}"></label> <label><span class="ff-desc">차압(ΔP) 태그: 플러딩 트리거용(선택). 비우면 미사용</span><input class="inp" id="ff-f-deltaPTag" value="${esc(def.deltaPTag||'')}"></label>
<label><span class="ff-desc">ΔP 플러딩 상한: 초과 지속 시 전환류 트리거. 미사용 시 매우 큰 값</span><input class="inp" type="number" step="any" id="ff-f-deltaPFloodLimit" value="${def.deltaPFloodLimit}"></label> <label><span class="ff-desc">ΔP 플러딩 상한: 초과 지속 시 전환류 트리거. 미사용 시 매우 큰 값</span><input class="inp" type="number" step="any" id="ff-f-deltaPFloodLimit" value="${def.deltaPFloodLimit}"></label>
<label><span class="ff-desc">온도 HIGH LIMIT(raw): 온도태그 중 최고값이 초과 시 전환류 트리거(단독). 미사용 시 매우 큰 값(1e9)</span><input class="inp" type="number" step="any" id="ff-f-tempHighLimit" value="${def.tempHighLimit==null?1e9:def.tempHighLimit}"></label>
</div>`; </div>`;
modal.innerHTML = ` modal.innerHTML = `
@@ -476,6 +477,7 @@ function ffSaveForm(existingId) {
feedRecoverySp: +g('ff-f-feedRecoverySp').value, feedRecoverySp: +g('ff-f-feedRecoverySp').value,
deltaPTag: g('ff-f-deltaPTag').value || null, deltaPTag: g('ff-f-deltaPTag').value || null,
deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value, deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value,
tempHighLimit: +g('ff-f-tempHighLimit').value,
streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => { streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => {
const v = (sel, f) => { const v = (sel, f) => {
const el = tr.querySelector(`[data-f="${f}"]`); const el = tr.querySelector(`[data-f="${f}"]`);

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
// 온도 HIGH LIMIT(raw, UI 설정) → 전환류 트리거(단독 severe). 코러보 불요.
public class FeedforwardTempHighLimitTests
{
private static ColumnConfig Cfg(double tempHigh) => new()
{
Id = 1, Name = "C-TH", Enabled = true, FeedTag = "f", ProductKey = "P", ScanSec = 2,
PressureTag = null, FeedMoveThresholdPerMin = 0.0,
RecoveryEnabled = true, RecoveryAutoArm = true, // 자동무장(테스트 간소화)
ImbalanceTriggerFrac = 0.10, ImbalanceTriggerSec = 4, // 짧게
TempHighLimit = tempHigh,
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 maxTemp) => 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", maxTemp, true, DateTime.UtcNow), new TagSample("b", maxTemp - 5, true, DateTime.UtcNow),
new TagSample("c", maxTemp - 10, true, DateTime.UtcNow), new TagSample("d", maxTemp - 20, true, DateTime.UtcNow)
}
};
[Fact]
public void Temp_above_high_limit_triggers_recovery_standalone()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
var cfg = Cfg(tempHigh: 120); // 한계 120
AdvisoryResult res = null!; string entryReason = "";
// 최고온도 130 > 120, 다른 신호(vloss/ΔP) 없음에도 지속 시 단독 트리거.
// 진입 틱의 사유에 "온도HIGH" 표기(이후 틱은 평형대기).
for (int i = 0; i < 5; i++)
{
res = engine.Tick(cfg, Snap(130), st, DateTime.UtcNow);
if (res.Mode == ColumnMode.Recovering && (res.ModeReason ?? "").Contains("진입")) entryReason = res.ModeReason!;
}
Assert.Equal(ColumnMode.Recovering, res.Mode);
Assert.Contains("온도HIGH", entryReason);
}
[Fact]
public void Temp_below_limit_no_trigger()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
var cfg = Cfg(tempHigh: 120);
AdvisoryResult res = null!;
for (int i = 0; i < 6; i++) res = engine.Tick(cfg, Snap(110), st, DateTime.UtcNow); // 110 < 120
Assert.Equal(ColumnMode.Normal, res.Mode);
}
[Fact]
public void Default_limit_disabled()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
var cfg = Cfg(tempHigh: double.MaxValue); // 기본=비활성
AdvisoryResult res = null!;
for (int i = 0; i < 6; i++) res = engine.Tick(cfg, Snap(500), st, DateTime.UtcNow); // 매우 높아도
Assert.Equal(ColumnMode.Normal, res.Mode);
}
}