diff --git a/MainWindow.SclLiveModelAuthority.cs b/MainWindow.SclLiveModelAuthority.cs new file mode 100644 index 00000000..dd61b00c --- /dev/null +++ b/MainWindow.SclLiveModelAuthority.cs @@ -0,0 +1,99 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using System.Windows; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +public partial class MainWindow +{ + private bool _sclLiveModelAuthorityTrackingAttached; + + static MainWindow() + { + EventManager.RegisterClassHandler( + typeof(MainWindow), + FrameworkElement.LoadedEvent, + new RoutedEventHandler(MainWindowAuthorityLoaded)); + } + + private static void MainWindowAuthorityLoaded(object sender, RoutedEventArgs e) + { + if (sender is MainWindow window) + window.AttachSclLiveModelAuthorityTracking(); + } + + private void AttachSclLiveModelAuthorityTracking() + { + if (_sclLiveModelAuthorityTrackingAttached) + return; + + _sclLiveModelAuthorityTrackingAttached = true; + Devices.CollectionChanged += Devices_AuthorityCollectionChanged; + foreach (var device in Devices) + TrackAuthorityDevice(device); + } + + private void Devices_AuthorityCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (var device in e.OldItems.OfType()) + device.PropertyChanged -= AuthorityDevice_PropertyChanged; + } + + if (e.NewItems != null) + { + foreach (var device in e.NewItems.OfType()) + TrackAuthorityDevice(device); + } + } + + private void TrackAuthorityDevice(Iec61850MonitorDevice device) + { + RegisterAuthorityModels(device); + device.PropertyChanged -= AuthorityDevice_PropertyChanged; + device.PropertyChanged += AuthorityDevice_PropertyChanged; + } + + private static void AuthorityDevice_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is Iec61850MonitorDevice device) + RegisterAuthorityModels(device); + } + + private static void RegisterAuthorityModels(Iec61850MonitorDevice device) + { + var workspace = device.SclWorkspace; + if (workspace != null) + { + SclLiveModelAuthorityRegistry.RegisterDesign( + workspace.IedName, + workspace.AccessPointName, + workspace.DesignModel); + } + + var live = device.LiveDiscoveryModel; + if (live == null) + return; + + RegisterLiveIdentity(device.Name, workspace?.AccessPointName, live); + RegisterLiveIdentity(live.IedName, workspace?.AccessPointName, live); + RegisterLiveIdentity(device.Name, device.SclAccessPointName, live); + RegisterLiveIdentity(live.IedName, device.SclAccessPointName, live); + RegisterLiveIdentity(device.Name, live.AccessPointName, live); + RegisterLiveIdentity(live.IedName, live.AccessPointName, live); + } + + private static void RegisterLiveIdentity( + string? iedName, + string? accessPointName, + AR.Iec61850.Discovery.LiveIedModelDiscoveryDocument model) + { + if (string.IsNullOrWhiteSpace(iedName)) + return; + + SclLiveModelAuthorityRegistry.RegisterLive(iedName, accessPointName, model); + } +} diff --git a/Models/MonitorModels.cs b/Models/MonitorModels.cs index d8303533..3fda8d1c 100644 --- a/Models/MonitorModels.cs +++ b/Models/MonitorModels.cs @@ -53,6 +53,7 @@ public SclIedWorkspace? SclWorkspace { if (ReferenceEquals(_sclWorkspace, value)) return; _sclWorkspace = value; + RefreshAuthoritativeSclComparison(); RefreshComputed(); } } @@ -64,6 +65,7 @@ public LiveIedModelDiscoveryDocument? LiveDiscoveryModel { if (ReferenceEquals(_liveDiscoveryModel, value)) return; _liveDiscoveryModel = value; + RefreshAuthoritativeSclComparison(); RefreshComputed(); } } @@ -73,12 +75,25 @@ public SclLiveModelComparisonResult? SclComparison get => _sclComparison; set { - if (ReferenceEquals(_sclComparison, value)) return; - _sclComparison = value; + var effective = BuildAuthoritativeSclComparison() ?? value; + if (ReferenceEquals(_sclComparison, effective)) return; + _sclComparison = effective; RefreshComputed(); } } + private SclLiveModelComparisonResult? BuildAuthoritativeSclComparison() + => _sclWorkspace != null && _liveDiscoveryModel != null + ? SclLiveModelComparer.Compare(_sclWorkspace.DesignModel, _liveDiscoveryModel) + : null; + + private void RefreshAuthoritativeSclComparison() + { + var comparison = BuildAuthoritativeSclComparison(); + if (comparison != null) + _sclComparison = comparison; + } + public string SclSourcePath { get => _sclSourcePath; diff --git a/SclLiveSignalModelProjection.Authority.cs b/SclLiveSignalModelProjection.Authority.cs new file mode 100644 index 00000000..d0e084c0 --- /dev/null +++ b/SclLiveSignalModelProjection.Authority.cs @@ -0,0 +1,37 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// MainWindow compatibility shim. Engine-owned design/live models are authoritative; +/// SignalDefinition projection is used only for legacy/cached rows without engine model provenance. +/// +internal static class SclLiveSignalModelProjection +{ + public static LiveIedModelDiscoveryDocument Build( + string iedName, + string accessPointName, + IReadOnlyList signals) + { + ArgumentNullException.ThrowIfNull(signals); + + var isDesignRows = signals.Any(signal => + signal.Source.Equals("SCL design model", StringComparison.OrdinalIgnoreCase)); + + if (isDesignRows && + SclLiveModelAuthorityRegistry.TryGetDesign(iedName, accessPointName, out var designModel)) + { + return designModel; + } + + if (!isDesignRows && + SclLiveModelAuthorityRegistry.TryGetLive(iedName, accessPointName, out var liveModel)) + { + return liveModel; + } + + return Services.SclLiveSignalModelProjection.Build(iedName, accessPointName, signals); + } +} diff --git a/Services/SclLiveModelAuthorityRegistry.cs b/Services/SclLiveModelAuthorityRegistry.cs new file mode 100644 index 00000000..6689d6da --- /dev/null +++ b/Services/SclLiveModelAuthorityRegistry.cs @@ -0,0 +1,53 @@ +using System.Collections.Concurrent; +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Services; + +/// +/// Application provenance registry for engine-owned design/live models. +/// It performs no IEC 61850 reference, CDC, FC, DataSet, or vendor interpretation. +/// +public static class SclLiveModelAuthorityRegistry +{ + private static readonly ConcurrentDictionary> DesignModels + = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary> LiveModels + = new(StringComparer.OrdinalIgnoreCase); + + public static void RegisterDesign(string? iedName, string? accessPointName, LiveIedModelDiscoveryDocument model) + { + ArgumentNullException.ThrowIfNull(model); + DesignModels[Key(iedName, accessPointName)] = new(model); + } + + public static void RegisterLive(string? iedName, string? accessPointName, LiveIedModelDiscoveryDocument model) + { + ArgumentNullException.ThrowIfNull(model); + LiveModels[Key(iedName, accessPointName)] = new(model); + } + + public static bool TryGetDesign(string? iedName, string? accessPointName, out LiveIedModelDiscoveryDocument model) + => TryGet(DesignModels, Key(iedName, accessPointName), out model); + + public static bool TryGetLive(string? iedName, string? accessPointName, out LiveIedModelDiscoveryDocument model) + => TryGet(LiveModels, Key(iedName, accessPointName), out model); + + private static bool TryGet( + ConcurrentDictionary> registry, + string key, + out LiveIedModelDiscoveryDocument model) + { + model = null!; + if (!registry.TryGetValue(key, out var weak)) + return false; + if (weak.TryGetTarget(out model!)) + return true; + + registry.TryRemove(key, out _); + model = null!; + return false; + } + + private static string Key(string? iedName, string? accessPointName) + => $"{(iedName ?? string.Empty).Trim()}|{(accessPointName ?? string.Empty).Trim()}"; +} diff --git a/Services/SclWorkspaceSignalMapper.cs b/Services/SclWorkspaceSignalMapper.cs index 25cd8a50..bfb3e55b 100644 --- a/Services/SclWorkspaceSignalMapper.cs +++ b/Services/SclWorkspaceSignalMapper.cs @@ -6,7 +6,8 @@ namespace ArIED61850Tester.Services; /// /// Presentation adapter from the engine-owned SCL workspace model to ArIED signal rows. -/// This class deliberately contains no XML parsing or IEC 61850 type-template traversal. +/// This class deliberately contains no XML parsing, IEC 61850 type-template traversal, +/// FCD expansion, or CDC value-selection semantics. /// public static class SclWorkspaceSignalMapper { @@ -19,7 +20,11 @@ public static IReadOnlyList BuildSignals(SclIedWorkspace works { ArgumentNullException.ThrowIfNull(workspace); - var dataSetBindings = BuildDataSetBindings(workspace.DesignModel); + // The engine is authoritative for FCD/FCDA -> DataAttribute semantics. + // ARSAS only projects the engine result into operator-facing rows. + var semanticBindings = Iec61850DataSetSemanticBindingResolver.Resolve(workspace.DesignModel); + var dataSetBindings = BuildDataSetBindings(semanticBindings); + var staticPrimaryReferences = BuildStaticPrimaryReferences(semanticBindings); var reportBindings = BuildReportBindings(workspace.DesignModel); var signals = new List(); @@ -36,8 +41,9 @@ public static IReadOnlyList BuildSignals(SclIedWorkspace works } return signals - .Where(signal => SasOperationalSignalPolicy.IsVisible(signal)) - .GroupBy(signal => NormalizeReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) + .Where(signal => SasOperationalSignalPolicy.IsVisible(signal) || + staticPrimaryReferences.Contains(signal.ObjectReference)) + .GroupBy(signal => NormalizePresentationReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .OrderBy(signal => signal.SortPriority) .ThenBy(signal => signal.LogicalNode, StringComparer.OrdinalIgnoreCase) @@ -64,9 +70,8 @@ private static void AddRuntimeSignals( continue; var reference = attribute.ObjectReference; - var normalized = NormalizeReference(reference); - dataSetBindings.TryGetValue(normalized, out var dataSetReference); - reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); + dataSetBindings.TryGetValue(reference, out var dataSetReference); + reportBindings.TryGetValue(dataSetReference ?? string.Empty, out var reportReference); var category = ResolveCategory(logicalNode.LnClass, dataObject.Name, dataObject.InferredCdc, fc); signals.Add(new SignalDefinition @@ -120,9 +125,8 @@ private static void AddControlSignal( Leaf(attribute.AttributePath).Equals("ctlModel", StringComparison.OrdinalIgnoreCase)); var status = dataObject.Attributes.FirstOrDefault(attribute => Leaf(attribute.AttributePath).Equals("stVal", StringComparison.OrdinalIgnoreCase)); - var normalizedStatus = NormalizeReference(status?.ObjectReference); - dataSetBindings.TryGetValue(normalizedStatus, out var dataSetReference); - reportBindings.TryGetValue(NormalizeReference(dataSetReference), out var reportReference); + dataSetBindings.TryGetValue(status?.ObjectReference ?? string.Empty, out var dataSetReference); + reportBindings.TryGetValue(dataSetReference ?? string.Empty, out var reportReference); signals.Add(new SignalDefinition { @@ -153,29 +157,37 @@ private static void AddControlSignal( }); } - private static Dictionary BuildDataSetBindings(LiveIedModelDiscoveryDocument model) + private static Dictionary BuildDataSetBindings(LiveIedDataSetSemanticBindingDocument semanticBindings) { var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var dataSet in model.DataSets) + foreach (var member in semanticBindings.Members) { - foreach (var member in dataSet.Members) + if (!member.IsResolved) + continue; + + foreach (var attribute in member.ResolvedAttributes) { - var key = NormalizeReference(member.Reference); - if (!string.IsNullOrWhiteSpace(key)) - bindings.TryAdd(key, dataSet.Reference); + if (!string.IsNullOrWhiteSpace(attribute.Reference)) + bindings.TryAdd(attribute.Reference, member.DataSetReference); } } + return bindings; } + private static HashSet BuildStaticPrimaryReferences(LiveIedDataSetSemanticBindingDocument semanticBindings) + => semanticBindings.Members + .Select(member => member.PrimaryValueReference) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + private static Dictionary BuildReportBindings(LiveIedModelDiscoveryDocument model) { var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var report in model.ReportControls) { - var key = NormalizeReference(report.DataSetReference); - if (!string.IsNullOrWhiteSpace(key)) - bindings.TryAdd(key, report.Reference); + if (!string.IsNullOrWhiteSpace(report.DataSetReference)) + bindings.TryAdd(report.DataSetReference, report.Reference); } return bindings; } @@ -243,19 +255,13 @@ private static string Leaf(string? path) return index >= 0 ? text[(index + 1)..] : text; } - private static string NormalizeReference(string? reference) + /// + /// Presentation-only dedup normalization. It must not be used to infer + /// DataSet membership or IEC 61850 semantic targets; the engine owns that. + /// + private static string NormalizePresentationReference(string? reference) { - var text = (reference ?? string.Empty).Trim(); - var fcMarker = text.LastIndexOf(" [", StringComparison.Ordinal); - if (fcMarker >= 0) - text = text[..fcMarker]; - text = text.Replace('$', '.').Replace("//", "/", StringComparison.Ordinal).Trim(); - - // SCL FCDA display references commonly use IED/LD/LN while MMS domains use IEDLD/LN. - var parts = text.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length >= 3) - text = string.Concat(parts[0], parts[1], "/", string.Join("/", parts.Skip(2))); - + var text = (reference ?? string.Empty).Trim().Replace('$', '.').Replace("//", "/", StringComparison.Ordinal); return text.ToUpperInvariant(); } } diff --git a/engines/ARIEC61850.lock.json b/engines/ARIEC61850.lock.json index 9d631dc0..43765e7a 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "8b46cfed0614a4d0b0a5bf73fa7a6e77cf6fe817", - "sourcePullRequest": 55, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance." + "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." } diff --git a/tests/ARSAS.Tests/SclLiveModelAuthorityTests.cs b/tests/ARSAS.Tests/SclLiveModelAuthorityTests.cs new file mode 100644 index 00000000..af134bc9 --- /dev/null +++ b/tests/ARSAS.Tests/SclLiveModelAuthorityTests.cs @@ -0,0 +1,69 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Scl.Workspace; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +public sealed class SclLiveModelAuthorityTests +{ + [Fact] + public void Device_PrefersNativeLiveModel_WhenSclDesignAndLiveModelAreAvailable() + { + var designModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED_EXPECTED" + }; + var liveModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED_OBSERVED" + }; + var device = new Iec61850MonitorDevice + { + SclWorkspace = new SclIedWorkspace + { + IedName = "IED_EXPECTED", + DesignModel = designModel + }, + LiveDiscoveryModel = liveModel + }; + + var comparison = Assert.IsType(device.SclComparison); + + Assert.False(comparison.IsCompatible); + Assert.Equal("IED_EXPECTED", comparison.ExpectedIedName); + Assert.Equal("IED_OBSERVED", comparison.ObservedIedName); + Assert.Contains( + comparison.Findings, + finding => finding.Kind == SclLiveModelFindingKind.IdentityMismatch); + } + + [Fact] + public void Device_DoesNotReplaceNativeComparison_WithProjectedFallbackResult() + { + var device = new Iec61850MonitorDevice + { + SclWorkspace = new SclIedWorkspace + { + IedName = "IED_EXPECTED", + DesignModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED_EXPECTED" + } + }, + LiveDiscoveryModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED_OBSERVED" + } + }; + + device.SclComparison = new SclLiveModelComparisonResult + { + ExpectedIedName = "PROJECTED_EXPECTED", + ObservedIedName = "PROJECTED_OBSERVED" + }; + + Assert.NotNull(device.SclComparison); + Assert.Equal("IED_EXPECTED", device.SclComparison!.ExpectedIedName); + Assert.Equal("IED_OBSERVED", device.SclComparison.ObservedIedName); + } +}