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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -202,4 +202,4 @@ private static void OnDispatcherUnhandledException(object sender, DispatcherUnha
}
}
}
}
}
22 changes: 22 additions & 0 deletions MainWindow.UiExceptionDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -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 !.");
}
}
109 changes: 78 additions & 31 deletions Services/Iec61850DataSetCompletenessDiagnostic.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@

namespace ArIED61850Tester.Services;

public sealed record Iec61850DataSetCompletenessDataSetSnapshot(
string Reference,
int StaticMemberCount,
int RepresentedCount,
IReadOnlyList<string> MissingReferences)
{
public int MissingCount => MissingReferences.Count;
}

public sealed record Iec61850DataSetCompletenessSnapshot(
int DataSetCount,
int StaticMemberCount,
Expand All @@ -13,9 +22,11 @@ public sealed record Iec61850DataSetCompletenessSnapshot(
{
public int MissingCount => MissingReferences.Count;
public bool IsComplete => StaticMemberCount == RepresentedCount && MissingCount == 0;
public IReadOnlyList<Iec61850DataSetCompletenessDataSetSnapshot> DataSets { get; init; }
= Array.Empty<Iec61850DataSetCompletenessDataSetSnapshot>();

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}";
}

Expand Down Expand Up @@ -45,58 +56,94 @@ public static Iec61850DataSetCompletenessSnapshot Evaluate(

var missing = new List<string>();
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<Iec61850DataSetCompletenessDataSetSnapshot>();

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<string>();
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 ? "<no static member reference>" : memberReference;
var diagnosticReference = $"{dataSet.Reference}[{member.Index}] -> {reference}";
missing.Add(diagnosticReference);
dataSetMissing.Add(diagnosticReference);
}

var reference = member.Reference.Length == 0 ? "<no static member reference>" : 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<string> 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)
Expand Down
9 changes: 7 additions & 2 deletions Services/Iec61850DataSetSignalInventoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/// <summary>
Expand Down
11 changes: 10 additions & 1 deletion SignalSelectionWizardWindow.DataSetAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
110 changes: 110 additions & 0 deletions tests/ARSAS.Tests/OfflineDataSetSignalSelectionRegressionTests.cs
Original file line number Diff line number Diff line change
@@ -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}'.");
}
}
Loading