From 0c3f160cb870aad1edbb9e3b14c5cdb6fd08a82f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 14:12:44 +0700 Subject: [PATCH 1/6] fix: recover DataSet inventory from offline SCL authority --- Services/Iec61850DataSetSignalInventoryService.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index 28ae4fd2..c8b7eff6 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -26,10 +26,15 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( { ArgumentNullException.ThrowIfNull(device); - if (device.LiveDiscoveryModel is null) + // Signal Selection is also opened directly from an offline CID/SCD workspace. + // In that workflow LiveDiscoveryModel is intentionally null; the SCL design model + // is the authoritative inventory and must not be ignored. Prefer the live model + // only after a real association/discovery has produced one. + var authoritativeModel = device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel; + if (authoritativeModel is null) return EmptyResult(); - return EnsureMandatorySignals(device.Signals, device.LiveDiscoveryModel); + return EnsureMandatorySignals(device.Signals, authoritativeModel); } /// From 1fe37eee5e07c2fad6824694845405546399f62c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 14:12:54 +0700 Subject: [PATCH 2/6] fix: preserve static FCDA identity in signal wizard --- SignalSelectionWizardWindow.DataSetAuthority.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/SignalSelectionWizardWindow.DataSetAuthority.cs b/SignalSelectionWizardWindow.DataSetAuthority.cs index db3d64c4..0bce6856 100644 --- a/SignalSelectionWizardWindow.DataSetAuthority.cs +++ b/SignalSelectionWizardWindow.DataSetAuthority.cs @@ -11,7 +11,16 @@ protected override void OnInitialized(EventArgs e) var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(_device); foreach (var signal in merge.AddedSignals) { - signal.DisplayReference = Iec61850MonitorPoint.StripIedNamePrefix(signal.ObjectReference, _device.Name); + // DisplayReference is the engine-authoritative static FCDA/FCD identity. + // Do not rewrite it from ObjectReference: ObjectReference may point to the + // resolved runtime leaf (for example .stVal) while Signal Selection must + // continue to show the exact DataSet member. + if (string.IsNullOrWhiteSpace(signal.DisplayReference)) + { + signal.DisplayReference = Iec61850MonitorPoint.StripIedNamePrefix( + signal.ObjectReference, + _device.Name); + } signal.PropertyChanged += Signal_PropertyChanged; } From cc57c6f325ce53a4fa30e0ed335d390ce6d05824 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 14:13:18 +0700 Subject: [PATCH 3/6] diag: show per-DataSet completeness and balanced missing samples --- .../Iec61850DataSetCompletenessDiagnostic.cs | 109 +++++++++++++----- 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/Services/Iec61850DataSetCompletenessDiagnostic.cs b/Services/Iec61850DataSetCompletenessDiagnostic.cs index 585aaadf..9b44624a 100644 --- a/Services/Iec61850DataSetCompletenessDiagnostic.cs +++ b/Services/Iec61850DataSetCompletenessDiagnostic.cs @@ -3,6 +3,15 @@ namespace ArIED61850Tester.Services; +public sealed record Iec61850DataSetCompletenessDataSetSnapshot( + string Reference, + int StaticMemberCount, + int RepresentedCount, + IReadOnlyList MissingReferences) +{ + public int MissingCount => MissingReferences.Count; +} + public sealed record Iec61850DataSetCompletenessSnapshot( int DataSetCount, int StaticMemberCount, @@ -13,9 +22,11 @@ public sealed record Iec61850DataSetCompletenessSnapshot( { public int MissingCount => MissingReferences.Count; public bool IsComplete => StaticMemberCount == RepresentedCount && MissingCount == 0; + public IReadOnlyList DataSets { get; init; } + = Array.Empty(); public string Summary => - $"DataSets={DataSetCount:N0}; static members={StaticMemberCount:N0}; mandatory inventory={MandatoryInventoryCount:N0}; " + + $"DataSets={DataSetCount:N0}; static members={StaticMemberCount:N0}; semantic descriptors={MandatoryInventoryCount:N0}; " + $"represented={RepresentedCount:N0}/{StaticMemberCount:N0}; primary leaf unresolved={PrimaryLeafUnresolvedCount:N0}; missing={MissingCount:N0}"; } @@ -45,58 +56,94 @@ public static Iec61850DataSetCompletenessSnapshot Evaluate( var missing = new List(); var represented = 0; - var staticMembers = model.DataSets - .OrderBy(dataSet => dataSet.Reference, StringComparer.OrdinalIgnoreCase) - .SelectMany(dataSet => dataSet.Members - .OrderBy(member => member.Index) - .Select(member => new - { - DataSetReference = dataSet.Reference, - member.Index, - Reference = Literal(member.Reference) - })) - .ToArray(); + var staticMemberCount = 0; + var dataSetSnapshots = new List(); - foreach (var member in staticMembers) + foreach (var dataSet in model.DataSets.OrderBy(item => item.Reference, StringComparer.OrdinalIgnoreCase)) { - if (member.Reference.Length > 0 && signalReferences.Contains(member.Reference)) + var dataSetMissing = new List(); + var dataSetRepresented = 0; + var members = dataSet.Members.OrderBy(member => member.Index).ToArray(); + staticMemberCount += members.Length; + + foreach (var member in members) { - represented++; - continue; + var memberReference = Literal(member.Reference); + if (memberReference.Length > 0 && signalReferences.Contains(memberReference)) + { + represented++; + dataSetRepresented++; + continue; + } + + var reference = memberReference.Length == 0 ? "" : memberReference; + var diagnosticReference = $"{dataSet.Reference}[{member.Index}] -> {reference}"; + missing.Add(diagnosticReference); + dataSetMissing.Add(diagnosticReference); } - var reference = member.Reference.Length == 0 ? "" : member.Reference; - missing.Add($"{member.DataSetReference}[{member.Index}] -> {reference}"); + dataSetSnapshots.Add(new Iec61850DataSetCompletenessDataSetSnapshot( + dataSet.Reference, + members.Length, + dataSetRepresented, + dataSetMissing)); } return new Iec61850DataSetCompletenessSnapshot( model.DataSets.Count, - staticMembers.Length, + staticMemberCount, mandatory.Count, represented, mandatory.Count(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved), - missing); + missing) + { + DataSets = dataSetSnapshots + }; } public static IEnumerable FormatReportLines(Iec61850DataSetCompletenessSnapshot snapshot, int maxMissing = 12) { ArgumentNullException.ThrowIfNull(snapshot); - yield return $"Static DataSets : {snapshot.DataSetCount:N0}"; - yield return $"Static members : {snapshot.StaticMemberCount:N0}"; - yield return $"Mandatory inventory: {snapshot.MandatoryInventoryCount:N0} descriptor(s)"; - yield return $"Signal Selection : {snapshot.RepresentedCount:N0}/{snapshot.StaticMemberCount:N0} static member(s) represented"; - yield return $"Primary unresolved: {snapshot.PrimaryLeafUnresolvedCount:N0}"; - yield return $"Missing inventory : {snapshot.MissingCount:N0}"; + yield return $"Static DataSets : {snapshot.DataSetCount:N0}"; + yield return $"Static members : {snapshot.StaticMemberCount:N0}"; + yield return $"Signal Selection : {snapshot.RepresentedCount:N0}/{snapshot.StaticMemberCount:N0} static member(s) represented"; + yield return $"Missing static member: {snapshot.MissingCount:N0}"; + yield return $"Semantic descriptors : {snapshot.MandatoryInventoryCount:N0}"; + yield return $"Primary unresolved : {snapshot.PrimaryLeafUnresolvedCount:N0}"; + + foreach (var dataSet in snapshot.DataSets) + { + yield return $" {dataSet.Reference}: {dataSet.RepresentedCount:N0}/{dataSet.StaticMemberCount:N0} represented • {dataSet.MissingCount:N0} missing"; + } - if (snapshot.MissingCount == 0) + if (snapshot.MissingCount == 0 || maxMissing <= 0) yield break; - foreach (var reference in snapshot.MissingReferences.Take(Math.Max(0, maxMissing))) - yield return $" MISSING : {reference}"; + // Sample every failing DataSet instead of taking only the first N members from + // the first DataSet. This keeps Analog and Digital evidence visible together. + var failingDataSets = snapshot.DataSets + .Where(dataSet => dataSet.MissingCount > 0) + .ToArray(); + var perDataSetLimit = Math.Max(1, maxMissing / Math.Max(1, failingDataSets.Length)); + var emitted = 0; + + foreach (var dataSet in failingDataSets) + { + foreach (var reference in dataSet.MissingReferences.Take(perDataSetLimit)) + { + if (emitted >= maxMissing) + break; + yield return $" MISSING : {reference}"; + emitted++; + } + + if (emitted >= maxMissing) + break; + } - if (snapshot.MissingCount > maxMissing) - yield return $" ... : {snapshot.MissingCount - maxMissing:N0} more missing member(s)"; + if (snapshot.MissingCount > emitted) + yield return $" ... : {snapshot.MissingCount - emitted:N0} more missing member(s)"; } private static string Literal(string? reference) From d2e197ebb3d53c9d1976f4c287da8e583a4debf5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 14:13:47 +0700 Subject: [PATCH 4/6] diag: route full UI exception evidence --- App.xaml.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/App.xaml.cs b/App.xaml.cs index f3412836..092cb9d4 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -181,7 +181,7 @@ private static void OnDispatcherUnhandledException(object sender, DispatcherUnha { Debug.WriteLine(exception); if (Current?.MainWindow is MainWindow mainWindow) - mainWindow.ReportUnexpectedUiError(exception); + mainWindow.ReportUnexpectedUiErrorWithStackTrace(exception); } catch (Exception reportingError) { @@ -202,4 +202,4 @@ private static void OnDispatcherUnhandledException(object sender, DispatcherUnha } } } -} \ No newline at end of file +} From d8ca3b4cd735bd75d8b7a3803d6b562d48b52bb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 14:13:58 +0700 Subject: [PATCH 5/6] diag: retain full UI exception stack trace --- MainWindow.UiExceptionDiagnostics.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 MainWindow.UiExceptionDiagnostics.cs diff --git a/MainWindow.UiExceptionDiagnostics.cs b/MainWindow.UiExceptionDiagnostics.cs new file mode 100644 index 00000000..27c388f6 --- /dev/null +++ b/MainWindow.UiExceptionDiagnostics.cs @@ -0,0 +1,22 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + internal void ReportUnexpectedUiErrorWithStackTrace(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + _pendingDiagnostics.Enqueue(new DiagnosticEntry + { + Time = DateTime.Now, + Level = "ERROR", + Source = "UI", + Message = exception.ToString() + }); + + MarkDiagnosticAlert(); + SetStatus("Unexpected UI error captured with stack trace. Diagnostics is marked with !."); + } +} From 3a383ea07fdb04ace48c4229417b68cf53864e6d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 14:14:18 +0700 Subject: [PATCH 6/6] test: lock offline DataSet Signal Selection recovery --- ...neDataSetSignalSelectionRegressionTests.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs diff --git a/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs new file mode 100644 index 00000000..202567b7 --- /dev/null +++ b/tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs @@ -0,0 +1,110 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class OfflineDataSetSignalSelectionRegressionTests +{ + [Fact] + public void DeviceInventoryMerge_FallsBackToOfflineSclDesignModel() + { + var source = File.ReadAllText(FindRepoFile("Services/Iec61850DataSetSignalInventoryService.cs")); + + Assert.Contains( + "device.LiveDiscoveryModel ?? device.SclWorkspace?.DesignModel", + source, + StringComparison.Ordinal); + Assert.DoesNotContain( + "if (device.LiveDiscoveryModel is null)\n return EmptyResult();", + source, + StringComparison.Ordinal); + } + + [Fact] + public void SignalSelectionRecovery_DoesNotOverwriteStaticFcdaDisplayIdentity() + { + var source = File.ReadAllText(FindRepoFile("SignalSelectionWizardWindow.DataSetAuthority.cs")); + + Assert.Contains( + "if (string.IsNullOrWhiteSpace(signal.DisplayReference))", + source, + StringComparison.Ordinal); + Assert.Contains( + "DisplayReference is the engine-authoritative static FCDA/FCD identity", + source, + StringComparison.Ordinal); + } + + [Fact] + public void CompletenessReport_SamplesEveryFailingDataSetAndSeparatesSemanticDescriptors() + { + var snapshot = new Iec61850DataSetCompletenessSnapshot( + DataSetCount: 2, + StaticMemberCount: 4, + MandatoryInventoryCount: 6, + RepresentedCount: 0, + PrimaryLeafUnresolvedCount: 2, + MissingReferences: new[] + { + "IEDApplication/LLN0$Analog[1] -> IEDLD0/MMXU1.A.phsA", + "IEDApplication/LLN0$Analog[2] -> IEDLD0/MMXU1.A.phsB", + "IEDApplication/LLN0$Digital[1] -> IEDADD/GGIO6.CBOpnd", + "IEDApplication/LLN0$Digital[2] -> IEDADD/GGIO6.CBClsd" + }) + { + DataSets = new[] + { + new Iec61850DataSetCompletenessDataSetSnapshot( + "IEDApplication/LLN0$Analog", + 2, + 0, + new[] + { + "IEDApplication/LLN0$Analog[1] -> IEDLD0/MMXU1.A.phsA", + "IEDApplication/LLN0$Analog[2] -> IEDLD0/MMXU1.A.phsB" + }), + new Iec61850DataSetCompletenessDataSetSnapshot( + "IEDApplication/LLN0$Digital", + 2, + 0, + new[] + { + "IEDApplication/LLN0$Digital[1] -> IEDADD/GGIO6.CBOpnd", + "IEDApplication/LLN0$Digital[2] -> IEDADD/GGIO6.CBClsd" + }) + } + }; + + var lines = Iec61850DataSetCompletenessDiagnostic.FormatReportLines(snapshot, maxMissing: 4).ToArray(); + + Assert.Contains(lines, line => line.Contains("Semantic descriptors : 6", StringComparison.Ordinal)); + Assert.Contains(lines, line => line.Contains("LLN0$Analog", StringComparison.Ordinal)); + Assert.Contains(lines, line => line.Contains("LLN0$Digital", StringComparison.Ordinal)); + Assert.Contains(lines, line => line.Contains("MMXU1.A.phsA", StringComparison.Ordinal)); + Assert.Contains(lines, line => line.Contains("GGIO6.CBOpnd", StringComparison.Ordinal)); + } + + [Fact] + public void UiDispatcherError_RoutesFullExceptionEvidence() + { + var appSource = File.ReadAllText(FindRepoFile("App.xaml.cs")); + var detailSource = File.ReadAllText(FindRepoFile("MainWindow.UiExceptionDiagnostics.cs")); + + Assert.Contains("ReportUnexpectedUiErrorWithStackTrace(exception)", appSource, StringComparison.Ordinal); + Assert.Contains("Message = exception.ToString()", detailSource, StringComparison.Ordinal); + } + + private static string FindRepoFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Could not locate repository file '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +}