From 86750f4392994ada1cace50843c18172a23bf28f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 06:30:26 +0700 Subject: [PATCH 1/6] Assess changed analog FAT pairs as PASS --- .../FatCurrentEvidenceAssessmentService.cs | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs b/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs index 8ce3e2dd..46289976 100644 --- a/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs +++ b/Services/IoTesting/FatCurrentEvidenceAssessmentService.cs @@ -5,6 +5,12 @@ namespace ArIED61850Tester.Services.IoTesting; /// /// 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 @@ -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) @@ -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); } @@ -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) @@ -131,7 +143,10 @@ 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; @@ -139,6 +154,35 @@ public static FatCurrentEvidenceAssessment Apply(IoTestPointPlan point) 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) @@ -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) From 46a457d6e2c1068f003bfb3fd4b3d622e1b8daa9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 06:33:49 +0700 Subject: [PATCH 2/6] Show analog FAT assessments as PASS or REVIEW --- Models/IoTesting/IoTestModels.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index cc6afbbf..f698a032 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -452,15 +452,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 @@ -797,4 +798,4 @@ public void InitializeRuntimeNotifications() foreach (var ied in Ieds) ied.InitializeRuntimeNotifications(); } -} +} \ No newline at end of file From 3ef9c45bec8f51c6ffccf83a91e25ad54c6f15b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 06:34:58 +0700 Subject: [PATCH 3/6] Lock discrete and analog FAT PASS semantics --- ...urrentEvidenceAssessmentRegressionTests.cs | 97 ++++++++++++++++++- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs b/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs index bf1dd6c9..cf996b18 100644 --- a/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs +++ b/tests/ARSAS.Tests/FatCurrentEvidenceAssessmentRegressionTests.cs @@ -41,6 +41,32 @@ public void GenericCurrentPair_TrueToFalse_IsPass_NotLegacyWaitingState() Assert.Contains("TRUE -> FALSE", assessment.Reason, StringComparison.Ordinal); } + [Fact] + public void GenericCurrentPair_OpenToClosed_IsPass() + { + var point = NewDiscretePoint("OPEN-CLOSED", expectedOn: "Closed", expectedOff: "Open"); + SetCurrentPair(point, "Open [01]", 22, "Closed [10]", 23); + + var assessment = FatCurrentEvidenceAssessmentService.Apply(point); + + Assert.Equal(IoTestPointState.Passed, assessment.State); + Assert.Equal("✔ PASS", point.FatResultText); + Assert.Contains("FALSE -> TRUE", assessment.Reason, StringComparison.Ordinal); + } + + [Fact] + public void GenericCurrentPair_ClosedToOpen_IsPass() + { + var point = NewDiscretePoint("CLOSED-OPEN", expectedOn: "Closed", expectedOff: "Open"); + SetCurrentPair(point, "Closed [10]", 24, "Open [01]", 25); + + var assessment = FatCurrentEvidenceAssessmentService.Apply(point); + + Assert.Equal(IoTestPointState.Passed, assessment.State); + Assert.Equal("✔ PASS", point.FatResultText); + Assert.Contains("TRUE -> FALSE", assessment.Reason, StringComparison.Ordinal); + } + [Fact] public void GenericCurrentPair_SameState_IsReview() { @@ -123,6 +149,50 @@ public void GenericCurrentPair_QuestionableQuality_IsReview() Assert.Contains("quality is not fully accepted", assessment.Reason, StringComparison.Ordinal); } + [Fact] + public void AnalogCurrentPair_MeaningfulNumericChange_IsPass() + { + var point = NewAnalogPoint("ANALOG-PASS"); + SetCurrentPair(point, "0.000 A", 70, "65.748 A", 71); + + var assessment = FatCurrentEvidenceAssessmentService.Apply(point); + + Assert.True(point.IsFatEvidenceComplete); + Assert.Equal(IoTestPointState.Passed, assessment.State); + Assert.Equal(IoTestPointState.Passed, point.Runtime.State); + Assert.Equal("COMPLETE", point.FatStatusText); + Assert.Equal("✔ PASS", point.FatResultText); + Assert.Contains("analog Value 1", assessment.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("value change", assessment.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AnalogCurrentPair_NoiseWithinSettlingTolerance_IsReview() + { + var point = NewAnalogPoint("ANALOG-NOISE"); + SetCurrentPair(point, "65.748", 72, "65.749", 73); + + var assessment = FatCurrentEvidenceAssessmentService.Apply(point); + + Assert.Equal(IoTestPointState.Review, assessment.State); + Assert.Equal("⚠ REVIEW", point.FatResultText); + Assert.Contains("equivalent within the settling tolerance", assessment.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void AnalogCurrentPair_BadQuality_IsReview() + { + var point = NewAnalogPoint("ANALOG-QUALITY"); + point.Runtime.SetFatValueEvidence(Evidence(FatValueSlot.Value1, "10.0", 74, quality: "Good")); + point.Runtime.SetFatValueEvidence(Evidence(FatValueSlot.Value2, "20.0", 75, quality: "Invalid")); + + var assessment = FatCurrentEvidenceAssessmentService.Apply(point); + + Assert.Equal(IoTestPointState.Review, assessment.State); + Assert.Equal("⚠ REVIEW", point.FatResultText); + Assert.Contains("quality is not fully accepted", assessment.Reason, StringComparison.OrdinalIgnoreCase); + } + private static void SetCurrentPair( IoTestPointPlan point, string value1, @@ -134,7 +204,10 @@ private static void SetCurrentPair( point.Runtime.SetFatValueEvidence(Evidence(FatValueSlot.Value2, value2, sequence2)); } - private static IoTestPointPlan NewDiscretePoint(string id) => new() + private static IoTestPointPlan NewDiscretePoint( + string id, + string expectedOn = "True", + string expectedOff = "False") => new() { TestPointId = id, IedName = "IED1", @@ -142,8 +215,8 @@ private static void SetCurrentPair( SignalName = id, ObjectReference = $"IED1LD0/GGIO1.{id}.stVal", FunctionalConstraint = "ST", - ExpectedOnText = "True", - ExpectedOffText = "False", + ExpectedOnText = expectedOn, + ExpectedOffText = expectedOff, ExpectedOnRaw = 1, ExpectedOffRaw = 0, SignalKind = FatSignalKind.Discrete, @@ -154,6 +227,24 @@ private static void SetCurrentPair( BindingStatus = IoTestSignalSelectionService.SclWorkspaceAuthorityBindingStatus }; + private static IoTestPointPlan NewAnalogPoint(string id) => new() + { + TestPointId = id, + IedName = "IED1", + IpAddress = "192.0.2.10", + SignalName = id, + ObjectReference = $"IED1LD0/MMXU1.{id}.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 FatValueEvidence Evidence( FatValueSlot slot, string raw, From 9c02db140fc68e89625571b63d430f1f9d1f3a09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 06:35:38 +0700 Subject: [PATCH 4/6] Verify analog auto capture promotes changed pair to PASS --- ...ogAutoCaptureAssessmentIntegrationTests.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/ARSAS.Tests/FatAnalogAutoCaptureAssessmentIntegrationTests.cs diff --git a/tests/ARSAS.Tests/FatAnalogAutoCaptureAssessmentIntegrationTests.cs b/tests/ARSAS.Tests/FatAnalogAutoCaptureAssessmentIntegrationTests.cs new file mode 100644 index 00000000..cb1f818d --- /dev/null +++ b/tests/ARSAS.Tests/FatAnalogAutoCaptureAssessmentIntegrationTests.cs @@ -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); + } +} From a07e6c59d45bb33cfd3c22d17b2493ac716ef31d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 06:45:04 +0700 Subject: [PATCH 5/6] Apply analog assessment when current evidence changes --- Models/IoTesting/IoTestModels.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Models/IoTesting/IoTestModels.cs b/Models/IoTesting/IoTestModels.cs index f698a032..12b4b5b0 100644 --- a/Models/IoTesting/IoTestModels.cs +++ b/Models/IoTesting/IoTestModels.cs @@ -1,4 +1,5 @@ using ArIED61850Tester.Models; +using ArIED61850Tester.Services.IoTesting; using System.ComponentModel; using System.Globalization; @@ -527,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 From e440424db8b29864088a5120581909e9d2095634 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 06:46:06 +0700 Subject: [PATCH 6/6] Expect PASS for changed analog snapshot pair --- tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs index 3042e168..8c4ff6de 100644 --- a/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs +++ b/tests/ARSAS.Tests/IoFatV2WorkspaceRegressionTests.cs @@ -27,7 +27,8 @@ public void OperatorSnapshot_Value1Value2Recapture_IsJournalFirstAndKeepsSession Assert.True(second.Succeeded, second.Message); Assert.True(fixture.Point.IsFatEvidenceComplete); Assert.Equal("18.90", fixture.Point.Value2Text); - Assert.Equal("✔ COMPLETE", fixture.Point.FatResultText); + Assert.Equal(IoTestPointState.Passed, fixture.Point.Runtime.State); + Assert.Equal("✔ PASS", fixture.Point.FatResultText); Assert.Equal(IoTestSessionState.Running, controller.State); fixture.LivePoint.Value = "13.01";