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
88 lines
4.0 KiB
C#
88 lines
4.0 KiB
C#
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);
|
|
}
|
|
}
|