feat: WP4-7 안전피드램프 후속 — 역전코러보·ΔP합성·국소PCT·프론트UI
This commit is contained in:
@@ -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