From 5a260ca4208c99a36ed44c235a0bd693a4d1ea93 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:24:29 +0700 Subject: [PATCH 1/9] feat: add actionable static DataSet completeness diagnostic --- .../Iec61850DataSetCompletenessDiagnostic.cs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 Services/Iec61850DataSetCompletenessDiagnostic.cs diff --git a/Services/Iec61850DataSetCompletenessDiagnostic.cs b/Services/Iec61850DataSetCompletenessDiagnostic.cs new file mode 100644 index 00000000..4205a358 --- /dev/null +++ b/Services/Iec61850DataSetCompletenessDiagnostic.cs @@ -0,0 +1,117 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +public sealed record Iec61850DataSetCompletenessSnapshot( + int DataSetCount, + int StaticMemberCount, + int MandatoryInventoryCount, + int RepresentedCount, + int PrimaryLeafUnresolvedCount, + IReadOnlyList MissingReferences) +{ + public int MissingCount => MissingReferences.Count; + public bool IsComplete => MandatoryInventoryCount == RepresentedCount && MissingCount == 0; + + public string Summary => + $"DataSets={DataSetCount:N0}; static members={StaticMemberCount:N0}; mandatory inventory={MandatoryInventoryCount:N0}; " + + $"represented={RepresentedCount:N0}/{MandatoryInventoryCount:N0}; primary leaf unresolved={PrimaryLeafUnresolvedCount:N0}; missing={MissingCount:N0}"; +} + +/// +/// Actionable diagnostic for static IEC 61850 DataSet inventory completeness. +/// ARIEC remains the authority for mandatory signal descriptors and reference identity; +/// ARSAS only measures whether those descriptors are represented in Signal Selection. +/// +public static class Iec61850DataSetCompletenessDiagnostic +{ + public static Iec61850DataSetCompletenessSnapshot Evaluate( + LiveIedModelDiscoveryDocument? model, + IEnumerable signals) + { + ArgumentNullException.ThrowIfNull(signals); + + if (model is null) + return new Iec61850DataSetCompletenessSnapshot(0, 0, 0, 0, 0, Array.Empty()); + + var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); + var signalReferences = signals + .Select(signal => Literal(signal.ObjectReference)) + .Where(reference => reference.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var missing = new List(); + var represented = 0; + foreach (var descriptor in mandatory) + { + var candidates = EngineReferenceCandidates(descriptor).ToArray(); + if (candidates.Any(signalReferences.Contains)) + { + represented++; + continue; + } + + var reference = candidates.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(reference)) + reference = ""; + + var membership = descriptor.DataSetMemberships + .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.MemberIndex) + .FirstOrDefault(); + missing.Add(membership is null + ? reference + : $"{membership.DataSetReference}[{membership.MemberIndex}] -> {reference}"); + } + + return new Iec61850DataSetCompletenessSnapshot( + model.DataSets.Count, + model.DataSets.Sum(dataSet => dataSet.Members.Count), + mandatory.Count, + represented, + mandatory.Count(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved), + missing); + } + + 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}"; + yield return $"Signal Selection : {snapshot.RepresentedCount:N0}/{snapshot.MandatoryInventoryCount:N0} represented"; + yield return $"Primary unresolved: {snapshot.PrimaryLeafUnresolvedCount:N0}"; + yield return $"Missing inventory : {snapshot.MissingCount:N0}"; + + if (snapshot.MissingCount == 0) + yield break; + + foreach (var reference in snapshot.MissingReferences.Take(Math.Max(0, maxMissing))) + yield return $" MISSING : {reference}"; + + if (snapshot.MissingCount > maxMissing) + yield return $" ... : {snapshot.MissingCount - maxMissing:N0} more missing member(s)"; + } + + private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescriptor descriptor) + { + return new[] + { + descriptor.PrimaryValueReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueMmsReference, + descriptor.CanonicalMmsReference, + descriptor.EffectiveMmsReference, + descriptor.ObservedMmsReference + } + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(Literal) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static string Literal(string? reference) + => (reference ?? string.Empty).Trim(); +} From 677248e913fbdff5d35ecfe08d92c0c670d2945b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:25:31 +0700 Subject: [PATCH 2/9] build: pin ARIEC61850 cross-LD DataSet fix --- 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 6796bc98..fb2c9b86 100644 --- a/engines/ARIEC61850.lock.json +++ b/engines/ARIEC61850.lock.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "repository": "masarray/ARIEC61850", "ref": "main", - "commit": "52f8e383711f63d4a37af742fc92243be80aa0d6", - "sourcePullRequest": 76, - "purpose": "Immutable ARIEC61850 revision for ARSAS CI, tests, packaging, diagnostics, and release provenance. Baseline includes connected reconciliation, COMTRADE/MMS FileOpen interoperability, UTC-Time wire provenance, typed signal catalog query/coverage APIs, authoritative hybrid reporting, bounded live DataSet-directory discovery, and cross-logical-device dynamic RCB planning. PR #76 adds the engine-owned DataSet selection inventory projection so every static DataSet member remains application-visible even when an FCD/DataObject-level member has no uniquely resolved primary DataAttribute yet. Resolved primary signals remain preferred; unresolved members are preserved without inventing leaf semantics." + "commit": "4fc3119be2bb97e0ea3a8d31fb990e6a1a089e60", + "sourcePullRequest": 77, + "purpose": "Pins the ARIEC61850 engine used by ARSAS. PR #76 preserves every mandatory static DataSet member, including unresolved object-level FCD members without inventing .stVal. PR #77 canonicalizes SCL DataSet member identity across logical devices so parser display references such as IED/LD/LN.DO [FC] resolve to the same IEDLD/LN.DO identity used by the live model." } From 093fa99ba14a1a7ea79cd07709e26942a5574047 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:27:53 +0700 Subject: [PATCH 3/9] test: cover real SCL cross-LD DataSet completeness path --- .../DataSetCompletenessDiagnosticTests.cs | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs diff --git a/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs b/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs new file mode 100644 index 00000000..b0c6ab94 --- /dev/null +++ b/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs @@ -0,0 +1,87 @@ +using System.Xml.Linq; +using AR.Iec61850.Scl.Engineering; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class DataSetCompletenessDiagnosticTests +{ + [Fact] + public void SiemensLike_CrossLd_Fcda_Is_Canonical_Visible_And_Diagnostically_Complete() + { + var model = SclLiveModelProjectionBuilder.Build( + XDocument.Parse(CrossLogicalDeviceFixture()), + "Siprotec_cross_ld.cid"); + var signals = new List(); + + var merge = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(signals, model); + var snapshot = Iec61850DataSetCompletenessDiagnostic.Evaluate(model, signals); + + Assert.Equal(1, merge.MandatoryCatalogCount); + var signal = Assert.Single(signals); + Assert.Equal("AA1C1F13R4ADD/GGIO6.CBOpnd", signal.ObjectReference); + Assert.False(signal.ObjectReference.EndsWith(".stVal", StringComparison.Ordinal)); + Assert.DoesNotContain("[ST]", signal.ObjectReference); + + Assert.Equal(1, snapshot.DataSetCount); + Assert.Equal(1, snapshot.StaticMemberCount); + Assert.Equal(1, snapshot.MandatoryInventoryCount); + Assert.Equal(1, snapshot.RepresentedCount); + Assert.Equal(0, snapshot.MissingCount); + Assert.True(snapshot.IsComplete); + } + + [Fact] + public void Missing_Static_DataSet_Member_Is_Reported_With_DataSet_Index_And_Reference() + { + var model = SclLiveModelProjectionBuilder.Build( + XDocument.Parse(CrossLogicalDeviceFixture()), + "Siprotec_cross_ld.cid"); + + var snapshot = Iec61850DataSetCompletenessDiagnostic.Evaluate( + model, + Array.Empty()); + + Assert.Equal(1, snapshot.MandatoryInventoryCount); + Assert.Equal(0, snapshot.RepresentedCount); + Assert.Equal(1, snapshot.MissingCount); + Assert.Contains("GGIO6.CBOpnd", snapshot.MissingReferences[0]); + Assert.Contains("[0]", snapshot.MissingReferences[0]); + Assert.False(snapshot.IsComplete); + } + + private static string CrossLogicalDeviceFixture() => """ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; +} From 63261b146bb7f55d355994507cc056122c9cad45 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:30:10 +0700 Subject: [PATCH 4/9] feat: include DataSet completeness in diagnostic report --- Services/DiagnosticReportBuilder.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Services/DiagnosticReportBuilder.cs b/Services/DiagnosticReportBuilder.cs index d36d25e4..45e7c8c1 100644 --- a/Services/DiagnosticReportBuilder.cs +++ b/Services/DiagnosticReportBuilder.cs @@ -83,6 +83,20 @@ public static async Task BuildAsync( builder.AppendLine($"Selected : live={device.SelectedLiveSignalCount:N0}, control={device.SelectedControlSignalCount:N0}"); builder.AppendLine($"Logical Devices : {EmptyAsUnavailable(device.LogicalDeviceSummary)}"); builder.AppendLine($"Identity source : {EmptyAsUnavailable(device.IdentitySource)}"); + + var dataSetModel = device.SclWorkspace?.DesignModel ?? device.LiveDiscoveryModel; + if (dataSetModel is not null) + { + var completeness = Iec61850DataSetCompletenessDiagnostic.Evaluate(dataSetModel, device.Signals); + builder.AppendLine("DataSet inventory:"); + foreach (var line in Iec61850DataSetCompletenessDiagnostic.FormatReportLines(completeness)) + builder.AppendLine($" {line}"); + } + else + { + builder.AppendLine("DataSet inventory: unavailable (no SCL/live discovery model)"); + } + var route = AnalyzeRoute(device.IpAddress); builder.AppendLine($"TCP probe : {probe?.Result ?? "not run"} {probe?.Detail ?? string.Empty}".TrimEnd()); builder.AppendLine($"Route source : {route.Source}"); @@ -369,4 +383,4 @@ private static string Truncate(string? value, int maximumLength) ? normalized : normalized[..maximumLength] + $"… [truncated {normalized.Length - maximumLength:N0} chars]"; } -} +} \ No newline at end of file From 01a81d616f4963571d6fc07e892974e38869a51b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:37:23 +0700 Subject: [PATCH 5/9] fix: preserve static DataSet identity in Signal Selection --- .../Iec61850DataSetSignalInventoryService.cs | 96 +++++++++++++++---- 1 file changed, 79 insertions(+), 17 deletions(-) diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index 991161c7..28ae4fd2 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -49,18 +49,20 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( return EmptyResult(); // 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. + // canonicalization; ARSAS only compares reference forms already exposed by ARIEC. + // DisplayReference is included because static FCD identity is intentionally kept + // separate from a resolved runtime DataAttribute reference such as .stVal. var existing = signals - .Where(signal => !string.IsNullOrWhiteSpace(signal.ObjectReference)) - .GroupBy(signal => LiteralReference(signal.ObjectReference), StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + .SelectMany(signal => SignalReferenceCandidates(signal).Select(reference => (reference, signal))) + .GroupBy(item => item.reference, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().signal, StringComparer.OrdinalIgnoreCase); var added = new List(); var enriched = 0; foreach (var descriptor in mandatory) { + var inventoryReference = InventoryReference(descriptor); var engineReferences = EngineReferenceCandidates(descriptor).ToArray(); var current = engineReferences .Select(reference => existing.TryGetValue(reference, out var signal) ? signal : null) @@ -68,22 +70,36 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( if (current is not null) { - if (ApplyEngineDataSetAuthority(current, descriptor)) + if (ApplyEngineDataSetAuthority(current, descriptor, inventoryReference)) enriched++; + + foreach (var key in EngineReferenceCandidates(descriptor) + .Concat(SignalReferenceCandidates(current)) + .Append(inventoryReference) + .Where(value => !string.IsNullOrWhiteSpace(value))) + { + existing.TryAdd(LiteralReference(key), current); + } continue; } - var reference = FirstNonEmpty( + var runtimeReference = FirstNonEmpty( descriptor.PrimaryValueReference, descriptor.DesignReference, descriptor.ObservedReference); + var reference = FirstNonEmpty(runtimeReference, inventoryReference); if (string.IsNullOrWhiteSpace(reference)) continue; - var signal = CreateSignal(descriptor, reference); + var signal = CreateSignal(descriptor, reference, inventoryReference); signals.Add(signal); - foreach (var key in EngineReferenceCandidates(descriptor).Append(LiteralReference(reference))) - existing.TryAdd(key, signal); + foreach (var key in EngineReferenceCandidates(descriptor) + .Concat(SignalReferenceCandidates(signal)) + .Append(inventoryReference) + .Where(value => !string.IsNullOrWhiteSpace(value))) + { + existing.TryAdd(LiteralReference(key), signal); + } added.Add(signal); } @@ -95,16 +111,23 @@ private static Iec61850DataSetSignalInventoryMergeResult EmptyResult() private static SignalDefinition CreateSignal( Iec61850SignalDescriptor descriptor, - string reference) + string runtimeReference, + string inventoryReference) { var primaryMembership = FirstMembership(descriptor); var report = descriptor.ReportMemberships.FirstOrDefault(); var dataType = FirstNonEmpty(descriptor.MmsType, descriptor.SclBType, "Unknown"); var unresolved = descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved; + var staticReference = FirstNonEmpty(inventoryReference, runtimeReference); + var objectReference = unresolved ? staticReference : FirstNonEmpty(runtimeReference, staticReference); + return new SignalDefinition { - Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, reference), - ObjectReference = reference, + Name = FirstNonEmpty(descriptor.DataObject, descriptor.DataAttributePath, staticReference), + ObjectReference = objectReference, + // Signal Selection binds IEC Telegram to DisplayReference. Preserve the exact + // static FCDA/FCD member here even when ARIEC resolves a readable primary leaf. + DisplayReference = staticReference, FunctionalConstraint = descriptor.FunctionalConstraint, DataType = dataType, Category = "DataSet", @@ -135,11 +158,23 @@ private static SignalDefinition CreateSignal( private static bool ApplyEngineDataSetAuthority( SignalDefinition signal, - Iec61850SignalDescriptor descriptor) + Iec61850SignalDescriptor descriptor, + string inventoryReference) { var changed = false; var membership = FirstMembership(descriptor); var report = descriptor.ReportMemberships.FirstOrDefault(); + var staticReference = FirstNonEmpty(inventoryReference, signal.DisplayReference, signal.ObjectReference); + + // Never replace the user-visible static DataSet member with a guessed/resolved leaf. + // ObjectReference can remain the engine-resolved runtime leaf for MMS reads; the + // selector's IEC Telegram column is bound to DisplayReference. + if (!string.IsNullOrWhiteSpace(staticReference) && + !string.Equals(signal.DisplayReference, staticReference, StringComparison.OrdinalIgnoreCase)) + { + signal.DisplayReference = staticReference; + changed = true; + } if (membership is not null && !string.Equals(signal.DataSetReference, membership.DataSetReference, StringComparison.OrdinalIgnoreCase)) @@ -197,9 +232,24 @@ private static bool ApplyEngineDataSetAuthority( return changed; } + private static IEnumerable SignalReferenceCandidates(SignalDefinition signal) + => new[] { signal.DisplayReference, signal.ObjectReference } + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(LiteralReference) + .Distinct(StringComparer.OrdinalIgnoreCase); + private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescriptor descriptor) { - var values = new[] + var membershipReferences = descriptor.DataSetMemberships + .OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(membership => membership.MemberIndex) + .SelectMany(membership => new[] + { + membership.CanonicalMemberReference, + membership.OriginalMemberReference + }); + + var descriptorReferences = new[] { descriptor.PrimaryValueReference, descriptor.DesignReference, @@ -210,12 +260,24 @@ private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescr descriptor.ObservedMmsReference }; - return values + return membershipReferences + .Concat(descriptorReferences) .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(LiteralReference) .Distinct(StringComparer.OrdinalIgnoreCase); } + private static string InventoryReference(Iec61850SignalDescriptor descriptor) + { + var membership = FirstMembership(descriptor); + return FirstNonEmpty( + membership?.CanonicalMemberReference, + membership?.OriginalMemberReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueReference); + } + private static Iec61850SignalDataSetMembership? FirstMembership(Iec61850SignalDescriptor descriptor) => descriptor.DataSetMemberships .OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase) @@ -239,7 +301,7 @@ private static string BuildCoverageReason(Iec61850SignalDescriptor descriptor) : "mandatory primary DataSet signal"; var resolutionText = unresolved ? " The original DataSet member is preserved while its unique primary DataAttribute remains unresolved." - : string.Empty; + : " The static FCDA identity stays visible even when a readable primary DataAttribute is resolved for runtime acquisition."; return $"ARIEC61850 {authorityText}: {membershipText}." + resolutionText + From 39cc94034eb51329575da20190a15d6f90940084 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:37:37 +0700 Subject: [PATCH 6/9] fix: audit static DataSet identity instead of resolved leaf only --- .../Iec61850DataSetCompletenessDiagnostic.cs | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/Services/Iec61850DataSetCompletenessDiagnostic.cs b/Services/Iec61850DataSetCompletenessDiagnostic.cs index 4205a358..64da4e2c 100644 --- a/Services/Iec61850DataSetCompletenessDiagnostic.cs +++ b/Services/Iec61850DataSetCompletenessDiagnostic.cs @@ -37,8 +37,9 @@ public static Iec61850DataSetCompletenessSnapshot Evaluate( var mandatory = Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(model); var signalReferences = signals - .Select(signal => Literal(signal.ObjectReference)) - .Where(reference => reference.Length > 0) + .SelectMany(signal => new[] { signal.DisplayReference, signal.ObjectReference }) + .Where(reference => !string.IsNullOrWhiteSpace(reference)) + .Select(Literal) .ToHashSet(StringComparer.OrdinalIgnoreCase); var missing = new List(); @@ -97,16 +98,28 @@ public static IEnumerable FormatReportLines(Iec61850DataSetCompletenessS private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescriptor descriptor) { - return new[] + var membershipReferences = descriptor.DataSetMemberships + .OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(membership => membership.MemberIndex) + .SelectMany(membership => new[] { - descriptor.PrimaryValueReference, - descriptor.DesignReference, - descriptor.ObservedReference, - descriptor.PrimaryValueMmsReference, - descriptor.CanonicalMmsReference, - descriptor.EffectiveMmsReference, - descriptor.ObservedMmsReference - } + membership.CanonicalMemberReference, + membership.OriginalMemberReference + }); + + var descriptorReferences = new[] + { + descriptor.PrimaryValueReference, + descriptor.DesignReference, + descriptor.ObservedReference, + descriptor.PrimaryValueMmsReference, + descriptor.CanonicalMmsReference, + descriptor.EffectiveMmsReference, + descriptor.ObservedMmsReference + }; + + return membershipReferences + .Concat(descriptorReferences) .Where(value => !string.IsNullOrWhiteSpace(value)) .Select(Literal) .Distinct(StringComparer.OrdinalIgnoreCase); From 0cca3b14323f3f17227db347e39ebf482038d1ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:37:53 +0700 Subject: [PATCH 7/9] test: verify static FCDA display and resolved runtime reference stay separate --- .../DataSetCompletenessDiagnosticTests.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs b/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs index b0c6ab94..2d04c3f7 100644 --- a/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs +++ b/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs @@ -20,9 +20,15 @@ public void SiemensLike_CrossLd_Fcda_Is_Canonical_Visible_And_Diagnostically_Com Assert.Equal(1, merge.MandatoryCatalogCount); var signal = Assert.Single(signals); - Assert.Equal("AA1C1F13R4ADD/GGIO6.CBOpnd", signal.ObjectReference); - Assert.False(signal.ObjectReference.EndsWith(".stVal", StringComparison.Ordinal)); - Assert.DoesNotContain("[ST]", signal.ObjectReference); + + // IEC Telegram / Signal Selection must preserve the exact static FCDA FCD identity. + Assert.Equal("AA1C1F13R4ADD/GGIO6.CBOpnd", signal.DisplayReference); + Assert.False(signal.DisplayReference.EndsWith(".stVal", StringComparison.Ordinal)); + Assert.DoesNotContain("[ST]", signal.DisplayReference); + + // Runtime acquisition may use the primary DataAttribute resolved by ARIEC. Keeping + // this separate from DisplayReference prevents the UI from rewriting the DataSet. + Assert.Equal("AA1C1F13R4ADD/GGIO6.CBOpnd.stVal", signal.ObjectReference); Assert.Equal(1, snapshot.DataSetCount); Assert.Equal(1, snapshot.StaticMemberCount); @@ -46,7 +52,8 @@ public void Missing_Static_DataSet_Member_Is_Reported_With_DataSet_Index_And_Ref Assert.Equal(1, snapshot.MandatoryInventoryCount); Assert.Equal(0, snapshot.RepresentedCount); Assert.Equal(1, snapshot.MissingCount); - Assert.Contains("GGIO6.CBOpnd", snapshot.MissingReferences[0]); + Assert.Contains("AA1C1F13R4ADD/GGIO6.CBOpnd", snapshot.MissingReferences[0]); + Assert.DoesNotContain("CBOpnd.stVal", snapshot.MissingReferences[0]); Assert.Contains("[0]", snapshot.MissingReferences[0]); Assert.False(snapshot.IsComplete); } From 02c96d6e50bd4a20be9aa07873340c8e327f2c18 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:38:57 +0700 Subject: [PATCH 8/9] fix: measure completeness against every static FCDA member --- .../Iec61850DataSetCompletenessDiagnostic.cs | 74 ++++++------------- 1 file changed, 24 insertions(+), 50 deletions(-) diff --git a/Services/Iec61850DataSetCompletenessDiagnostic.cs b/Services/Iec61850DataSetCompletenessDiagnostic.cs index 64da4e2c..585aaadf 100644 --- a/Services/Iec61850DataSetCompletenessDiagnostic.cs +++ b/Services/Iec61850DataSetCompletenessDiagnostic.cs @@ -12,17 +12,18 @@ public sealed record Iec61850DataSetCompletenessSnapshot( IReadOnlyList MissingReferences) { public int MissingCount => MissingReferences.Count; - public bool IsComplete => MandatoryInventoryCount == RepresentedCount && MissingCount == 0; + public bool IsComplete => StaticMemberCount == RepresentedCount && MissingCount == 0; public string Summary => $"DataSets={DataSetCount:N0}; static members={StaticMemberCount:N0}; mandatory inventory={MandatoryInventoryCount:N0}; " + - $"represented={RepresentedCount:N0}/{MandatoryInventoryCount:N0}; primary leaf unresolved={PrimaryLeafUnresolvedCount:N0}; missing={MissingCount:N0}"; + $"represented={RepresentedCount:N0}/{StaticMemberCount:N0}; primary leaf unresolved={PrimaryLeafUnresolvedCount:N0}; missing={MissingCount:N0}"; } /// /// Actionable diagnostic for static IEC 61850 DataSet inventory completeness. -/// ARIEC remains the authority for mandatory signal descriptors and reference identity; -/// ARSAS only measures whether those descriptors are represented in Signal Selection. +/// ARIEC remains the authority for reference identity and primary-leaf resolution. +/// ARSAS measures every static FCDA member directly so projection aggregation can never +/// make a partially represented DataSet appear complete. /// public static class Iec61850DataSetCompletenessDiagnostic { @@ -44,31 +45,33 @@ public static Iec61850DataSetCompletenessSnapshot Evaluate( var missing = new List(); var represented = 0; - foreach (var descriptor in mandatory) + 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(); + + foreach (var member in staticMembers) { - var candidates = EngineReferenceCandidates(descriptor).ToArray(); - if (candidates.Any(signalReferences.Contains)) + if (member.Reference.Length > 0 && signalReferences.Contains(member.Reference)) { represented++; continue; } - var reference = candidates.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(reference)) - reference = ""; - - var membership = descriptor.DataSetMemberships - .OrderBy(item => item.DataSetReference, StringComparer.OrdinalIgnoreCase) - .ThenBy(item => item.MemberIndex) - .FirstOrDefault(); - missing.Add(membership is null - ? reference - : $"{membership.DataSetReference}[{membership.MemberIndex}] -> {reference}"); + var reference = member.Reference.Length == 0 ? "" : member.Reference; + missing.Add($"{member.DataSetReference}[{member.Index}] -> {reference}"); } return new Iec61850DataSetCompletenessSnapshot( model.DataSets.Count, - model.DataSets.Sum(dataSet => dataSet.Members.Count), + staticMembers.Length, mandatory.Count, represented, mandatory.Count(descriptor => descriptor.ResolutionStatus == Iec61850SignalCatalogResolutionStatus.Unresolved), @@ -81,8 +84,8 @@ public static IEnumerable FormatReportLines(Iec61850DataSetCompletenessS yield return $"Static DataSets : {snapshot.DataSetCount:N0}"; yield return $"Static members : {snapshot.StaticMemberCount:N0}"; - yield return $"Mandatory inventory: {snapshot.MandatoryInventoryCount:N0}"; - yield return $"Signal Selection : {snapshot.RepresentedCount:N0}/{snapshot.MandatoryInventoryCount:N0} represented"; + 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}"; @@ -96,35 +99,6 @@ public static IEnumerable FormatReportLines(Iec61850DataSetCompletenessS yield return $" ... : {snapshot.MissingCount - maxMissing:N0} more missing member(s)"; } - private static IEnumerable EngineReferenceCandidates(Iec61850SignalDescriptor descriptor) - { - var membershipReferences = descriptor.DataSetMemberships - .OrderBy(membership => membership.DataSetReference, StringComparer.OrdinalIgnoreCase) - .ThenBy(membership => membership.MemberIndex) - .SelectMany(membership => new[] - { - membership.CanonicalMemberReference, - membership.OriginalMemberReference - }); - - var descriptorReferences = new[] - { - descriptor.PrimaryValueReference, - descriptor.DesignReference, - descriptor.ObservedReference, - descriptor.PrimaryValueMmsReference, - descriptor.CanonicalMmsReference, - descriptor.EffectiveMmsReference, - descriptor.ObservedMmsReference - }; - - return membershipReferences - .Concat(descriptorReferences) - .Where(value => !string.IsNullOrWhiteSpace(value)) - .Select(Literal) - .Distinct(StringComparer.OrdinalIgnoreCase); - } - private static string Literal(string? reference) => (reference ?? string.Empty).Trim(); } From f8e7449d17206b19c24102a150b8b16c8f6c184d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 15 Aug 2026 13:42:58 +0700 Subject: [PATCH 9/9] test: align diagnostic member index with SCL parser --- tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs b/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs index 2d04c3f7..99df31a4 100644 --- a/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs +++ b/tests/ARSAS.Tests/DataSetCompletenessDiagnosticTests.cs @@ -54,7 +54,7 @@ public void Missing_Static_DataSet_Member_Is_Reported_With_DataSet_Index_And_Ref Assert.Equal(1, snapshot.MissingCount); Assert.Contains("AA1C1F13R4ADD/GGIO6.CBOpnd", snapshot.MissingReferences[0]); Assert.DoesNotContain("CBOpnd.stVal", snapshot.MissingReferences[0]); - Assert.Contains("[0]", snapshot.MissingReferences[0]); + Assert.Contains("[1]", snapshot.MissingReferences[0]); Assert.False(snapshot.IsComplete); }