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

@@ -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);
}
}