Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions Models/IoTesting/IoTestModels.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ArIED61850Tester.Models;
using ArIED61850Tester.Services.IoTesting;

using System.ComponentModel;
using System.Globalization;
Expand Down Expand Up @@ -452,15 +453,16 @@ public string FatStatusText
}

[JsonIgnore]
public string FatResultText => CaptureMode == FatCaptureMode.AutomaticTransition
? Runtime.State switch
{
IoTestPointState.Passed => "✔ PASS",
IoTestPointState.Review => "⚠ REVIEW",
IoTestPointState.Failed => "✖ FAILED",
_ => "—"
}
: IsFatEvidenceComplete ? "✔ COMPLETE" : "—";
public string FatResultText =>
CaptureMode == FatCaptureMode.AutomaticTransition || SignalKind == FatSignalKind.Analog
? Runtime.State switch
{
IoTestPointState.Passed => "✔ PASS",
IoTestPointState.Review => "⚠ REVIEW",
IoTestPointState.Failed => "✖ FAILED",
_ => "—"
}
: IsFatEvidenceComplete ? "✔ COMPLETE" : "—";

[JsonIgnore]
public string ReportIecReference
Expand Down Expand Up @@ -526,6 +528,13 @@ internal void RestoreFatDisposition(FatSignalDisposition disposition)

private void Runtime_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (SignalKind == FatSignalKind.Analog &&
(e.PropertyName is nameof(IoTestPointRuntime.Value1Evidence) or nameof(IoTestPointRuntime.Value2Evidence)) &&
IsFatEvidenceComplete)
{
FatCurrentEvidenceAssessmentService.Apply(this);
}

if (e.PropertyName is nameof(IoTestPointRuntime.OnEvidence) or
nameof(IoTestPointRuntime.OffEvidence) or
nameof(IoTestPointRuntime.Value1Evidence) or
Expand Down Expand Up @@ -797,4 +806,4 @@ public void InitializeRuntimeNotifications()
foreach (var ied in Ieds)
ied.InitializeRuntimeNotifications();
}
}
}
62 changes: 53 additions & 9 deletions Services/IoTesting/FatCurrentEvidenceAssessmentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ namespace ArIED61850Tester.Services.IoTesting;
/// <summary>
/// Assesses the exact Value 1 / Value 2 pair currently presented by FAT v2.
///
/// Discrete rows PASS when the current pair proves a good-quality state change in one
/// connection generation. Analog rows PASS when both current values are good-quality,
/// ordered, numeric, and differ beyond the same adaptive settling tolerance used by
/// automatic analog capture. This keeps capture and assessment on one meaning of
/// "changed" instead of treating measurement noise as a successful FAT operation.
///
/// The legacy transition state machine remains responsible for collecting historical
/// OFF -> ON -> OFF evidence. Once generic FAT evidence overrides either current slot,
/// this service becomes the assessment authority only while that pair belongs to the
Expand All @@ -19,11 +25,14 @@ public static FatCurrentEvidenceAssessment Evaluate(IoTestPointPlan point)
{
ArgumentNullException.ThrowIfNull(point);

if (point.CaptureMode != FatCaptureMode.AutomaticTransition)
var usesPassAssessment =
point.CaptureMode == FatCaptureMode.AutomaticTransition ||
point.SignalKind == FatSignalKind.Analog;
if (!usesPassAssessment)
{
return new FatCurrentEvidenceAssessment(
IoTestPointState.NotStarted,
"Operator-snapshot rows are complete when both current value slots are captured; no digital PASS assessment is applied.");
"Operator-snapshot rows are complete when both current value slots are captured; no PASS assessment is applied for this signal kind.");
}

if (!point.IsFatEvidenceComplete)
Expand Down Expand Up @@ -76,9 +85,9 @@ public static FatCurrentEvidenceAssessment Evaluate(IoTestPointPlan point)
// Resume/rebind deliberately establishes a new continuity authority. Old
// automatic V1/V2 pointers may remain visible for audit, but they must not
// overwrite REVIEW after a potentially missed edge or overwrite a later
// PASS earned by a complete new legacy cycle. If no terminal continuity
// verdict exists yet, fail closed to REVIEW rather than showing COMPLETE
// with a blank/non-terminal Result.
// PASS earned by a complete new cycle. If no terminal continuity verdict
// exists yet, fail closed to REVIEW rather than showing COMPLETE with a
// blank/non-terminal Result.
return PreserveRuntimeAssessment(point, pairIsSameGeneration);
}

Expand Down Expand Up @@ -106,6 +115,9 @@ public static FatCurrentEvidenceAssessment Evaluate(IoTestPointPlan point)
"REVIEW: current Value 2 does not follow current Value 1 in the live evidence sequence; recapture Value 2 after the intended condition change.");
}

if (point.SignalKind == FatSignalKind.Analog)
return AssessAnalogChange(value1, value2);

var state1 = IoTestValueNormalizer.Normalize(point, value1.RawValue);
var state2 = IoTestValueNormalizer.Normalize(point, value2.RawValue);
if (state1 is null || state2 is null)
Expand All @@ -131,14 +143,46 @@ public static FatCurrentEvidenceAssessment Apply(IoTestPointPlan point)
{
ArgumentNullException.ThrowIfNull(point);
var assessment = Evaluate(point);
if (point.CaptureMode == FatCaptureMode.AutomaticTransition && point.IsFatEvidenceComplete)
var usesPassAssessment =
point.CaptureMode == FatCaptureMode.AutomaticTransition ||
point.SignalKind == FatSignalKind.Analog;
if (usesPassAssessment && point.IsFatEvidenceComplete)
{
point.Runtime.State = assessment.State;
point.Runtime.StatusReason = assessment.Reason;
}
return assessment;
}

private static FatCurrentEvidenceAssessment AssessAnalogChange(
CurrentEvidence value1,
CurrentEvidence value2)
{
if (!FatAutoCaptureCoordinator.TryParseNumeric(value1.RawValue, out var numeric1) ||
!FatAutoCaptureCoordinator.TryParseNumeric(value2.RawValue, out var numeric2))
{
return new FatCurrentEvidenceAssessment(
IoTestPointState.Review,
"REVIEW: one or both current analog values cannot be parsed as numeric evidence.");
}

var scale = Math.Max(1d, Math.Max(Math.Abs(numeric1), Math.Abs(numeric2)));
var tolerance = Math.Max(
1e-9d,
scale * FatAutoCaptureCoordinator.AnalogRelativeSettlingFraction);
var delta = Math.Abs(numeric2 - numeric1);
if (delta <= tolerance)
{
return new FatCurrentEvidenceAssessment(
IoTestPointState.Review,
$"REVIEW: analog Value 1 ({value1.RawValue}) and Value 2 ({value2.RawValue}) are equivalent within the settling tolerance; the current pair does not prove a meaningful value change.");
}

return new FatCurrentEvidenceAssessment(
IoTestPointState.Passed,
$"PASS: analog Value 1 ({value1.RawValue}) -> Value 2 ({value2.RawValue}) proves a good-quality value change beyond settling tolerance in one connection generation.");
}

private static FatCurrentEvidenceAssessment PreserveRuntimeAssessment(
IoTestPointPlan point,
bool pairIsSameGeneration)
Expand All @@ -149,15 +193,15 @@ private static FatCurrentEvidenceAssessment PreserveRuntimeAssessment(
return new FatCurrentEvidenceAssessment(
point.Runtime.State,
string.IsNullOrWhiteSpace(point.Runtime.StatusReason)
? "Automatic current Value 1 / Value 2 evidence predates or straddles the active IED connection generation; the existing live transition continuity verdict remains authoritative."
? "Automatic current Value 1 / Value 2 evidence predates or straddles the active IED connection generation; the existing live continuity verdict remains authoritative."
: point.Runtime.StatusReason);
}

return new FatCurrentEvidenceAssessment(
IoTestPointState.Review,
pairIsSameGeneration
? "REVIEW: automatic current Value 1 / Value 2 evidence belongs to an earlier IED connection generation and no terminal live transition continuity verdict is available."
: "REVIEW: automatic current Value 1 and Value 2 belong to different IED connection generations and no terminal live transition continuity verdict is available.");
? "REVIEW: automatic current Value 1 / Value 2 evidence belongs to an earlier IED connection generation and no terminal live continuity verdict is available."
: "REVIEW: automatic current Value 1 and Value 2 belong to different IED connection generations and no terminal live continuity verdict is available.");
}

private static CurrentEvidence? EffectiveValue1(IoTestPointPlan point)
Expand Down
110 changes: 110 additions & 0 deletions tests/ARSAS.Tests/FatAnalogAutoCaptureAssessmentIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using ArIED61850Tester.Models.IoTesting;
using ArIED61850Tester.Services.IoTesting;

namespace ARSAS.Tests;

public sealed class FatAnalogAutoCaptureAssessmentIntegrationTests
{
[Fact]
public void StableAutoCapturedAnalogPair_WithMeaningfulChange_IsPass()
{
var point = NewAnalogPoint();
var coordinator = new FatAutoCaptureCoordinator();
long sequence = 0;

Feed(point, coordinator, ref sequence, "0", "0", "0");
Assert.Equal("0", point.Value1Text);
Assert.Equal("WAITING V2", point.FatStatusText);

Feed(
point,
coordinator,
ref sequence,
"18.412",
"43.920",
"61.850",
"65.702",
"65.746",
"65.748",
"65.748",
"65.748");

Assert.Equal("65.748", point.Value2Text);
Assert.True(point.IsFatEvidenceComplete);

var assessment = FatCurrentEvidenceAssessmentService.Apply(point);

Assert.Equal(IoTestPointState.Passed, assessment.State);
Assert.Equal(IoTestPointState.Passed, point.Runtime.State);
Assert.Equal("COMPLETE", point.FatStatusText);
Assert.Equal("✔ PASS", point.FatResultText);
}

[Fact]
public void AnalogAutoCapture_DoesNotCreateValue2FromNoiseInsideSettlingTolerance()
{
var point = NewAnalogPoint();
var coordinator = new FatAutoCaptureCoordinator();
long sequence = 0;

Feed(point, coordinator, ref sequence, "65.748", "65.748", "65.748");
Assert.Equal("65.748", point.Value1Text);

Feed(point, coordinator, ref sequence, "65.749", "65.749", "65.749", "65.749");

Assert.Null(point.Runtime.Value2Evidence);
Assert.False(point.IsFatEvidenceComplete);
Assert.Equal("WAITING V2", point.FatStatusText);
Assert.Equal("—", point.FatResultText);
}

private static void Feed(
IoTestPointPlan point,
FatAutoCaptureCoordinator coordinator,
ref long sequence,
params string[] values)
{
foreach (var raw in values)
{
var decision = coordinator.Observe(point, Observation(raw, ++sequence));
if (decision.Evidence is null)
continue;

point.Runtime.SetFatValueEvidence(decision.Evidence);
point.Runtime.AutoCaptureStage = decision.Stage;
}
}

private static IoTestPointPlan NewAnalogPoint() => new()
{
TestPointId = "AN-AUTO-ASSESS",
IedName = "IED1",
IpAddress = "192.0.2.10",
SignalName = "Current",
ObjectReference = "IED1LD0/MMXU1.A.phsA.cVal.mag.f",
FunctionalConstraint = "MX",
ExpectedOnText = "Value 2",
ExpectedOffText = "Value 1",
SignalKind = FatSignalKind.Analog,
CaptureMode = FatCaptureMode.OperatorSnapshot,
WorkspaceSelected = true,
TestEnabled = true,
ImportReady = true,
BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus
};

private static IoTestObservation Observation(string raw, long sequence)
{
var timestamp = new DateTimeOffset(2026, 9, 4, 0, 0, 0, TimeSpan.Zero)
.AddMilliseconds(sequence * 100);
return new IoTestObservation(
null,
raw,
timestamp,
timestamp.AddMilliseconds(-2),
"Good",
"MMS-POLL",
sequence,
1);
}
}
Loading
Loading