From 1bfe8f917a3b9ed8db20e0c0bd526cac736d9090 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 02:24:54 +0700 Subject: [PATCH 01/10] Consume engine DataSet semantic bindings --- Services/SclWorkspaceSignalMapper.cs | 68 +++++++++++++++------------- 1 file changed, 37 insertions(+), 31 deletions(-) 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(); } } From 82bc88d070abb4b454886239951fc3f7a71d3ecf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 02:25:27 +0700 Subject: [PATCH 02/10] Pin semantic binding engine integration head --- 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 9d631dc0..54934cf9 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": "5f64b0b47ece5c0b62bedb087d111c2db013ff45", + "sourcePullRequest": 59, + "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Integration branch pins the CI-green DataSet semantic-binding head; production must move to the merged main SHA before release." } From e4e4339196d3221398ab7dfc75e6087a2b09389e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 02:26:04 +0700 Subject: [PATCH 03/10] Test engine-owned DataSet semantic integration --- ...Iec61850EngineTimestampIntegrationTests.cs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs b/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs index 08b62447..e749637f 100644 --- a/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs +++ b/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs @@ -1,8 +1,11 @@ using System.Buffers.Binary; using System.Globalization; using AR.Iec61850.Binding; +using AR.Iec61850.Discovery; using AR.Iec61850.Mms; +using AR.Iec61850.Scl.Workspace; using ArIED61850Tester; +using ArIED61850Tester.Services; namespace ARSAS.Tests; @@ -42,3 +45,129 @@ public void PinnedEngine_Preserves_2006000_Fraction_And_Arsas_Rounds_It_To_201ms Iec61850TimestampPresentation.FormatMilliseconds(parsed, "ss.fff")); } } + +public sealed class Iec61850DataSetSemanticIntegrationTests +{ + [Fact] + public void SclMapper_UsesEngineBcrBinding_ForFcdEnergyMember() + { + const string dataSetReference = "IEDLD/LLN0$DS$Energy"; + var objectReference = "IEDLD/MMTR1.SupWh"; + var workspace = BuildWorkspace( + "MMTR1", + "MMTR", + new LiveIedDataObjectModel + { + Reference = objectReference, + Name = "SupWh", + InferredCdc = "BCR", + Attributes = new[] + { + Attribute(objectReference, "actVal", "INT64"), + Attribute(objectReference, "frVal", "INT64"), + Attribute(objectReference, "q", "Quality"), + Attribute(objectReference, "t", "Timestamp") + } + }, + dataSetReference, + "IED/LD/MMTR1.SupWh [ST]"); + + var signal = Assert.Single(SclWorkspaceSignalMapper.BuildSignals(workspace)); + + Assert.Equal("IEDLD/MMTR1.SupWh.actVal", signal.ObjectReference); + Assert.Equal(dataSetReference, signal.DataSetReference); + Assert.Equal("IEDLD/MMTR1.SupWh.q", signal.QualityReference); + Assert.Equal("IEDLD/MMTR1.SupWh.t", signal.TimestampReference); + } + + [Fact] + public void SclMapper_StaticDataSetPrimaryValue_OverridesGenericVisibilityPruning() + { + const string dataSetReference = "IEDLD/LLN0$DS$Temperature"; + var objectReference = "IEDLD/TTMP1.TmpAlm"; + var workspace = BuildWorkspace( + "TTMP1", + "TTMP", + new LiveIedDataObjectModel + { + Reference = objectReference, + Name = "TmpAlm", + InferredCdc = "SPS", + Attributes = new[] + { + Attribute(objectReference, "stVal", "BOOLEAN"), + Attribute(objectReference, "q", "Quality"), + Attribute(objectReference, "t", "Timestamp") + } + }, + dataSetReference, + "IED/LD/TTMP1.TmpAlm [ST]"); + + var signal = Assert.Single(SclWorkspaceSignalMapper.BuildSignals(workspace)); + + Assert.Equal("IEDLD/TTMP1.TmpAlm.stVal", signal.ObjectReference); + Assert.Equal(dataSetReference, signal.DataSetReference); + Assert.Equal("IEDLD/TTMP1.TmpAlm.q", signal.QualityReference); + Assert.Equal("IEDLD/TTMP1.TmpAlm.t", signal.TimestampReference); + } + + private static SclIedWorkspace BuildWorkspace( + string logicalNodeName, + string logicalNodeClass, + LiveIedDataObjectModel dataObject, + string dataSetReference, + string memberReference) + => new() + { + IedName = "IED", + DesignModel = new LiveIedModelDiscoveryDocument + { + IedName = "IED", + Source = "SclOfflineProjection", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD", + Inst = "LD", + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = logicalNodeName, + LnClass = logicalNodeClass, + DataObjects = new[] { dataObject } + } + } + } + }, + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = dataSetReference, + Members = new[] + { + new LiveIedDataSetMemberModel + { + Index = 1, + Reference = memberReference, + FunctionalConstraint = "ST" + } + } + } + } + } + }; + + private static LiveIedDataAttributeModel Attribute(string objectReference, string path, string bType) + => new() + { + ObjectReference = $"{objectReference}.{path}", + AttributePath = path, + FunctionalConstraint = "ST", + SclBType = bType, + Source = "SCL.DataTypeTemplates", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; +} From e51c6aebcaa7231764034561f0cb2f8df42e6478 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 03:09:02 +0700 Subject: [PATCH 04/10] Keep IEC semantics owned by ARIEC61850 --- ...Iec61850EngineTimestampIntegrationTests.cs | 129 ------------------ 1 file changed, 129 deletions(-) diff --git a/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs b/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs index e749637f..08b62447 100644 --- a/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs +++ b/tests/ARSAS.Tests/Iec61850EngineTimestampIntegrationTests.cs @@ -1,11 +1,8 @@ using System.Buffers.Binary; using System.Globalization; using AR.Iec61850.Binding; -using AR.Iec61850.Discovery; using AR.Iec61850.Mms; -using AR.Iec61850.Scl.Workspace; using ArIED61850Tester; -using ArIED61850Tester.Services; namespace ARSAS.Tests; @@ -45,129 +42,3 @@ public void PinnedEngine_Preserves_2006000_Fraction_And_Arsas_Rounds_It_To_201ms Iec61850TimestampPresentation.FormatMilliseconds(parsed, "ss.fff")); } } - -public sealed class Iec61850DataSetSemanticIntegrationTests -{ - [Fact] - public void SclMapper_UsesEngineBcrBinding_ForFcdEnergyMember() - { - const string dataSetReference = "IEDLD/LLN0$DS$Energy"; - var objectReference = "IEDLD/MMTR1.SupWh"; - var workspace = BuildWorkspace( - "MMTR1", - "MMTR", - new LiveIedDataObjectModel - { - Reference = objectReference, - Name = "SupWh", - InferredCdc = "BCR", - Attributes = new[] - { - Attribute(objectReference, "actVal", "INT64"), - Attribute(objectReference, "frVal", "INT64"), - Attribute(objectReference, "q", "Quality"), - Attribute(objectReference, "t", "Timestamp") - } - }, - dataSetReference, - "IED/LD/MMTR1.SupWh [ST]"); - - var signal = Assert.Single(SclWorkspaceSignalMapper.BuildSignals(workspace)); - - Assert.Equal("IEDLD/MMTR1.SupWh.actVal", signal.ObjectReference); - Assert.Equal(dataSetReference, signal.DataSetReference); - Assert.Equal("IEDLD/MMTR1.SupWh.q", signal.QualityReference); - Assert.Equal("IEDLD/MMTR1.SupWh.t", signal.TimestampReference); - } - - [Fact] - public void SclMapper_StaticDataSetPrimaryValue_OverridesGenericVisibilityPruning() - { - const string dataSetReference = "IEDLD/LLN0$DS$Temperature"; - var objectReference = "IEDLD/TTMP1.TmpAlm"; - var workspace = BuildWorkspace( - "TTMP1", - "TTMP", - new LiveIedDataObjectModel - { - Reference = objectReference, - Name = "TmpAlm", - InferredCdc = "SPS", - Attributes = new[] - { - Attribute(objectReference, "stVal", "BOOLEAN"), - Attribute(objectReference, "q", "Quality"), - Attribute(objectReference, "t", "Timestamp") - } - }, - dataSetReference, - "IED/LD/TTMP1.TmpAlm [ST]"); - - var signal = Assert.Single(SclWorkspaceSignalMapper.BuildSignals(workspace)); - - Assert.Equal("IEDLD/TTMP1.TmpAlm.stVal", signal.ObjectReference); - Assert.Equal(dataSetReference, signal.DataSetReference); - Assert.Equal("IEDLD/TTMP1.TmpAlm.q", signal.QualityReference); - Assert.Equal("IEDLD/TTMP1.TmpAlm.t", signal.TimestampReference); - } - - private static SclIedWorkspace BuildWorkspace( - string logicalNodeName, - string logicalNodeClass, - LiveIedDataObjectModel dataObject, - string dataSetReference, - string memberReference) - => new() - { - IedName = "IED", - DesignModel = new LiveIedModelDiscoveryDocument - { - IedName = "IED", - Source = "SclOfflineProjection", - LogicalDevices = new[] - { - new LiveIedLogicalDeviceModel - { - MmsDomain = "IEDLD", - Inst = "LD", - LogicalNodes = new[] - { - new LiveIedLogicalNodeModel - { - Name = logicalNodeName, - LnClass = logicalNodeClass, - DataObjects = new[] { dataObject } - } - } - } - }, - DataSets = new[] - { - new LiveIedDataSetModel - { - Reference = dataSetReference, - Members = new[] - { - new LiveIedDataSetMemberModel - { - Index = 1, - Reference = memberReference, - FunctionalConstraint = "ST" - } - } - } - } - } - }; - - private static LiveIedDataAttributeModel Attribute(string objectReference, string path, string bType) - => new() - { - ObjectReference = $"{objectReference}.{path}", - AttributePath = path, - FunctionalConstraint = "ST", - SclBType = bType, - Source = "SCL.DataTypeTemplates", - TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact - }; -} From 885666be1e2e39f837515c29c1b1cde7841969c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 03:10:35 +0700 Subject: [PATCH 05/10] Prefer native live model for SCL comparison --- Models/MonitorModels.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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; From 8877ccc9a731416e5571d7aad766288c482381ca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 03:10:48 +0700 Subject: [PATCH 06/10] Guard native live model authority for SCL comparison --- .../ARSAS.Tests/SclLiveModelAuthorityTests.cs | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/ARSAS.Tests/SclLiveModelAuthorityTests.cs 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); + } +} From f77f15f0cdab553adeb5b9fff4551996f56d917e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 06:19:13 +0700 Subject: [PATCH 07/10] Repin ARIEC61850 engine to reconciliation 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 54934cf9..43765e7a 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "5f64b0b47ece5c0b62bedb087d111c2db013ff45", - "sourcePullRequest": 59, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Integration branch pins the CI-green DataSet semantic-binding head; production must move to the merged main SHA before release." + "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." } From e00d3c995c1eb42fd199f9508f91e10454e42a8f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 06:33:26 +0700 Subject: [PATCH 08/10] Add authoritative SCL live model registry --- Services/SclLiveModelAuthorityRegistry.cs | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Services/SclLiveModelAuthorityRegistry.cs 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()}"; +} From 0a91322b0b4102d32cd98671b86c31b1901dfe8d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 06:33:33 +0700 Subject: [PATCH 09/10] Prefer engine models over signal projection in MainWindow --- SclLiveSignalModelProjection.Authority.cs | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 SclLiveSignalModelProjection.Authority.cs 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); + } +} From 86654e3431fb9228f3565b336914862d9092aaf8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 14 Aug 2026 06:33:47 +0700 Subject: [PATCH 10/10] Track authoritative engine models for SCL comparison --- MainWindow.SclLiveModelAuthority.cs | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 MainWindow.SclLiveModelAuthority.cs 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); + } +}