From 90b15f8b34b2eeacdf3dfc03c2fd9878dcb9ebc9 Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 1 Jun 2026 21:54:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=A8=EB=8F=84=20HIGH=20LIMIT=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EB=A5=98=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20(?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=EB=B3=84=20UI=20=EC=84=A4=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/측류추출-운전제안-사용매뉴얼.md | 4 +- .../Feedforward/FeedforwardModels.cs | 1 + .../Control/FeedforwardConfigStore.cs | 12 ++-- .../Control/FeedforwardEngine.cs | 10 ++- .../Database/ExperionDbContext.cs | 1 + src/Web/Controllers/FeedforwardController.cs | 1 + src/Web/wwwroot/js/ff.js | 4 +- .../FeedforwardTempHighLimitTests.cs | 70 +++++++++++++++++++ 8 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/ExperionCrawler.Tests/FeedforwardTempHighLimitTests.cs diff --git a/docs/측류추출-운전제안-사용매뉴얼.md b/docs/측류추출-운전제안-사용매뉴얼.md index 3de5ddb..8996b88 100644 --- a/docs/측류추출-운전제안-사용매뉴얼.md +++ b/docs/측류추출-운전제안-사용매뉴얼.md @@ -197,7 +197,8 @@ LevelDriven 드로우(D 경비물·B 중비물)에 대해 **"제거 목표량 + 1. **물질수지**: |V_loss(MA)|/FEED > 임계(기본 10%) 2. **프론트 드리프트** 3. **차압(ΔP) 플러딩**: 탑 차압 > 상한 *(신규: ΔP 태그 없으면 **pi-6111b − pica-6111**로 자동 합성)* -4. **온도역전/붕괴** *(신규)* — **단, ②③④ 중 하나와 동반(코러보)될 때만 발동**. 온도만 이상하면 "센서 점검 권고"에 그침(오발동 방지). +4. **온도역전/붕괴** *(신규)* — **단, 1·3·5 중 하나와 동반(코러보)될 때만 발동**. 온도만 이상하면 "센서 점검 권고"에 그침(오발동 방지). +5. **온도 HIGH LIMIT** *(신규)* — 온도 태그 중 **최고값(raw)이 운전원이 설정한 한계 초과**. 운전원이 명시한 안전 한계라 **단독 발동**(코러보 불요). 컬럼별 UI 설정값이라 다른 물질·컬럼에도 동일 루틴 적용. 미설정 시 비활성(1e9). ### 12.3 운전원 절차 ``` @@ -228,6 +229,7 @@ LevelDriven 드로우(D 경비물·B 중비물)에 대해 **"제거 목표량 + | **트리거 지속(초)**(기본 600) | 민감도 | | 평형 대기/복귀 램프(초) | | | 차압(ΔP) 태그 / 플러딩 상한 | 설정 시 ΔP 트리거+코러보 활성 | +| **온도 HIGH LIMIT(raw)** | 온도 최고값 초과 시 단독 전환류 트리거. 컬럼별 설정(다른 물질 재사용) | ### 13.4 스트림 역할·K·θ·τ·SP한계·Rate·환류 / 전환류R / 복귀SP. diff --git a/src/Core/Application/Feedforward/FeedforwardModels.cs b/src/Core/Application/Feedforward/FeedforwardModels.cs index ca0be9c..95e3448 100644 --- a/src/Core/Application/Feedforward/FeedforwardModels.cs +++ b/src/Core/Application/Feedforward/FeedforwardModels.cs @@ -66,6 +66,7 @@ public sealed record ColumnConfig public double FeedRecoverySp { get; init; } = 0.0; public string? DeltaPTag { get; init; } 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); diff --git a/src/Infrastructure/Control/FeedforwardConfigStore.cs b/src/Infrastructure/Control/FeedforwardConfigStore.cs index 7743d96..cb28f90 100644 --- a/src/Infrastructure/Control/FeedforwardConfigStore.cs +++ b/src/Infrastructure/Control/FeedforwardConfigStore.cs @@ -34,7 +34,8 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore imbalance_trigger_frac, imbalance_trigger_sec, recovery_settle_sec, return_ramp_sec, feed_recovery_sp, delta_p_tag, delta_p_flood_limit, - advisory_only + advisory_only, + temp_high_limit FROM ff_column_config """; await using var rd = await cmd.ExecuteReaderAsync(ct); @@ -86,6 +87,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore FeedRecoverySp = rd.GetDouble(27), DeltaPTag = rd.IsDBNull(28) ? null : rd.GetString(28).ToLowerInvariant(), DeltaPFloodLimit = rd.GetDouble(29), + TempHighLimit = rd.GetDouble(31), }; cols[cfg.Id] = (cfg, new List()); } @@ -164,14 +166,14 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore recovery_enabled, recovery_auto_arm, imbalance_trigger_frac, imbalance_trigger_sec, 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, @tempTags,@sensTray,@dtdp,@pRef,@steamOp, @thetaAuto,@biasMaWin, @recEn,@recAutoArm, @imbFrac,@imbSec, @recSettle,@retRamp,@feedRecSp, - @deltaPTag,@deltaPFlood) + @deltaPTag,@deltaPFlood,@tempHigh) RETURNING id """; 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,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); + P(cmd,"@tempHigh",cfg.TempHighLimit); id = Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); } else @@ -208,7 +211,7 @@ public sealed class FeedforwardConfigStore : IFeedforwardConfigStore recovery_enabled=@recEn, recovery_auto_arm=@recAutoArm, imbalance_trigger_frac=@imbFrac, imbalance_trigger_sec=@imbSec, 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 """; 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,"@deltaPTag",(object?)cfg.DeltaPTag?.ToLowerInvariant() ?? DBNull.Value); P(cmd,"@deltaPFlood",cfg.DeltaPFloodLimit); + P(cmd,"@tempHigh",cfg.TempHighLimit); await cmd.ExecuteNonQueryAsync(ct); } diff --git a/src/Infrastructure/Control/FeedforwardEngine.cs b/src/Infrastructure/Control/FeedforwardEngine.cs index cb0aa91..c4a71fb 100644 --- a/src/Infrastructure/Control/FeedforwardEngine.cs +++ b/src/Infrastructure/Control/FeedforwardEngine.cs @@ -422,7 +422,12 @@ public sealed class FeedforwardEngine bool sigCollapse = tempProfileState == "프로파일붕괴"; bool corroborated = sigVloss || sigDp; 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 아님) string? sensorCheck = (sigInv && !corroborated) @@ -432,7 +437,8 @@ public sealed class FeedforwardEngine (sigVloss ? $"물질수지({frac:P0}) " : "") + (sigFront ? "프론트드리프트 " : "") + (sigDp ? "ΔP플러딩 " : "") - + (tempSevere ? "온도역전/붕괴 " : ""); + + (tempSevere ? "온도역전/붕괴 " : "") + + (sigTHigh ? $"온도HIGH({maxTemp:F1}>{cfg.TempHighLimit:F0}) " : ""); switch (st.Mode) { diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 5d5dea9..0d6855d 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -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 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 temp_high_limit DOUBLE PRECISION NOT NULL DEFAULT 1e9; """); // ── FF operator action audit log ──────────────────────────────── diff --git a/src/Web/Controllers/FeedforwardController.cs b/src/Web/Controllers/FeedforwardController.cs index b32ab68..bd13166 100644 --- a/src/Web/Controllers/FeedforwardController.cs +++ b/src/Web/Controllers/FeedforwardController.cs @@ -151,6 +151,7 @@ public sealed class FeedforwardController : ControllerBase recoverySettleSec = c.RecoverySettleSec, returnRampSec = c.ReturnRampSec, feedRecoverySp = c.FeedRecoverySp, deltaPTag = c.DeltaPTag, deltaPFloodLimit = c.DeltaPFloodLimit, + tempHighLimit = c.TempHighLimit, streams = c.Streams.Select(s => new { key = s.Key, flowTag = s.FlowTag, role = s.Role.ToString(), levelTag = s.LevelTag, targetCoeff = s.TargetCoeff, diff --git a/src/Web/wwwroot/js/ff.js b/src/Web/wwwroot/js/ff.js index 14f1dca..43aff71 100644 --- a/src/Web/wwwroot/js/ff.js +++ b/src/Web/wwwroot/js/ff.js @@ -308,7 +308,7 @@ function ffEditColumn(c) { tempTags:[], sensitiveTrayTag:'', dtdp:0, pRef:null, steamOpTag:'', thetaAutoTune:false, biasMaWindowSec:21600, // WO-6 전환류 복귀 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:[ {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:''}, @@ -357,6 +357,7 @@ function ffEditColumn(c) { + `; modal.innerHTML = ` @@ -476,6 +477,7 @@ function ffSaveForm(existingId) { feedRecoverySp: +g('ff-f-feedRecoverySp').value, deltaPTag: g('ff-f-deltaPTag').value || null, deltaPFloodLimit: +g('ff-f-deltaPFloodLimit').value, + tempHighLimit: +g('ff-f-tempHighLimit').value, streams: Array.from(document.querySelectorAll('#ff-stream-body tr')).map(tr => { const v = (sel, f) => { const el = tr.querySelector(`[data-f="${f}"]`); diff --git a/tests/ExperionCrawler.Tests/FeedforwardTempHighLimitTests.cs b/tests/ExperionCrawler.Tests/FeedforwardTempHighLimitTests.cs new file mode 100644 index 0000000..0280931 --- /dev/null +++ b/tests/ExperionCrawler.Tests/FeedforwardTempHighLimitTests.cs @@ -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(), + new Dictionary { ["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); + } +}