From 419fa64b56fc10bf983f8af3017364c9622653b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 06:47:53 +0700 Subject: [PATCH 01/34] Add ARIEC reconciliation presentation mapping --- .../IoTestReconciliationPresentation.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 Services/IoTesting/IoTestReconciliationPresentation.cs diff --git a/Services/IoTesting/IoTestReconciliationPresentation.cs b/Services/IoTesting/IoTestReconciliationPresentation.cs new file mode 100644 index 00000000..15557927 --- /dev/null +++ b/Services/IoTesting/IoTestReconciliationPresentation.cs @@ -0,0 +1,79 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Presentation-only mapping from ARIEC61850 reconciliation verdicts to the existing +/// FAT live-binding state. Protocol semantics, reference canonicalization and probe +/// failure interpretation remain entirely in ARIEC61850. +/// +public static class IoTestReconciliationPresentation +{ + public static IoTestReconciliationPresentationResult FromEnginePoint( + Iec61850DesignLivePointReconciliation point) + { + ArgumentNullException.ThrowIfNull(point); + + var state = point.Status switch + { + Iec61850DesignLiveStatus.Exact => IoTestLiveBindingState.BoundExact, + Iec61850DesignLiveStatus.Compatible => IoTestLiveBindingState.BoundNormalized, + Iec61850DesignLiveStatus.RecoveredByProbe => IoTestLiveBindingState.BoundExact, + // SignalNotFound is deliberately reserved for an engine-confirmed Absent verdict. + Iec61850DesignLiveStatus.Absent => IoTestLiveBindingState.SignalNotFound, + _ => IoTestLiveBindingState.NotEvaluated + }; + + var statusText = point.Status switch + { + Iec61850DesignLiveStatus.Exact => "ARIEC verified exact design/live binding", + Iec61850DesignLiveStatus.Compatible => "ARIEC verified compatible design/live binding", + Iec61850DesignLiveStatus.RecoveredByProbe => "ARIEC verified by exact MMS probe", + Iec61850DesignLiveStatus.DesignOnly => "ARIEC design-only · exact verification required", + Iec61850DesignLiveStatus.LiveOnly => "ARIEC live-only point", + Iec61850DesignLiveStatus.FunctionalConstraintMismatch => "ARIEC functional-constraint mismatch", + Iec61850DesignLiveStatus.TypeMismatch => "ARIEC type mismatch", + Iec61850DesignLiveStatus.Ambiguous => "ARIEC reconciliation ambiguous", + Iec61850DesignLiveStatus.InvalidTarget => "ARIEC exact target invalid", + Iec61850DesignLiveStatus.Unreadable => "ARIEC exact target unreadable", + Iec61850DesignLiveStatus.Absent => "ARIEC confirmed signal absent", + Iec61850DesignLiveStatus.TransportFailure => "ARIEC probe transport failure", + Iec61850DesignLiveStatus.UnresolvedDesign => "ARIEC design target unresolved", + _ => $"ARIEC {point.Status}" + }; + + var evidence = new List { statusText }; + foreach (var item in point.Evidence.Where(item => !string.IsNullOrWhiteSpace(item))) + evidence.Add(item.Trim()); + + if (point.Probe != null) + { + var probe = point.Probe; + evidence.Add($"Exact probe: {probe.Status}"); + if (!string.IsNullOrWhiteSpace(probe.MmsReference)) + evidence.Add($"Target: {probe.MmsReference}"); + if (!string.IsNullOrWhiteSpace(probe.Message)) + evidence.Add(probe.Message.Trim()); + if (!string.IsNullOrWhiteSpace(probe.ValueSummary)) + evidence.Add($"Value: {probe.ValueSummary}"); + if (probe.FailureCode.HasValue) + evidence.Add($"Engine failure code: {probe.FailureCode.Value}"); + } + + return new IoTestReconciliationPresentationResult( + state, + string.Join(" · ", evidence.Distinct(StringComparer.OrdinalIgnoreCase)), + FirstNonEmpty(point.ObservedReference, point.Reference), + point.Status == Iec61850DesignLiveStatus.Absent); + } + + private static string FirstNonEmpty(string first, string second) + => string.IsNullOrWhiteSpace(first) ? second : first; +} + +public sealed record IoTestReconciliationPresentationResult( + IoTestLiveBindingState State, + string Reason, + string Reference, + bool IsConfirmedAbsent); From 89b52d7080628c66402bef0adff6a0dd40a93f79 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 06:48:41 +0700 Subject: [PATCH 02/34] Consume ARIEC reconciliation verdicts in FAT binding --- .../IoTesting/IoTestLiveBindingService.cs | 181 ++++++++++++++++-- 1 file changed, 163 insertions(+), 18 deletions(-) diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index a7534ff7..42e4d7b7 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; @@ -72,9 +73,10 @@ private static IoTestLiveBindingSummary BindPlans( device.IsConnected, device.IsMonitoring); + var reconciliation = BuildEngineReconciliation(device); foreach (var point in iedPlan.TestPoints) { - var binding = BindPoint(point, device); + var binding = BindPoint(point, device, reconciliation); point.ApplyLiveBinding(binding.State, binding.Reason, device.DeviceId, binding.Reference); if (point.IsLiveBound) signalBoundCount++; @@ -93,6 +95,7 @@ private static IoTestLiveBindingSummary BindPlans( } else if (binding.State == IoTestLiveBindingState.SignalNotFound) { + // SignalNotFound is reserved for ARIEC61850's confirmed Absent verdict. missingSignalCount++; } } @@ -107,18 +110,70 @@ private static IoTestLiveBindingSummary BindPlans( missingSignalCount); } - private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevice device) + private static EngineReconciliationContext BuildEngineReconciliation(Iec61850MonitorDevice device) + { + if (device.SclWorkspace == null) + return new EngineReconciliationContext(null, "No SCL design model is attached to this ARSAS IED workspace."); + if (device.LiveDiscoveryModel == null) + return new EngineReconciliationContext(null, "No authoritative ARIEC live discovery model is available yet."); + + try + { + // Reconciliation is engine-owned. No app-side protocol/reference inference is + // added here. A null probe deliberately means DesignOnly remains DesignOnly; + // ARSAS must never turn a discovery miss into confirmed absence. + var document = Iec61850DesignLiveReconciler.ReconcileAsync( + device.SclWorkspace.DesignModel, + device.LiveDiscoveryModel, + probe: null) + .GetAwaiter() + .GetResult(); + return new EngineReconciliationContext(document, string.Empty); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return new EngineReconciliationContext( + null, + $"ARIEC reconciliation could not be produced: {ex.GetType().Name}: {ex.Message}"); + } + } + + private static PointBinding BindPoint( + IoTestPointPlan point, + Iec61850MonitorDevice device, + EngineReconciliationContext reconciliation) { var importedReferences = ImportedReferences(point); if (!point.ImportReady || importedReferences.Count == 0) { return new PointBinding( - IoTestLiveBindingState.SignalNotFound, - "The imported row is not ready for automatic live binding.", + IoTestLiveBindingState.NotEvaluated, + "The imported row is not ready for automatic live binding; no absence conclusion was made.", string.Empty, null); } + var enginePoint = FindEngineReconciliationPoint( + point, + device, + importedReferences, + reconciliation.Document, + out var engineAmbiguous); + var enginePresentation = enginePoint == null + ? null + : IoTestReconciliationPresentation.FromEnginePoint(enginePoint); + + // Only ARIEC61850 may prove true absence. Never let cached/discovered application + // rows override a confirmed engine Absent verdict. + if (enginePresentation?.IsConfirmedAbsent == true) + { + return new PointBinding( + IoTestLiveBindingState.SignalNotFound, + enginePresentation.Reason, + enginePresentation.Reference, + null); + } + var expectedReferences = importedReferences .Select(NormalizeReference) .Where(value => value.Length > 0) @@ -131,7 +186,9 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Exact imported or prepared IEC 61850 reference is already active in the live monitor.", + WithEngineEvidence( + "Exact imported or prepared IEC 61850 reference is already active in the live monitor.", + enginePresentation), exactLivePoints[0].IecReference, exactLivePoints[0]); } @@ -144,7 +201,9 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.BoundExact, - "Exact imported or prepared IEC 61850 reference is present in the discovered IED model.", + WithEngineEvidence( + "Exact imported or prepared IEC 61850 reference is present in the ARSAS signal workspace.", + enginePresentation), exactSignals[0].ObjectReference, null); } @@ -159,7 +218,9 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.LivePointReady, - "Live point matched uniquely using canonical IEC 61850 spelling, including IED/Application, MMS FC tokens and verified functional-group/LN boundary rules.", + WithEngineEvidence( + "One existing ARSAS live row matched the imported FAT row; protocol absence status remains owned by ARIEC reconciliation.", + enginePresentation), livePointCandidates[0].IecReference, livePointCandidates[0]); } @@ -174,20 +235,101 @@ private static PointBinding BindPoint(IoTestPointPlan point, Iec61850MonitorDevi { return new PointBinding( IoTestLiveBindingState.BoundNormalized, - "Discovered signal matched uniquely using canonical IEC 61850 spelling, including IED/Application, MMS FC tokens and verified functional-group/LN boundary rules.", + WithEngineEvidence( + "One existing ARSAS signal row matched the imported FAT row; protocol absence status remains owned by ARIEC reconciliation.", + enginePresentation), signalCandidates[0].ObjectReference, null); } - var reason = exactLivePoints.Count > 1 || exactSignals.Count > 1 || - signalCandidates.Count > 1 || livePointCandidates.Count > 1 - ? "More than one equally strong IEC 61850 candidate matched the imported telegram; automatic binding was withheld." + if (enginePresentation != null) + { + return new PointBinding( + enginePresentation.State, + enginePresentation.Reason, + enginePresentation.Reference, + null); + } + + var localReason = exactLivePoints.Count > 1 || exactSignals.Count > 1 || + signalCandidates.Count > 1 || livePointCandidates.Count > 1 + ? "More than one equally strong ARSAS row matched the imported FAT point; automatic live-row binding was withheld." : device.Signals.Count == 0 - ? "The IED is loaded but its signal model has not been discovered yet." - : "None of the imported IEC 61850/event-log references was found in the loaded IED model after conservative canonical matching."; - return new PointBinding(IoTestLiveBindingState.SignalNotFound, reason, string.Empty, null); + ? "The IED is loaded but its ARSAS signal workspace has not been populated yet." + : "No unique ARSAS live/signal row matched this imported FAT point."; + + var engineReason = engineAmbiguous + ? "More than one ARIEC reconciliation point matched the imported row; no absence conclusion was made." + : string.IsNullOrWhiteSpace(reconciliation.FailureReason) + ? "No unique ARIEC reconciliation point was associated with this imported row; no absence conclusion was made." + : reconciliation.FailureReason + " No absence conclusion was made."; + + // A local lookup miss is explicitly NotEvaluated. It is never SignalNotFound. + return new PointBinding( + IoTestLiveBindingState.NotEvaluated, + $"{localReason} {engineReason}", + string.Empty, + null); } + private static Iec61850DesignLivePointReconciliation? FindEngineReconciliationPoint( + IoTestPointPlan point, + Iec61850MonitorDevice device, + IReadOnlyCollection importedReferences, + Iec61850DesignLiveReconciliationDocument? document, + out bool ambiguous) + { + ambiguous = false; + if (document == null || document.Points.Count == 0) + return null; + + var scored = document.Points + .Select(candidate => new + { + Candidate = candidate, + Score = EngineReferences(candidate) + .Select(engineReference => importedReferences.Max(importedReference => + IoTestReferenceMatcher.Score( + importedReference, + engineReference, + point.IedName, + device.Name, + device.SclIedName, + point.LogicalNode))) + .DefaultIfEmpty(0) + .Max() + }) + .Where(item => item.Score > 0) + .ToList(); + + if (scored.Count == 0) + return null; + + var bestScore = scored.Max(item => item.Score); + var best = scored.Where(item => item.Score == bestScore).Select(item => item.Candidate).ToList(); + ambiguous = best.Count > 1; + return best.Count == 1 ? best[0] : null; + } + + private static IEnumerable EngineReferences(Iec61850DesignLivePointReconciliation point) + { + if (!string.IsNullOrWhiteSpace(point.Reference)) + yield return point.Reference; + if (!string.IsNullOrWhiteSpace(point.MmsReference)) + yield return point.MmsReference; + if (!string.IsNullOrWhiteSpace(point.ObservedReference)) + yield return point.ObservedReference; + if (!string.IsNullOrWhiteSpace(point.ObservedMmsReference)) + yield return point.ObservedMmsReference; + } + + private static string WithEngineEvidence( + string localReason, + IoTestReconciliationPresentationResult? enginePresentation) + => enginePresentation == null + ? localReason + : $"{localReason} {enginePresentation.Reason}"; + private static bool IsSignalEligible(SignalDefinition signal, IoTestPointPlan point) { if (signal.IsControlSignal || string.IsNullOrWhiteSpace(signal.ObjectReference)) @@ -245,10 +387,9 @@ void Add(string? value) Add(point.ReportDisplayReference); // During FAT preparation the signal-selection pass may prove one unique live - // model reference from otherwise incomplete source metadata (for example a - // legacy 7SX80 ANSI-27 row). Keep that exact prepared reference authoritative - // for subsequent model/live-point binding. It is transient runtime state and is - // cleared automatically whenever ApplyLiveBinding reports a non-bound result. + // model reference from otherwise incomplete source metadata. Keep that exact + // prepared row available for subsequent UI/live-point lookup only; it is not + // evidence that an IEC 61850 design point is present or absent. if (point.IsLiveBound) Add(point.LiveSignalReference); @@ -323,6 +464,10 @@ internal static IReadOnlySet NormalizeImportedTelegramForms( string? logicalNode) => IoTestReferenceMatcher.ImportedForms(reference, iedName, logicalNode); + private sealed record EngineReconciliationContext( + Iec61850DesignLiveReconciliationDocument? Document, + string FailureReason); + private sealed record PointBinding( IoTestLiveBindingState State, string Reason, From 5d929e0c440617c83fe73fa42ceb2490552a9693 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 06:49:08 +0700 Subject: [PATCH 03/34] Lock FAT false-missing reconciliation behavior --- .../IoTestLiveBindingServiceTests.cs | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs index de25160c..4aa43151 100644 --- a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs @@ -1,3 +1,4 @@ +using AR.Iec61850.Discovery; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services.IoTesting; @@ -24,6 +25,7 @@ public void ExactDiscoveredSignal_IsBoundToImportedPoint() Assert.Equal(1, summary.DeviceBoundCount); Assert.Equal(1, summary.SignalBoundCount); + Assert.Equal(0, summary.MissingSignalCount); Assert.Equal(IoTestLiveBindingState.BoundExact, project.Ieds[0].TestPoints[0].LiveBindingState); } @@ -59,7 +61,6 @@ public void ApplicationFolderHierarchy_IsNormalizedToLiveLnPrefix() _binding.Bind(project, new[] { device }); Assert.Equal(IoTestLiveBindingState.BoundNormalized, project.Ieds[0].TestPoints[0].LiveBindingState); - Assert.Contains("verified functional-group/LN boundary", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -85,7 +86,7 @@ public void PartialTcsLeaf_IsBoundUniquelyToDiscoveredSignal() } [Fact] - public void PartialTcsLeaf_DuplicateObjectsRemainBlocked() + public void PartialTcsLeaf_DuplicateObjectsRemainUnverified_NotMissing() { var project = Project(".TCS1Fail"); var device = Device(); @@ -105,9 +106,30 @@ public void PartialTcsLeaf_DuplicateObjectsRemainBlocked() var summary = _binding.Bind(project, new[] { device }); Assert.Equal(0, summary.SignalBoundCount); - Assert.Equal(1, summary.MissingSignalCount); - Assert.Equal(IoTestLiveBindingState.SignalNotFound, project.Ieds[0].TestPoints[0].LiveBindingState); - Assert.Contains("more than one equally strong", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.NotEvaluated, project.Ieds[0].TestPoints[0].LiveBindingState); + Assert.Contains("no absence conclusion", project.Ieds[0].TestPoints[0].LiveBindingReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void NoLocalCandidate_RemainsUnverified_NotMissing() + { + var project = Project("AA1C1F03R4ADD/GGIO6.Unknown.stVal"); + var device = Device(); + device.Signals.Add(new SignalDefinition + { + Name = "Different signal", + ObjectReference = "AA1C1F03R4ADD/GGIO6.Other.stVal", + FunctionalConstraint = "ST" + }); + + var summary = _binding.Bind(project, new[] { device }); + + var point = project.Ieds[0].TestPoints[0]; + Assert.Equal(0, summary.MissingSignalCount); + Assert.Equal(IoTestLiveBindingState.NotEvaluated, point.LiveBindingState); + Assert.NotEqual("Signal missing", point.LiveBindingText); + Assert.Contains("no absence conclusion", point.LiveBindingReason, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -150,6 +172,65 @@ public void MissingWorkspaceDevice_IsExplicitlyReported() Assert.Contains("load or connect", project.Ieds[0].TestPoints[0].LiveBindingReason.ToLowerInvariant()); } + [Fact] + public void EngineAbsent_IsTheOnlyPresentationThatMapsToSignalNotFound() + { + var absent = EnginePoint(Iec61850DesignLiveStatus.Absent); + var designOnly = EnginePoint(Iec61850DesignLiveStatus.DesignOnly); + var unreadable = EnginePoint(Iec61850DesignLiveStatus.Unreadable); + var transportFailure = EnginePoint(Iec61850DesignLiveStatus.TransportFailure); + + Assert.Equal( + IoTestLiveBindingState.SignalNotFound, + IoTestReconciliationPresentation.FromEnginePoint(absent).State); + Assert.True(IoTestReconciliationPresentation.FromEnginePoint(absent).IsConfirmedAbsent); + + foreach (var point in new[] { designOnly, unreadable, transportFailure }) + { + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + Assert.Equal(IoTestLiveBindingState.NotEvaluated, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + } + } + + [Fact] + public void EngineRecoveredByProbe_IsPresentedAsVerifiedBinding() + { + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/GGIO1.Test.stVal", + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + Status = Iec61850DesignLiveStatus.RecoveredByProbe, + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Readable, + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + ValueSummary = "true", + Message = "Exact read succeeded." + }, + Evidence = new[] { "Recovered by engine exact probe." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.BoundExact, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Contains("exact MMS probe", presentation.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Readable", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + + private static Iec61850DesignLivePointReconciliation EnginePoint(Iec61850DesignLiveStatus status) + => new() + { + Reference = "IEDLD/GGIO1.Test.stVal", + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + Status = status, + Evidence = new[] { $"Engine status: {status}" } + }; + private static IoTestProject Project(string reference) { var project = new IoTestProject From 3ec94f40bbbc5388599a08e5aa1662f95f8d72fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 08:35:51 +0700 Subject: [PATCH 04/34] Repin ARIEC61850 P1.2 baseline --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 43765e7a..ec66ac02 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "d041a1e05c2082f966b1e06a977b2f67261fea02", - "sourcePullRequest": 60, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Production baseline includes engine-owned DataSet semantic binding plus design/live reconciliation and exact targeted MMS probe APIs." + "commit": "f362cfcdce36ffc7ade933c16cf06da6fe3b4921", + "sourcePullRequest": 62, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. P1.2 baseline adds conservative alternate MMS probing with canonical/effective reference evidence and probe-attempt history while keeping protocol semantics engine-owned." } From 451b8984a8bb192dd5736f9a6be25d1ff424e993 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 08:36:10 +0700 Subject: [PATCH 05/34] Present ARIEC P1.2 reconciliation evidence --- .../IoTestReconciliationPresentation.cs | 88 +++++++++++++------ 1 file changed, 61 insertions(+), 27 deletions(-) diff --git a/Services/IoTesting/IoTestReconciliationPresentation.cs b/Services/IoTesting/IoTestReconciliationPresentation.cs index 15557927..f87bc354 100644 --- a/Services/IoTesting/IoTestReconciliationPresentation.cs +++ b/Services/IoTesting/IoTestReconciliationPresentation.cs @@ -20,6 +20,7 @@ public static IoTestReconciliationPresentationResult FromEnginePoint( Iec61850DesignLiveStatus.Exact => IoTestLiveBindingState.BoundExact, Iec61850DesignLiveStatus.Compatible => IoTestLiveBindingState.BoundNormalized, Iec61850DesignLiveStatus.RecoveredByProbe => IoTestLiveBindingState.BoundExact, + Iec61850DesignLiveStatus.RecoveredByAlternateProbe => IoTestLiveBindingState.BoundNormalized, // SignalNotFound is deliberately reserved for an engine-confirmed Absent verdict. Iec61850DesignLiveStatus.Absent => IoTestLiveBindingState.SignalNotFound, _ => IoTestLiveBindingState.NotEvaluated @@ -27,49 +28,82 @@ public static IoTestReconciliationPresentationResult FromEnginePoint( var statusText = point.Status switch { - Iec61850DesignLiveStatus.Exact => "ARIEC verified exact design/live binding", - Iec61850DesignLiveStatus.Compatible => "ARIEC verified compatible design/live binding", - Iec61850DesignLiveStatus.RecoveredByProbe => "ARIEC verified by exact MMS probe", - Iec61850DesignLiveStatus.DesignOnly => "ARIEC design-only · exact verification required", - Iec61850DesignLiveStatus.LiveOnly => "ARIEC live-only point", - Iec61850DesignLiveStatus.FunctionalConstraintMismatch => "ARIEC functional-constraint mismatch", - Iec61850DesignLiveStatus.TypeMismatch => "ARIEC type mismatch", - Iec61850DesignLiveStatus.Ambiguous => "ARIEC reconciliation ambiguous", - Iec61850DesignLiveStatus.InvalidTarget => "ARIEC exact target invalid", - Iec61850DesignLiveStatus.Unreadable => "ARIEC exact target unreadable", - Iec61850DesignLiveStatus.Absent => "ARIEC confirmed signal absent", - Iec61850DesignLiveStatus.TransportFailure => "ARIEC probe transport failure", - Iec61850DesignLiveStatus.UnresolvedDesign => "ARIEC design target unresolved", - _ => $"ARIEC {point.Status}" + Iec61850DesignLiveStatus.Exact => "ARIEC status: Exact", + Iec61850DesignLiveStatus.Compatible => "ARIEC status: Compatible", + Iec61850DesignLiveStatus.RecoveredByProbe => "ARIEC status: RecoveredByProbe", + Iec61850DesignLiveStatus.RecoveredByAlternateProbe => "ARIEC status: RecoveredByAlternateProbe", + Iec61850DesignLiveStatus.DesignOnly => "ARIEC status: DesignOnly · exact verification required", + Iec61850DesignLiveStatus.LiveOnly => "ARIEC status: LiveOnly", + Iec61850DesignLiveStatus.FunctionalConstraintMismatch => "ARIEC status: FunctionalConstraintMismatch", + Iec61850DesignLiveStatus.TypeMismatch => "ARIEC status: TypeMismatch", + Iec61850DesignLiveStatus.Ambiguous => "ARIEC status: Ambiguous", + Iec61850DesignLiveStatus.InvalidTarget => "ARIEC status: InvalidTarget", + Iec61850DesignLiveStatus.Unreadable => "ARIEC status: Unreadable", + Iec61850DesignLiveStatus.Absent => "ARIEC status: Absent · protocol-confirmed signal absence", + Iec61850DesignLiveStatus.TransportFailure => "ARIEC status: TransportFailure", + Iec61850DesignLiveStatus.UnresolvedDesign => "ARIEC status: UnresolvedDesign", + _ => $"ARIEC status: {point.Status}" }; var evidence = new List { statusText }; + + var canonical = FirstNonEmpty(point.CanonicalMmsReference, point.MmsReference); + var effective = FirstNonEmpty( + point.EffectiveMmsReference, + point.ObservedMmsReference, + point.ObservedReference, + canonical); + + if (!string.IsNullOrWhiteSpace(canonical)) + evidence.Add($"Canonical: {canonical}"); + if (!string.IsNullOrWhiteSpace(effective)) + evidence.Add($"Effective: {effective}"); + foreach (var item in point.Evidence.Where(item => !string.IsNullOrWhiteSpace(item))) evidence.Add(item.Trim()); if (point.Probe != null) + AppendProbeEvidence(evidence, "Final probe", point.Probe); + + for (var i = 0; i < point.ProbeAttempts.Count; i++) { - var probe = point.Probe; - evidence.Add($"Exact probe: {probe.Status}"); - if (!string.IsNullOrWhiteSpace(probe.MmsReference)) - evidence.Add($"Target: {probe.MmsReference}"); - if (!string.IsNullOrWhiteSpace(probe.Message)) - evidence.Add(probe.Message.Trim()); - if (!string.IsNullOrWhiteSpace(probe.ValueSummary)) - evidence.Add($"Value: {probe.ValueSummary}"); - if (probe.FailureCode.HasValue) - evidence.Add($"Engine failure code: {probe.FailureCode.Value}"); + var attempt = point.ProbeAttempts[i]; + var kind = attempt.IsCanonical + ? "canonical" + : attempt.AlternateStrategy.HasValue + ? $"alternate/{attempt.AlternateStrategy.Value}" + : "alternate"; + evidence.Add($"Probe attempt {i + 1}: {kind}"); + if (!string.IsNullOrWhiteSpace(attempt.Explanation)) + evidence.Add(attempt.Explanation.Trim()); + AppendProbeEvidence(evidence, $"Attempt {i + 1}", attempt.Probe); } return new IoTestReconciliationPresentationResult( state, string.Join(" · ", evidence.Distinct(StringComparer.OrdinalIgnoreCase)), - FirstNonEmpty(point.ObservedReference, point.Reference), + effective, point.Status == Iec61850DesignLiveStatus.Absent); } - private static string FirstNonEmpty(string first, string second) - => string.IsNullOrWhiteSpace(first) ? second : first; + private static void AppendProbeEvidence( + ICollection evidence, + string label, + Iec61850ExactProbeEvidence probe) + { + evidence.Add($"{label}: {probe.Status}"); + if (!string.IsNullOrWhiteSpace(probe.MmsReference)) + evidence.Add($"{label} target: {probe.MmsReference}"); + if (!string.IsNullOrWhiteSpace(probe.Message)) + evidence.Add(probe.Message.Trim()); + if (!string.IsNullOrWhiteSpace(probe.ValueSummary)) + evidence.Add($"{label} value: {probe.ValueSummary}"); + if (probe.FailureCode.HasValue) + evidence.Add($"{label} engine failure code: {probe.FailureCode.Value}"); + } + + private static string FirstNonEmpty(params string[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; } public sealed record IoTestReconciliationPresentationResult( From 7390bc277c28dd0e87d9c39d39c4c32904eaae7e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 08:37:35 +0700 Subject: [PATCH 06/34] Add async cached ARIEC reconciliation lifecycle --- .../IoTesting/IoTestReconciliationCache.cs | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 Services/IoTesting/IoTestReconciliationCache.cs diff --git a/Services/IoTesting/IoTestReconciliationCache.cs b/Services/IoTesting/IoTestReconciliationCache.cs new file mode 100644 index 00000000..39f48a22 --- /dev/null +++ b/Services/IoTesting/IoTestReconciliationCache.cs @@ -0,0 +1,160 @@ +using System.Collections.Concurrent; +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services.IoTesting; + +/// +/// Owns the ARSAS-side lifecycle of engine reconciliation documents. +/// +/// Reconciliation production is asynchronous and cancellable; synchronous FAT/UI binding +/// only reads the latest document for the exact design/live model object pair. This keeps +/// network-capable reconciliation out of UI binding paths when ARIEC later exposes the +/// connected P1.3 facade. +/// +/// The current P1.2 integration deliberately passes probe:null. ARIEC therefore owns all +/// reconciliation semantics while discovery misses remain DesignOnly instead of being +/// promoted to protocol absence by ARSAS. +/// +public static class IoTestReconciliationCache +{ + private static readonly ConcurrentDictionary Entries = new(); + + public static async Task RefreshAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + + var designModel = device.SclWorkspace?.DesignModel; + var liveModel = device.LiveDiscoveryModel; + if (designModel == null || liveModel == null) + { + Entries.TryRemove(device, out _); + return; + } + + try + { + var document = await Iec61850DesignLiveReconciler.ReconcileAsync( + designModel, + liveModel, + probe: null, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + // Do not publish a document for a model generation that changed while the + // reconciliation task was running. The next refresh will reconcile the new pair. + if (!ReferenceEquals(device.SclWorkspace?.DesignModel, designModel) || + !ReferenceEquals(device.LiveDiscoveryModel, liveModel)) + { + return; + } + + Entries[device] = new CacheEntry( + designModel, + liveModel, + document, + string.Empty, + DateTimeOffset.UtcNow); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + if (ReferenceEquals(device.SclWorkspace?.DesignModel, designModel) && + ReferenceEquals(device.LiveDiscoveryModel, liveModel)) + { + Entries[device] = new CacheEntry( + designModel, + liveModel, + null, + $"ARIEC reconciliation could not be produced asynchronously: {ex.GetType().Name}: {ex.Message}", + DateTimeOffset.UtcNow); + } + } + } + + /// + /// Refreshes IEDs sequentially by design. P1.2 does not perform network reads here, + /// and the sequential contract also prevents a future connected reconciler from turning + /// a project-level refresh into an uncontrolled parallel probe storm. + /// + public static async Task RefreshAsync( + IEnumerable devices, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(devices); + + foreach (var device in devices.Distinct()) + { + cancellationToken.ThrowIfCancellationRequested(); + await RefreshAsync(device, cancellationToken).ConfigureAwait(false); + } + } + + public static IoTestReconciliationCacheSnapshot Get(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + var designModel = device.SclWorkspace?.DesignModel; + if (designModel == null) + { + return new IoTestReconciliationCacheSnapshot( + null, + "No SCL design model is attached to this ARSAS IED workspace.", + null, + false); + } + + var liveModel = device.LiveDiscoveryModel; + if (liveModel == null) + { + return new IoTestReconciliationCacheSnapshot( + null, + "No authoritative ARIEC live discovery model is available yet.", + null, + false); + } + + if (!Entries.TryGetValue(device, out var entry) || + !ReferenceEquals(entry.DesignModel, designModel) || + !ReferenceEquals(entry.LiveModel, liveModel)) + { + return new IoTestReconciliationCacheSnapshot( + null, + "ARIEC reconciliation cache is not ready for the current design/live model generation.", + null, + false); + } + + return new IoTestReconciliationCacheSnapshot( + entry.Document, + entry.FailureReason, + entry.ProducedAtUtc, + true); + } + + public static void Invalidate(Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + Entries.TryRemove(device, out _); + } + + private sealed record CacheEntry( + object DesignModel, + object LiveModel, + Iec61850DesignLiveReconciliationDocument? Document, + string FailureReason, + DateTimeOffset ProducedAtUtc); +} + +public sealed record IoTestReconciliationCacheSnapshot( + Iec61850DesignLiveReconciliationDocument? Document, + string FailureReason, + DateTimeOffset? ProducedAtUtc, + bool IsCurrent); From 316caad671d484016563801dd441a8a58afbc8fa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 08:38:14 +0700 Subject: [PATCH 07/34] Consume cached reconciliation in FAT binding --- .../IoTesting/IoTestLiveBindingService.cs | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/Services/IoTesting/IoTestLiveBindingService.cs b/Services/IoTesting/IoTestLiveBindingService.cs index 42e4d7b7..96193280 100644 --- a/Services/IoTesting/IoTestLiveBindingService.cs +++ b/Services/IoTesting/IoTestLiveBindingService.cs @@ -73,6 +73,9 @@ private static IoTestLiveBindingSummary BindPlans( device.IsConnected, device.IsMonitoring); + // Binding is intentionally cache-only. Reconciliation production is async and + // happens in the FAT/session lifecycle; this path must never perform MMS reads + // or block the UI while an IED is slow. var reconciliation = BuildEngineReconciliation(device); foreach (var point in iedPlan.TestPoints) { @@ -112,30 +115,8 @@ private static IoTestLiveBindingSummary BindPlans( private static EngineReconciliationContext BuildEngineReconciliation(Iec61850MonitorDevice device) { - if (device.SclWorkspace == null) - return new EngineReconciliationContext(null, "No SCL design model is attached to this ARSAS IED workspace."); - if (device.LiveDiscoveryModel == null) - return new EngineReconciliationContext(null, "No authoritative ARIEC live discovery model is available yet."); - - try - { - // Reconciliation is engine-owned. No app-side protocol/reference inference is - // added here. A null probe deliberately means DesignOnly remains DesignOnly; - // ARSAS must never turn a discovery miss into confirmed absence. - var document = Iec61850DesignLiveReconciler.ReconcileAsync( - device.SclWorkspace.DesignModel, - device.LiveDiscoveryModel, - probe: null) - .GetAwaiter() - .GetResult(); - return new EngineReconciliationContext(document, string.Empty); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - return new EngineReconciliationContext( - null, - $"ARIEC reconciliation could not be produced: {ex.GetType().Name}: {ex.Message}"); - } + var cached = IoTestReconciliationCache.Get(device); + return new EngineReconciliationContext(cached.Document, cached.FailureReason); } private static PointBinding BindPoint( @@ -317,6 +298,10 @@ private static IEnumerable EngineReferences(Iec61850DesignLivePointRecon yield return point.Reference; if (!string.IsNullOrWhiteSpace(point.MmsReference)) yield return point.MmsReference; + if (!string.IsNullOrWhiteSpace(point.CanonicalMmsReference)) + yield return point.CanonicalMmsReference; + if (!string.IsNullOrWhiteSpace(point.EffectiveMmsReference)) + yield return point.EffectiveMmsReference; if (!string.IsNullOrWhiteSpace(point.ObservedReference)) yield return point.ObservedReference; if (!string.IsNullOrWhiteSpace(point.ObservedMmsReference)) From 5e5b2428d8aad2f8832b4a21c5ed56461ad71500 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 08:39:12 +0700 Subject: [PATCH 08/34] Refresh ARIEC reconciliation asynchronously after FAT discovery --- MainWindow.IoTesting.AutoConnect.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index c6aab0b1..9202fb9e 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -237,6 +237,12 @@ void ReportProgress(string message) device.RefreshComputed(); RaiseWorkspaceCounts(); + // Reconciliation belongs to the connection/discovery lifecycle, not to the + // synchronous UI binding loop. P1.2 remains probe:null inside the cache; + // P1.3 can later replace that producer with the engine-owned connected facade. + ReportProgress("Reconciling SCL design with authoritative live model"); + await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); + _ioTestLiveBindingService.BindIed(ied, Devices); var allRequestedPointsLive = requestedPoints.All(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); From 578083a47b425dc482e8092b9fe6bd0f29c51bd2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 08:40:08 +0700 Subject: [PATCH 09/34] Cover P1.2 status evidence and async reconciliation cache --- .../IoTestLiveBindingServiceTests.cs | 155 +++++++++++++++++- 1 file changed, 149 insertions(+), 6 deletions(-) diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs index 4aa43151..1d2ab75a 100644 --- a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs @@ -1,4 +1,5 @@ using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; using ArIED61850Tester.Models; using ArIED61850Tester.Models.IoTesting; using ArIED61850Tester.Services.IoTesting; @@ -176,18 +177,28 @@ public void MissingWorkspaceDevice_IsExplicitlyReported() public void EngineAbsent_IsTheOnlyPresentationThatMapsToSignalNotFound() { var absent = EnginePoint(Iec61850DesignLiveStatus.Absent); - var designOnly = EnginePoint(Iec61850DesignLiveStatus.DesignOnly); - var unreadable = EnginePoint(Iec61850DesignLiveStatus.Unreadable); - var transportFailure = EnginePoint(Iec61850DesignLiveStatus.TransportFailure); Assert.Equal( IoTestLiveBindingState.SignalNotFound, IoTestReconciliationPresentation.FromEnginePoint(absent).State); Assert.True(IoTestReconciliationPresentation.FromEnginePoint(absent).IsConfirmedAbsent); - foreach (var point in new[] { designOnly, unreadable, transportFailure }) + var diagnosticStatuses = new[] { - var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + Iec61850DesignLiveStatus.DesignOnly, + Iec61850DesignLiveStatus.InvalidTarget, + Iec61850DesignLiveStatus.Unreadable, + Iec61850DesignLiveStatus.TransportFailure, + Iec61850DesignLiveStatus.FunctionalConstraintMismatch, + Iec61850DesignLiveStatus.TypeMismatch, + Iec61850DesignLiveStatus.Ambiguous, + Iec61850DesignLiveStatus.UnresolvedDesign, + Iec61850DesignLiveStatus.LiveOnly + }; + + foreach (var status in diagnosticStatuses) + { + var presentation = IoTestReconciliationPresentation.FromEnginePoint(EnginePoint(status)); Assert.Equal(IoTestLiveBindingState.NotEvaluated, presentation.State); Assert.False(presentation.IsConfirmedAbsent); } @@ -200,6 +211,8 @@ public void EngineRecoveredByProbe_IsPresentedAsVerifiedBinding() { Reference = "IEDLD/GGIO1.Test.stVal", MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + CanonicalMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + EffectiveMmsReference = "IEDLD/GGIO1$ST$Test$stVal", FunctionalConstraint = "ST", Status = Iec61850DesignLiveStatus.RecoveredByProbe, Probe = new Iec61850ExactProbeEvidence @@ -217,15 +230,118 @@ public void EngineRecoveredByProbe_IsPresentedAsVerifiedBinding() Assert.Equal(IoTestLiveBindingState.BoundExact, presentation.State); Assert.False(presentation.IsConfirmedAbsent); - Assert.Contains("exact MMS probe", presentation.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("RecoveredByProbe", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("Canonical:", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("Effective:", presentation.Reason, StringComparison.Ordinal); Assert.Contains("Readable", presentation.Reason, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void EngineRecoveredByAlternateProbe_PresentsCanonicalEffectiveAndAttempts() + { + const string canonical = "IEDLD/MMXU1$MX$TotW$mag$f"; + const string effective = "IEDLD/MMXU1$MX$TotW$instMag$f"; + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/MMXU1.TotW.mag.f", + MmsReference = canonical, + CanonicalMmsReference = canonical, + EffectiveMmsReference = effective, + FunctionalConstraint = "MX", + Status = Iec61850DesignLiveStatus.RecoveredByAlternateProbe, + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Readable, + MmsReference = effective, + FunctionalConstraint = "MX", + ValueSummary = "123.4", + Message = "Alternate exact read succeeded." + }, + ProbeAttempts = new Iec61850ProbeAttemptEvidence[] + { + new() + { + IsCanonical = true, + Explanation = "Canonical MMS target.", + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Absent, + MmsReference = canonical, + FunctionalConstraint = "MX", + FailureCode = 4, + Message = "object-undefined" + } + }, + new() + { + IsCanonical = false, + AlternateStrategy = Iec61850AlternateReferenceStrategyKind.MagnitudeInstantaneousSibling, + Explanation = "IEC 61850 measurement sibling mag.f -> instMag.f.", + Probe = new Iec61850ExactProbeEvidence + { + Status = Iec61850ExactProbeStatus.Readable, + MmsReference = effective, + FunctionalConstraint = "MX", + ValueSummary = "123.4", + Message = "Alternate exact read succeeded." + } + } + }, + Evidence = new[] { "Recovered by bounded engine alternate probing." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.BoundNormalized, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Equal(effective, presentation.Reference); + Assert.Contains("RecoveredByAlternateProbe", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Canonical: {canonical}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Effective: {effective}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("MagnitudeInstantaneousSibling", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("Probe attempt 2", presentation.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("engine failure code: 4", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CacheCold_BindingDoesNotProduceReconciliationSynchronously() + { + var project = Project("AA1C1F03R4ADD/GGIO6.Unknown.stVal"); + var device = DeviceWithModels(); + IoTestReconciliationCache.Invalidate(device); + + var before = IoTestReconciliationCache.Get(device); + _binding.Bind(project, new[] { device }); + var after = IoTestReconciliationCache.Get(device); + + Assert.False(before.IsCurrent); + Assert.False(after.IsCurrent); + Assert.Null(after.Document); + Assert.Contains("cache is not ready", after.FailureReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AsyncRefresh_PublishesReconciliationForExactModelGeneration() + { + var device = DeviceWithModels(); + IoTestReconciliationCache.Invalidate(device); + + await IoTestReconciliationCache.RefreshAsync(device); + var cached = IoTestReconciliationCache.Get(device); + + Assert.True(cached.IsCurrent); + Assert.NotNull(cached.Document); + Assert.NotNull(cached.ProducedAtUtc); + Assert.Equal(string.Empty, cached.FailureReason); + } + private static Iec61850DesignLivePointReconciliation EnginePoint(Iec61850DesignLiveStatus status) => new() { Reference = "IEDLD/GGIO1.Test.stVal", MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + CanonicalMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + EffectiveMmsReference = "IEDLD/GGIO1$ST$Test$stVal", FunctionalConstraint = "ST", Status = status, Evidence = new[] { $"Engine status: {status}" } @@ -276,4 +392,31 @@ private static IoTestProject Project(string reference) Port = 102, Status = "Ready" }; + + private static Iec61850MonitorDevice DeviceWithModels() + { + var designModel = new LiveIedModelDiscoveryDocument + { + IedName = "AA1C1F03R4" + }; + var liveModel = new LiveIedModelDiscoveryDocument + { + IedName = "AA1C1F03R4" + }; + + return new Iec61850MonitorDevice + { + Name = "AA1C1F03R4", + SclIedName = "AA1C1F03R4", + IpAddress = "192.168.81.70", + Port = 102, + Status = "Ready", + SclWorkspace = new SclIedWorkspace + { + IedName = "AA1C1F03R4", + DesignModel = designModel + }, + LiveDiscoveryModel = liveModel + }; + } } From 896ab2c15a090f723bf342e9379d2870166ab420 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 08:41:59 +0700 Subject: [PATCH 10/34] Order FAT reconciliation immediately after discovery --- MainWindow.IoTesting.AutoConnect.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/MainWindow.IoTesting.AutoConnect.cs b/MainWindow.IoTesting.AutoConnect.cs index 9202fb9e..008c6612 100644 --- a/MainWindow.IoTesting.AutoConnect.cs +++ b/MainWindow.IoTesting.AutoConnect.cs @@ -136,6 +136,12 @@ void ReportProgress(string message) ReportProgress($"{ied.IedName} association ready · reusing the loaded model"); } + // Reconciliation belongs to the connection/discovery lifecycle, before local + // FAT row selection. P1.2 remains probe:null inside the cache; P1.3 can later + // replace only that producer with the engine-owned connected facade. + ReportProgress("Reconciling SCL design with authoritative live model"); + await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); + // Never let a runtime anchor from an earlier model silently decide a fresh // FAT preparation. Re-prove every requested row against the current model; // successful smart matches are anchored again immediately below. @@ -175,6 +181,9 @@ void ReportProgress(string message) } usedSavedModel = false; + ReportProgress("Reconciling refreshed live model with SCL design"); + await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); + foreach (var point in requestedPoints) { point.ApplyLiveBinding( @@ -237,12 +246,6 @@ void ReportProgress(string message) device.RefreshComputed(); RaiseWorkspaceCounts(); - // Reconciliation belongs to the connection/discovery lifecycle, not to the - // synchronous UI binding loop. P1.2 remains probe:null inside the cache; - // P1.3 can later replace that producer with the engine-owned connected facade. - ReportProgress("Reconciling SCL design with authoritative live model"); - await IoTestReconciliationCache.RefreshAsync(device, _applicationCancellation.Token); - _ioTestLiveBindingService.BindIed(ied, Devices); var allRequestedPointsLive = requestedPoints.All(point => point.LiveBindingState == IoTestLiveBindingState.LivePointReady); From f3c3fb9c9513b9cb8563f3fe027438184595b0e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:12:33 +0700 Subject: [PATCH 11/34] Repin ARSAS to ARIEC P1.3 connected reconciliation --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index ec66ac02..279f94c7 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "f362cfcdce36ffc7ade933c16cf06da6fe3b4921", - "sourcePullRequest": 62, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. P1.2 baseline adds conservative alternate MMS probing with canonical/effective reference evidence and probe-attempt history while keeping protocol semantics engine-owned." + "commit": "0f1db91a7c96dc99bfb08f0246817fd61030b2ad", + "sourcePullRequest": 63, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. P1.3 baseline adds the engine-owned connected reconciliation facade, alternate-discovery recovery, centralized probe classification, and bounded probe-budget evidence while keeping IEC 61850 verification semantics out of ARSAS." } From 5ff420d3c9a9b8f2ca62b4301a95c27068c3ed98 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:12:49 +0700 Subject: [PATCH 12/34] Consume ARIEC P1.3 reconciliation presentation status --- Services/IoTesting/IoTestReconciliationPresentation.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Services/IoTesting/IoTestReconciliationPresentation.cs b/Services/IoTesting/IoTestReconciliationPresentation.cs index f87bc354..6808e960 100644 --- a/Services/IoTesting/IoTestReconciliationPresentation.cs +++ b/Services/IoTesting/IoTestReconciliationPresentation.cs @@ -21,6 +21,7 @@ public static IoTestReconciliationPresentationResult FromEnginePoint( Iec61850DesignLiveStatus.Compatible => IoTestLiveBindingState.BoundNormalized, Iec61850DesignLiveStatus.RecoveredByProbe => IoTestLiveBindingState.BoundExact, Iec61850DesignLiveStatus.RecoveredByAlternateProbe => IoTestLiveBindingState.BoundNormalized, + Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery => IoTestLiveBindingState.BoundNormalized, // SignalNotFound is deliberately reserved for an engine-confirmed Absent verdict. Iec61850DesignLiveStatus.Absent => IoTestLiveBindingState.SignalNotFound, _ => IoTestLiveBindingState.NotEvaluated @@ -32,7 +33,10 @@ public static IoTestReconciliationPresentationResult FromEnginePoint( Iec61850DesignLiveStatus.Compatible => "ARIEC status: Compatible", Iec61850DesignLiveStatus.RecoveredByProbe => "ARIEC status: RecoveredByProbe", Iec61850DesignLiveStatus.RecoveredByAlternateProbe => "ARIEC status: RecoveredByAlternateProbe", - Iec61850DesignLiveStatus.DesignOnly => "ARIEC status: DesignOnly · exact verification required", + Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery => "ARIEC status: RecoveredByAlternateDiscovery", + Iec61850DesignLiveStatus.DesignOnly => point.ProbeDeferredByBudget + ? "ARIEC status: DesignOnly · verification deferred by probe budget" + : "ARIEC status: DesignOnly · exact verification required", Iec61850DesignLiveStatus.LiveOnly => "ARIEC status: LiveOnly", Iec61850DesignLiveStatus.FunctionalConstraintMismatch => "ARIEC status: FunctionalConstraintMismatch", Iec61850DesignLiveStatus.TypeMismatch => "ARIEC status: TypeMismatch", @@ -58,6 +62,10 @@ public static IoTestReconciliationPresentationResult FromEnginePoint( evidence.Add($"Canonical: {canonical}"); if (!string.IsNullOrWhiteSpace(effective)) evidence.Add($"Effective: {effective}"); + if (point.AlternateStrategy.HasValue) + evidence.Add($"Alternate strategy: {point.AlternateStrategy.Value}"); + if (point.ProbeDeferredByBudget) + evidence.Add("Verification deferred by ARIEC probe budget; no absence conclusion was made"); foreach (var item in point.Evidence.Where(item => !string.IsNullOrWhiteSpace(item))) evidence.Add(item.Trim()); From 3f8c7d51dbcd0c1461aed40a59e1a8f412f8bd43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:13:49 +0700 Subject: [PATCH 13/34] Test ARIEC P1.3 alternate discovery and probe budget presentation --- .../IoTestLiveBindingServiceTests.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs index 1d2ab75a..0a30ef90 100644 --- a/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs +++ b/tests/ARSAS.Tests/IoTestLiveBindingServiceTests.cs @@ -303,6 +303,58 @@ public void EngineRecoveredByAlternateProbe_PresentsCanonicalEffectiveAndAttempt Assert.Contains("engine failure code: 4", presentation.Reason, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void EngineRecoveredByAlternateDiscovery_IsVerifiedWithoutProbe() + { + const string canonical = "IEDLD/MMXU1$MX$TotW$mag$f"; + const string effective = "IEDLD/MMXU1$MX$TotW$instMag$f"; + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/MMXU1.TotW.mag.f", + MmsReference = canonical, + CanonicalMmsReference = canonical, + EffectiveMmsReference = effective, + ObservedMmsReference = effective, + FunctionalConstraint = "MX", + AlternateStrategy = Iec61850AlternateReferenceStrategyKind.MagnitudeInstantaneousSibling, + Status = Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery, + Evidence = new[] { "Recovered from native live discovery before probing." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.BoundNormalized, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Equal(effective, presentation.Reference); + Assert.Contains("RecoveredByAlternateDiscovery", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Canonical: {canonical}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains($"Effective: {effective}", presentation.Reason, StringComparison.Ordinal); + Assert.Contains("MagnitudeInstantaneousSibling", presentation.Reason, StringComparison.Ordinal); + Assert.DoesNotContain("Probe attempt", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ProbeBudgetDeferred_RemainsNotVerifiedAndNeverMissing() + { + var point = new Iec61850DesignLivePointReconciliation + { + Reference = "IEDLD/GGIO1.Test.stVal", + MmsReference = "IEDLD/GGIO1$ST$Test$stVal", + CanonicalMmsReference = "IEDLD/GGIO1$ST$Test$stVal", + FunctionalConstraint = "ST", + Status = Iec61850DesignLiveStatus.DesignOnly, + ProbeDeferredByBudget = true, + Evidence = new[] { "Exact verification was deferred because the bounded probe budget was exhausted." } + }; + + var presentation = IoTestReconciliationPresentation.FromEnginePoint(point); + + Assert.Equal(IoTestLiveBindingState.NotEvaluated, presentation.State); + Assert.False(presentation.IsConfirmedAbsent); + Assert.Contains("probe budget", presentation.Reason, StringComparison.OrdinalIgnoreCase); + Assert.Contains("no absence conclusion", presentation.Reason, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void CacheCold_BindingDoesNotProduceReconciliationSynchronously() { From 77c5e17e546f42ca6c8ae737e6cba5ca5f05d228 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:17:51 +0700 Subject: [PATCH 14/34] Inject high-level reconciliation producer into FAT cache --- .../IoTesting/IoTestReconciliationCache.cs | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/Services/IoTesting/IoTestReconciliationCache.cs b/Services/IoTesting/IoTestReconciliationCache.cs index 39f48a22..5170c837 100644 --- a/Services/IoTesting/IoTestReconciliationCache.cs +++ b/Services/IoTesting/IoTestReconciliationCache.cs @@ -8,23 +8,41 @@ namespace ArIED61850Tester.Services.IoTesting; /// Owns the ARSAS-side lifecycle of engine reconciliation documents. /// /// Reconciliation production is asynchronous and cancellable; synchronous FAT/UI binding -/// only reads the latest document for the exact design/live model object pair. This keeps -/// network-capable reconciliation out of UI binding paths when ARIEC later exposes the -/// connected P1.3 facade. -/// -/// The current P1.2 integration deliberately passes probe:null. ARIEC therefore owns all -/// reconciliation semantics while discovery misses remain DesignOnly instead of being -/// promoted to protocol absence by ARSAS. +/// only reads the latest document for the exact design/live model object pair. Network-capable +/// reconciliation is injected as a high-level producer, so FAT/UI code never owns an MMS +/// session, an exact-read probe, or protocol failure classification. /// public static class IoTestReconciliationCache { private static readonly ConcurrentDictionary Entries = new(); + /// + /// Offline/model-only producer retained for deterministic tests and non-connected model + /// inspection. Production FAT connection flows should supply the session-bound ARIEC + /// connected producer overload below. + /// + public static Task RefreshAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) + => RefreshAsync( + device, + static (design, live, token) => Iec61850DesignLiveReconciler.ReconcileAsync( + design, + live, + probe: null, + cancellationToken: token), + cancellationToken); + public static async Task RefreshAsync( Iec61850MonitorDevice device, + Func> producer, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(producer); var designModel = device.SclWorkspace?.DesignModel; var liveModel = device.LiveDiscoveryModel; @@ -36,13 +54,7 @@ public static async Task RefreshAsync( try { - var document = await Iec61850DesignLiveReconciler.ReconcileAsync( - designModel, - liveModel, - probe: null, - cancellationToken: cancellationToken) - .ConfigureAwait(false); - + var document = await producer(designModel, liveModel, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); // Do not publish a document for a model generation that changed while the @@ -80,20 +92,29 @@ public static async Task RefreshAsync( } /// - /// Refreshes IEDs sequentially by design. P1.2 does not perform network reads here, - /// and the sequential contract also prevents a future connected reconciler from turning - /// a project-level refresh into an uncontrolled parallel probe storm. + /// Refreshes IEDs sequentially by design. The sequential contract prevents a project + /// refresh from turning bounded relay verification into an uncontrolled multi-IED probe storm. /// public static async Task RefreshAsync( IEnumerable devices, + Func> producer, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(devices); + ArgumentNullException.ThrowIfNull(producer); foreach (var device in devices.Distinct()) { cancellationToken.ThrowIfCancellationRequested(); - await RefreshAsync(device, cancellationToken).ConfigureAwait(false); + await RefreshAsync( + device, + (design, live, token) => producer(device, design, live, token), + cancellationToken) + .ConfigureAwait(false); } } From 67546ca3bc4c4b5f601ee9ef96cd84523b054b6a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:26:11 +0700 Subject: [PATCH 15/34] Open native client for session-owned reconciliation extension --- Services/NativeIec61850Client.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/NativeIec61850Client.cs b/Services/NativeIec61850Client.cs index b1c2d02c..ec14e7a0 100644 --- a/Services/NativeIec61850Client.cs +++ b/Services/NativeIec61850Client.cs @@ -17,7 +17,7 @@ namespace ArIED61850Tester.Services; /// /// Native IEC 61850 MMS client backed by the ARIEC61850 engine. /// -public sealed class NativeIec61850Client : IIec61850Client, IIec61850ControlClient +public sealed partial class NativeIec61850Client : IIec61850Client, IIec61850ControlClient { private readonly ArMms.MmsClientSession _session = new(); private readonly SemaphoreSlim _mmsIoGate = new(1, 1); From e839d747e255e94367680cc377a50d958736a007 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:28:41 +0700 Subject: [PATCH 16/34] Bridge ARIEC connected reconciliation through native session owner --- .../NativeIec61850Client.Reconciliation.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 Services/NativeIec61850Client.Reconciliation.cs diff --git a/Services/NativeIec61850Client.Reconciliation.cs b/Services/NativeIec61850Client.Reconciliation.cs new file mode 100644 index 00000000..9412f63d --- /dev/null +++ b/Services/NativeIec61850Client.Reconciliation.cs @@ -0,0 +1,125 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Services; + +/// +/// Session-owner bridge for ARIEC61850 connected reconciliation. +/// The raw MMS session never leaves NativeIec61850Client; ARSAS callers receive only +/// the engine reconciliation document and never construct probes or classify MMS failures. +/// +public sealed partial class NativeIec61850Client +{ + private static readonly object ReconciliationOwnerRegistryGate = new(); + private static readonly List> ReconciliationOwners = new(); + private static long _nextReconciliationOwnerSequence; + private readonly long _reconciliationOwnerSequence; + + public NativeIec61850Client() + { + _reconciliationOwnerSequence = Interlocked.Increment(ref _nextReconciliationOwnerSequence); + lock (ReconciliationOwnerRegistryGate) + { + PruneReconciliationOwnersLocked(); + ReconciliationOwners.Add(new WeakReference(this)); + } + } + + /// + /// Runs the engine-owned connected reconciliation pipeline against this client's + /// already-owned MMS association. No second association is created. + /// + public Task ReconcileDesignLiveAsync( + LiveIedModelDiscoveryDocument designModel, + LiveIedModelDiscoveryDocument liveModel, + Iec61850DesignLiveReconciliationOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(designModel); + ArgumentNullException.ThrowIfNull(liveModel); + + var service = new Iec61850ConnectedReconciliationService(_session); + + // When the association is active, serialize reconciliation reads with the other + // native MMS work owned by this client. When it is already down, call the engine + // facade directly so ARIEC can return TransportFailure rather than an ARSAS-side + // lifecycle guess or an ObjectDisposedException from the client's I/O gate. + return _session.IsMmsInitiated + ? RunMmsOperationAsync( + () => service.ReconcileAsync(designModel, liveModel, options, cancellationToken), + cancellationToken) + : service.ReconcileAsync(designModel, liveModel, options, cancellationToken); + } + + /// + /// Resolves the NativeIec61850Client that already owns the requested endpoint and + /// delegates to its ARIEC connected facade. Multiple simultaneously active owners are + /// rejected instead of guessing which association belongs to the FAT workflow. + /// + public static Task ReconcileConnectedAsync( + string ipAddress, + int port, + LiveIedModelDiscoveryDocument designModel, + LiveIedModelDiscoveryDocument liveModel, + Iec61850DesignLiveReconciliationOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ipAddress); + ArgumentNullException.ThrowIfNull(designModel); + ArgumentNullException.ThrowIfNull(liveModel); + + var owner = ResolveReconciliationOwner(ipAddress, port); + return owner.ReconcileDesignLiveAsync( + designModel, + liveModel, + options, + cancellationToken); + } + + private static NativeIec61850Client ResolveReconciliationOwner(string ipAddress, int port) + { + var host = ipAddress.Trim(); + var normalizedPort = port <= 0 ? 102 : port; + + lock (ReconciliationOwnerRegistryGate) + { + PruneReconciliationOwnersLocked(); + var matches = ReconciliationOwners + .Select(reference => reference.TryGetTarget(out var client) ? client : null) + .Where(client => client != null && + client._host.Equals(host, StringComparison.OrdinalIgnoreCase) && + client._port == normalizedPort) + .Cast() + .OrderByDescending(client => client._reconciliationOwnerSequence) + .ToList(); + + var active = matches.Where(client => client.IsConnected).ToList(); + if (active.Count == 1) + return active[0]; + + if (active.Count > 1) + { + throw new InvalidOperationException( + $"More than one active native MMS association owns {host}:{normalizedPort}; connected reconciliation was withheld rather than guessing the FAT session."); + } + + if (matches.Count > 0) + { + // Preserve the newest session owner after a disconnect so the ARIEC + // connected facade can classify the session state as TransportFailure. + return matches[0]; + } + } + + throw new InvalidOperationException( + $"No native MMS session owner is registered for {host}:{normalizedPort}; connected reconciliation cannot run until the IED session exists."); + } + + private static void PruneReconciliationOwnersLocked() + { + for (var index = ReconciliationOwners.Count - 1; index >= 0; index--) + { + if (!ReconciliationOwners[index].TryGetTarget(out _)) + ReconciliationOwners.RemoveAt(index); + } + } +} From 85d303da0b0f494a96380827dd6ecaab30ac7d24 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:29:10 +0700 Subject: [PATCH 17/34] Use ARIEC connected facade as FAT reconciliation producer --- .../IoTesting/IoTestReconciliationCache.cs | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Services/IoTesting/IoTestReconciliationCache.cs b/Services/IoTesting/IoTestReconciliationCache.cs index 5170c837..31060ee7 100644 --- a/Services/IoTesting/IoTestReconciliationCache.cs +++ b/Services/IoTesting/IoTestReconciliationCache.cs @@ -8,22 +8,43 @@ namespace ArIED61850Tester.Services.IoTesting; /// Owns the ARSAS-side lifecycle of engine reconciliation documents. /// /// Reconciliation production is asynchronous and cancellable; synchronous FAT/UI binding -/// only reads the latest document for the exact design/live model object pair. Network-capable -/// reconciliation is injected as a high-level producer, so FAT/UI code never owns an MMS -/// session, an exact-read probe, or protocol failure classification. +/// only reads the latest document for the exact design/live model object pair. Production +/// refreshes delegate to the native session owner, which in turn calls the ARIEC connected +/// facade. FAT/UI code never owns an MMS session, an exact-read probe, or protocol failure +/// classification. /// public static class IoTestReconciliationCache { private static readonly ConcurrentDictionary Entries = new(); /// - /// Offline/model-only producer retained for deterministic tests and non-connected model - /// inspection. Production FAT connection flows should supply the session-bound ARIEC - /// connected producer overload below. + /// Production refresh: use the already-active NativeIec61850Client association and let + /// ARIEC61850 own exact reads, alternate strategies, probe budgets, and failure verdicts. /// public static Task RefreshAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(device); + return RefreshAsync( + device, + (design, live, token) => NativeIec61850Client.ReconcileConnectedAsync( + device.IpAddress, + device.Port, + design, + live, + options: null, + cancellationToken: token), + cancellationToken); + } + + /// + /// Explicit model-only refresh for deterministic tests and offline model inspection. + /// It never produces protocol absence because no exact read probe is supplied. + /// + public static Task RefreshModelOnlyAsync( + Iec61850MonitorDevice device, + CancellationToken cancellationToken = default) => RefreshAsync( device, static (design, live, token) => Iec61850DesignLiveReconciler.ReconcileAsync( From 3851e40ab55b516a1a8dd4e1fab5307112f9a953 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:30:03 +0700 Subject: [PATCH 18/34] Expose safe native reconciliation owner presence --- Services/NativeIec61850Client.Reconciliation.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Services/NativeIec61850Client.Reconciliation.cs b/Services/NativeIec61850Client.Reconciliation.cs index 9412f63d..f2de1125 100644 --- a/Services/NativeIec61850Client.Reconciliation.cs +++ b/Services/NativeIec61850Client.Reconciliation.cs @@ -75,6 +75,23 @@ public static Task ReconcileConnectedA cancellationToken); } + internal static bool HasReconciliationOwner(string? ipAddress, int port) + { + var host = (ipAddress ?? string.Empty).Trim(); + if (host.Length == 0) + return false; + var normalizedPort = port <= 0 ? 102 : port; + + lock (ReconciliationOwnerRegistryGate) + { + PruneReconciliationOwnersLocked(); + return ReconciliationOwners.Any(reference => + reference.TryGetTarget(out var client) && + client._host.Equals(host, StringComparison.OrdinalIgnoreCase) && + client._port == normalizedPort); + } + } + private static NativeIec61850Client ResolveReconciliationOwner(string ipAddress, int port) { var host = ipAddress.Trim(); From 1fdb30994911581d2ae047dc213d493efda094a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:30:23 +0700 Subject: [PATCH 19/34] Keep offline reconciliation model-only and connected FAT session-bound --- Services/IoTesting/IoTestReconciliationCache.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Services/IoTesting/IoTestReconciliationCache.cs b/Services/IoTesting/IoTestReconciliationCache.cs index 31060ee7..7a272401 100644 --- a/Services/IoTesting/IoTestReconciliationCache.cs +++ b/Services/IoTesting/IoTestReconciliationCache.cs @@ -18,14 +18,20 @@ public static class IoTestReconciliationCache private static readonly ConcurrentDictionary Entries = new(); /// - /// Production refresh: use the already-active NativeIec61850Client association and let - /// ARIEC61850 own exact reads, alternate strategies, probe budgets, and failure verdicts. + /// Production refresh: when a native session owner exists for this endpoint, use its + /// already-active association and let ARIEC61850 own exact reads, alternate strategies, + /// probe budgets, and failure verdicts. A workspace that has never created a session may + /// still build a model-only document; that path can only remain DesignOnly, never Absent. /// public static Task RefreshAsync( Iec61850MonitorDevice device, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(device); + + if (!NativeIec61850Client.HasReconciliationOwner(device.IpAddress, device.Port)) + return RefreshModelOnlyAsync(device, cancellationToken); + return RefreshAsync( device, (design, live, token) => NativeIec61850Client.ReconcileConnectedAsync( From 7167a1d1f46bbcc209bcfb93424999c5e39e1d07 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:33:52 +0700 Subject: [PATCH 20/34] Test ARIEC connected reconciliation through native owner --- ...iveIec61850ConnectedReconciliationTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs diff --git a/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs b/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs new file mode 100644 index 00000000..e4f762d6 --- /dev/null +++ b/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs @@ -0,0 +1,153 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class NativeIec61850ConnectedReconciliationTests +{ + [Fact] + public async Task DisconnectedNativeOwner_ReturnsEngineTransportFailure_NotAbsent() + { + await using var client = new NativeIec61850Client(); + var design = BuildModel(new TestAttribute( + "IEDLD0/GGIO1.Ind1.stVal", + "IEDLD0/GGIO1$ST$Ind1$stVal", + "ST", + "BOOLEAN", + string.Empty)); + + var result = await client.ReconcileDesignLiveAsync( + design, + EmptyLive(), + new Iec61850DesignLiveReconciliationOptions + { + ProbeAllMissingDesignAttributes = true, + ProbeKnownAlternateReferences = false, + MaxProbeTargetCount = 1 + }); + + var point = Assert.Single(result.Points); + Assert.Equal(Iec61850DesignLiveStatus.TransportFailure, point.Status); + Assert.Equal(Iec61850ExactProbeStatus.TransportFailure, point.Probe?.Status); + Assert.NotEqual(Iec61850DesignLiveStatus.Absent, point.Status); + Assert.Equal(0, result.AbsentCount); + } + + [Fact] + public async Task AlternateDiscovery_ThroughNativeOwner_RecoversWithoutNetworkProbe() + { + const string canonical = "IEDLD0/MMXU1$MX$TotW$mag$f"; + const string alternate = "IEDLD0/MMXU1$MX$TotW$instMag$f"; + await using var client = new NativeIec61850Client(); + var design = BuildModel(new TestAttribute( + "IEDLD0/MMXU1.TotW.mag.f", + canonical, + "MX", + "FLOAT32", + string.Empty)); + var observed = BuildModel( + new TestAttribute( + "IEDLD0/MMXU1.TotW.instMag.f", + alternate, + "MX", + "FLOAT32", + "floating-point"), + "LiveMmsDiscovery"); + + var result = await client.ReconcileDesignLiveAsync( + design, + observed, + new Iec61850DesignLiveReconciliationOptions + { + ProbeAllMissingDesignAttributes = true, + MaxProbeTargetCount = 1 + }); + + var point = Assert.Single(result.Points); + Assert.Equal(Iec61850DesignLiveStatus.RecoveredByAlternateDiscovery, point.Status); + Assert.Equal(canonical, point.CanonicalMmsReference); + Assert.Equal(alternate, point.EffectiveMmsReference); + Assert.Equal(alternate, point.ObservedMmsReference); + Assert.Equal( + Iec61850AlternateReferenceStrategyKind.MagnitudeInstantaneousSibling, + point.AlternateStrategy); + Assert.Empty(point.ProbeAttempts); + Assert.Null(point.Probe); + Assert.Equal(0, result.AbsentCount); + Assert.Equal(0, result.LiveOnlyCount); + } + + private static LiveIedModelDiscoveryDocument BuildModel( + TestAttribute attribute, + string source = "SclWorkspace") + => BuildModel(new[] { attribute }, source); + + private static LiveIedModelDiscoveryDocument BuildModel( + IReadOnlyCollection attributes, + string source) + { + var models = attributes.Select((attribute, index) => + { + var slash = attribute.MmsReference.IndexOf('/'); + var item = attribute.MmsReference[(slash + 1)..]; + var logicalNode = item.Split('$', StringSplitOptions.RemoveEmptyEntries)[0]; + var domain = attribute.MmsReference[..slash]; + return new + { + Domain = domain, + LogicalNode = logicalNode, + DataObject = new LiveIedDataObjectModel + { + Reference = $"{domain}/{logicalNode}.DO{index + 1}", + Name = $"DO{index + 1}", + InferredCdc = "MV", + Attributes = new[] + { + new LiveIedDataAttributeModel + { + ObjectReference = attribute.ObjectReference, + AttributePath = attribute.ObjectReference[(attribute.ObjectReference.LastIndexOf('.') + 1)..], + FunctionalConstraint = attribute.FunctionalConstraint, + MmsReference = attribute.MmsReference, + MmsItemName = item, + SclBType = attribute.SclBType, + MmsType = attribute.MmsType, + Source = source + } + } + } + }; + }).ToArray(); + + return new LiveIedModelDiscoveryDocument + { + Source = source, + IedName = "IED", + LogicalDevices = models + .GroupBy(model => model.Domain, StringComparer.OrdinalIgnoreCase) + .Select(domain => new LiveIedLogicalDeviceModel + { + MmsDomain = domain.Key, + LogicalNodes = domain + .GroupBy(model => model.LogicalNode, StringComparer.OrdinalIgnoreCase) + .Select(node => new LiveIedLogicalNodeModel + { + Name = node.Key, + DataObjects = node.Select(model => model.DataObject).ToArray() + }) + .ToArray() + }) + .ToArray() + }; + } + + private static LiveIedModelDiscoveryDocument EmptyLive() + => new() { Source = "LiveMmsDiscovery", IedName = "IED" }; + + private sealed record TestAttribute( + string ObjectReference, + string MmsReference, + string FunctionalConstraint, + string SclBType, + string MmsType); +} From 2bf04af077026755fc26485ed2198d883ae2aa2d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 09:37:52 +0700 Subject: [PATCH 21/34] Test native endpoint owner resolution for connected reconciliation --- ...iveIec61850ConnectedReconciliationTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs b/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs index e4f762d6..d1cbade6 100644 --- a/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs +++ b/tests/ARSAS.Tests/NativeIec61850ConnectedReconciliationTests.cs @@ -33,6 +33,46 @@ public async Task DisconnectedNativeOwner_ReturnsEngineTransportFailure_NotAbsen Assert.Equal(0, result.AbsentCount); } + [Fact] + public async Task EndpointResolver_UsesExistingNativeOwner_WithoutCreatingAnotherSession() + { + const string ipAddress = "203.0.113.77"; + const int port = 65102; + await using var client = new NativeIec61850Client(); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + + // NativeIec61850Client records the endpoint before entering the cancellable + // association operation. An already-cancelled token therefore registers the + // session owner deterministically without performing TCP/MMS network I/O. + await Assert.ThrowsAnyAsync( + () => client.ConnectAsync(ipAddress, port, cancelled.Token)); + + var design = BuildModel(new TestAttribute( + "IEDLD0/GGIO1.Ind1.stVal", + "IEDLD0/GGIO1$ST$Ind1$stVal", + "ST", + "BOOLEAN", + string.Empty)); + + var result = await NativeIec61850Client.ReconcileConnectedAsync( + ipAddress, + port, + design, + EmptyLive(), + new Iec61850DesignLiveReconciliationOptions + { + ProbeAllMissingDesignAttributes = true, + ProbeKnownAlternateReferences = false, + MaxProbeTargetCount = 1 + }); + + var point = Assert.Single(result.Points); + Assert.Equal(Iec61850DesignLiveStatus.TransportFailure, point.Status); + Assert.Equal(Iec61850ExactProbeStatus.TransportFailure, point.Probe?.Status); + Assert.Equal(0, result.AbsentCount); + } + [Fact] public async Task AlternateDiscovery_ThroughNativeOwner_RecoversWithoutNetworkProbe() { From 7d75222bba49c8bedb1b53197ea2d040ba1bbca8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 10:09:55 +0700 Subject: [PATCH 22/34] Add fast live signal workspace search --- MainWindow.LiveSignalSearch.cs | 405 +++++++++++++++++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 MainWindow.LiveSignalSearch.cs diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs new file mode 100644 index 00000000..2150298a --- /dev/null +++ b/MainWindow.LiveSignalSearch.cs @@ -0,0 +1,405 @@ +using System.Collections; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +/// +/// Lightweight, presentation-only search for the Explorer live-value workspace. +/// Filtering is performed over the existing ICollectionView; it never changes the +/// monitored point collection, acquisition lifecycle, or IEC 61850 network traffic. +/// +public partial class MainWindow +{ + private static readonly bool LiveSignalSearchClassHandlerRegistered = RegisterLiveSignalSearchClassHandler(); + + private DataGrid? _liveSignalSearchGrid; + private TextBox? _liveSignalSearchBox; + private TextBlock? _liveSignalSearchPlaceholder; + private TextBlock? _liveSignalSearchCount; + private Button? _liveSignalSearchClearButton; + private ICollectionView? _liveSignalSearchView; + private INotifyCollectionChanged? _liveSignalSearchCollection; + private DependencyPropertyDescriptor? _liveSignalItemsSourceDescriptor; + private bool _liveSignalSearchInstalled; + + private static bool RegisterLiveSignalSearchClassHandler() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(LiveSignalSearch_MainWindowLoaded)); + return true; + } + + private static void LiveSignalSearch_MainWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not MainWindow window || !ReferenceEquals(e.OriginalSource, window)) + return; + + window.Dispatcher.BeginInvoke(window.InstallLiveSignalSearch); + } + + private void InstallLiveSignalSearch() + { + if (_liveSignalSearchInstalled) + return; + + var dataGrid = FindVisualChildren(this) + .FirstOrDefault(IsExplorerLiveSignalGrid); + if (dataGrid?.Parent is not Grid host) + return; + + _liveSignalSearchInstalled = true; + _liveSignalSearchGrid = dataGrid; + + // The existing host contains the DataGrid plus its empty-workspace overlay. + // Give both row 1 and reserve a compact row 0 for the search toolbar. + if (host.RowDefinitions.Count == 0) + { + host.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + host.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + foreach (UIElement child in host.Children.Cast().ToArray()) + Grid.SetRow(child, 1); + } + + var toolbar = BuildLiveSignalSearchToolbar(); + Grid.SetRow(toolbar, 0); + host.Children.Add(toolbar); + + _liveSignalItemsSourceDescriptor = DependencyPropertyDescriptor.FromProperty( + ItemsControl.ItemsSourceProperty, + typeof(DataGrid)); + _liveSignalItemsSourceDescriptor?.AddValueChanged(dataGrid, LiveSignalSearch_ItemsSourceChanged); + + PreviewKeyDown += LiveSignalSearch_WindowPreviewKeyDown; + Closed += LiveSignalSearch_WindowClosed; + AttachLiveSignalSearchSource(); + } + + private FrameworkElement BuildLiveSignalSearchToolbar() + { + var toolbar = new Grid + { + Margin = new Thickness(2, 0, 2, 9), + Height = 38 + }; + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titlePanel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center + }; + titlePanel.Children.Add(new Border + { + Width = 7, + Height = 7, + CornerRadius = new CornerRadius(3.5), + Background = new SolidColorBrush(Color.FromRgb(37, 99, 235)), + Margin = new Thickness(2, 0, 8, 0) + }); + titlePanel.Children.Add(new TextBlock + { + Text = "LIVE SIGNAL VALUES", + FontSize = 11.2, + FontWeight = FontWeights.SemiBold, + Foreground = new SolidColorBrush(Color.FromRgb(70, 86, 109)), + VerticalAlignment = VerticalAlignment.Center + }); + _liveSignalSearchCount = new TextBlock + { + Text = "", + FontSize = 10.8, + Foreground = new SolidColorBrush(Color.FromRgb(126, 142, 165)), + Margin = new Thickness(9, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center + }; + titlePanel.Children.Add(_liveSignalSearchCount); + toolbar.Children.Add(titlePanel); + + var searchShell = new Border + { + Width = 390, + Height = 36, + CornerRadius = new CornerRadius(12), + Background = Brushes.White, + BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 233)), + BorderThickness = new Thickness(1), + Padding = new Thickness(10, 0, 5, 0), + VerticalAlignment = VerticalAlignment.Center + }; + Grid.SetColumn(searchShell, 1); + + var searchGrid = new Grid(); + searchGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(22) }); + searchGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + searchGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); + + var searchIcon = new Grid + { + Width = 15, + Height = 15, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Left + }; + searchIcon.Children.Add(new Ellipse + { + Width = 9, + Height = 9, + Stroke = new SolidColorBrush(Color.FromRgb(99, 117, 142)), + StrokeThickness = 1.55, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top + }); + searchIcon.Children.Add(new Border + { + Width = 6, + Height = 1.5, + Background = new SolidColorBrush(Color.FromRgb(99, 117, 142)), + RenderTransform = new RotateTransform(45), + RenderTransformOrigin = new Point(0.5, 0.5), + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Bottom, + Margin = new Thickness(0, 0, 1, 2) + }); + searchGrid.Children.Add(searchIcon); + + var textHost = new Grid(); + Grid.SetColumn(textHost, 1); + _liveSignalSearchPlaceholder = new TextBlock + { + Text = "Search signal, IEC reference, value…", + FontSize = 11.6, + Foreground = new SolidColorBrush(Color.FromRgb(144, 158, 179)), + VerticalAlignment = VerticalAlignment.Center, + IsHitTestVisible = false + }; + textHost.Children.Add(_liveSignalSearchPlaceholder); + + _liveSignalSearchBox = new TextBox + { + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + FontSize = 11.8, + Foreground = new SolidColorBrush(Color.FromRgb(42, 57, 78)), + VerticalContentAlignment = VerticalAlignment.Center, + CaretBrush = new SolidColorBrush(Color.FromRgb(37, 99, 235)), + ToolTip = "Filter the current live workspace. Ctrl+F focuses search; Esc clears it." + }; + _liveSignalSearchBox.TextChanged += LiveSignalSearch_TextChanged; + _liveSignalSearchBox.PreviewKeyDown += LiveSignalSearch_BoxPreviewKeyDown; + textHost.Children.Add(_liveSignalSearchBox); + searchGrid.Children.Add(textHost); + + _liveSignalSearchClearButton = new Button + { + Content = "×", + Width = 24, + Height = 24, + Padding = new Thickness(0), + Margin = new Thickness(2, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(104, 120, 144)), + FontSize = 16, + FontWeight = FontWeights.Normal, + Cursor = Cursors.Hand, + Visibility = Visibility.Collapsed, + ToolTip = "Clear search" + }; + _liveSignalSearchClearButton.Click += (_, _) => ClearLiveSignalSearch(); + Grid.SetColumn(_liveSignalSearchClearButton, 2); + searchGrid.Children.Add(_liveSignalSearchClearButton); + + searchShell.Child = searchGrid; + toolbar.Children.Add(searchShell); + return toolbar; + } + + private static bool IsExplorerLiveSignalGrid(DataGrid grid) + { + var headers = grid.Columns + .Select(column => column.Header?.ToString() ?? string.Empty) + .ToArray(); + return headers.Length == 6 && + headers[0].Equals("Signal", StringComparison.OrdinalIgnoreCase) && + headers[1].Equals("IEC Telegram", StringComparison.OrdinalIgnoreCase) && + headers[2].Equals("Value", StringComparison.OrdinalIgnoreCase) && + headers[3].Equals("Quality", StringComparison.OrdinalIgnoreCase) && + headers[4].Equals("IED Timestamp", StringComparison.OrdinalIgnoreCase) && + headers[5].Equals("Acquisition", StringComparison.OrdinalIgnoreCase) && + FindAncestor(grid)?.Header?.ToString()?.Equals( + "IEC 61850 Explorer", + StringComparison.OrdinalIgnoreCase) == true; + } + + private void LiveSignalSearch_ItemsSourceChanged(object? sender, EventArgs e) + => AttachLiveSignalSearchSource(); + + private void AttachLiveSignalSearchSource() + { + if (_liveSignalSearchCollection != null) + _liveSignalSearchCollection.CollectionChanged -= LiveSignalSearch_CollectionChanged; + if (_liveSignalSearchView != null) + _liveSignalSearchView.Filter = null; + + var source = _liveSignalSearchGrid?.ItemsSource; + _liveSignalSearchCollection = source as INotifyCollectionChanged; + if (_liveSignalSearchCollection != null) + _liveSignalSearchCollection.CollectionChanged += LiveSignalSearch_CollectionChanged; + + _liveSignalSearchView = source == null + ? null + : CollectionViewSource.GetDefaultView(source); + ApplyLiveSignalSearch(); + } + + private void LiveSignalSearch_TextChanged(object sender, TextChangedEventArgs e) + { + if (_liveSignalSearchPlaceholder != null) + _liveSignalSearchPlaceholder.Visibility = string.IsNullOrWhiteSpace(_liveSignalSearchBox?.Text) + ? Visibility.Visible + : Visibility.Collapsed; + if (_liveSignalSearchClearButton != null) + _liveSignalSearchClearButton.Visibility = string.IsNullOrWhiteSpace(_liveSignalSearchBox?.Text) + ? Visibility.Collapsed + : Visibility.Visible; + ApplyLiveSignalSearch(); + } + + private void LiveSignalSearch_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + => Dispatcher.BeginInvoke(UpdateLiveSignalSearchCount); + + private void ApplyLiveSignalSearch() + { + if (_liveSignalSearchView == null) + { + UpdateLiveSignalSearchCount(); + return; + } + + _liveSignalSearchView.Filter = LiveSignalSearch_Matches; + _liveSignalSearchView.Refresh(); + UpdateLiveSignalSearchCount(); + } + + private bool LiveSignalSearch_Matches(object item) + { + var query = (_liveSignalSearchBox?.Text ?? string.Empty).Trim(); + if (query.Length == 0) + return true; + if (item is not Iec61850MonitorPoint point) + return true; + + var searchable = string.Join('\n', new[] + { + point.DeviceName, + point.SignalName, + point.IecTelegram, + point.IecReference, + point.Value, + point.Quality, + point.DeviceTimestamp, + point.SourceMode + }.Where(value => !string.IsNullOrWhiteSpace(value))); + + // Multiple terms use AND semantics: "MMXU instCVal" quickly narrows large workspaces. + var tokens = query.Split( + new[] { ' ', '\t', '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return tokens.All(token => searchable.Contains(token, StringComparison.OrdinalIgnoreCase)); + } + + private void UpdateLiveSignalSearchCount() + { + if (_liveSignalSearchCount == null) + return; + + var source = _liveSignalSearchGrid?.ItemsSource; + var total = source switch + { + ICollection collection => collection.Count, + IEnumerable enumerable => enumerable.Count(), + _ => 0 + }; + var visible = _liveSignalSearchView?.Cast().Count() ?? total; + var filtered = !string.IsNullOrWhiteSpace(_liveSignalSearchBox?.Text); + _liveSignalSearchCount.Text = filtered + ? $"{visible:N0} of {total:N0} shown" + : $"{total:N0} signals"; + } + + private void LiveSignalSearch_WindowPreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.F && Keyboard.Modifiers.HasFlag(ModifierKeys.Control) && + MainTabs?.SelectedIndex == 0 && _liveSignalSearchBox != null) + { + _liveSignalSearchBox.Focus(); + _liveSignalSearchBox.SelectAll(); + e.Handled = true; + } + } + + private void LiveSignalSearch_BoxPreviewKeyDown(object sender, KeyEventArgs e) + { + if (e.Key != Key.Escape) + return; + ClearLiveSignalSearch(); + e.Handled = true; + } + + private void ClearLiveSignalSearch() + { + if (_liveSignalSearchBox == null) + return; + _liveSignalSearchBox.Clear(); + _liveSignalSearchBox.Focus(); + } + + private void LiveSignalSearch_WindowClosed(object? sender, EventArgs e) + { + PreviewKeyDown -= LiveSignalSearch_WindowPreviewKeyDown; + Closed -= LiveSignalSearch_WindowClosed; + if (_liveSignalSearchGrid != null) + _liveSignalItemsSourceDescriptor?.RemoveValueChanged(_liveSignalSearchGrid, LiveSignalSearch_ItemsSourceChanged); + if (_liveSignalSearchCollection != null) + _liveSignalSearchCollection.CollectionChanged -= LiveSignalSearch_CollectionChanged; + if (_liveSignalSearchView != null) + _liveSignalSearchView.Filter = null; + } + + private static IEnumerable FindVisualChildren(DependencyObject root) + where T : DependencyObject + { + for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is T typed) + yield return typed; + foreach (var descendant in FindVisualChildren(child)) + yield return descendant; + } + } + + private static T? FindAncestor(DependencyObject child) + where T : DependencyObject + { + var current = VisualTreeHelper.GetParent(child); + while (current != null) + { + if (current is T typed) + return typed; + current = VisualTreeHelper.GetParent(current); + } + return null; + } +} From 9df03187679ddee0f4b4509871c42822c3bef298 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 10:11:05 +0700 Subject: [PATCH 23/34] Harden live signal search installation --- MainWindow.LiveSignalSearch.cs | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs index 2150298a..a430109d 100644 --- a/MainWindow.LiveSignalSearch.cs +++ b/MainWindow.LiveSignalSearch.cs @@ -43,7 +43,7 @@ private static void LiveSignalSearch_MainWindowLoaded(object sender, RoutedEvent if (sender is not MainWindow window || !ReferenceEquals(e.OriginalSource, window)) return; - window.Dispatcher.BeginInvoke(window.InstallLiveSignalSearch); + window.Dispatcher.BeginInvoke(new Action(window.InstallLiveSignalSearch)); } private void InstallLiveSignalSearch() @@ -230,16 +230,17 @@ private static bool IsExplorerLiveSignalGrid(DataGrid grid) var headers = grid.Columns .Select(column => column.Header?.ToString() ?? string.Empty) .ToArray(); + + // This six-column signature is unique to the selected-IED live-value workspace. + // The global monitor has an additional IED column and the command grid has a + // different schema, so no visual-tree TabItem assumptions are required. return headers.Length == 6 && headers[0].Equals("Signal", StringComparison.OrdinalIgnoreCase) && headers[1].Equals("IEC Telegram", StringComparison.OrdinalIgnoreCase) && headers[2].Equals("Value", StringComparison.OrdinalIgnoreCase) && headers[3].Equals("Quality", StringComparison.OrdinalIgnoreCase) && headers[4].Equals("IED Timestamp", StringComparison.OrdinalIgnoreCase) && - headers[5].Equals("Acquisition", StringComparison.OrdinalIgnoreCase) && - FindAncestor(grid)?.Header?.ToString()?.Equals( - "IEC 61850 Explorer", - StringComparison.OrdinalIgnoreCase) == true; + headers[5].Equals("Acquisition", StringComparison.OrdinalIgnoreCase); } private void LiveSignalSearch_ItemsSourceChanged(object? sender, EventArgs e) @@ -277,7 +278,7 @@ private void LiveSignalSearch_TextChanged(object sender, TextChangedEventArgs e) } private void LiveSignalSearch_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - => Dispatcher.BeginInvoke(UpdateLiveSignalSearchCount); + => Dispatcher.BeginInvoke(new Action(UpdateLiveSignalSearchCount)); private void ApplyLiveSignalSearch() { @@ -389,17 +390,4 @@ private static IEnumerable FindVisualChildren(DependencyObject root) yield return descendant; } } - - private static T? FindAncestor(DependencyObject child) - where T : DependencyObject - { - var current = VisualTreeHelper.GetParent(child); - while (current != null) - { - if (current is T typed) - return typed; - current = VisualTreeHelper.GetParent(current); - } - return null; - } } From 8bf54dc27f37549d548b74ba9038cd0d0d3cd526 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 10:13:24 +0700 Subject: [PATCH 24/34] Avoid MainWindow visual helper collision --- MainWindow.LiveSignalSearch.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs index a430109d..d1cd40e4 100644 --- a/MainWindow.LiveSignalSearch.cs +++ b/MainWindow.LiveSignalSearch.cs @@ -51,7 +51,7 @@ private void InstallLiveSignalSearch() if (_liveSignalSearchInstalled) return; - var dataGrid = FindVisualChildren(this) + var dataGrid = FindLiveSignalVisualChildren(this) .FirstOrDefault(IsExplorerLiveSignalGrid); if (dataGrid?.Parent is not Grid host) return; @@ -378,7 +378,7 @@ private void LiveSignalSearch_WindowClosed(object? sender, EventArgs e) _liveSignalSearchView.Filter = null; } - private static IEnumerable FindVisualChildren(DependencyObject root) + private static IEnumerable FindLiveSignalVisualChildren(DependencyObject root) where T : DependencyObject { for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++) @@ -386,7 +386,7 @@ private static IEnumerable FindVisualChildren(DependencyObject root) var child = VisualTreeHelper.GetChild(root, index); if (child is T typed) yield return typed; - foreach (var descendant in FindVisualChildren(child)) + foreach (var descendant in FindLiveSignalVisualChildren(child)) yield return descendant; } } From 02ac69ce49cdde04f14850d1a3ac51301704570c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 14:11:34 +0700 Subject: [PATCH 25/34] Match live signal search to IED Explorer style --- MainWindow.LiveSignalSearch.cs | 128 +++++++-------------------------- 1 file changed, 24 insertions(+), 104 deletions(-) diff --git a/MainWindow.LiveSignalSearch.cs b/MainWindow.LiveSignalSearch.cs index d1cd40e4..8250a27a 100644 --- a/MainWindow.LiveSignalSearch.cs +++ b/MainWindow.LiveSignalSearch.cs @@ -21,9 +21,7 @@ public partial class MainWindow private DataGrid? _liveSignalSearchGrid; private TextBox? _liveSignalSearchBox; - private TextBlock? _liveSignalSearchPlaceholder; private TextBlock? _liveSignalSearchCount; - private Button? _liveSignalSearchClearButton; private ICollectionView? _liveSignalSearchView; private INotifyCollectionChanged? _liveSignalSearchCollection; private DependencyPropertyDescriptor? _liveSignalItemsSourceDescriptor; @@ -88,7 +86,7 @@ private FrameworkElement BuildLiveSignalSearchToolbar() var toolbar = new Grid { Margin = new Thickness(2, 0, 2, 9), - Height = 38 + Height = 34 }; toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); toolbar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); @@ -116,7 +114,7 @@ private FrameworkElement BuildLiveSignalSearchToolbar() }); _liveSignalSearchCount = new TextBlock { - Text = "", + Text = string.Empty, FontSize = 10.8, Foreground = new SolidColorBrush(Color.FromRgb(126, 142, 165)), Margin = new Thickness(9, 0, 0, 0), @@ -125,104 +123,36 @@ private FrameworkElement BuildLiveSignalSearchToolbar() titlePanel.Children.Add(_liveSignalSearchCount); toolbar.Children.Add(titlePanel); - var searchShell = new Border + _liveSignalSearchBox = new TextBox { Width = 390, - Height = 36, - CornerRadius = new CornerRadius(12), - Background = Brushes.White, - BorderBrush = new SolidColorBrush(Color.FromRgb(205, 217, 233)), - BorderThickness = new Thickness(1), - Padding = new Thickness(10, 0, 5, 0), + Style = ResolveIndustrialSearchStyle(), + Tag = "Search signal, IEC reference, value, quality or acquisition", + ToolTip = "Filter the current live workspace. Ctrl+F focuses search; Esc clears it.", VerticalAlignment = VerticalAlignment.Center }; - Grid.SetColumn(searchShell, 1); - - var searchGrid = new Grid(); - searchGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(22) }); - searchGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - searchGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(28) }); - - var searchIcon = new Grid - { - Width = 15, - Height = 15, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Left - }; - searchIcon.Children.Add(new Ellipse - { - Width = 9, - Height = 9, - Stroke = new SolidColorBrush(Color.FromRgb(99, 117, 142)), - StrokeThickness = 1.55, - HorizontalAlignment = HorizontalAlignment.Left, - VerticalAlignment = VerticalAlignment.Top - }); - searchIcon.Children.Add(new Border - { - Width = 6, - Height = 1.5, - Background = new SolidColorBrush(Color.FromRgb(99, 117, 142)), - RenderTransform = new RotateTransform(45), - RenderTransformOrigin = new Point(0.5, 0.5), - HorizontalAlignment = HorizontalAlignment.Right, - VerticalAlignment = VerticalAlignment.Bottom, - Margin = new Thickness(0, 0, 1, 2) - }); - searchGrid.Children.Add(searchIcon); - - var textHost = new Grid(); - Grid.SetColumn(textHost, 1); - _liveSignalSearchPlaceholder = new TextBlock - { - Text = "Search signal, IEC reference, value…", - FontSize = 11.6, - Foreground = new SolidColorBrush(Color.FromRgb(144, 158, 179)), - VerticalAlignment = VerticalAlignment.Center, - IsHitTestVisible = false - }; - textHost.Children.Add(_liveSignalSearchPlaceholder); - - _liveSignalSearchBox = new TextBox - { - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(0), - FontSize = 11.8, - Foreground = new SolidColorBrush(Color.FromRgb(42, 57, 78)), - VerticalContentAlignment = VerticalAlignment.Center, - CaretBrush = new SolidColorBrush(Color.FromRgb(37, 99, 235)), - ToolTip = "Filter the current live workspace. Ctrl+F focuses search; Esc clears it." - }; _liveSignalSearchBox.TextChanged += LiveSignalSearch_TextChanged; _liveSignalSearchBox.PreviewKeyDown += LiveSignalSearch_BoxPreviewKeyDown; - textHost.Children.Add(_liveSignalSearchBox); - searchGrid.Children.Add(textHost); + Grid.SetColumn(_liveSignalSearchBox, 1); + toolbar.Children.Add(_liveSignalSearchBox); + + return toolbar; + } - _liveSignalSearchClearButton = new Button + private Style? ResolveIndustrialSearchStyle() + { + var style = TryFindResource("IndustrialSearchTextBox") as Style + ?? Application.Current?.TryFindResource("IndustrialSearchTextBox") as Style; + if (style != null) + return style; + + // Defensive fallback for load ordering: use the same resource dictionary that + // P2IndustrialWorkstationUx uses for the IED Explorer search field. + var dictionary = new ResourceDictionary { - Content = "×", - Width = 24, - Height = 24, - Padding = new Thickness(0), - Margin = new Thickness(2, 0, 0, 0), - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Foreground = new SolidColorBrush(Color.FromRgb(104, 120, 144)), - FontSize = 16, - FontWeight = FontWeights.Normal, - Cursor = Cursors.Hand, - Visibility = Visibility.Collapsed, - ToolTip = "Clear search" + Source = new Uri("/ARSAS;component/Resources/P2IndustrialControls.xaml", UriKind.Relative) }; - _liveSignalSearchClearButton.Click += (_, _) => ClearLiveSignalSearch(); - Grid.SetColumn(_liveSignalSearchClearButton, 2); - searchGrid.Children.Add(_liveSignalSearchClearButton); - - searchShell.Child = searchGrid; - toolbar.Children.Add(searchShell); - return toolbar; + return dictionary["IndustrialSearchTextBox"] as Style; } private static bool IsExplorerLiveSignalGrid(DataGrid grid) @@ -265,17 +195,7 @@ private void AttachLiveSignalSearchSource() } private void LiveSignalSearch_TextChanged(object sender, TextChangedEventArgs e) - { - if (_liveSignalSearchPlaceholder != null) - _liveSignalSearchPlaceholder.Visibility = string.IsNullOrWhiteSpace(_liveSignalSearchBox?.Text) - ? Visibility.Visible - : Visibility.Collapsed; - if (_liveSignalSearchClearButton != null) - _liveSignalSearchClearButton.Visibility = string.IsNullOrWhiteSpace(_liveSignalSearchBox?.Text) - ? Visibility.Collapsed - : Visibility.Visible; - ApplyLiveSignalSearch(); - } + => ApplyLiveSignalSearch(); private void LiveSignalSearch_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => Dispatcher.BeginInvoke(new Action(UpdateLiveSignalSearchCount)); From 874a05b61ad341507716e5c6bf35ed6e91a0006b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 14:11:48 +0700 Subject: [PATCH 26/34] Add full-string millisecond timestamp presentation --- Iec61850TimestampPresentation.cs | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/Iec61850TimestampPresentation.cs b/Iec61850TimestampPresentation.cs index e182f0ed..33e28dc2 100644 --- a/Iec61850TimestampPresentation.cs +++ b/Iec61850TimestampPresentation.cs @@ -41,6 +41,44 @@ public static string FormatMilliseconds(DateTimeOffset? value, string format, st public static string FormatMilliseconds(DateTime value, string format) => RoundToNearestMillisecond(value).ToString(format, CultureInfo.InvariantCulture); + /// + /// Formats a full-resolution timestamp string for a compact live-grid display while + /// leaving the source string untouched for evidence, search and full-precision tooltips. + /// Non-timestamp values are returned unchanged rather than guessed. + /// + public static string FormatMilliseconds( + string? value, + string format = "yyyy-MM-dd HH:mm:ss.fff", + string missing = "-") + { + var text = (value ?? string.Empty).Trim(); + if (text.Length == 0 || text == "-") + return text.Length == 0 ? missing : text; + + // Relay timestamps in the live workspace normally have no explicit offset. + // Parse those as DateTime first so presentation never invents or shifts a zone. + if (DateTime.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.RoundtripKind, + out var dateTime)) + { + return FormatMilliseconds(dateTime, format); + } + + // Preserve explicit-offset timestamps when they are supplied by another surface. + if (DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.AllowWhiteSpaces, + out var dateTimeOffset)) + { + return FormatMilliseconds(dateTimeOffset, format); + } + + return text; + } + private static long MillisecondDelta(long ticks) { var remainder = ticks % TicksPerMillisecond; From d3a72851112f2e4b4835f395110e2fc37e9d3b1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 14:12:17 +0700 Subject: [PATCH 27/34] Round live timestamps and show full precision tooltip --- MainWindow.ExplorerSignalGrid.cs | 97 ++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/MainWindow.ExplorerSignalGrid.cs b/MainWindow.ExplorerSignalGrid.cs index 155ccbba..3103ce4d 100644 --- a/MainWindow.ExplorerSignalGrid.cs +++ b/MainWindow.ExplorerSignalGrid.cs @@ -1,7 +1,12 @@ +using System.Globalization; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; using System.Windows.Media; +using System.Windows.Media.Effects; using System.Windows.Threading; +using ArIED61850Tester.Models; namespace ArIED61850Tester; @@ -52,6 +57,71 @@ private void ConfigureExplorerSignalGridForCompactFit() signalGrid.Columns[index].MinWidth = minimums[index]; signalGrid.Columns[index].Width = new DataGridLength(weights[index], DataGridLengthUnitType.Star); } + + ConfigureExplorerTimestampPresentation(signalGrid); + } + + private static void ConfigureExplorerTimestampPresentation(DataGrid signalGrid) + { + if (signalGrid.Columns.Count <= 4 || signalGrid.Columns[4] is not DataGridTextColumn timestampColumn) + return; + + // Display only is rounded to nearest millisecond. The monitor point keeps the + // original full-resolution timestamp for evidence, search and hover detail. + timestampColumn.Binding = new Binding(nameof(Iec61850MonitorPoint.DeviceTimestamp)) + { + Converter = ExplorerTimestampMillisecondsConverter.Instance, + Mode = BindingMode.OneWay + }; + + var timestampTextStyle = new Style(typeof(TextBlock), timestampColumn.ElementStyle); + timestampTextStyle.Setters.Add(new Setter( + FrameworkElement.ToolTipProperty, + new Binding(nameof(Iec61850MonitorPoint.DeviceTimestamp)) + { + Converter = ExplorerTimestampFullPrecisionToolTipConverter.Instance, + Mode = BindingMode.OneWay + })); + timestampTextStyle.Setters.Add(new Setter(ToolTipService.ShowDurationProperty, 30000)); + timestampTextStyle.Setters.Add(new Setter(TextBlock.TextTrimmingProperty, TextTrimming.CharacterEllipsis)); + timestampColumn.ElementStyle = timestampTextStyle; + + // Scope the premium tooltip styling to this live-value DataGrid so unrelated + // application tooltips retain their established appearance. + signalGrid.Resources[typeof(ToolTip)] = BuildExplorerTimestampToolTipStyle(); + } + + private static Style BuildExplorerTimestampToolTipStyle() + { + var style = new Style(typeof(ToolTip)); + style.Setters.Add(new Setter(Control.ForegroundProperty, Brushes.White)); + style.Setters.Add(new Setter(Control.FontSizeProperty, 11.4)); + style.Setters.Add(new Setter(Control.FontWeightProperty, FontWeights.Medium)); + style.Setters.Add(new Setter(ToolTip.PlacementProperty, PlacementMode.Mouse)); + style.Setters.Add(new Setter(ToolTip.HorizontalOffsetProperty, 10d)); + style.Setters.Add(new Setter(ToolTip.VerticalOffsetProperty, 12d)); + + var template = new ControlTemplate(typeof(ToolTip)); + var chrome = new FrameworkElementFactory(typeof(Border)); + chrome.SetValue(Border.BackgroundProperty, new SolidColorBrush(Color.FromRgb(35, 49, 59))); + chrome.SetValue(Border.BorderBrushProperty, new SolidColorBrush(Color.FromRgb(91, 111, 123))); + chrome.SetValue(Border.BorderThicknessProperty, new Thickness(1)); + chrome.SetValue(Border.CornerRadiusProperty, new CornerRadius(8)); + chrome.SetValue(Border.PaddingProperty, new Thickness(11, 8, 11, 8)); + chrome.SetValue(Border.EffectProperty, new DropShadowEffect + { + BlurRadius = 16, + ShadowDepth = 4, + Opacity = 0.20, + Color = Color.FromRgb(15, 23, 42) + }); + + var content = new FrameworkElementFactory(typeof(ContentPresenter)); + content.SetValue(ContentPresenter.RecognizesAccessKeyProperty, false); + chrome.AppendChild(content); + template.VisualTree = chrome; + style.Setters.Add(new Setter(Control.TemplateProperty, template)); + return style; } private static IEnumerable FindExplorerVisualChildren(DependencyObject? root) @@ -71,3 +141,30 @@ private static IEnumerable FindExplorerVisualChildren(DependencyObject? ro } } } + +internal sealed class ExplorerTimestampMillisecondsConverter : IValueConverter +{ + public static ExplorerTimestampMillisecondsConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => Iec61850TimestampPresentation.FormatMilliseconds(value as string); + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +internal sealed class ExplorerTimestampFullPrecisionToolTipConverter : IValueConverter +{ + public static ExplorerTimestampFullPrecisionToolTipConverter Instance { get; } = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var text = (value as string)?.Trim(); + return string.IsNullOrWhiteSpace(text) || text == "-" + ? "IED TIMESTAMP · FULL PRECISION\nNo timestamp available" + : $"IED TIMESTAMP · FULL PRECISION\n{text}"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotSupportedException(); +} From 204f979a16091dcbab64ec2dc50436ea848b17ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 14:12:34 +0700 Subject: [PATCH 28/34] Test rounded live timestamp string presentation --- .../Iec61850TimestampPresentationTests.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs b/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs index a2ef0bc4..4398d6b4 100644 --- a/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs +++ b/tests/ARSAS.Tests/Iec61850TimestampPresentationTests.cs @@ -34,6 +34,23 @@ public void CustomerCase_31Point2006_IsPresentedAs31Point201() Iec61850TimestampPresentation.FormatMilliseconds(timestamp, "ss.fff")); } + [Fact] + public void LiveWorkspaceString_RoundsToThreeFractionalDigits_WithoutChangingSource() + { + const string fullPrecision = "2026-08-14 13:48:12.9165859"; + + var display = Iec61850TimestampPresentation.FormatMilliseconds(fullPrecision); + + Assert.Equal("2026-08-14 13:48:12.917", display); + Assert.Equal("2026-08-14 13:48:12.9165859", fullPrecision); + } + + [Theory] + [InlineData("-", "-")] + [InlineData("relay timestamp unavailable", "relay timestamp unavailable")] + public void LiveWorkspaceString_NonTimestampValues_AreNotInvented(string source, string expected) + => Assert.Equal(expected, Iec61850TimestampPresentation.FormatMilliseconds(source)); + [Fact] public void Rounding_CarriesAcrossSecondAndMinuteBoundary() { From 60db87f4f058e6db65cd0ee75f71dc712a281465 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 14:14:46 +0700 Subject: [PATCH 29/34] Qualify WPF tooltip type in explorer timestamp style --- MainWindow.ExplorerSignalGrid.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/MainWindow.ExplorerSignalGrid.cs b/MainWindow.ExplorerSignalGrid.cs index 3103ce4d..6d7391cb 100644 --- a/MainWindow.ExplorerSignalGrid.cs +++ b/MainWindow.ExplorerSignalGrid.cs @@ -7,6 +7,7 @@ using System.Windows.Media.Effects; using System.Windows.Threading; using ArIED61850Tester.Models; +using WpfToolTip = System.Windows.Controls.ToolTip; namespace ArIED61850Tester; @@ -88,20 +89,20 @@ private static void ConfigureExplorerTimestampPresentation(DataGrid signalGrid) // Scope the premium tooltip styling to this live-value DataGrid so unrelated // application tooltips retain their established appearance. - signalGrid.Resources[typeof(ToolTip)] = BuildExplorerTimestampToolTipStyle(); + signalGrid.Resources[typeof(WpfToolTip)] = BuildExplorerTimestampToolTipStyle(); } private static Style BuildExplorerTimestampToolTipStyle() { - var style = new Style(typeof(ToolTip)); + var style = new Style(typeof(WpfToolTip)); style.Setters.Add(new Setter(Control.ForegroundProperty, Brushes.White)); style.Setters.Add(new Setter(Control.FontSizeProperty, 11.4)); style.Setters.Add(new Setter(Control.FontWeightProperty, FontWeights.Medium)); - style.Setters.Add(new Setter(ToolTip.PlacementProperty, PlacementMode.Mouse)); - style.Setters.Add(new Setter(ToolTip.HorizontalOffsetProperty, 10d)); - style.Setters.Add(new Setter(ToolTip.VerticalOffsetProperty, 12d)); + style.Setters.Add(new Setter(WpfToolTip.PlacementProperty, PlacementMode.Mouse)); + style.Setters.Add(new Setter(WpfToolTip.HorizontalOffsetProperty, 10d)); + style.Setters.Add(new Setter(WpfToolTip.VerticalOffsetProperty, 12d)); - var template = new ControlTemplate(typeof(ToolTip)); + var template = new ControlTemplate(typeof(WpfToolTip)); var chrome = new FrameworkElementFactory(typeof(Border)); chrome.SetValue(Border.BackgroundProperty, new SolidColorBrush(Color.FromRgb(35, 49, 59))); chrome.SetValue(Border.BorderBrushProperty, new SolidColorBrush(Color.FromRgb(91, 111, 123))); From dc36d99a2d644642bb371c79bde900a1ce26310b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 14:54:33 +0700 Subject: [PATCH 30/34] Sync ARIEC61850 COMTRADE FileOpen recovery baseline --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 279f94c7..277b7a68 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "0f1db91a7c96dc99bfb08f0246817fd61030b2ad", - "sourcePullRequest": 63, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. P1.3 baseline adds the engine-owned connected reconciliation facade, alternate-discovery recovery, centralized probe classification, and bounded probe-budget evidence while keeping IEC 61850 verification semantics out of ARSAS." + "commit": "41c939eb1c080389487b461453a304f3c9c3747e", + "sourcePullRequest": 69, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes engine-owned connected reconciliation plus merged COMTRADE/MMS FileOpen interoperability P0-P2: canonical nested single-GraphicString paths, bounded remote-entry revalidation, and one evidence-driven exact-case recovery retry, with no ARSAS-side MMS path heuristics." } From 33fd65f8df1bc2931d4f32801ce57d7ed76f47a1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 15:00:48 +0700 Subject: [PATCH 31/34] noop From 66ac38925e29fe63c30237ba2b5bc78aebfed165 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 16:28:35 +0700 Subject: [PATCH 32/34] Add FAT grid header select-all checkbox --- IoListTestingWindow.HeaderSelection.cs | 208 +++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 IoListTestingWindow.HeaderSelection.cs diff --git a/IoListTestingWindow.HeaderSelection.cs b/IoListTestingWindow.HeaderSelection.cs new file mode 100644 index 00000000..944f689e --- /dev/null +++ b/IoListTestingWindow.HeaderSelection.cs @@ -0,0 +1,208 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using ArIED61850Tester.Models.IoTesting; + +namespace ArIED61850Tester; + +/// +/// Adds a compact tri-state header checkbox to the FAT TEST column without changing +/// the grid item source or the per-row TestEnabled contract. The header uses the same +/// editability gate as the row checkboxes and persists one bulk plan change. +/// +public partial class IoListTestingWindow +{ + private static readonly bool IoTestHeaderSelectionClassHandlerRegistered = + RegisterIoTestHeaderSelectionClassHandler(); + + private bool _ioTestHeaderSelectionInstallScheduled; + private bool _ioTestHeaderSelectionInstalled; + private CheckBox? _ioTestHeaderSelectionCheckBox; + private IoTestIedPlan? _ioTestHeaderObservedIed; + + private static bool RegisterIoTestHeaderSelectionClassHandler() + { + EventManager.RegisterClassHandler( + typeof(IoListTestingWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnIoTestHeaderSelectionWindowLoaded), + handledEventsToo: true); + return true; + } + + private static void OnIoTestHeaderSelectionWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not IoListTestingWindow window || + window._ioTestHeaderSelectionInstalled || + window._ioTestHeaderSelectionInstallScheduled) + { + return; + } + + window._ioTestHeaderSelectionInstallScheduled = true; + window.Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + window._ioTestHeaderSelectionInstallScheduled = false; + window.EnsureIoTestHeaderSelection(); + })); + } + + private void EnsureIoTestHeaderSelection() + { + if (_ioTestHeaderSelectionInstalled) + { + RefreshIoTestHeaderSelection(); + return; + } + + var grid = FindIoTestSelectionGrid(this); + if (grid == null) + return; + + var column = grid.Columns.FirstOrDefault(candidate => + string.Equals(candidate.Header?.ToString(), "TEST", StringComparison.OrdinalIgnoreCase)); + if (column == null) + return; + + var headerCheckBox = new CheckBox + { + Width = 16, + Height = 16, + IsThreeState = true, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Cursor = Cursors.Hand, + Focusable = true, + ToolTip = "Check / uncheck all TEST rows for the selected IED" + }; + AutomationProperties.SetName(headerCheckBox, "Toggle all FAT test rows"); + headerCheckBox.Click += IoTestHeaderSelectionCheckBox_Click; + + column.Header = headerCheckBox; + _ioTestHeaderSelectionCheckBox = headerCheckBox; + _ioTestHeaderSelectionInstalled = true; + + PropertyChanged += IoTestHeaderSelectionWindow_PropertyChanged; + Closed += IoTestHeaderSelectionWindow_Closed; + RewireIoTestHeaderObservedIed(); + RefreshIoTestHeaderSelection(); + } + + private void IoTestHeaderSelectionCheckBox_Click(object sender, RoutedEventArgs e) + { + var ied = SelectedIed; + if (ied == null || !CanEditPlan) + { + RefreshIoTestHeaderSelection(); + return; + } + + var target = GetIoTestHeaderSelectionState(ied) != true; + foreach (var point in ied.TestPoints) + point.TestEnabled = target; + + Storage?.ScheduleSave(); + Raise(nameof(SelectedIedSummary)); + RaiseSelectedIedContextProperties(); + RefreshIoTestHeaderSelection(); + } + + private void IoTestHeaderSelectionWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SelectedIed)) + RewireIoTestHeaderObservedIed(); + + RefreshIoTestHeaderSelection(); + } + + private void IoTestHeaderObservedIed_PropertyChanged(object? sender, PropertyChangedEventArgs e) + => RefreshIoTestHeaderSelection(); + + private void RewireIoTestHeaderObservedIed() + { + if (ReferenceEquals(_ioTestHeaderObservedIed, SelectedIed)) + return; + + if (_ioTestHeaderObservedIed != null) + _ioTestHeaderObservedIed.PropertyChanged -= IoTestHeaderObservedIed_PropertyChanged; + + _ioTestHeaderObservedIed = SelectedIed; + if (_ioTestHeaderObservedIed != null) + _ioTestHeaderObservedIed.PropertyChanged += IoTestHeaderObservedIed_PropertyChanged; + } + + private void RefreshIoTestHeaderSelection() + { + var header = _ioTestHeaderSelectionCheckBox; + if (header == null) + return; + + var ied = SelectedIed; + header.IsEnabled = CanEditPlan && ied?.TestPoints.Count > 0; + header.IsChecked = ied == null ? false : GetIoTestHeaderSelectionState(ied); + } + + private static bool? GetIoTestHeaderSelectionState(IoTestIedPlan ied) + { + if (ied.TestPoints.Count == 0) + return false; + + var enabled = ied.TestPoints.Count(point => point.TestEnabled); + if (enabled == 0) + return false; + if (enabled == ied.TestPoints.Count) + return true; + return null; + } + + private void IoTestHeaderSelectionWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= IoTestHeaderSelectionWindow_PropertyChanged; + Closed -= IoTestHeaderSelectionWindow_Closed; + + if (_ioTestHeaderObservedIed != null) + _ioTestHeaderObservedIed.PropertyChanged -= IoTestHeaderObservedIed_PropertyChanged; + _ioTestHeaderObservedIed = null; + + if (_ioTestHeaderSelectionCheckBox != null) + _ioTestHeaderSelectionCheckBox.Click -= IoTestHeaderSelectionCheckBox_Click; + } + + private static DataGrid? FindIoTestSelectionGrid(DependencyObject root) + { + foreach (var grid in FindIoTestHeaderVisualChildren(root)) + { + if (grid.Columns.Any(column => + string.Equals(column.Header?.ToString(), "TEST", StringComparison.OrdinalIgnoreCase))) + { + return grid; + } + } + + return null; + } + + private static IEnumerable FindIoTestHeaderVisualChildren(DependencyObject root) + where T : DependencyObject + { + if (root == null) + yield break; + + var count = VisualTreeHelper.GetChildrenCount(root); + for (var index = 0; index < count; index++) + { + var child = VisualTreeHelper.GetChild(root, index); + if (child is T match) + yield return match; + + foreach (var descendant in FindIoTestHeaderVisualChildren(child)) + yield return descendant; + } + } +} From 6cccd08243ee4d5194a0af76ab1e16c69ce30eab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 16:29:01 +0700 Subject: [PATCH 33/34] Add fault-record header select-all checkbox --- FaultRecordWindow.HeaderSelection.cs | 196 +++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 FaultRecordWindow.HeaderSelection.cs diff --git a/FaultRecordWindow.HeaderSelection.cs b/FaultRecordWindow.HeaderSelection.cs new file mode 100644 index 00000000..1540a685 --- /dev/null +++ b/FaultRecordWindow.HeaderSelection.cs @@ -0,0 +1,196 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; + +namespace ArIED61850Tester; + +/// +/// Adds a tri-state select-all checkbox to the fault-record Get column. Selecting all +/// affects only rows that are currently eligible for download; clearing always clears +/// every row so stale disabled selections cannot remain hidden. +/// +public partial class FaultRecordWindow +{ + private static readonly bool FaultRecordHeaderSelectionClassHandlerRegistered = + RegisterFaultRecordHeaderSelectionClassHandler(); + + private readonly HashSet _faultRecordHeaderObservedRows = new(); + private bool _faultRecordHeaderSelectionInstallScheduled; + private bool _faultRecordHeaderSelectionInstalled; + private bool _faultRecordHeaderBulkUpdate; + private CheckBox? _faultRecordHeaderSelectionCheckBox; + + private static bool RegisterFaultRecordHeaderSelectionClassHandler() + { + EventManager.RegisterClassHandler( + typeof(FaultRecordWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(OnFaultRecordHeaderSelectionWindowLoaded), + handledEventsToo: true); + return true; + } + + private static void OnFaultRecordHeaderSelectionWindowLoaded(object sender, RoutedEventArgs e) + { + if (sender is not FaultRecordWindow window || + window._faultRecordHeaderSelectionInstalled || + window._faultRecordHeaderSelectionInstallScheduled) + { + return; + } + + window._faultRecordHeaderSelectionInstallScheduled = true; + window.Dispatcher.BeginInvoke( + DispatcherPriority.ContextIdle, + new Action(() => + { + window._faultRecordHeaderSelectionInstallScheduled = false; + window.EnsureFaultRecordHeaderSelection(); + })); + } + + private void EnsureFaultRecordHeaderSelection() + { + if (_faultRecordHeaderSelectionInstalled) + { + RefreshFaultRecordHeaderSelection(); + return; + } + + var column = FaultRecordsGrid.Columns.FirstOrDefault(candidate => + string.Equals(candidate.Header?.ToString(), "Get", StringComparison.OrdinalIgnoreCase)); + if (column == null) + return; + + var headerCheckBox = new CheckBox + { + Width = 16, + Height = 16, + IsThreeState = true, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Cursor = Cursors.Hand, + Focusable = true, + ToolTip = "Check / uncheck all downloadable fault records" + }; + AutomationProperties.SetName(headerCheckBox, "Toggle all downloadable fault records"); + headerCheckBox.Click += FaultRecordHeaderSelectionCheckBox_Click; + + column.Header = headerCheckBox; + _faultRecordHeaderSelectionCheckBox = headerCheckBox; + _faultRecordHeaderSelectionInstalled = true; + + PropertyChanged += FaultRecordHeaderSelectionWindow_PropertyChanged; + Records.CollectionChanged += FaultRecordHeaderSelectionRecords_CollectionChanged; + Closed += FaultRecordHeaderSelectionWindow_Closed; + RewireFaultRecordHeaderRows(); + RefreshFaultRecordHeaderSelection(); + } + + private void FaultRecordHeaderSelectionCheckBox_Click(object sender, RoutedEventArgs e) + { + if (IsBusy) + { + RefreshFaultRecordHeaderSelection(); + return; + } + + var target = GetFaultRecordHeaderSelectionState() != true; + _faultRecordHeaderBulkUpdate = true; + try + { + foreach (var row in Records) + { + if (target) + row.IsSelected = row.CanSelectForDownload; + else + row.IsSelected = false; + } + } + finally + { + _faultRecordHeaderBulkUpdate = false; + } + + RaiseSelectionState(); + RefreshFaultRecordHeaderSelection(); + } + + private void FaultRecordHeaderSelectionWindow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + => RefreshFaultRecordHeaderSelection(); + + private void FaultRecordHeaderSelectionRecords_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RewireFaultRecordHeaderRows(); + RefreshFaultRecordHeaderSelection(); + } + + private void RewireFaultRecordHeaderRows() + { + var current = Records.ToHashSet(); + foreach (var stale in _faultRecordHeaderObservedRows.Where(row => !current.Contains(row)).ToArray()) + { + stale.PropertyChanged -= FaultRecordHeaderSelectionRow_PropertyChanged; + _faultRecordHeaderObservedRows.Remove(stale); + } + + foreach (var row in Records) + { + if (!_faultRecordHeaderObservedRows.Add(row)) + continue; + + row.PropertyChanged += FaultRecordHeaderSelectionRow_PropertyChanged; + } + } + + private void FaultRecordHeaderSelectionRow_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_faultRecordHeaderBulkUpdate) + return; + + RefreshFaultRecordHeaderSelection(); + } + + private void RefreshFaultRecordHeaderSelection() + { + var header = _faultRecordHeaderSelectionCheckBox; + if (header == null) + return; + + var eligibleCount = Records.Count(row => row.CanSelectForDownload); + header.IsEnabled = !IsBusy && eligibleCount > 0; + header.IsChecked = GetFaultRecordHeaderSelectionState(); + } + + private bool? GetFaultRecordHeaderSelectionState() + { + var eligible = Records.Where(row => row.CanSelectForDownload).ToArray(); + if (eligible.Length == 0) + return false; + + var selected = eligible.Count(row => row.IsSelected); + if (selected == 0) + return false; + if (selected == eligible.Length) + return true; + return null; + } + + private void FaultRecordHeaderSelectionWindow_Closed(object? sender, EventArgs e) + { + PropertyChanged -= FaultRecordHeaderSelectionWindow_PropertyChanged; + Records.CollectionChanged -= FaultRecordHeaderSelectionRecords_CollectionChanged; + Closed -= FaultRecordHeaderSelectionWindow_Closed; + + foreach (var row in _faultRecordHeaderObservedRows) + row.PropertyChanged -= FaultRecordHeaderSelectionRow_PropertyChanged; + _faultRecordHeaderObservedRows.Clear(); + + if (_faultRecordHeaderSelectionCheckBox != null) + _faultRecordHeaderSelectionCheckBox.Click -= FaultRecordHeaderSelectionCheckBox_Click; + } +} From 27c6e07912b3ba9bb58fb6ee6210305a2b85c468 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 16:57:07 +0700 Subject: [PATCH 34/34] Pin ARIEC COMTRADE success baseline #71 --- engines/ARIEC61850.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 277b7a68..03cd9c9e 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "41c939eb1c080389487b461453a304f3c9c3747e", - "sourcePullRequest": 69, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes engine-owned connected reconciliation plus merged COMTRADE/MMS FileOpen interoperability P0-P2: canonical nested single-GraphicString paths, bounded remote-entry revalidation, and one evidence-driven exact-case recovery retry, with no ARSAS-side MMS path heuristics." + "commit": "b60d1272345ec7eabde9efb4e0acbfe26ebf2584", + "sourcePullRequest": 71, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes engine-owned connected reconciliation plus merged COMTRADE/MMS FileOpen interoperability through PR #71, including canonical nested-path handling, bounded directory revalidation/recovery, preservation of the server-returned raw FileDirectory GraphicString identity, and one bounded evidence-driven replay of that exact identity. ARSAS does not invent MMS file-path semantics." }