diff --git a/MainWindow.DataSetSignalInventory.cs b/MainWindow.DataSetSignalInventory.cs new file mode 100644 index 000000000..ca1413ddc --- /dev/null +++ b/MainWindow.DataSetSignalInventory.cs @@ -0,0 +1,40 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + internal void RegisterRecoveredDataSetSignals( + Iec61850MonitorDevice device, + Iec61850DataSetSignalInventoryMergeResult merge) + { + ArgumentNullException.ThrowIfNull(device); + ArgumentNullException.ThrowIfNull(merge); + + foreach (var signal in merge.AddedSignals) + { + // The wizard can recover a row after the normal discovery collection has + // already been registered with MainWindow. Bring that row under the same + // application lifecycle without changing its user-selection state. + signal.PropertyChanged -= Signal_PropertyChanged; + signal.PropertyChanged += Signal_PropertyChanged; + _signalOwners[signal] = device; + } + + if (merge.AddedCount == 0 && merge.EnrichedExistingCount == 0) + return; + + device.RecountSelectedSignals(); + device.RefreshComputed(); + RaiseWorkspaceCounts(); + + if (merge.AddedCount > 0) + { + AddLog( + "INFO", + device.Name, + $"ARIEC DataSet authority restored {merge.AddedCount} mandatory primary signal(s) to the selection inventory; user selection was not changed."); + } + } +} diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs new file mode 100644 index 000000000..652280b77 --- /dev/null +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -0,0 +1,217 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public sealed record Iec61850DataSetSignalInventoryMergeResult( + IReadOnlyList AddedSignals, + int EnrichedExistingCount, + int MandatoryCatalogCount) +{ + public int AddedCount => AddedSignals.Count; +} + +/// +/// Application-side projection of ARIEC-owned DataSet signal authority. +/// +/// ARIEC decides which IEC 61850 signals are mandatory primary DataSet members. +/// ARSAS only guarantees that those already-classified engine signals are present in +/// the user-visible signal inventory. No IEC reference guessing, fuzzy matching, or +/// DataSet semantic inference is performed here. +/// +public static class Iec61850DataSetSignalInventoryService +{ + public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( + Iec61850MonitorDevice device) + { + ArgumentNullException.ThrowIfNull(device); + + if (device.LiveDiscoveryModel is null) + return new Iec61850DataSetSignalInventoryMergeResult(Array.Empty(), 0, 0); + + var catalog = Iec61850SignalCatalogBuilder.Build(device.LiveDiscoveryModel); + var mandatory = catalog.GetMandatoryPrimarySignals(); + if (mandatory.Count == 0) + return new Iec61850DataSetSignalInventoryMergeResult(Array.Empty(), 0, 0); + + // Keep application matching literal. The engine owns IEC 61850 reference + // canonicalization; ARSAS only compares the reference forms that the engine has + // already exposed on the descriptor. + var existing = device.Signals + .Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference)) + .GroupBy(signal => LiteralReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + var added = new List(); + var enriched = 0; + + foreach (var descriptor in mandatory) + { + var engineReferences = EngineReferenceCandidates(descriptor).ToArray(); + var current = engineReferences + .Select(reference => existing.TryGetValue(reference, out var signal) ? signal : null) + .FirstOrDefault(signal => signal is not null); + + if (current is not null) + { + if (ApplyEngineDataSetAuthority(current, descriptor)) + enriched++; + continue; + } + + var reference = FirstNonEmpty( + descriptor.PrimaryValueReference, + descriptor.DesignReference, + descriptor.ObservedReference); + if (string.IsNullOrWhiteSpace(reference)) + continue; + + var signal = CreateSignal(descriptor, reference); + device.Signals.Add(signal); + foreach (var key in EngineReferenceCandidates(descriptor).Append(LiteralReference(reference))) + existing.TryAdd(key, signal); + added.Add(signal); + } + + return new Iec61850DataSetSignalInventoryMergeResult(added, enriched, mandatory.Count); + } + + private static SignalDefinition CreateSignal( + Iec61850SignalDescriptor descriptor, + string reference) + { + var primaryMembership = FirstMembership(descriptor); + var report = descriptor.ReportMemberships.FirstOrDefault(); + var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"); + return new SignalDefinition + { + Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, reference), + ObjectReference = reference, + FunctionalConstraint = descriptor.FunctionalConstraint, + DataType = dataType, + Category = "DataSet", + Confidence = "High", + DataSetReference = primaryMembership?.DataSetReference ?? string.Empty, + ReportControlReference = report?.ReportControlReference ?? string.Empty, + QualityReference = descriptor.QualityReference, + TimestampReference = descriptor.TimestampReference, + Source = "ARIEC61850 signal catalog • mandatory static DataSet member", + IsSelected = false, + IsReportCapable = true, + ReportCoverage = report is null + ? "Static DataSet member • MMS polling fallback" + : "Static report/DataSet • polling fallback", + ReportCoverageReason = BuildCoverageReason(descriptor), + ProbeStatus = "Not probed", + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-" + }; + } + + private static bool ApplyEngineDataSetAuthority( + SignalDefinition signal, + Iec61850SignalDescriptor descriptor) + { + var changed = false; + var membership = FirstMembership(descriptor); + var report = descriptor.ReportMemberships.FirstOrDefault(); + + if (membership is not null && + !string.Equals(signal.DataSetReference, membership.DataSetReference, StringComparison.OrdinalIgnoreCase)) + { + signal.DataSetReference = membership.DataSetReference; + changed = true; + } + + if (report is not null && string.IsNullOrWhiteSpace(signal.ReportControlReference)) + { + signal.ReportControlReference = report.ReportControlReference; + changed = true; + } + + if (!signal.IsReportCapable) + { + signal.IsReportCapable = true; + changed = true; + } + + if ((string.IsNullOrWhiteSpace(signal.ReportCoverage) || + signal.ReportCoverage.Equals("Polling fallback", StringComparison.OrdinalIgnoreCase)) && + (membership is not null || report is not null)) + { + signal.ReportCoverage = report is null + ? "Static DataSet member • MMS polling fallback" + : "Static report/DataSet • polling fallback"; + changed = true; + } + + var reason = BuildCoverageReason(descriptor); + if (!string.Equals(signal.ReportCoverageReason, reason, StringComparison.Ordinal)) + { + signal.ReportCoverageReason = reason; + changed = true; + } + + if (string.IsNullOrWhiteSpace(signal.QualityReference) && !string.IsNullOrWhiteSpace(descriptor.QualityReference)) + { + signal.QualityReference = descriptor.QualityReference; + changed = true; + } + + if (string.IsNullOrWhiteSpace(signal.TimestampReference) && !string.IsNullOrWhiteSpace(descriptor.TimestampReference)) + { + signal.TimestampReference = descriptor.TimestampReference; + changed = true; + } + + return changed; + } + + private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescriptor descriptor) + { + var values = new[] + { + descriptor.PrimaryValueReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueMmsReference, + descriptor.CanonicalMmsReference, + descriptor.EffectiveMmsReference, + descriptor.ObservedMmsReference + }; + + return values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(LiteralReference) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static Iec61850SignalDataSetMembership? FirstMembership(Iec61850SignalDescriptor descriptor) + => descriptor.DataSetMemberships + .OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(membership => membership.MemberIndex) + .FirstOrDefault(); + + private static string BuildCoverageReason(Iec61850SignalDescriptor descriptor) + { + var memberships = descriptor.DataSetMemberships + .OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(membership => membership.MemberIndex) + .Select(membership => $"{membership.DataSetReference}[{membership.MemberIndex}]") + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var membershipText = memberships.Length == 0 + ? "static DataSet membership" + : string.Join(", ", memberships); + + return $"ARIEC61850 mandatory primary DataSet signal: {membershipText}. " + + "Inventory presence is engine-authoritative; user selection remains independent."; + } + + private static string LiteralReference(string? reference) + => (reference ?? string.Empty).Trim(); + + private static string FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; +} diff --git a/SignalSelectionWizardWindow.DataSetAuthority.cs b/SignalSelectionWizardWindow.DataSetAuthority.cs new file mode 100644 index 000000000..db3d64c40 --- /dev/null +++ b/SignalSelectionWizardWindow.DataSetAuthority.cs @@ -0,0 +1,32 @@ +using System.Windows; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class SignalSelectionWizardWindow +{ + 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); + signal.PropertyChanged += Signal_PropertyChanged; + } + + // Window initialization can run inside InitializeComponent(), before the caller's + // object initializer assigns Owner. Register recovered rows through the actual + // application MainWindow so they receive the same owner/property-change lifecycle + // as rows produced by the normal discovery pipeline. + if (Application.Current?.MainWindow is MainWindow mainWindow) + mainWindow.RegisterRecoveredDataSetSignals(_device, merge); + else + { + _device.RecountSelectedSignals(); + _device.RefreshComputed(); + } + + base.OnInitialized(e); + } +} diff --git a/tests/ARSAS.Tests/DataSetSignalInventoryTests.cs b/tests/ARSAS.Tests/DataSetSignalInventoryTests.cs new file mode 100644 index 000000000..2d2e8072d --- /dev/null +++ b/tests/ARSAS.Tests/DataSetSignalInventoryTests.cs @@ -0,0 +1,213 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class DataSetSignalInventoryTests +{ + [Fact] + public void MandatoryPrimaryDataSetSignal_IsPresentBeforeUserSelection() + { + var device = new Iec61850MonitorDevice + { + Name = "IED", + LiveDiscoveryModel = BuildDataSetModel() + }; + + Assert.Empty(device.Signals); + + var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + + var signal = Assert.Single(device.Signals); + Assert.Equal(1, result.MandatoryCatalogCount); + Assert.Equal(1, result.AddedCount); + Assert.Equal("IEDLD0/GGIO1.Ind1.stVal", signal.ObjectReference); + Assert.Equal("IEDLD0/LLN0.Events", signal.DataSetReference); + Assert.Equal("ST", signal.FunctionalConstraint); + Assert.False(signal.IsSelected); + Assert.True(signal.IsReportCapable); + Assert.Equal("Not probed", signal.ProbeStatus); + Assert.Contains("mandatory primary DataSet signal", signal.ReportCoverageReason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ReopeningSignalSelection_DoesNotDuplicateMandatoryDataSetSignal() + { + var device = new Iec61850MonitorDevice + { + Name = "IED", + LiveDiscoveryModel = BuildDataSetModel() + }; + + var first = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + var second = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + + Assert.Equal(1, first.AddedCount); + Assert.Equal(0, second.AddedCount); + Assert.Single(device.Signals); + Assert.False(device.Signals[0].IsSelected); + } + + [Fact] + public void ExistingDiscoveredSignal_IsEnrichedInsteadOfDuplicated() + { + var device = new Iec61850MonitorDevice + { + Name = "IED", + LiveDiscoveryModel = BuildDataSetModel() + }; + device.Signals.Add(new SignalDefinition + { + Name = "Ind1", + ObjectReference = "IEDLD0/GGIO1.Ind1.stVal", + FunctionalConstraint = "ST", + DataType = "Boolean", + Category = "Status", + IsSelected = false + }); + + var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + + var signal = Assert.Single(device.Signals); + Assert.Equal(0, result.AddedCount); + Assert.Equal(1, result.EnrichedExistingCount); + Assert.Equal("IEDLD0/LLN0.Events", signal.DataSetReference); + Assert.True(signal.IsReportCapable); + Assert.False(signal.IsSelected); + } + + [Fact] + public void ExistingMmsStyleReference_MatchesLiteralEngineReferenceWithoutAppCanonicalization() + { + var device = new Iec61850MonitorDevice + { + Name = "IED", + LiveDiscoveryModel = BuildDataSetModel() + }; + device.Signals.Add(new SignalDefinition + { + Name = "Ind1", + ObjectReference = "IEDLD0/GGIO1$ST$Ind1$stVal", + FunctionalConstraint = "ST", + DataType = "Boolean", + Category = "Status", + IsSelected = false + }); + + var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + + var signal = Assert.Single(device.Signals); + Assert.Equal(0, result.AddedCount); + Assert.Equal(1, result.EnrichedExistingCount); + Assert.Equal("IEDLD0/GGIO1$ST$Ind1$stVal", signal.ObjectReference); + Assert.Equal("IEDLD0/LLN0.Events", signal.DataSetReference); + } + + [Fact] + public void NonDataSetSignal_IsNotPromotedIntoMandatoryInventory() + { + var model = BuildDataSetModel(includeNonDataSetAttribute: true); + var device = new Iec61850MonitorDevice + { + Name = "IED", + LiveDiscoveryModel = model + }; + + var result = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + + Assert.Equal(1, result.MandatoryCatalogCount); + Assert.Single(device.Signals); + Assert.DoesNotContain(device.Signals, signal => + signal.ObjectReference.Equals("IEDLD0/GGIO1.Beh.stVal", StringComparison.OrdinalIgnoreCase)); + } + + private static LiveIedModelDiscoveryDocument BuildDataSetModel(bool includeNonDataSetAttribute = false) + { + var attributes = new List + { + new() + { + ObjectReference = "IEDLD0/GGIO1.Ind1.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + MmsReference = "IEDLD0/GGIO1$ST$Ind1$stVal", + MmsItemName = "GGIO1$ST$Ind1$stVal", + SclBType = "BOOLEAN", + MmsType = "boolean", + Source = "LiveMmsDiscovery" + } + }; + + if (includeNonDataSetAttribute) + { + attributes.Add(new LiveIedDataAttributeModel + { + ObjectReference = "IEDLD0/GGIO1.Beh.stVal", + AttributePath = "stVal", + FunctionalConstraint = "ST", + MmsReference = "IEDLD0/GGIO1$ST$Beh$stVal", + MmsItemName = "GGIO1$ST$Beh$stVal", + SclBType = "Enum", + MmsType = "integer", + Source = "LiveMmsDiscovery" + }); + } + + return new LiveIedModelDiscoveryDocument + { + Source = "LiveMmsDiscovery", + IedName = "IED", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", + Inst = "LD0", + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = "GGIO1", + LnClass = "GGIO", + LnInst = "1", + DataObjects = new[] + { + new LiveIedDataObjectModel + { + Reference = "IEDLD0/GGIO1.Ind1", + Name = "Ind1", + InferredCdc = "SPS", + ConfidenceLevel = LiveIedDiscoveryConfidenceLevel.Exact, + Attributes = attributes + } + } + } + } + } + }, + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = "IEDLD0/LLN0.Events", + Domain = "IEDLD0", + LogicalNode = "LLN0", + Name = "Events", + MemberCount = 1, + Members = new[] + { + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = "IEDLD0/GGIO1.Ind1.stVal", + FunctionalConstraint = "ST", + MmsReference = "IEDLD0/GGIO1$ST$Ind1$stVal", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + } + } + } + } + }; + } +}