feat: Phase II auto-write (WriteGuard, audit, auth) + WO-2~7 완료

Phase II:
- FfOperatorAction entity + ff_operator_action DDL/DbSet
- IFeedforwardWriteGuard + FeedforwardWriteGuard (SP bounds, grade C, transient, NaN)
- IFeedforwardAuditService + FeedforwardAuditService (raw ADO insert/query)
- FeedforwardSupervisor.AutoWriteAsync (per-stream OPC UA after Tick, rate-limited)
- FeedforwardConfigStore: advisory_only now read/writes DB, sp_node_id column
- FeedforwardController: auth (X-Kb-Token) on config/delete/write/audit;
  POST write/{id}/{key} manual SP write; GET audit; write results in MapColumn
- ff.js: token header, auto-write badge, per-stream write result, spNodeId, advisoryOnly
- ff.css: .ff-write-badge, .ff-write, .ff-write-err, .ff-wg-blocked
- Program.cs: register audit (Scoped) + write guard (Singleton)

WO-2~7 (build 0W/0E, test 22/22):
- PCT monitor, θ auto-tune, slow bias, front position indicator,
  total reflux recovery, config form expansion
This commit is contained in:
windpacer
2026-05-31 20:30:06 +09:00
parent 671d4ee1e5
commit 7c26aa7361
32 changed files with 4468 additions and 80 deletions

View File

@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardBiasTests
{
private static ColumnConfig Cfg() => new()
{
Id = 1, Name = "C-BIAS", Enabled = true, FeedTag = "f", ProductKey = "P",
ScanSec = 2, BiasMaWindowSec = 20, // 10 샘플 창
Streams = new[]
{
new StreamConfig { Key = "P", FlowTag = "p", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 },
new StreamConfig { Key = "D", FlowTag = "d", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.02 },
new StreamConfig { Key = "B", FlowTag = "b", Role = StreamRole.LevelDriven, Grade = Confidence.B, TargetCoeff = 0.03 },
}
};
// FEED 100 고정, P=95 → K_obs ≈ 0.95, D/B는 물질수지 충족용
private static PvSnapshot Snap() => new(
new TagSample("f", 100, true, DateTime.UtcNow), null, Array.Empty<TagSample>(),
new Dictionary<string, TagSample> {
["P"] = new("p", 95, true, DateTime.UtcNow),
["D"] = new("d", 2, true, DateTime.UtcNow),
["B"] = new("b", 3, true, DateTime.UtcNow),
});
[Fact]
public void KObs_and_VLossMa_accumulate_in_steady_state()
{
var engine = new FeedforwardEngine();
var st = new ColumnState();
AdvisoryResult res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
for (int i = 0; i < 20; i++) res = engine.Tick(Cfg(), Snap(), st, DateTime.UtcNow);
var p = res.Streams.First(s => s.Key == "P");
Assert.NotNull(p.KObsSuggest);
Assert.InRange(p.KObsSuggest!.Value, 0.94, 0.96); // 95/100
Assert.NotNull(res.VLossMa);
Assert.InRange(res.VLossMa!.Value, -0.5, 0.5); // 100-(95+2+3)=0
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
/// <summary>WO-1 (P-5 confidence 자동강등) 엔진 통합 검증. Downgrade는 private이라 Tick 경유로 관측.</summary>
public class FeedforwardEngineTests
{
private static ColumnConfig Cfg(double feedFilterTau = 300, double moveThr = 0) => new()
{
Id = 1, Name = "C-TEST", Enabled = true, FeedTag = "f", ProductKey = "P",
ScanSec = 2, FeedFilterTauSec = feedFilterTau, FeedMoveThresholdPerMin = moveThr,
Streams = new[]
{
new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded,
Grade = Confidence.A, TargetCoeff = 0.95 }
}
};
private static PvSnapshot Snap(double feed, double streamVal, bool streamGood) => new(
new TagSample("f", feed, true, DateTime.UtcNow),
null,
Array.Empty<TagSample>(),
new Dictionary<string, TagSample> { ["P"] = new TagSample("ficq-6118", streamVal, streamGood, DateTime.UtcNow) });
[Fact] // 정상·신선 → config 등급(A) 유지, 사유 없음
public void Fresh_normal_keeps_config_grade()
{
var res = new FeedforwardEngine().Tick(Cfg(), Snap(100, 95, true), new ColumnState(), DateTime.UtcNow);
var p = res.Streams[0];
Assert.Equal(Confidence.A, p.Grade);
Assert.Null(p.GradeReason);
}
[Fact] // PV 신선도 불량 → 한 단계 강등(A→B) + 사유
public void Stale_pv_downgrades_one_level()
{
var res = new FeedforwardEngine().Tick(Cfg(), Snap(100, 80, false), new ColumnState(), DateTime.UtcNow);
var p = res.Streams[0];
Assert.Equal(Confidence.B, p.Grade);
Assert.Contains("신선도", p.GradeReason);
}
[Fact] // stale + 과도 동시 → 두 단계 강등(A→C) Clamp 확인
public void Stale_plus_transient_clamps_to_C()
{
var cfg = Cfg(feedFilterTau: 0, moveThr: 1); // 필터 off + 작은 임계 → 큰 피드점프가 과도 유발
var engine = new FeedforwardEngine();
var st = new ColumnState();
engine.Tick(cfg, Snap(100, 80, false), st, DateTime.UtcNow); // tick1: 시드
var res = engine.Tick(cfg, Snap(1000, 80, false), st, DateTime.UtcNow); // tick2: 피드 급변 → 과도
var p = res.Streams[0];
Assert.True(res.Transient);
Assert.Equal(Confidence.C, p.Grade); // A→(stale)B→(transient)C, C에서 클램프
}
}

View File

@@ -0,0 +1,40 @@
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardFrontTests
{
[Fact]
public void Front_stable_within_band()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
for (int i = 0; i < 50; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: true);
var (state, trim, grade) = ind.Update(100.1, 2, 60, true);
Assert.Contains("정상", state);
Assert.Null(trim);
Assert.Equal(Confidence.B, grade);
}
[Fact]
public void Front_rise_triggers_reflux_advice()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
for (int i = 0; i < 200; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: false);
var (state, trim, grade) = ind.Update(105.0, 2, 60, false);
Assert.Contains("상승", state);
Assert.Equal("환류↑ 권장", trim);
Assert.Equal(Confidence.C, grade);
}
[Fact]
public void Front_fall_triggers_boilup_advice()
{
var ind = new FrontPositionIndicator(bandwidth: 0.3);
for (int i = 0; i < 200; i++) ind.Update(100.0, tsSec: 2, refTauSec: 60, strongSignal: true);
var (state, trim, _) = ind.Update(95.0, 2, 60, true);
Assert.Contains("하강", state);
Assert.Contains("boilup", trim);
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardRecoveryTests
{
private static ColumnConfig Cfg(bool autoArm) => new()
{
Id = 1, Name = "C-REC", Enabled = true, FeedTag = "f", ProductKey = "P",
ScanSec = 2, BiasMaWindowSec = 4,
RecoveryEnabled = true, RecoveryAutoArm = autoArm,
ImbalanceTriggerFrac = 0.10, ImbalanceTriggerSec = 4,
RecoverySettleSec = 4, ReturnRampSec = 4, FeedRecoverySp = 0,
Streams = new[]
{
new StreamConfig { Key="P", FlowTag="p", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.95, SpMax=950 },
new StreamConfig { Key="R", FlowTag="r", Role=StreamRole.Commanded, Grade=Confidence.A, TargetCoeff=0.80, SpMax=1100, RefluxFromProduct=true },
new StreamConfig { Key="D", FlowTag="d", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.02, SpMax=60 },
new StreamConfig { Key="B", FlowTag="b", Role=StreamRole.LevelDriven, Grade=Confidence.B, TargetCoeff=0.03, SpMax=80 },
}
};
private static PvSnapshot Imbalanced() => 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) });
private static PvSnapshot Balanced() => 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) });
[Fact]
public void AutoArm_enters_recovering_on_sustained_imbalance()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
AdvisoryResult res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
for (int i = 0; i < 6; i++) res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, res.Mode);
Assert.Equal(0.0, res.FeedRecommendedSp);
var r = res.Streams.First(s => s.Key == "R");
var p = res.Streams.First(s => s.Key == "P");
Assert.Equal(1100.0, r.RecommendedSp);
Assert.Equal(0.0, p.RecommendedSp);
Assert.False(p.Valid);
}
[Fact]
public void ManualArm_required_when_autoArm_false()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Normal, st.Mode);
st.OperatorArmed = true;
var res = engine.Tick(Cfg(autoArm:false), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, res.Mode);
}
[Fact]
public void Recovers_then_returns_to_normal()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, st.Mode);
AdvisoryResult res = null!;
for (int i = 0; i < 10; i++) res = engine.Tick(Cfg(autoArm:true), Balanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Normal, res.Mode);
}
[Fact]
public void Cancel_returns_to_normal_immediately()
{
var engine = new FeedforwardEngine(); var st = new ColumnState();
for (int i = 0; i < 6; i++) engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Recovering, st.Mode);
st.OperatorCancel = true;
var res = engine.Tick(Cfg(autoArm:true), Imbalanced(), st, DateTime.UtcNow);
Assert.Equal(ColumnMode.Normal, res.Mode);
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using ExperionCrawler.Core.Application.Feedforward;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardTempTests
{
// ── 순수 블록 ────────────────────────────────────────────────
[Fact]
public void TempCorrection_compensates_pressure()
{
Assert.Equal(99.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.5), 6);
Assert.Equal(100.0, TempCorrection.PressureCompensated(100, p: 52, pRef: 50, dTdP: 0.0), 6);
}
[Fact]
public void DiffTemp_delta_and_double()
{
Assert.Equal(2.0, DiffTemp.Delta(81, 79), 6);
Assert.Equal(0.0, DiffTemp.Double(82, 81, 80), 6);
Assert.Equal(1.0, DiffTemp.Double(83, 81, 80), 6);
}
// ── 엔진 배선 ────────────────────────────────────────────────
private static ColumnConfig Cfg(double dtdp, double pRef) => new()
{
Id = 1, Name = "C-TEMP", Enabled = true, FeedTag = "f", ProductKey = "P",
ScanSec = 2, DTdP = dtdp, PRef = pRef, PressureTag = "p",
TempTags = new[] { "t1" },
Streams = new[] { new StreamConfig { Key = "P", FlowTag = "ficq-6118", Role = StreamRole.Commanded, Grade = Confidence.A, TargetCoeff = 0.95 } }
};
private static PvSnapshot Snap(double pressure, double temp) => new(
new TagSample("f", 100, true, DateTime.UtcNow),
new TagSample("p", pressure, true, DateTime.UtcNow),
Array.Empty<TagSample>(),
new Dictionary<string, TagSample> { ["P"] = new TagSample("ficq-6118", 95, true, DateTime.UtcNow)})
{ Temps = new[] { new TagSample("t1", temp, true, DateTime.UtcNow) } };
[Fact]
public void Engine_populates_pct_with_explicit_pref()
{
var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.5, pRef: 50), Snap(pressure: 52, temp: 100),
new ColumnState(), DateTime.UtcNow);
Assert.NotNull(res.Temps);
var tp = res.Temps![0];
Assert.Equal("t1", tp.Tag);
Assert.Equal(100.0, tp.Raw, 6);
Assert.Equal(99.0, tp.Pct, 6);
}
[Fact]
public void Engine_seeds_pref_on_first_tick_when_nan()
{
var engine = new FeedforwardEngine();
var st = new ColumnState();
var r1 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 50, temp: 100), st, DateTime.UtcNow);
Assert.Equal(100.0, r1.Temps![0].Pct, 6);
var r2 = engine.Tick(Cfg(dtdp: 0.5, pRef: double.NaN), Snap(pressure: 54, temp: 100), st, DateTime.UtcNow);
Assert.Equal(98.0, r2.Temps![0].Pct, 6);
}
[Fact]
public void Engine_no_pct_when_dtdp_zero()
{
var res = new FeedforwardEngine().Tick(Cfg(dtdp: 0.0, pRef: 50), Snap(pressure: 80, temp: 100),
new ColumnState(), DateTime.UtcNow);
Assert.Equal(100.0, res.Temps![0].Pct, 6);
}
}

View File

@@ -0,0 +1,40 @@
using System;
using ExperionCrawler.Infrastructure.Control;
using Xunit;
namespace ExperionCrawler.Tests;
public class FeedforwardThetaTests
{
// 알려진 지연(5 샘플)으로 응답이 피드를 따라가면 θ≈5*ts 로 식별되어야 함
[Fact]
public void Estimator_finds_known_lag()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-9, recomputeEvery: 1);
var feed = new System.Collections.Generic.List<double>();
(double thetaUpSec, double thetaDnSec, double conf)? last = null;
for (int t = 0; t < 400; t++)
{
double df = Math.Sin(t * 0.3); // 풍부한 양/음 외란
feed.Add(df);
double dr = t >= 5 ? feed[t - 5] : 0.0; // 응답 = 피드 5샘플 지연
last = est.Push(df, dr, 0.0, tsSec: 1.0); // 스팀 0
}
Assert.NotNull(last);
Assert.InRange(last!.Value.thetaUpSec, 4.0, 6.0);
Assert.InRange(last!.Value.thetaDnSec, 4.0, 6.0);
Assert.True(last!.Value.conf > 0.5);
}
// 피드 외란이 없으면(평탄) 제안 억제(null)
[Fact]
public void Estimator_suppresses_when_no_excitation()
{
var est = new CrossCorrLagEstimator(maxLagSamples: 20, historySamples: 400,
minSignalStd: 1e-6, recomputeEvery: 1);
(double, double, double)? last = (0, 0, 0);
for (int t = 0; t < 200; t++) last = est.Push(0.0, 0.0, 0.0, 1.0); // Δ 전부 0
Assert.Null(last);
}
}