From 7b5659f6a3a701fe6e2b380548cdc166a780868b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 14:13:05 +0700 Subject: [PATCH 01/23] Resolve structured DataSet members to primary runtime leaves --- .../Iec61850DataSetSemanticBindingResolver.cs | 292 +++++++++++++++--- 1 file changed, 254 insertions(+), 38 deletions(-) diff --git a/src/AR.Iec61850/Discovery/Iec61850DataSetSemanticBindingResolver.cs b/src/AR.Iec61850/Discovery/Iec61850DataSetSemanticBindingResolver.cs index 6ecd2953..e79c8ce4 100644 --- a/src/AR.Iec61850/Discovery/Iec61850DataSetSemanticBindingResolver.cs +++ b/src/AR.Iec61850/Discovery/Iec61850DataSetSemanticBindingResolver.cs @@ -82,6 +82,11 @@ public sealed class LiveIedDataSetSemanticBindingDocument /// Resolves original FCD/FCDA DataSet members to typed DataAttribute targets. /// Original member identity and list ordering remain protocol evidence; semantic /// leaves are application bindings only and never additional DataSet members. +/// +/// Structured FCDA members are intentionally supported. A member such as A.phsA or +/// PPV.phsAB can identify an intermediate structured component rather than a final DA. +/// In that case only typed descendants below the exact member boundary are considered. +/// The resolver never widens the member to sibling phases and never invents a leaf. /// public static class Iec61850DataSetSemanticBindingResolver { @@ -136,11 +141,20 @@ private static LiveIedDataSetMemberSemanticBinding ResolveMember( var bestLength = objectMatches[0].Reference.Length; var bestMatches = objectMatches.Where(candidate => candidate.Reference.Length == bestLength).ToArray(); if (bestMatches.Length != 1) - return BuildBinding(dataSet, member, string.Empty, LiveIedDataSetMemberResolutionStatus.Ambiguous, Array.Empty(), $"Multiple DataObjects match canonical DataSet member '{reference}'."); + { + return BuildBinding( + dataSet, + member, + string.Empty, + LiveIedDataSetMemberResolutionStatus.Ambiguous, + Array.Empty(), + $"Multiple DataObjects match canonical DataSet member '{reference}'."); + } var dataObject = bestMatches[0].DataObject; + var dataObjectReference = bestMatches[0].Reference; var cdc = dataObject.InferredCdc?.Trim() ?? string.Empty; - var isObjectLevelMember = string.Equals(reference, bestMatches[0].Reference, StringComparison.OrdinalIgnoreCase); + var isObjectLevelMember = string.Equals(reference, dataObjectReference, StringComparison.OrdinalIgnoreCase); var fcCompatibleAttributes = dataObject.Attributes .Where(attribute => IsFunctionalConstraintCompatible(fc, attribute.FunctionalConstraint)) .ToArray(); @@ -148,16 +162,73 @@ private static LiveIedDataSetMemberSemanticBinding ResolveMember( if (!isObjectLevelMember) { var exact = fcCompatibleAttributes - .Where(attribute => string.Equals(NormalizeReference(attribute.ObjectReference), reference, StringComparison.OrdinalIgnoreCase)) + .Where(attribute => string.Equals( + NormalizeReference(attribute.ObjectReference), + reference, + StringComparison.OrdinalIgnoreCase)) .Select(attribute => ToResolvedAttribute(dataObject, attribute, fc)) .ToArray(); if (exact.Length == 1) - return BuildBinding(dataSet, member, cdc, LiveIedDataSetMemberResolutionStatus.ExactAttribute, exact, $"Explicit DataAttribute member matched '{exact[0].Reference}'."); + { + return BuildBinding( + dataSet, + member, + cdc, + LiveIedDataSetMemberResolutionStatus.ExactAttribute, + PromoteUniqueFallbackPrimary(exact), + $"Explicit DataAttribute member matched '{exact[0].Reference}'."); + } if (exact.Length > 1) - return BuildBinding(dataSet, member, cdc, LiveIedDataSetMemberResolutionStatus.Ambiguous, exact, $"Explicit DataAttribute member '{reference}' matched more than one attribute model."); + { + return BuildBinding( + dataSet, + member, + cdc, + LiveIedDataSetMemberResolutionStatus.Ambiguous, + exact, + $"Explicit DataAttribute member '{reference}' matched more than one attribute model."); + } + + // IEC 61850 FCDA may stop at an intermediate structured component. Siemens + // measurement DataSets commonly contain A.phsA / A.phsB / A.phsC and + // PPV.phsAB-style members while SCL DataTypeTemplates expose final leaves such + // as cVal.mag.f below those components. Restrict expansion to descendants of + // the exact static member boundary so no sibling phase can be captured. + var descendants = fcCompatibleAttributes + .Where(attribute => IsDescendantReference( + NormalizeReference(attribute.ObjectReference), + reference)) + .Select(attribute => ToResolvedAttribute(dataObject, attribute, fc)) + .OrderBy(attribute => SemanticOrder(attribute.SemanticRole)) + .ThenBy(attribute => attribute.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (descendants.Length > 0) + { + return BuildExpandedBinding( + document, + dataSet, + member, + cdc, + descendants, + $"Structured DataSet member '{reference}' expanded only to typed descendants below the exact member boundary."); + } + if (dataObject.Attributes.Count > 0 && fcCompatibleAttributes.Length == 0) - return BuildBinding(dataSet, member, cdc, LiveIedDataSetMemberResolutionStatus.FunctionalConstraintMismatch, Array.Empty(), $"DataObject exists, but no attribute is compatible with FC={fc}."); - return Unresolved(dataSet, member, $"Explicit DataAttribute '{reference}' is not present in the resolved DataObject model.", cdc); + { + return BuildBinding( + dataSet, + member, + cdc, + LiveIedDataSetMemberResolutionStatus.FunctionalConstraintMismatch, + Array.Empty(), + $"DataObject exists, but no attribute is compatible with FC={fc}."); + } + + return Unresolved( + dataSet, + member, + $"Explicit or structured DataAttribute member '{reference}' is not present in the resolved DataObject model.", + cdc); } if (fcCompatibleAttributes.Length > 0) @@ -167,30 +238,121 @@ private static LiveIedDataSetMemberSemanticBinding ResolveMember( .OrderBy(attribute => SemanticOrder(attribute.SemanticRole)) .ThenBy(attribute => attribute.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); - var primaryCount = resolved.Count(attribute => attribute.IsPrimaryValue); - var fromScl = IsSclProjection(document, resolved); - var status = primaryCount > 1 - ? LiveIedDataSetMemberResolutionStatus.Ambiguous - : fromScl ? LiveIedDataSetMemberResolutionStatus.TemplateResolved : LiveIedDataSetMemberResolutionStatus.DiscoveredAttributes; - var evidence = fromScl - ? $"FCD member expanded from authoritative SCL DataTypeTemplates for CDC={cdc}." - : $"FCD member resolved from discovered attribute-level MMS evidence for CDC={cdc}."; - if (primaryCount > 1) - evidence += " More than one primary-value candidate was found; no primary target is selected."; - return BuildBinding(dataSet, member, cdc, status, resolved, evidence); + return BuildExpandedBinding( + document, + dataSet, + member, + cdc, + resolved, + $"DataObject-level FCD '{reference}' expanded to FC-compatible typed attributes."); } if (dataObject.Attributes.Count > 0) - return BuildBinding(dataSet, member, cdc, LiveIedDataSetMemberResolutionStatus.FunctionalConstraintMismatch, Array.Empty(), $"DataObject has attribute evidence, but none is compatible with FC={fc}; CDC fallback is intentionally not used over conflicting typed evidence."); + { + return BuildBinding( + dataSet, + member, + cdc, + LiveIedDataSetMemberResolutionStatus.FunctionalConstraintMismatch, + Array.Empty(), + $"DataObject has attribute evidence, but none is compatible with FC={fc}; CDC fallback is intentionally not used over conflicting typed evidence."); + } var fallback = BuildCdcFallback(dataObject, fc).ToArray(); if (fallback.Length > 0) - return BuildBinding(dataSet, member, cdc, LiveIedDataSetMemberResolutionStatus.CdcFallback, fallback, $"No attribute-level evidence was available. Standard CDC={cdc} semantics supplied read candidates without changing the original DataSet member."); + { + return BuildBinding( + dataSet, + member, + cdc, + LiveIedDataSetMemberResolutionStatus.CdcFallback, + fallback, + $"No attribute-level evidence was available. Standard CDC={cdc} semantics supplied read candidates without changing the original DataSet member."); + } + + return Unresolved( + dataSet, + member, + $"DataObject '{dataObject.Reference}' has no attribute-level evidence and CDC={cdc} has no safe fallback mapping.", + cdc); + } + + private static LiveIedDataSetMemberSemanticBinding BuildExpandedBinding( + LiveIedModelDiscoveryDocument document, + LiveIedDataSetModel dataSet, + LiveIedDataSetMemberModel member, + string cdc, + IReadOnlyList attributes, + string evidencePrefix) + { + var resolved = PromoteUniqueFallbackPrimary(attributes); + var primaryCount = resolved.Count(attribute => attribute.IsPrimaryValue); + var fromScl = IsSclProjection(document, resolved); + var status = primaryCount > 1 + ? LiveIedDataSetMemberResolutionStatus.Ambiguous + : fromScl + ? LiveIedDataSetMemberResolutionStatus.TemplateResolved + : LiveIedDataSetMemberResolutionStatus.DiscoveredAttributes; + var evidence = evidencePrefix + (fromScl + ? $" Authority is SCL DataTypeTemplates for CDC={cdc}." + : $" Authority is discovered attribute-level MMS evidence for CDC={cdc}."); + + if (primaryCount > 1) + evidence += " More than one primary-value candidate was found; no primary target is selected."; + else if (primaryCount == 1) + evidence += $" Unique primary runtime leaf is '{resolved.Single(attribute => attribute.IsPrimaryValue).Reference}'."; + else + evidence += " No unique primary-value leaf is available; the static membership remains unresolved for scalar acquisition."; + + return BuildBinding(dataSet, member, cdc, status, resolved, evidence); + } - return Unresolved(dataSet, member, $"DataObject '{dataObject.Reference}' has no attribute-level evidence and CDC={cdc} has no safe fallback mapping.", cdc); + /// + /// Prefer explicit semantic PrimaryValue roles. When a legacy/shallow typed model has + /// no such role, promote exactly one engine-approved primary-value-bearing attribute. + /// Multiple candidates remain unresolved rather than selecting by ordering. + /// + private static IReadOnlyList PromoteUniqueFallbackPrimary( + IReadOnlyList attributes) + { + if (attributes.Count(attribute => attribute.IsPrimaryValue) != 0) + return attributes; + + var candidates = attributes + .Where(Iec61850ProbeValuePolicy.IsPrimaryValueBearing) + .ToArray(); + if (candidates.Length != 1) + return attributes; + + var selected = candidates[0]; + return attributes + .Select(attribute => ReferenceEquals(attribute.Reference, selected.Reference) + ? CloneWithRole(attribute, Iec61850DataAttributeSemanticRole.PrimaryValue) + : attribute) + .ToArray(); } - private static IEnumerable BuildCdcFallback(LiveIedDataObjectModel dataObject, string functionalConstraint) + private static LiveIedResolvedDataSetAttributeModel CloneWithRole( + LiveIedResolvedDataSetAttributeModel attribute, + Iec61850DataAttributeSemanticRole role) + => new() + { + Reference = attribute.Reference, + FunctionalConstraint = attribute.FunctionalConstraint, + MmsReference = attribute.MmsReference, + MmsItemName = attribute.MmsItemName, + Cdc = attribute.Cdc, + SclBType = attribute.SclBType, + MmsType = attribute.MmsType, + SemanticRole = role, + Confidence = attribute.Confidence, + Source = attribute.Source, + IsSyntheticFallback = attribute.IsSyntheticFallback + }; + + private static IEnumerable BuildCdcFallback( + LiveIedDataObjectModel dataObject, + string functionalConstraint) { if (!string.Equals(dataObject.InferredCdc, "BCR", StringComparison.OrdinalIgnoreCase)) yield break; @@ -201,7 +363,12 @@ private static IEnumerable BuildCdcFallbac yield return BuildFallbackAttribute(dataObject, functionalConstraint, "t", "Timestamp", Iec61850DataAttributeSemanticRole.Timestamp); } - private static LiveIedResolvedDataSetAttributeModel BuildFallbackAttribute(LiveIedDataObjectModel dataObject, string functionalConstraint, string attributePath, string sclBType, Iec61850DataAttributeSemanticRole role) + private static LiveIedResolvedDataSetAttributeModel BuildFallbackAttribute( + LiveIedDataObjectModel dataObject, + string functionalConstraint, + string attributePath, + string sclBType, + Iec61850DataAttributeSemanticRole role) { var reference = NormalizeReference(dataObject.Reference) + "." + attributePath; var target = BuildMmsTarget(reference, functionalConstraint); @@ -220,7 +387,10 @@ private static LiveIedResolvedDataSetAttributeModel BuildFallbackAttribute(LiveI }; } - private static LiveIedResolvedDataSetAttributeModel ToResolvedAttribute(LiveIedDataObjectModel dataObject, LiveIedDataAttributeModel attribute, string memberFunctionalConstraint) + private static LiveIedResolvedDataSetAttributeModel ToResolvedAttribute( + LiveIedDataObjectModel dataObject, + LiveIedDataAttributeModel attribute, + string memberFunctionalConstraint) { var reference = NormalizeReference(attribute.ObjectReference); var fc = NormalizeFunctionalConstraint(attribute.FunctionalConstraint); @@ -253,24 +423,45 @@ private static LiveIedResolvedDataSetAttributeModel ToResolvedAttribute(LiveIedD private static Iec61850DataAttributeSemanticRole ClassifySemanticRole(string cdc, string attributePath) { var path = (attributePath ?? string.Empty).Trim().Replace('$', '.').Trim('.'); - var leaf = path.Contains('.') ? path[(path.LastIndexOf('.') + 1)..] : path; - if (string.Equals(leaf, "q", StringComparison.OrdinalIgnoreCase)) + var lower = path.ToLowerInvariant(); + var leaf = lower.Contains('.') ? lower[(lower.LastIndexOf('.') + 1)..] : lower; + + if (leaf == "q") return Iec61850DataAttributeSemanticRole.Quality; - if (string.Equals(leaf, "t", StringComparison.OrdinalIgnoreCase)) + if (leaf == "t") return Iec61850DataAttributeSemanticRole.Timestamp; + if (string.Equals(cdc, "BCR", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(path, "actVal", StringComparison.OrdinalIgnoreCase)) + if (lower == "actval") return Iec61850DataAttributeSemanticRole.PrimaryValue; - if (string.Equals(path, "frVal", StringComparison.OrdinalIgnoreCase)) + if (lower == "frval") return Iec61850DataAttributeSemanticRole.FrozenValue; } - if (string.Equals(path, "stVal", StringComparison.OrdinalIgnoreCase)) + + if (lower is "stval" or "general" or "posval" or "actval") + return Iec61850DataAttributeSemanticRole.PrimaryValue; + + // Canonical engineering values are preferred over their instantaneous siblings. + // Alternate-reference policy can still recover cVal <-> instCVal or mag <-> instMag + // at live verification time without making an FCD with both representations ambiguous. + if (lower.EndsWith(".mag.f", StringComparison.Ordinal) && + !lower.EndsWith(".instcval.mag.f", StringComparison.Ordinal) && + !lower.EndsWith(".instmag.f", StringComparison.Ordinal)) + { return Iec61850DataAttributeSemanticRole.PrimaryValue; + } + return Iec61850DataAttributeSemanticRole.Other; } - private static LiveIedDataSetMemberSemanticBinding BuildBinding(LiveIedDataSetModel dataSet, LiveIedDataSetMemberModel member, string cdc, LiveIedDataSetMemberResolutionStatus status, IReadOnlyList attributes, string evidence) + private static LiveIedDataSetMemberSemanticBinding BuildBinding( + LiveIedDataSetModel dataSet, + LiveIedDataSetMemberModel member, + string cdc, + LiveIedDataSetMemberResolutionStatus status, + IReadOnlyList attributes, + string evidence) => new() { DataSetReference = dataSet.Reference, @@ -284,14 +475,31 @@ private static LiveIedDataSetMemberSemanticBinding BuildBinding(LiveIedDataSetMo Evidence = new[] { evidence } }; - private static LiveIedDataSetMemberSemanticBinding Unresolved(LiveIedDataSetModel dataSet, LiveIedDataSetMemberModel member, string evidence, string cdc = "") - => BuildBinding(dataSet, member, cdc, LiveIedDataSetMemberResolutionStatus.Unresolved, Array.Empty(), evidence); - - private static bool IsSclProjection(LiveIedModelDiscoveryDocument document, IReadOnlyList attributes) - => document.Source.Contains("Scl", StringComparison.OrdinalIgnoreCase) || attributes.Any(attribute => attribute.Source.Contains("SCL", StringComparison.OrdinalIgnoreCase)); + private static LiveIedDataSetMemberSemanticBinding Unresolved( + LiveIedDataSetModel dataSet, + LiveIedDataSetMemberModel member, + string evidence, + string cdc = "") + => BuildBinding( + dataSet, + member, + cdc, + LiveIedDataSetMemberResolutionStatus.Unresolved, + Array.Empty(), + evidence); + + private static bool IsSclProjection( + LiveIedModelDiscoveryDocument document, + IReadOnlyList attributes) + => document.Source.Contains("Scl", StringComparison.OrdinalIgnoreCase) || + attributes.Any(attribute => attribute.Source.Contains("SCL", StringComparison.OrdinalIgnoreCase)); private static bool IsReferenceInsideDataObject(string memberReference, string dataObjectReference) - => string.Equals(memberReference, dataObjectReference, StringComparison.OrdinalIgnoreCase) || memberReference.StartsWith(dataObjectReference + ".", StringComparison.OrdinalIgnoreCase); + => string.Equals(memberReference, dataObjectReference, StringComparison.OrdinalIgnoreCase) || + memberReference.StartsWith(dataObjectReference + ".", StringComparison.OrdinalIgnoreCase); + + private static bool IsDescendantReference(string candidateReference, string memberReference) + => candidateReference.StartsWith(memberReference + ".", StringComparison.OrdinalIgnoreCase); private static bool IsFunctionalConstraintCompatible(string memberFc, string attributeFc) { @@ -302,7 +510,15 @@ private static bool IsFunctionalConstraintCompatible(string memberFc, string att return string.Equals(memberFc, NormalizeFunctionalConstraint(attributeFc), StringComparison.OrdinalIgnoreCase); } - private static (string Reference, string ItemName) BuildMmsTarget(string userReference, string functionalConstraint) + private static bool ReferenceEquals(string? left, string? right) + => string.Equals( + NormalizeReference(left), + NormalizeReference(right), + StringComparison.OrdinalIgnoreCase); + + private static (string Reference, string ItemName) BuildMmsTarget( + string userReference, + string functionalConstraint) { var reference = NormalizeReference(userReference); var slash = reference.IndexOf('/'); From c8e91d0ceccf4fb13c3182cf2580c435bfff1f62 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 14:15:06 +0700 Subject: [PATCH 02/23] Project mandatory DataSet runtime leaf from semantic binding --- ...ec61850DataSetSignalInventoryProjection.cs | 99 +++++++++++-------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs b/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs index b982a800..cdd6f26c 100644 --- a/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs +++ b/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs @@ -17,36 +17,31 @@ public static IReadOnlyList GetMandatorySignals( { ArgumentNullException.ThrowIfNull(design); + var semanticBindings = Iec61850DataSetSemanticBindingResolver.Resolve(design); var catalog = Iec61850SignalCatalogBuilder.Build(design, reconciliation); - var primaryByMember = catalog.GetMandatoryPrimarySignals() - .SelectMany(signal => signal.DataSetMemberships - .Where(membership => membership.IsPrimaryValueForMember) - .Select(membership => new - { - Key = MembershipKey(membership), - Signal = signal - })) - .GroupBy(item => item.Key, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - group => group.Key, - group => group.Select(item => item.Signal).Distinct().ToArray(), - StringComparer.OrdinalIgnoreCase); - var result = new List(); + foreach (var dataSet in design.DataSets.OrderBy(x => x.Reference, StringComparer.OrdinalIgnoreCase)) { foreach (var member in dataSet.Members.OrderBy(x => x.Index)) { - var key = MembershipKey(dataSet.Reference, member.Index); - if (primaryByMember.TryGetValue(key, out var candidates) && candidates.Length == 1) + var binding = semanticBindings.Find(dataSet.Reference, member.Index); + var primary = binding?.PrimaryValue; + if (primary is not null) { - result.Add(ProjectResolvedMemberDescriptor(candidates[0], dataSet, member)); - continue; + var source = FindCatalogSignal(catalog, primary); + if (source is not null) + { + result.Add(ProjectResolvedMemberDescriptor(source, dataSet, member, binding!)); + continue; + } } - var reason = candidates is { Length: > 1 } - ? $"Static DataSet member {dataSet.Reference}[{member.Index}] has {candidates.Length} primary-value candidates; the member identity is preserved without guessing a runtime leaf." - : $"Static DataSet member {dataSet.Reference}[{member.Index}] has no unique primary DataAttribute; the member identity is preserved in the signal inventory."; + var reason = binding is null + ? $"Static DataSet member {dataSet.Reference}[{member.Index}] has no semantic binding; the member identity is preserved in the signal inventory." + : binding.ResolutionStatus == LiveIedDataSetMemberResolutionStatus.Ambiguous + ? $"Static DataSet member {dataSet.Reference}[{member.Index}] has ambiguous primary-value semantics; the member identity is preserved without guessing a runtime leaf." + : $"Static DataSet member {dataSet.Reference}[{member.Index}] has no unique primary DataAttribute; the member identity is preserved in the signal inventory."; result.Add(BuildUnresolvedMemberDescriptor(design, dataSet, member, reason)); } } @@ -54,17 +49,28 @@ public static IReadOnlyList GetMandatorySignals( return result.ToArray(); } + private static Iec61850SignalDescriptor? FindCatalogSignal( + Iec61850SignalCatalogDocument catalog, + LiveIedResolvedDataSetAttributeModel primary) + { + if (!string.IsNullOrWhiteSpace(primary.MmsReference)) + { + var byMms = catalog.FindByCanonicalMmsReference(primary.MmsReference); + if (byMms is not null) + return byMms; + } + + return catalog.Signals.FirstOrDefault(signal => + ReferenceEquals(signal.DesignReference, primary.Reference)); + } + private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor( Iec61850SignalDescriptor source, LiveIedDataSetModel dataSet, - LiveIedDataSetMemberModel member) + LiveIedDataSetMemberModel member, + LiveIedDataSetMemberSemanticBinding binding) { var memberReference = NormalizeReference(member.Reference); - var sourceMembership = source.DataSetMemberships.FirstOrDefault(membership => - string.Equals( - MembershipKey(membership), - MembershipKey(dataSet.Reference, member.Index), - StringComparison.OrdinalIgnoreCase)); var functionalConstraint = (member.FunctionalConstraint ?? string.Empty).Trim().ToUpperInvariant(); var membership = new Iec61850SignalDataSetMembership { @@ -72,14 +78,18 @@ private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor( MemberIndex = member.Index, OriginalMemberReference = member.Reference, CanonicalMemberReference = memberReference, - FunctionalConstraint = FirstNonEmpty(sourceMembership?.FunctionalConstraint, functionalConstraint), - Cdc = FirstNonEmpty(sourceMembership?.Cdc, source.Cdc), - ResolutionStatus = sourceMembership?.ResolutionStatus ?? LiveIedDataSetMemberResolutionStatus.Unresolved, + FunctionalConstraint = FirstNonEmpty(binding.FunctionalConstraint, source.FunctionalConstraint, functionalConstraint), + Cdc = FirstNonEmpty(binding.Cdc, source.Cdc), + ResolutionStatus = binding.ResolutionStatus, IsPrimaryValueForMember = true }; var reports = source.ReportMemberships .Where(report => ReferenceEquals(report.DataSetReference, dataSet.Reference)) .ToArray(); + if (reports.Length == 0) + { + reports = designReports(dataSet, source).ToArray(); + } var evidence = source.Evidence .Concat(new[] { @@ -87,7 +97,7 @@ private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor( { Kind = Iec61850SignalEvidenceKind.DataSetSemanticBinding, SourceReference = memberReference, - Message = $"Static DataSet member {dataSet.Reference}[{member.Index}] keeps its original FCD/FCDA identity; runtime primary binding is '{FirstNonEmpty(source.PrimaryValueReference, source.DesignReference)}'." + Message = $"Static DataSet member {dataSet.Reference}[{member.Index}] keeps its original FCD/FCDA identity; runtime primary binding is '{binding.PrimaryValueReference}'. {string.Join(" ", binding.Evidence)}" } }) .ToArray(); @@ -100,7 +110,7 @@ private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor( EffectiveMmsReference = source.EffectiveMmsReference, ObservedMmsReference = source.ObservedMmsReference, FunctionalConstraint = FirstNonEmpty(source.FunctionalConstraint, functionalConstraint), - Cdc = source.Cdc, + Cdc = FirstNonEmpty(binding.Cdc, source.Cdc), SclBType = source.SclBType, MmsType = source.MmsType, MmsDomain = source.MmsDomain, @@ -110,9 +120,9 @@ private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor( DataObject = source.DataObject, DataObjectReference = source.DataObjectReference, DataAttributePath = source.DataAttributePath, - SemanticRole = source.SemanticRole, - PrimaryValueReference = FirstNonEmpty(source.PrimaryValueReference, source.DesignReference), - PrimaryValueMmsReference = FirstNonEmpty(source.PrimaryValueMmsReference, source.CanonicalMmsReference), + SemanticRole = Iec61850DataAttributeSemanticRole.PrimaryValue, + PrimaryValueReference = binding.PrimaryValueReference, + PrimaryValueMmsReference = binding.PrimaryValueMmsReference, QualityReference = source.QualityReference, QualityMmsReference = source.QualityMmsReference, TimestampReference = source.TimestampReference, @@ -120,13 +130,24 @@ private static Iec61850SignalDescriptor ProjectResolvedMemberDescriptor( DataSetMemberships = new[] { membership }, ReportMemberships = reports, IsStaticDataSetMandatory = true, - IsOperationalCandidate = source.IsOperationalCandidate, + IsOperationalCandidate = true, IsEngineeringOnly = false, ResolutionStatus = source.ResolutionStatus, LiveStatus = source.LiveStatus, AlternateStrategy = source.AlternateStrategy, Evidence = evidence }; + + static IEnumerable designReports( + LiveIedDataSetModel dataSet, + Iec61850SignalDescriptor source) + { + // Source catalog normally already carries report membership. Keep the helper + // deliberately empty when it does not; the unresolved path remains responsible + // for design-only report projection. This avoids fabricating report authority. + return source.ReportMemberships.Where(report => + string.Equals(report.DataSetReference, dataSet.Reference, StringComparison.OrdinalIgnoreCase)); + } } private static Iec61850SignalDescriptor BuildUnresolvedMemberDescriptor( @@ -287,12 +308,6 @@ private static string ExtractDataObjectReference(string reference) return secondDot < 0 ? reference : reference[..secondDot]; } - private static string MembershipKey(Iec61850SignalDataSetMembership membership) - => MembershipKey(membership.DataSetReference, membership.MemberIndex); - - private static string MembershipKey(string dataSetReference, int memberIndex) - => $"{NormalizeReference(dataSetReference)}\u001f{memberIndex}"; - private static bool ReferenceEquals(string? left, string? right) => string.Equals(NormalizeReference(left), NormalizeReference(right), StringComparison.OrdinalIgnoreCase); From 7d40a3ea5e130b3e2245098c63a0e33c2da03418 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 14:15:48 +0700 Subject: [PATCH 03/23] Cover structured measurement DataSet member binding --- ...1850StructuredDataSetMemberBindingTests.cs | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Discovery/Iec61850StructuredDataSetMemberBindingTests.cs diff --git a/tests/AR.Iec61850.Tests/Discovery/Iec61850StructuredDataSetMemberBindingTests.cs b/tests/AR.Iec61850.Tests/Discovery/Iec61850StructuredDataSetMemberBindingTests.cs new file mode 100644 index 00000000..65b9579c --- /dev/null +++ b/tests/AR.Iec61850.Tests/Discovery/Iec61850StructuredDataSetMemberBindingTests.cs @@ -0,0 +1,189 @@ +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Tests.Discovery; + +public sealed class Iec61850StructuredDataSetMemberBindingTests +{ + [Fact] + public void Phase_Member_Resolves_Canonical_Magnitude_Without_Sibling_Phase() + { + const string objectReference = "IEDLD0/MMXU1.A"; + const string memberReference = objectReference + ".phsA"; + const string expected = memberReference + ".cVal.mag.f"; + var design = BuildDesign( + objectReference, + "WYE", + memberReference, + Attribute(expected, "phsA.cVal.mag.f"), + Attribute(memberReference + ".instCVal.mag.f", "phsA.instCVal.mag.f"), + Attribute(memberReference + ".cVal.ang.f", "phsA.cVal.ang.f"), + Attribute(memberReference + ".q", "phsA.q", "Quality"), + Attribute(memberReference + ".t", "phsA.t", "Timestamp"), + Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f")); + + var binding = Assert.Single(Iec61850DataSetSemanticBindingResolver.Resolve(design).Members); + + Assert.Equal(LiveIedDataSetMemberResolutionStatus.TemplateResolved, binding.ResolutionStatus); + Assert.Equal(expected, binding.PrimaryValueReference); + Assert.DoesNotContain(binding.ResolvedAttributes, attribute => + attribute.Reference.Contains(".phsB.", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(binding.ResolvedAttributes, attribute => + attribute.Reference.EndsWith(".phsA.cVal.ang.f", StringComparison.OrdinalIgnoreCase)); + + var signal = Assert.Single(Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(design)); + var membership = Assert.Single(signal.DataSetMemberships); + Assert.Equal(memberReference, membership.CanonicalMemberReference); + Assert.Equal(expected, signal.PrimaryValueReference); + Assert.Equal(expected, signal.DesignReference); + Assert.True(membership.IsPrimaryValueForMember); + Assert.NotEqual(Iec61850SignalCatalogResolutionStatus.Unresolved, signal.ResolutionStatus); + } + + [Fact] + public void PhasePair_Member_Resolves_Only_That_Pair_Magnitude() + { + const string objectReference = "IEDLD0/MMXU1.PPV"; + const string memberReference = objectReference + ".phsAB"; + const string expected = memberReference + ".cVal.mag.f"; + var design = BuildDesign( + objectReference, + "DEL", + memberReference, + Attribute(expected, "phsAB.cVal.mag.f"), + Attribute(memberReference + ".cVal.ang.f", "phsAB.cVal.ang.f"), + Attribute(objectReference + ".phsBC.cVal.mag.f", "phsBC.cVal.mag.f")); + + var signal = Assert.Single(Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(design)); + + Assert.Equal(expected, signal.PrimaryValueReference); + Assert.Equal(memberReference, Assert.Single(signal.DataSetMemberships).CanonicalMemberReference); + } + + [Fact] + public void Whole_Structured_Member_With_Multiple_Phase_Magnitudes_Remains_Ambiguous() + { + const string objectReference = "IEDLD0/MMXU1.A"; + var design = BuildDesign( + objectReference, + "WYE", + objectReference, + Attribute(objectReference + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(objectReference + ".phsC.cVal.mag.f", "phsC.cVal.mag.f")); + + var binding = Assert.Single(Iec61850DataSetSemanticBindingResolver.Resolve(design).Members); + Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus); + Assert.Null(binding.PrimaryValue); + + var signal = Assert.Single(Iec61850DataSetSignalInventoryProjection.GetMandatorySignals(design)); + Assert.Equal(Iec61850SignalCatalogResolutionStatus.Unresolved, signal.ResolutionStatus); + Assert.Equal(objectReference, signal.DesignReference); + } + + private static LiveIedModelDiscoveryDocument BuildDesign( + string objectReference, + string cdc, + string memberReference, + params LiveIedDataAttributeModel[] attributes) + { + var slash = objectReference.IndexOf('/'); + var domain = objectReference[..slash]; + var logicalPath = objectReference[(slash + 1)..]; + var firstDot = logicalPath.IndexOf('.'); + var logicalNode = logicalPath[..firstDot]; + var dataObjectName = logicalPath[(firstDot + 1)..]; + + return new LiveIedModelDiscoveryDocument + { + Source = "SclWorkspace", + IedName = "IED", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = domain, + Inst = "LD0", + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = logicalNode, + LnClass = "MMXU", + LnInst = "1", + DataObjects = new[] + { + new LiveIedDataObjectModel + { + Reference = objectReference, + Name = dataObjectName, + InferredCdc = cdc, + Attributes = attributes + } + } + } + } + } + }, + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = domain + "/LLN0.Analog", + Domain = domain, + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = 1, + Members = new[] { Member(0, memberReference, "MX") } + } + } + }; + } + + private static LiveIedDataAttributeModel Attribute( + string reference, + string attributePath, + string sclBType = "FLOAT32") + { + var slash = reference.IndexOf('/'); + var domain = reference[..slash]; + var logicalPath = reference[(slash + 1)..]; + var firstDot = logicalPath.IndexOf('.'); + var logicalNode = logicalPath[..firstDot]; + var objectAndAttribute = logicalPath[(firstDot + 1)..].Replace('.', '$'); + var mmsItem = $"{logicalNode}$MX${objectAndAttribute}"; + return new LiveIedDataAttributeModel + { + ObjectReference = reference, + AttributePath = attributePath, + FunctionalConstraint = "MX", + MmsReference = $"{domain}/{mmsItem}", + MmsItemName = mmsItem, + SclBType = sclBType, + MmsType = sclBType == "FLOAT32" ? "floating-point" : string.Empty, + Source = "SCL.DataTypeTemplates", + TypeSource = "SCL.DataTypeTemplates", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + } + + private static LiveIedDataSetMemberModel Member( + int index, + string reference, + string functionalConstraint) + { + var slash = reference.IndexOf('/'); + var domain = reference[..slash]; + var path = reference[(slash + 1)..]; + var firstDot = path.IndexOf('.'); + var logicalNode = path[..firstDot]; + var objectPath = path[(firstDot + 1)..].Replace('.', '$'); + return new LiveIedDataSetMemberModel + { + Index = index, + Reference = reference, + FunctionalConstraint = functionalConstraint, + MmsReference = $"{domain}/{logicalNode}${functionalConstraint}${objectPath}", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + } +} From bd959bc68719c52853cede272c1f562aec9900e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 14:21:08 +0700 Subject: [PATCH 04/23] Preserve unresolved DataSet evidence wording --- .../Discovery/Iec61850DataSetSignalInventoryProjection.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs b/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs index cdd6f26c..b9b1c4ce 100644 --- a/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs +++ b/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs @@ -38,10 +38,10 @@ public static IReadOnlyList GetMandatorySignals( } var reason = binding is null - ? $"Static DataSet member {dataSet.Reference}[{member.Index}] has no semantic binding; the member identity is preserved in the signal inventory." + ? $"Static DataSet member {dataSet.Reference}[{member.Index}] has no semantic binding; the member identity is preserved without guessing a runtime leaf." : binding.ResolutionStatus == LiveIedDataSetMemberResolutionStatus.Ambiguous ? $"Static DataSet member {dataSet.Reference}[{member.Index}] has ambiguous primary-value semantics; the member identity is preserved without guessing a runtime leaf." - : $"Static DataSet member {dataSet.Reference}[{member.Index}] has no unique primary DataAttribute; the member identity is preserved in the signal inventory."; + : $"Static DataSet member {dataSet.Reference}[{member.Index}] has no unique primary DataAttribute; the member identity is preserved without guessing a runtime leaf."; result.Add(BuildUnresolvedMemberDescriptor(design, dataSet, member, reason)); } } @@ -217,7 +217,7 @@ private static Iec61850SignalDescriptor BuildUnresolvedMemberDescriptor( { Kind = Iec61850SignalEvidenceKind.DataSetSemanticBinding, SourceReference = memberReference, - Message = evidenceMessage ?? $"Static DataSet member {dataSet.Reference}[{member.Index}] is preserved in the signal inventory although no unique primary DataAttribute has been resolved yet." + Message = evidenceMessage ?? $"Static DataSet member {dataSet.Reference}[{member.Index}] is preserved in the signal inventory although no unique primary DataAttribute has been resolved yet, without guessing a runtime leaf." } } }; From a5b07238157543059ad59dfe1797c1050a156559 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:39:47 +0700 Subject: [PATCH 05/23] P5.4: add fail-closed semantic structured report projection --- .../Mms/MmsSemanticReportValueProjector.cs | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs new file mode 100644 index 00000000..eb995f95 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -0,0 +1,368 @@ +using AR.Iec61850.Discovery; + +namespace AR.Iec61850.Mms; + +/// +/// Model-backed projection context for structured IEC 61850 report members. +/// The static DataSet member remains the protocol identity. This context maps only +/// below that exact member boundary and never chooses one sibling phase as primary. +/// +public sealed class MmsReportSemanticProjectionContext +{ + private readonly IReadOnlyList _members; + + private MmsReportSemanticProjectionContext(IReadOnlyList members) + => _members = members; + + public static MmsReportSemanticProjectionContext Create(LiveIedModelDiscoveryDocument model) + { + ArgumentNullException.ThrowIfNull(model); + + var bindings = Iec61850DataSetSemanticBindingResolver.Resolve(model); + var dataObjects = model.LogicalDevices + .SelectMany(device => device.LogicalNodes) + .SelectMany(node => node.DataObjects) + .ToArray(); + var members = new List(); + + foreach (var dataSet in bindings.DataSets) + { + foreach (var binding in dataSet.Members) + { + var rootReference = NormalizeReference( + string.IsNullOrWhiteSpace(binding.CanonicalReference) + ? binding.OriginalReference + : binding.CanonicalReference); + if (string.IsNullOrWhiteSpace(rootReference)) + continue; + + var dataObject = dataObjects + .Where(candidate => IsInside(rootReference, NormalizeReference(candidate.Reference))) + .OrderByDescending(candidate => NormalizeReference(candidate.Reference).Length) + .FirstOrDefault(); + if (dataObject is null) + continue; + + var root = new SchemaNode(rootReference); + foreach (var attribute in dataObject.Attributes) + { + var attributeReference = NormalizeReference(attribute.ObjectReference); + if (!IsDescendant(attributeReference, rootReference)) + continue; + if (!IsFunctionalConstraintCompatible(binding.FunctionalConstraint, attribute.FunctionalConstraint)) + continue; + + var relative = attributeReference[(rootReference.Length + 1)..]; + if (string.IsNullOrWhiteSpace(relative)) + continue; + + var current = root; + var accumulated = rootReference; + foreach (var part in relative.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + accumulated = $"{accumulated}.{part}"; + current = current.GetOrAdd(part, accumulated); + } + } + + if (root.Children.Count == 0) + continue; + + members.Add(new MemberSchema( + dataSet.DataSetReference, + binding.Index, + rootReference, + binding.FunctionalConstraint, + root)); + } + } + + return new MmsReportSemanticProjectionContext(members); + } + + internal bool TryExpand( + string dataSetReference, + MmsReportValue reportValue, + out IReadOnlyList expanded, + out string reason) + { + expanded = Array.Empty(); + reason = string.Empty; + + if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array)) + { + reason = "report value is not a structured MMS value"; + return false; + } + + var schema = ResolveMember(dataSetReference, reportValue); + if (schema is null) + { + reason = "no unique semantic DataSet member schema matched the report value"; + return false; + } + + var leaves = new List(); + if (!TryFlatten(schema.Root, reportValue.Value, leaves, out reason)) + return false; + if (leaves.Count == 0) + { + reason = "semantic structure contained no scalar descendants"; + return false; + } + + expanded = leaves + .Select(leaf => new MmsReportValue + { + Index = reportValue.Index, + Member = new MmsDataSetDirectoryMember + { + UserReference = leaf.Reference, + FunctionalConstraint = schema.FunctionalConstraint, + Source = "SemanticDataSetProjection", + Confidence = 100 + }, + Value = leaf.Value, + DataReference = leaf.Reference, + ReasonForInclusion = reportValue.ReasonForInclusion + }) + .ToArray(); + reason = $"expanded {schema.MemberReference} into {expanded.Count} scalar semantic descendant(s)"; + return true; + } + + private MemberSchema? ResolveMember(string dataSetReference, MmsReportValue reportValue) + { + var memberReference = NormalizeReference(reportValue.MemberReference); + var normalizedDataSet = NormalizeDataSetReference(dataSetReference); + + var exact = _members + .Where(candidate => candidate.Index == reportValue.Index) + .Where(candidate => string.IsNullOrWhiteSpace(normalizedDataSet) + || string.Equals(NormalizeDataSetReference(candidate.DataSetReference), normalizedDataSet, StringComparison.OrdinalIgnoreCase)) + .Where(candidate => string.IsNullOrWhiteSpace(memberReference) + || string.Equals(candidate.MemberReference, memberReference, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (exact.Length == 1) + return exact[0]; + + // DatSet can be omitted by OptFlds. Fall back only when index + exact static + // member reference is unique across the entire design model. + var byIdentity = _members + .Where(candidate => candidate.Index == reportValue.Index) + .Where(candidate => string.Equals(candidate.MemberReference, memberReference, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + return byIdentity.Length == 1 ? byIdentity[0] : null; + } + + private static bool TryFlatten( + SchemaNode schema, + MmsDataValue value, + ICollection leaves, + out string reason) + { + if (schema.Children.Count == 0) + { + if (value.Kind is MmsDataKind.Structure or MmsDataKind.Array) + { + reason = $"semantic leaf {schema.Reference} received nested MMS {value.Kind}"; + return false; + } + + leaves.Add(new ExpandedLeaf(schema.Reference, value)); + reason = string.Empty; + return true; + } + + if (value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array)) + { + reason = $"semantic structure {schema.Reference} expected nested MMS value but received {value.Kind}"; + return false; + } + if (schema.Children.Count != value.Children.Count) + { + reason = $"semantic structure {schema.Reference} child-count mismatch: schema={schema.Children.Count}, report={value.Children.Count}"; + return false; + } + + for (var index = 0; index < schema.Children.Count; index++) + { + if (!TryFlatten(schema.Children[index], value.Children[index], leaves, out reason)) + return false; + } + + reason = string.Empty; + return true; + } + + private static bool IsInside(string reference, string parent) + => string.Equals(reference, parent, StringComparison.OrdinalIgnoreCase) + || IsDescendant(reference, parent); + + private static bool IsDescendant(string reference, string parent) + => !string.IsNullOrWhiteSpace(reference) + && !string.IsNullOrWhiteSpace(parent) + && reference.Length > parent.Length + && reference.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase); + + private static bool IsFunctionalConstraintCompatible(string memberFc, string attributeFc) + => string.IsNullOrWhiteSpace(memberFc) + || string.IsNullOrWhiteSpace(attributeFc) + || string.Equals(memberFc.Trim(), attributeFc.Trim(), StringComparison.OrdinalIgnoreCase); + + private static string NormalizeReference(string value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().Replace('$', '.').Replace("..", ".", StringComparison.Ordinal); + + private static string NormalizeDataSetReference(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var normalized = value.Trim(); + var slash = normalized.IndexOf('/'); + if (slash < 0 || slash >= normalized.Length - 1) + return normalized.Replace('$', '.'); + + return normalized[..(slash + 1)] + normalized[(slash + 1)..].Replace('$', '.'); + } + + private sealed class SchemaNode + { + private readonly Dictionary _byName = new(StringComparer.OrdinalIgnoreCase); + private readonly List _children = new(); + + public SchemaNode(string reference) + => Reference = reference; + + public string Reference { get; } + public IReadOnlyList Children => _children; + + public SchemaNode GetOrAdd(string name, string reference) + { + if (_byName.TryGetValue(name, out var existing)) + return existing; + + var created = new SchemaNode(reference); + _byName[name] = created; + _children.Add(created); + return created; + } + } + + private sealed record MemberSchema( + string DataSetReference, + int Index, + string MemberReference, + string FunctionalConstraint, + SchemaNode Root); + + private sealed record ExpandedLeaf(string Reference, MmsDataValue Value); +} + +/// +/// Backward-compatible overlay for structures that the established report projector +/// intentionally leaves raw. Existing known CDC projections remain untouched. +/// +public static class MmsSemanticReportValueProjector +{ + public static MmsReportValueProjection Project( + MmsReportFrame frame, + MmsReportSemanticProjectionContext context) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(context); + + var baseline = MmsReportValueProjector.Project(frame); + var replacementParents = new HashSet(StringComparer.OrdinalIgnoreCase); + var semanticUpdates = new List(); + var semanticWarnings = new List(); + + foreach (var reportValue in frame.Values) + { + if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array)) + continue; + + var parentReference = reportValue.MemberReference; + var rawPrefix = $"REPORT_RAW_STRUCT: {parentReference} "; + if (!baseline.Warnings.Any(warning => warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase))) + continue; + + if (!context.TryExpand(frame.Header.DataSetReference, reportValue, out var expanded, out var expansionReason)) + { + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); + continue; + } + + var synthetic = new MmsReportFrame + { + ReceivedAt = frame.ReceivedAt, + Header = frame.Header, + Values = expanded, + DecoderMode = frame.DecoderMode, + Message = frame.Message + }; + var projected = MmsReportValueProjector.Project(synthetic); + if (projected.Updates.Count == 0 || projected.Warnings.Any(warning => warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase))) + { + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved."); + continue; + } + + replacementParents.Add(Normalize(parentReference)); + semanticUpdates.AddRange(projected.Updates.Select(update => new MmsReportSignalUpdate + { + Reference = update.Reference, + FunctionalConstraint = update.FunctionalConstraint, + DisplayName = update.DisplayName, + Source = update.Source, + Value = update.Value, + Quality = update.Quality, + Timestamp = update.Timestamp, + Reason = update.Reason, + UpdatedAt = update.UpdatedAt, + HasValue = update.HasValue, + HasQuality = update.HasQuality, + HasTimestamp = update.HasTimestamp, + IsProjectedChild = true, + ProjectionStatus = "semantic-structured-leaf" + })); + semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; static DataSet membership identity was preserved."); + } + + if (replacementParents.Count == 0) + { + return new MmsReportValueProjection + { + Updates = baseline.Updates, + Warnings = baseline.Warnings.Concat(semanticWarnings).Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + }; + } + + var updates = baseline.Updates + .Where(update => !replacementParents.Contains(Normalize(update.Reference))) + .Concat(semanticUpdates) + .GroupBy(update => Normalize(update.Reference) + "|" + update.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) + .Select(group => group.Last()) + .OrderBy(update => update.Reference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var warnings = baseline.Warnings + .Where(warning => !replacementParents.Any(parent => warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase))) + .Concat(semanticWarnings) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new MmsReportValueProjection + { + Updates = updates, + Warnings = warnings + }; + } + + private static string Normalize(string value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().Replace('$', '.').Replace("..", ".", StringComparison.Ordinal); +} From 892540b07b7f3241474e9b4d4e3a9b49d2a887a1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:40:23 +0700 Subject: [PATCH 06/23] P5.4: cover multi-phase structured report fan-out --- .../MmsSemanticReportValueProjectorTests.cs | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs new file mode 100644 index 00000000..27fa5761 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -0,0 +1,200 @@ +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; + +namespace AR.Iec61850.Tests.Mms; + +public sealed class MmsSemanticReportValueProjectorTests +{ + [Fact] + public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting_Primary_Phase() + { + const string objectReference = "AA1E1F02R2VI3p1_THDHarmonics/I_MHAI1.ThdA"; + const string dataSetReference = "AA1E1F02R2Application/LLN0.Analog"; + var model = BuildThreePhaseModel(objectReference, dataSetReference); + var binding = Assert.Single(Iec61850DataSetSemanticBindingResolver.Resolve(model).Members); + + Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus); + Assert.Null(binding.PrimaryValue); + + var frame = BuildFrame( + objectReference, + dataSetReference, + PhaseValue(19.97612), + PhaseValue(40.04636), + PhaseValue(60.02344)); + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + var phaseA = Assert.Single(projection.Updates.Where(update => + update.Reference.Equals(objectReference + ".phsA.cVal.mag.f", StringComparison.OrdinalIgnoreCase))); + var phaseB = Assert.Single(projection.Updates.Where(update => + update.Reference.Equals(objectReference + ".phsB.cVal.mag.f", StringComparison.OrdinalIgnoreCase))); + var phaseC = Assert.Single(projection.Updates.Where(update => + update.Reference.Equals(objectReference + ".phsC.cVal.mag.f", StringComparison.OrdinalIgnoreCase))); + + Assert.Equal("19.97612", phaseA.Value); + Assert.Equal("40.04636", phaseB.Value); + Assert.Equal("60.02344", phaseC.Value); + Assert.All(new[] { phaseA, phaseB, phaseC }, update => + { + Assert.True(update.IsProjectedChild); + Assert.Equal("semantic-structured-leaf", update.ProjectionStatus); + }); + Assert.DoesNotContain(projection.Updates, update => + update.Reference.Equals(objectReference, StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Warnings, warning => + warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase) + && warning.Contains(objectReference, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() + { + const string objectReference = "AA1E1F02R2VI3p1_THDHarmonics/I_MHAI1.ThdA"; + const string dataSetReference = "AA1E1F02R2Application/LLN0.Analog"; + var model = BuildThreePhaseModel(objectReference, dataSetReference); + var frame = BuildFrame( + objectReference, + dataSetReference, + PhaseValue(19.97612), + PhaseValue(40.04636)); + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + Assert.Contains(projection.Updates, update => + update.Reference.Equals(objectReference, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Warnings, warning => + warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase) + && warning.Contains(objectReference, StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_FALLBACK:", StringComparison.OrdinalIgnoreCase) + && warning.Contains("child-count mismatch", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Updates, update => + update.ProjectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase)); + } + + private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( + string objectReference, + string dataSetReference) + { + var attributes = new[] + { + Attribute(objectReference + ".phsA.cVal.mag.f", "phsA.cVal.mag.f"), + Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), + Attribute(objectReference + ".phsC.cVal.mag.f", "phsC.cVal.mag.f") + }; + var slash = objectReference.IndexOf('/'); + var domain = objectReference[..slash]; + var logicalPath = objectReference[(slash + 1)..]; + var firstDot = logicalPath.IndexOf('.'); + var logicalNode = logicalPath[..firstDot]; + var dataObjectName = logicalPath[(firstDot + 1)..]; + + return new LiveIedModelDiscoveryDocument + { + Source = "SclWorkspace", + IedName = "AA1E1F02R2", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = domain, + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = logicalNode, + LnClass = "MHAI", + LnInst = "1", + DataObjects = new[] + { + new LiveIedDataObjectModel + { + Reference = objectReference, + Name = dataObjectName, + InferredCdc = "WYE", + Attributes = attributes + } + } + } + } + } + }, + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = dataSetReference, + Domain = "AA1E1F02R2Application", + LogicalNode = "LLN0", + Name = "Analog", + MemberCount = 1, + Members = new[] + { + new LiveIedDataSetMemberModel + { + Index = 0, + Reference = objectReference, + FunctionalConstraint = "MX", + MmsReference = "AA1E1F02R2VI3p1_THDHarmonics/I_MHAI1$MX$ThdA", + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + } + } + } + } + }; + } + + private static LiveIedDataAttributeModel Attribute(string reference, string path) + => new() + { + ObjectReference = reference, + AttributePath = path, + FunctionalConstraint = "MX", + MmsReference = reference.Replace('.', '$'), + SclBType = "FLOAT32", + MmsType = "floating-point", + Source = "SCL.DataTypeTemplates", + TypeSource = "SCL.DataTypeTemplates", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact + }; + + private static MmsReportFrame BuildFrame( + string memberReference, + string dataSetReference, + params MmsDataValue[] phases) + => new() + { + ReceivedAt = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.Zero), + Header = new MmsReportHeader { DataSetReference = dataSetReference }, + Values = new[] + { + new MmsReportValue + { + Index = 0, + Member = new MmsDataSetDirectoryMember + { + UserReference = memberReference, + FunctionalConstraint = "MX" + }, + Value = MmsDataValue.Structure(phases), + ReasonForInclusion = new[] { "data-change" } + } + } + }; + + private static MmsDataValue PhaseValue(double value) + => MmsDataValue.Structure(new[] + { + MmsDataValue.Structure(new[] + { + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(value) }) + }) + }); +} From fe686527fc513cf2d34f3cb0855ca19f49fd035a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:43:18 +0700 Subject: [PATCH 07/23] P5.4: record physical THD bench provenance --- .../Mms/MmsSemanticReportValueProjectorTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 27fa5761..40cd870b 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -16,6 +16,9 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus); Assert.Null(binding.PrimaryValue); + // Physical F02 bench evidence from IEDScout included phsB.cVal.mag.f = 40.04636. + // Keep that exact value here so the report fan-out regression stays tied to the + // field failure that exposed REPORT_RAW_STRUCT for the whole ThdA member. var frame = BuildFrame( objectReference, dataSetReference, From 7a4e52a217fa53df94e7057325a6a4ec7ed81796 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:46:37 +0700 Subject: [PATCH 08/23] P5.4: keep public regression provenance vendor-neutral --- .../Mms/MmsSemanticReportValueProjectorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 40cd870b..25e9314a 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -16,7 +16,7 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus); Assert.Null(binding.PrimaryValue); - // Physical F02 bench evidence from IEDScout included phsB.cVal.mag.f = 40.04636. + // Physical bench evidence included phsB.cVal.mag.f = 40.04636. // Keep that exact value here so the report fan-out regression stays tied to the // field failure that exposed REPORT_RAW_STRUCT for the whole ThdA member. var frame = BuildFrame( From 448b26c657ab0397467de93e79b2a9524f7f418c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:52:20 +0700 Subject: [PATCH 09/23] P5.4: satisfy xUnit analyzer in semantic report regression --- .../Mms/MmsSemanticReportValueProjectorTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 25e9314a..0f9c8616 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -30,12 +30,12 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting frame, MmsReportSemanticProjectionContext.Create(model)); - var phaseA = Assert.Single(projection.Updates.Where(update => - update.Reference.Equals(objectReference + ".phsA.cVal.mag.f", StringComparison.OrdinalIgnoreCase))); - var phaseB = Assert.Single(projection.Updates.Where(update => - update.Reference.Equals(objectReference + ".phsB.cVal.mag.f", StringComparison.OrdinalIgnoreCase))); - var phaseC = Assert.Single(projection.Updates.Where(update => - update.Reference.Equals(objectReference + ".phsC.cVal.mag.f", StringComparison.OrdinalIgnoreCase))); + var phaseA = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsA.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); + var phaseB = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsB.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); + var phaseC = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsC.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); Assert.Equal("19.97612", phaseA.Value); Assert.Equal("40.04636", phaseB.Value); From 888545bbca1e26040b3f7a86193e2dae013e964f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 1 Sep 2026 15:54:52 +0700 Subject: [PATCH 10/23] P5.4: align semantic projection regression with renderer precision --- .../Mms/MmsSemanticReportValueProjectorTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 0f9c8616..28ee41dd 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -17,8 +17,9 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting Assert.Null(binding.PrimaryValue); // Physical bench evidence included phsB.cVal.mag.f = 40.04636. - // Keep that exact value here so the report fan-out regression stays tied to the - // field failure that exposed REPORT_RAW_STRUCT for the whole ThdA member. + // Keep that exact floating-point input here so the report fan-out regression stays tied + // to the field failure that exposed REPORT_RAW_STRUCT. The public display renderer has + // an established three-decimal contract, which is asserted below independently of routing. var frame = BuildFrame( objectReference, dataSetReference, @@ -37,9 +38,9 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting var phaseC = Assert.Single(projection.Updates, update => update.Reference.Equals(objectReference + ".phsC.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); - Assert.Equal("19.97612", phaseA.Value); - Assert.Equal("40.04636", phaseB.Value); - Assert.Equal("60.02344", phaseC.Value); + Assert.Equal("19.976", phaseA.Value); + Assert.Equal("40.046", phaseB.Value); + Assert.Equal("60.023", phaseC.Value); Assert.All(new[] { phaseA, phaseB, phaseC }, update => { Assert.True(update.IsProjectedChild); From 1fc06d26f580bef5477968aee6432dbe53ad5391 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 2 Sep 2026 09:33:58 +0700 Subject: [PATCH 11/23] Fix structured report semantic matching by exact member reference --- .../Mms/MmsSemanticReportValueProjector.cs | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs index eb995f95..e8e40984 100644 --- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -136,23 +136,42 @@ internal bool TryExpand( var memberReference = NormalizeReference(reportValue.MemberReference); var normalizedDataSet = NormalizeDataSetReference(dataSetReference); - var exact = _members + // InformationReport values can be sparse. In that case the decoder-side value index + // is not a safe substitute for the authoritative static DataSet member index. When the + // report carries an exact member reference, that engineering identity is stronger than + // the transient value position and must be resolved independently of reportValue.Index. + // Keep this fail-closed: duplicate static memberships with the same reference do not + // collapse unless the DataSet identity makes the reference unique. + if (!string.IsNullOrWhiteSpace(memberReference)) + { + var byExactReference = _members + .Where(candidate => string.Equals( + candidate.MemberReference, + memberReference, + StringComparison.OrdinalIgnoreCase)) + .Where(candidate => string.IsNullOrWhiteSpace(normalizedDataSet) + || string.Equals( + NormalizeDataSetReference(candidate.DataSetReference), + normalizedDataSet, + StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + return byExactReference.Length == 1 ? byExactReference[0] : null; + } + + // Some report encodings omit the member reference. Only then is the static DataSet + // index used, and only when it identifies exactly one member in the supplied DataSet + // scope (or globally when OptFlds omitted the DataSet reference as well). + var byIndex = _members .Where(candidate => candidate.Index == reportValue.Index) .Where(candidate => string.IsNullOrWhiteSpace(normalizedDataSet) - || string.Equals(NormalizeDataSetReference(candidate.DataSetReference), normalizedDataSet, StringComparison.OrdinalIgnoreCase)) - .Where(candidate => string.IsNullOrWhiteSpace(memberReference) - || string.Equals(candidate.MemberReference, memberReference, StringComparison.OrdinalIgnoreCase)) + || string.Equals( + NormalizeDataSetReference(candidate.DataSetReference), + normalizedDataSet, + StringComparison.OrdinalIgnoreCase)) .ToArray(); - if (exact.Length == 1) - return exact[0]; - // DatSet can be omitted by OptFlds. Fall back only when index + exact static - // member reference is unique across the entire design model. - var byIdentity = _members - .Where(candidate => candidate.Index == reportValue.Index) - .Where(candidate => string.Equals(candidate.MemberReference, memberReference, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - return byIdentity.Length == 1 ? byIdentity[0] : null; + return byIndex.Length == 1 ? byIndex[0] : null; } private static bool TryFlatten( From b9ee5fc9650b72e69bc81287a1e1b047ad19b054 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 2 Sep 2026 09:34:22 +0700 Subject: [PATCH 12/23] Add regression for sparse structured report index drift --- .../MmsSemanticReportValueProjectorTests.cs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 28ee41dd..13b93c02 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -55,6 +55,41 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Sparse_Report_Value_Index_Drift_Still_Uses_Exact_Static_Member_Reference() + { + const string objectReference = "AA1E1F02R2VI3p1_THDHarmonics/I_MHAI1.ThdA"; + const string dataSetReference = "AA1E1F02R2Application/LLN0.Analog"; + var model = BuildThreePhaseModel(objectReference, dataSetReference); + + // The static DataSet member index in the model is 0. A sparse InformationReport may + // expose a decoder-side value position that differs from that static member index. + // Exact IEC member identity must remain sufficient and must not be rejected solely + // because the transient report value index is different. + var frame = BuildFrame( + objectReference, + dataSetReference, + reportValueIndex: 17, + PhaseValue(19.97612), + PhaseValue(40.04636), + PhaseValue(60.02344)); + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + Assert.Contains(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsA.cVal.mag.f", StringComparison.OrdinalIgnoreCase) + && update.ProjectionStatus.Equals("semantic-structured-leaf", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsB.cVal.mag.f", StringComparison.OrdinalIgnoreCase) + && update.Value == "40.046"); + Assert.Contains(projection.Updates, update => + update.Reference.Equals(objectReference + ".phsC.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_FALLBACK:", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() { @@ -173,6 +208,13 @@ private static MmsReportFrame BuildFrame( string memberReference, string dataSetReference, params MmsDataValue[] phases) + => BuildFrame(memberReference, dataSetReference, 0, phases); + + private static MmsReportFrame BuildFrame( + string memberReference, + string dataSetReference, + int reportValueIndex, + params MmsDataValue[] phases) => new() { ReceivedAt = new DateTimeOffset(2026, 9, 1, 8, 0, 0, TimeSpan.Zero), @@ -181,7 +223,7 @@ private static MmsReportFrame BuildFrame( { new MmsReportValue { - Index = 0, + Index = reportValueIndex, Member = new MmsDataSetDirectoryMember { UserReference = memberReference, From 2eaefb8a8e5396f06635f7840c5a55fff3c38368 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:09:21 +0700 Subject: [PATCH 13/23] Prefer exact semantic schema over generic structured report heuristics --- .../Mms/MmsSemanticReportValueProjector.cs | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs index e8e40984..c90e207b 100644 --- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -281,8 +281,9 @@ private sealed record ExpandedLeaf(string Reference, MmsDataValue Value); } /// -/// Backward-compatible overlay for structures that the established report projector -/// intentionally leaves raw. Existing known CDC projections remain untouched. +/// Model-backed overlay for structured report members. Exact static DataSet/SCL schema is +/// authoritative when it can expand the structure safely; the established generic projector +/// remains the fail-closed fallback when no unique semantic schema matches. /// public static class MmsSemanticReportValueProjector { @@ -305,12 +306,19 @@ public static MmsReportValueProjection Project( var parentReference = reportValue.MemberReference; var rawPrefix = $"REPORT_RAW_STRUCT: {parentReference} "; - if (!baseline.Warnings.Any(warning => warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase))) - continue; + var baselineWasRaw = baseline.Warnings.Any(warning => + warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase)); + // Static DataSet identity + exact SCL/live schema is stronger evidence than a + // generic shape heuristic. Try semantic expansion first for every structured + // member, including structures the baseline recognizes as instMag/mag pairs. + // If the exact schema cannot prove the mapping, preserve baseline behavior. if (!context.TryExpand(frame.Header.DataSetReference, reportValue, out var expanded, out var expansionReason)) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); + if (baselineWasRaw) + { + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); + } continue; } @@ -325,7 +333,10 @@ public static MmsReportValueProjection Project( var projected = MmsReportValueProjector.Project(synthetic); if (projected.Updates.Count == 0 || projected.Warnings.Any(warning => warning.StartsWith("REPORT_RAW_STRUCT:", StringComparison.OrdinalIgnoreCase))) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved."); + if (baselineWasRaw) + { + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved."); + } continue; } @@ -347,7 +358,7 @@ public static MmsReportValueProjection Project( IsProjectedChild = true, ProjectionStatus = "semantic-structured-leaf" })); - semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; static DataSet membership identity was preserved."); + semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); } if (replacementParents.Count == 0) @@ -360,15 +371,20 @@ public static MmsReportValueProjection Project( } var updates = baseline.Updates - .Where(update => !replacementParents.Contains(Normalize(update.Reference))) + .Where(update => !replacementParents.Any(parent => IsInside(Normalize(update.Reference), parent))) .Concat(semanticUpdates) .GroupBy(update => Normalize(update.Reference) + "|" + update.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) .Select(group => group.Last()) - .OrderBy(update => update.Reference, StringComparer.OrdinalIgnoreCase) + // q/t companions are intentionally delivered before scalar values. Consumers can + // therefore attach report-native quality/timestamp to semantic value leaves without + // inventing defaults or issuing a separate MMS read. + .OrderBy(update => CompanionPriority(update.Reference)) + .ThenBy(update => update.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); var warnings = baseline.Warnings - .Where(warning => !replacementParents.Any(parent => warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase))) + .Where(warning => !replacementParents.Any(parent => + warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase))) .Concat(semanticWarnings) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -380,6 +396,21 @@ public static MmsReportValueProjection Project( }; } + private static int CompanionPriority(string reference) + { + var normalized = Normalize(reference); + return normalized.EndsWith(".q", StringComparison.OrdinalIgnoreCase) || + normalized.EndsWith(".t", StringComparison.OrdinalIgnoreCase) + ? 0 + : 1; + } + + private static bool IsInside(string reference, string parent) + => string.Equals(reference, parent, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(reference) && + !string.IsNullOrWhiteSpace(parent) && + reference.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase)); + private static string Normalize(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty From 52c19088e215f2dc482db6550367beb1dcb216bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:10:00 +0700 Subject: [PATCH 14/23] Add TotPF regression for exact semantic report schema authority --- .../MmsSemanticReportValueProjectorTests.cs | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 13b93c02..df0ecee2 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -90,6 +90,43 @@ public void Sparse_Report_Value_Index_Drift_Still_Uses_Exact_Static_Member_Refer warning.StartsWith("REPORT_SEMANTIC_FALLBACK:", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() + { + const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF"; + const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog"; + var model = BuildMeasurementPairModel(objectReference, dataSetReference); + var frame = BuildFrame( + objectReference, + dataSetReference, + MmsDataValue.FloatingPoint(0.125), + MmsDataValue.FloatingPoint(0.25)); + + // The generic projector recognizes a two-float structure as an instMag/mag pair. + // Static DataSet semantic authority must still win so exact schema leaf identities + // (including .f) reach ARSAS instead of heuristic aliases. + var baseline = MmsReportValueProjector.Project(frame); + Assert.Contains(baseline.Updates, update => + update.ProjectionStatus.Equals("measurement-pair(instMag/mag)", StringComparison.OrdinalIgnoreCase)); + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + var instant = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".instMag.f", StringComparison.OrdinalIgnoreCase)); + var magnitude = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus); + Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus); + Assert.DoesNotContain(projection.Updates, update => + update.Reference.Equals(objectReference + ".instMag", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Updates, update => + update.Reference.Equals(objectReference + ".mag", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() { @@ -128,6 +165,27 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( Attribute(objectReference + ".phsB.cVal.mag.f", "phsB.cVal.mag.f"), Attribute(objectReference + ".phsC.cVal.mag.f", "phsC.cVal.mag.f") }; + return BuildModel(objectReference, dataSetReference, "WYE", attributes); + } + + private static LiveIedModelDiscoveryDocument BuildMeasurementPairModel( + string objectReference, + string dataSetReference) + { + var attributes = new[] + { + Attribute(objectReference + ".instMag.f", "instMag.f"), + Attribute(objectReference + ".mag.f", "mag.f") + }; + return BuildModel(objectReference, dataSetReference, "MV", attributes); + } + + private static LiveIedModelDiscoveryDocument BuildModel( + string objectReference, + string dataSetReference, + string cdc, + IReadOnlyList attributes) + { var slash = objectReference.IndexOf('/'); var domain = objectReference[..slash]; var logicalPath = objectReference[(slash + 1)..]; @@ -138,7 +196,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( return new LiveIedModelDiscoveryDocument { Source = "SclWorkspace", - IedName = "AA1E1F02R2", + IedName = "AA1E1F06R4", LogicalDevices = new[] { new LiveIedLogicalDeviceModel @@ -149,7 +207,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( new LiveIedLogicalNodeModel { Name = logicalNode, - LnClass = "MHAI", + LnClass = logicalNode.Contains("MHAI", StringComparison.OrdinalIgnoreCase) ? "MHAI" : "MMXU", LnInst = "1", DataObjects = new[] { @@ -157,7 +215,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( { Reference = objectReference, Name = dataObjectName, - InferredCdc = "WYE", + InferredCdc = cdc, Attributes = attributes } } @@ -170,7 +228,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( new LiveIedDataSetModel { Reference = dataSetReference, - Domain = "AA1E1F02R2Application", + Domain = dataSetReference.Split('/')[0], LogicalNode = "LLN0", Name = "Analog", MemberCount = 1, @@ -181,7 +239,7 @@ private static LiveIedModelDiscoveryDocument BuildThreePhaseModel( Index = 0, Reference = objectReference, FunctionalConstraint = "MX", - MmsReference = "AA1E1F02R2VI3p1_THDHarmonics/I_MHAI1$MX$ThdA", + MmsReference = objectReference.Replace('.', '$'), Confidence = LiveIedDiscoveryConfidenceLevel.Exact } } From 69bfe70e2c779c7e8268af087bd1a3a38986c0fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 4 Sep 2026 20:16:15 +0700 Subject: [PATCH 15/23] Model TotPF report shape with q and t companions --- .../MmsSemanticReportValueProjectorTests.cs | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index df0ecee2..8902ff9d 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -16,10 +16,6 @@ public void Whole_MultiPhase_Member_Fans_Out_All_Scalar_Leaves_Without_Selecting Assert.Equal(LiveIedDataSetMemberResolutionStatus.Ambiguous, binding.ResolutionStatus); Assert.Null(binding.PrimaryValue); - // Physical bench evidence included phsB.cVal.mag.f = 40.04636. - // Keep that exact floating-point input here so the report fan-out regression stays tied - // to the field failure that exposed REPORT_RAW_STRUCT. The public display renderer has - // an established three-decimal contract, which is asserted below independently of routing. var frame = BuildFrame( objectReference, dataSetReference, @@ -62,10 +58,6 @@ public void Sparse_Report_Value_Index_Drift_Still_Uses_Exact_Static_Member_Refer const string dataSetReference = "AA1E1F02R2Application/LLN0.Analog"; var model = BuildThreePhaseModel(objectReference, dataSetReference); - // The static DataSet member index in the model is 0. A sparse InformationReport may - // expose a decoder-side value position that differs from that static member index. - // Exact IEC member identity must remain sufficient and must not be rejected solely - // because the transient report value index is different. var frame = BuildFrame( objectReference, dataSetReference, @@ -95,19 +87,22 @@ public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() { const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF"; const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog"; + var timestamp = new DateTimeOffset(2026, 9, 4, 13, 20, 51, TimeSpan.Zero); var model = BuildMeasurementPairModel(objectReference, dataSetReference); var frame = BuildFrame( objectReference, dataSetReference, - MmsDataValue.FloatingPoint(0.125), - MmsDataValue.FloatingPoint(0.25)); + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.125) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.25) }), + MmsDataValue.BitString(3, new byte[] { 0x00, 0x00 }), + MmsDataValue.UtcTime(new Iec61850UtcTime(timestamp, 0))); - // The generic projector recognizes a two-float structure as an instMag/mag pair. - // Static DataSet semantic authority must still win so exact schema leaf identities - // (including .f) reach ARSAS instead of heuristic aliases. + // The generic projector can correctly recognize the wire shape as an MX pair. + // Static DataSet semantic authority must still win so exact schema leaf identity, + // including the final .f and the report-native q/t, reaches the consumer. var baseline = MmsReportValueProjector.Project(frame); Assert.Contains(baseline.Updates, update => - update.ProjectionStatus.Equals("measurement-pair(instMag/mag)", StringComparison.OrdinalIgnoreCase)); + update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); var projection = MmsSemanticReportValueProjector.Project( frame, @@ -119,6 +114,10 @@ public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase)); Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus); Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus); + Assert.Equal("good", magnitude.Quality); + Assert.True(magnitude.HasQuality); + Assert.True(magnitude.HasTimestamp); + Assert.Contains("2026-09-04", magnitude.Timestamp, StringComparison.Ordinal); Assert.DoesNotContain(projection.Updates, update => update.Reference.Equals(objectReference + ".instMag", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(projection.Updates, update => @@ -175,7 +174,9 @@ private static LiveIedModelDiscoveryDocument BuildMeasurementPairModel( var attributes = new[] { Attribute(objectReference + ".instMag.f", "instMag.f"), - Attribute(objectReference + ".mag.f", "mag.f") + Attribute(objectReference + ".mag.f", "mag.f"), + Attribute(objectReference + ".q", "q", "Quality", "bit-string"), + Attribute(objectReference + ".t", "t", "Timestamp", "utc-time") }; return BuildModel(objectReference, dataSetReference, "MV", attributes); } @@ -248,15 +249,19 @@ private static LiveIedModelDiscoveryDocument BuildModel( }; } - private static LiveIedDataAttributeModel Attribute(string reference, string path) + private static LiveIedDataAttributeModel Attribute( + string reference, + string path, + string sclBType = "FLOAT32", + string mmsType = "floating-point") => new() { ObjectReference = reference, AttributePath = path, FunctionalConstraint = "MX", MmsReference = reference.Replace('.', '$'), - SclBType = "FLOAT32", - MmsType = "floating-point", + SclBType = sclBType, + MmsType = mmsType, Source = "SCL.DataTypeTemplates", TypeSource = "SCL.DataTypeTemplates", TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact From ba6211b7c7bcac7ab76ff326949e2e1cdb40cc24 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:24:35 +0700 Subject: [PATCH 16/23] Harden semantic report replacement for index-resolved members --- .../Mms/MmsSemanticReportValueProjector.cs | 76 ++++++++++++++----- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs index c90e207b..07f7aedf 100644 --- a/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs +++ b/src/AR.Iec61850/Mms/MmsSemanticReportValueProjector.cs @@ -85,8 +85,22 @@ internal bool TryExpand( MmsReportValue reportValue, out IReadOnlyList expanded, out string reason) + => TryExpand( + dataSetReference, + reportValue, + out expanded, + out _, + out reason); + + internal bool TryExpand( + string dataSetReference, + MmsReportValue reportValue, + out IReadOnlyList expanded, + out string resolvedMemberReference, + out string reason) { expanded = Array.Empty(); + resolvedMemberReference = string.Empty; reason = string.Empty; if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array)) @@ -127,6 +141,7 @@ internal bool TryExpand( ReasonForInclusion = reportValue.ReasonForInclusion }) .ToArray(); + resolvedMemberReference = schema.MemberReference; reason = $"expanded {schema.MemberReference} into {expanded.Count} scalar semantic descendant(s)"; return true; } @@ -295,17 +310,18 @@ public static MmsReportValueProjection Project( ArgumentNullException.ThrowIfNull(context); var baseline = MmsReportValueProjector.Project(frame); - var replacementParents = new HashSet(StringComparer.OrdinalIgnoreCase); + var semanticReplacementPositions = new HashSet(); var semanticUpdates = new List(); var semanticWarnings = new List(); - foreach (var reportValue in frame.Values) + for (var valuePosition = 0; valuePosition < frame.Values.Count; valuePosition++) { + var reportValue = frame.Values[valuePosition]; if (reportValue.Value is null || reportValue.Value.Kind is not (MmsDataKind.Structure or MmsDataKind.Array)) continue; - var parentReference = reportValue.MemberReference; - var rawPrefix = $"REPORT_RAW_STRUCT: {parentReference} "; + var reportedMemberReference = reportValue.MemberReference; + var rawPrefix = $"REPORT_RAW_STRUCT: {reportedMemberReference} "; var baselineWasRaw = baseline.Warnings.Any(warning => warning.StartsWith(rawPrefix, StringComparison.OrdinalIgnoreCase)); @@ -313,11 +329,16 @@ public static MmsReportValueProjection Project( // generic shape heuristic. Try semantic expansion first for every structured // member, including structures the baseline recognizes as instMag/mag pairs. // If the exact schema cannot prove the mapping, preserve baseline behavior. - if (!context.TryExpand(frame.Header.DataSetReference, reportValue, out var expanded, out var expansionReason)) + if (!context.TryExpand( + frame.Header.DataSetReference, + reportValue, + out var expanded, + out var resolvedMemberReference, + out var expansionReason)) { if (baselineWasRaw) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} remained raw; {expansionReason}. Exact scalar MMS fallback remains eligible."); } continue; } @@ -335,12 +356,18 @@ public static MmsReportValueProjection Project( { if (baselineWasRaw) { - semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {parentReference} expansion was not publishable; baseline raw projection was preserved."); + semanticWarnings.Add($"REPORT_SEMANTIC_FALLBACK: {reportedMemberReference} expansion was not publishable; baseline raw projection was preserved."); } continue; } - replacementParents.Add(Normalize(parentReference)); + // Replace the generic projection by report-value position, not by a guessed parent + // reference. Some valid InformationReports omit member identity and are resolved + // only by the exact static DataSet + member index. In that case the generic + // projector can emit unrooted heuristic leaves, so descendant-name filtering is + // neither sufficient nor safe. A successful semantic projection owns this report + // value completely; generic projection remains available only for other values. + semanticReplacementPositions.Add(valuePosition); semanticUpdates.AddRange(projected.Updates.Select(update => new MmsReportSignalUpdate { Reference = update.Reference, @@ -358,10 +385,10 @@ public static MmsReportValueProjection Project( IsProjectedChild = true, ProjectionStatus = "semantic-structured-leaf" })); - semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {parentReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); + semanticWarnings.Add($"REPORT_SEMANTIC_STRUCT: {resolvedMemberReference} {expansionReason}; exact static DataSet schema overrode generic structured-value heuristics."); } - if (replacementParents.Count == 0) + if (semanticReplacementPositions.Count == 0) { return new MmsReportValueProjection { @@ -370,8 +397,23 @@ public static MmsReportValueProjection Project( }; } - var updates = baseline.Updates - .Where(update => !replacementParents.Any(parent => IsInside(Normalize(update.Reference), parent))) + // Re-project only report values that were not replaced semantically. This preserves + // normal generic scalar/companion behavior for unrelated members while guaranteeing + // that no heuristic output from a successfully resolved structured member survives, + // even when the wire report omitted MemberReference entirely. + var retainedFrame = new MmsReportFrame + { + ReceivedAt = frame.ReceivedAt, + Header = frame.Header, + Values = frame.Values + .Where((_, index) => !semanticReplacementPositions.Contains(index)) + .ToArray(), + DecoderMode = frame.DecoderMode, + Message = frame.Message + }; + var retainedBaseline = MmsReportValueProjector.Project(retainedFrame); + + var updates = retainedBaseline.Updates .Concat(semanticUpdates) .GroupBy(update => Normalize(update.Reference) + "|" + update.FunctionalConstraint, StringComparer.OrdinalIgnoreCase) .Select(group => group.Last()) @@ -382,9 +424,7 @@ public static MmsReportValueProjection Project( .ThenBy(update => update.Reference, StringComparer.OrdinalIgnoreCase) .ToArray(); - var warnings = baseline.Warnings - .Where(warning => !replacementParents.Any(parent => - warning.StartsWith($"REPORT_RAW_STRUCT: {parent} ", StringComparison.OrdinalIgnoreCase))) + var warnings = retainedBaseline.Warnings .Concat(semanticWarnings) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -405,12 +445,6 @@ private static int CompanionPriority(string reference) : 1; } - private static bool IsInside(string reference, string parent) - => string.Equals(reference, parent, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrWhiteSpace(reference) && - !string.IsNullOrWhiteSpace(parent) && - reference.StartsWith(parent + ".", StringComparison.OrdinalIgnoreCase)); - private static string Normalize(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty From 915878326e40aa1dfb85d4aa0a65448a89e38bbf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:25:10 +0700 Subject: [PATCH 17/23] Add regression for index-resolved semantic report members --- .../MmsSemanticReportValueProjectorTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 8902ff9d..5a0e4564 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -126,6 +126,49 @@ public void Exact_Static_Schema_Overrides_Generic_InstMagMag_Heuristic() warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public void Missing_MemberReference_Uses_Static_DataSet_Index_Without_Leaking_Generic_Heuristic() + { + const string objectReference = "AA1E1F06R4VI3p1_OperationalValues/PPRE_MMXU1.TotPF"; + const string dataSetReference = "AA1E1F06R4Application/LLN0.Analog"; + var timestamp = new DateTimeOffset(2026, 9, 4, 13, 20, 51, TimeSpan.Zero); + var model = BuildMeasurementPairModel(objectReference, dataSetReference); + var frame = BuildFrame( + string.Empty, + dataSetReference, + reportValueIndex: 0, + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.125) }), + MmsDataValue.Structure(new[] { MmsDataValue.FloatingPoint(0.25) }), + MmsDataValue.BitString(3, new byte[] { 0x00, 0x00 }), + MmsDataValue.UtcTime(new Iec61850UtcTime(timestamp, 0))); + + // With no MemberReference, the generic projector has wire-shape evidence but no + // authoritative engineering parent. It can therefore emit unrooted MX-pair leaves. + var baseline = MmsReportValueProjector.Project(frame); + Assert.Contains(baseline.Updates, update => + update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); + + var projection = MmsSemanticReportValueProjector.Project( + frame, + MmsReportSemanticProjectionContext.Create(model)); + + var instant = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".instMag.f", StringComparison.OrdinalIgnoreCase)); + var magnitude = Assert.Single(projection.Updates, update => + update.Reference.Equals(objectReference + ".mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Equal("semantic-structured-leaf", instant.ProjectionStatus); + Assert.Equal("semantic-structured-leaf", magnitude.ProjectionStatus); + Assert.DoesNotContain(projection.Updates, update => + update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(projection.Updates, update => + string.IsNullOrWhiteSpace(update.Reference) + || update.Reference.Equals("instMag.f", StringComparison.OrdinalIgnoreCase) + || update.Reference.Equals("mag.f", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(projection.Warnings, warning => + warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase) + && warning.Contains(objectReference, StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Schema_Mismatch_Fails_Closed_And_Preserves_Raw_Projection() { From 0d7525bd330900917fb9f6d15a46059dc3d7a70a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 15:26:33 +0700 Subject: [PATCH 18/23] Clarify index-fallback regression intent --- .../Mms/MmsSemanticReportValueProjectorTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs index 5a0e4564..c4e6a34f 100644 --- a/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs +++ b/tests/AR.Iec61850.Tests/Mms/MmsSemanticReportValueProjectorTests.cs @@ -161,8 +161,7 @@ public void Missing_MemberReference_Uses_Static_DataSet_Index_Without_Leaking_Ge Assert.DoesNotContain(projection.Updates, update => update.ProjectionStatus.Equals("projected-mx-pair", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(projection.Updates, update => - string.IsNullOrWhiteSpace(update.Reference) - || update.Reference.Equals("instMag.f", StringComparison.OrdinalIgnoreCase) + update.Reference.Equals("instMag.f", StringComparison.OrdinalIgnoreCase) || update.Reference.Equals("mag.f", StringComparison.OrdinalIgnoreCase)); Assert.Contains(projection.Warnings, warning => warning.StartsWith("REPORT_SEMANTIC_STRUCT:", StringComparison.OrdinalIgnoreCase) From 11ab2304482600c19ba979f4fc9021ddb46b9af9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 5 Sep 2026 16:10:08 +0700 Subject: [PATCH 19/23] Harden persistent BRCB activation for mature client behavior --- ...sistentReportMonitorClientCompatibility.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs diff --git a/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs new file mode 100644 index 00000000..402538f8 --- /dev/null +++ b/src/AR.Iec61850/Mms/MmsPersistentReportMonitorClientCompatibility.cs @@ -0,0 +1,153 @@ +namespace AR.Iec61850.Mms; + +/// +/// Client-compatibility activation wrapper for persistent reporting. +/// +/// Mature IEC 61850 clients normally reserve a BRCB when ResvTms is exposed, +/// enable reporting, install/retain the report receiver, and only then request GI. +/// Some servers also support implicit BRCB reservation through RptEna=true, so an +/// explicit ResvTms rejection is non-fatal and the baseline activation is still tried. +/// +/// This wrapper does not create dynamic DataSets and does not schedule cyclic process +/// reads. It only hardens the RCB control-plane sequence used by report acquisition. +/// +public sealed partial class MmsClientSession +{ + public async Task StartPersistentReportMonitorClientCompatibleAsync( + MmsReportSubscriptionPlan plan, + bool triggerGeneralInterrogation = true, + bool deleteDynamicDataSetOnStop = true, + MmsIedModelDirectory? directory = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + + var rcb = plan.ReportControl; + MmsReportAttributeWriteStep? reservationStep = null; + var compatibilityWarnings = new List(); + + if (rcb is { Buffered: true } && + rcb.Attributes.Contains("ResvTms", StringComparer.OrdinalIgnoreCase) && + !MmsReportSubscriptionPlanner.IsExplicitlyEnabled(rcb) && + !MmsReportSubscriptionPlanner.IsReservedByOtherClient(rcb)) + { + reservationStep = await WriteReportAttributeAsync( + rcb, + "ResvTms", + MmsDataValue.Unsigned(60), + cancellationToken).ConfigureAwait(false); + + if (!reservationStep.IsSuccess) + { + compatibilityWarnings.Add( + $"BRCB ResvTms=60 explicit reservation was not accepted ({reservationStep.Message}). Continuing with standards-compatible implicit reservation through RptEna=true."); + } + } + + // Deliberately suppress GI inside the baseline start. The baseline method registers + // the persistent monitor only after RptEna succeeds. Requesting GI below guarantees + // that the report receiver/session is already registered when the server emits the + // initial InformationReport, while the receive router still preserves any earlier + // unconfirmed traffic that arrived during confirmed writes. + var attempt = await StartPersistentReportMonitorWithAttemptEvidenceAsync( + plan, + triggerGeneralInterrogation: false, + deleteDynamicDataSetOnStop, + directory, + cancellationToken).ConfigureAwait(false); + + var start = attempt.StartResult; + var writes = new List(); + if (reservationStep is not null) + writes.Add(reservationStep); + writes.AddRange(start.WriteSteps); + + var warnings = start.Warnings + .Where(warning => !warning.Contains("ResvTms pre-reserve was skipped", StringComparison.OrdinalIgnoreCase)) + .Concat(compatibilityWarnings) + .ToList(); + + if (!attempt.IsSuccess || start.Session is null) + { + var cleanupSteps = attempt.CleanupSteps.ToList(); + var cleanupWarnings = attempt.CleanupWarnings.ToList(); + var cleanupAttempted = attempt.CleanupAttempted; + var cleanupSucceeded = attempt.CleanupSucceeded; + + if (reservationStep?.IsSuccess == true && rcb is not null) + { + var release = await TryWriteReportAttributeForCleanupAsync( + rcb, + "ResvTms", + MmsDataValue.Unsigned(0), + CancellationToken.None).ConfigureAwait(false); + cleanupSteps.Add(release); + cleanupAttempted = true; + cleanupSucceeded &= release.IsSuccess; + if (!release.IsSuccess) + cleanupWarnings.Add($"BRCB ResvTms cleanup after failed activation was not accepted: {release.Message}"); + } + + return new MmsPersistentReportMonitorAttemptResult + { + StartResult = CopyStartResult(start, writes, warnings), + DynamicAttemptState = attempt.DynamicAttemptState, + FailureReason = attempt.FailureReason, + CleanupAttempted = cleanupAttempted, + CleanupSucceeded = cleanupSucceeded, + CleanupSteps = cleanupSteps, + CleanupWarnings = cleanupWarnings + }; + } + + if (reservationStep?.IsSuccess == true) + start.Session.ReservationTouched = true; + + if (triggerGeneralInterrogation) + { + var gi = await WriteReportAttributeAsync( + start.Session.ReportControl, + "GI", + MmsDataValue.Boolean(true), + cancellationToken).ConfigureAwait(false); + writes.Add(gi); + if (!gi.IsSuccess) + warnings.Add("GI=true write failed or is not supported by this RCB. Waiting for spontaneous/integrity reports only."); + } + + var compatibilityMessage = reservationStep?.IsSuccess == true + ? "BRCB explicitly reserved with ResvTms=60 before RptEna; GI was requested only after the persistent receiver was registered." + : "GI was requested only after the persistent receiver was registered."; + + return new MmsPersistentReportMonitorAttemptResult + { + StartResult = CopyStartResult( + start, + writes, + warnings, + $"{start.Message} {compatibilityMessage}"), + DynamicAttemptState = attempt.DynamicAttemptState, + FailureReason = attempt.FailureReason, + CleanupAttempted = attempt.CleanupAttempted, + CleanupSucceeded = attempt.CleanupSucceeded, + CleanupSteps = attempt.CleanupSteps, + CleanupWarnings = attempt.CleanupWarnings + }; + } + + private static MmsPersistentReportMonitorStartResult CopyStartResult( + MmsPersistentReportMonitorStartResult source, + IReadOnlyList writes, + IReadOnlyList warnings, + string? message = null) + => new() + { + IsSuccess = source.IsSuccess, + Message = message ?? source.Message, + Session = source.Session, + WriteSteps = writes.ToArray(), + Warnings = warnings.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + RcbSnapshots = source.RcbSnapshots, + DataSetSnapshots = source.DataSetSnapshots + }; +} From 1b12744653c3153cf02af9a4ff3822263d024c77 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 11:23:31 +0700 Subject: [PATCH 20/23] Support multiple RCB selections in legacy SAS export --- .../Scl/Export/LegacySasSclExporter.cs | 171 +++++++++++------- 1 file changed, 107 insertions(+), 64 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs index ff4e9946..d6f25429 100644 --- a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs @@ -10,9 +10,34 @@ public sealed class LegacySasSclExportOptions public string IedName { get; init; } = string.Empty; public string AccessPointName { get; init; } = string.Empty; public SclSchemaProfile SchemaProfile { get; init; } = SclSchemaProfile.Edition1V16; + // Backward-compatible single-selection entry point. public SclReportControlSelection SelectedReportControl { get; init; } = new(string.Empty); + // Preferred P0 entry point: retain every selected ReportControl/DataSet pair. + public IReadOnlyList SelectedReportControls { get; init; } + = Array.Empty(); public bool RemoveUnreferencedDataSets { get; init; } public string ToolId { get; init; } = "ARIEC61850"; + + internal IReadOnlyList EffectiveSelections() + { + var explicitSelections = SelectedReportControls + .Where(selection => !string.IsNullOrWhiteSpace(selection.SelectionKey)) + .GroupBy(selection => selection.SelectionKey.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToArray(); + if (explicitSelections.Length > 0) + return explicitSelections; + return string.IsNullOrWhiteSpace(SelectedReportControl.SelectionKey) + ? Array.Empty() + : new[] { SelectedReportControl }; + } +} + +public sealed record LegacySasRetainedReportControl +{ + public string Reference { get; init; } = string.Empty; + public string DataSetName { get; init; } = string.Empty; + public int DataSetMemberCount { get; init; } } public sealed record LegacySasSclExportResult @@ -26,9 +51,13 @@ public sealed record LegacySasSclExportResult public string IedName { get; init; } = string.Empty; public string AccessPointName { get; init; } = string.Empty; public string SclSchema { get; init; } = string.Empty; + // Legacy aggregate fields remain populated for existing callers. public string RetainedReportControlReference { get; init; } = string.Empty; public string RetainedDataSetName { get; init; } = string.Empty; public int RetainedDataSetMemberCount { get; init; } + public IReadOnlyList RetainedReportControls { get; init; } + = Array.Empty(); + public int RetainedReportControlCount => RetainedReportControls.Count; public int RemovedReportControlCount { get; init; } public int RemovedDataSetCount { get; init; } public IReadOnlyList Findings { get; init; } = Array.Empty(); @@ -46,8 +75,9 @@ public static LegacySasSclExportResult Build( { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(options); - if (string.IsNullOrWhiteSpace(options.SelectedReportControl.SelectionKey)) - throw new InvalidOperationException("Legacy SAS export requires exactly one selected ReportControl."); + var selections = options.EffectiveSelections(); + if (selections.Count == 0) + throw new InvalidOperationException("Legacy SAS export requires at least one selected ReportControl."); var normalized = InteroperableSclConverter.Convert( source, @@ -67,30 +97,37 @@ public static LegacySasSclExportResult Build( { IedName = normalized.SelectedIedName, AccessPointName = options.AccessPointName, - SelectedReportControls = new[] { options.SelectedReportControl }, - RequireExactlyOneReportControl = true, + SelectedReportControls = selections, + RequireExactlyOneReportControl = false, RemoveUnreferencedDataSets = options.RemoveUnreferencedDataSets, CollapseIndexedSelectionToSingleInstance = true }, sourceName); var document = new XDocument(filtered.Document); - ApplyExactRuntimeReportControlIdentity(document, options.SelectedReportControl); var root = document.Root ?? throw new InvalidDataException("Filtered SCL document has no root element."); var schema = SclSchemaProfiles.Get(options.SchemaProfile); ApplySchemaProfile(root, schema); - Validate(document, normalized.SelectedIedName); - ValidateExactRuntimeReportControlIdentity(document, options.SelectedReportControl); + Validate(document, normalized.SelectedIedName, selections.Count); + ValidateExactRuntimeReportControlIdentities(document, selections); - var retained = AssertSingleRetained(filtered); + var retained = AssertRetained(filtered, selections.Count); + var retainedResults = retained + .Select((descriptor, index) => new LegacySasRetainedReportControl + { + Reference = ExactRetainedReference(descriptor, FindSelection(descriptor, selections, index)), + DataSetName = descriptor.DataSetName, + DataSetMemberCount = descriptor.DataSetMemberCount + }) + .ToArray(); var findings = normalized.Findings .Concat(filtered.Findings) .Append(new InteroperableSclFinding { Severity = "Info", Code = "SCL.LEGACY_SAS_EXPORT_READY", - Reference = retained.DisplayReference, - Message = $"Prepared a single-RCB {schema.DisplayName} capability document for deterministic legacy SAS import." + Reference = string.Join(", ", retainedResults.Select(item => item.Reference)), + Message = $"Prepared a {retainedResults.Length}-RCB {schema.DisplayName} capability document for deterministic legacy SAS import." }) .GroupBy(item => $"{item.Severity}|{item.Code}|{item.Reference}|{item.Message}", StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) @@ -100,11 +137,12 @@ public static LegacySasSclExportResult Build( { Document = document, IedName = normalized.SelectedIedName, - AccessPointName = retained.AccessPointName, + AccessPointName = retained[0].AccessPointName, SclSchema = schema.DisplayName, - RetainedReportControlReference = ExactRetainedReference(retained, options.SelectedReportControl), - RetainedDataSetName = retained.DataSetName, - RetainedDataSetMemberCount = retained.DataSetMemberCount, + RetainedReportControlReference = string.Join(", ", retainedResults.Select(item => item.Reference)), + RetainedDataSetName = string.Join(", ", retainedResults.Select(item => item.DataSetName).Distinct(StringComparer.OrdinalIgnoreCase)), + RetainedDataSetMemberCount = retainedResults.Sum(item => item.DataSetMemberCount), + RetainedReportControls = retainedResults, RemovedReportControlCount = filtered.RemovedReportControlCount, RemovedDataSetCount = filtered.RemovedDataSetCount, Findings = findings @@ -151,47 +189,44 @@ public static LegacySasSclExportResult WriteFiles( return written; } - private static void ApplyExactRuntimeReportControlIdentity( - XDocument document, - SclReportControlSelection selection) + private static SclReportControlSelection FindSelection( + SclReportControlDescriptor retained, + IReadOnlyList selections, + int fallbackIndex) { - var exactRuntimeName = (selection.ExportName ?? string.Empty).Trim(); - if (exactRuntimeName.Length == 0) - return; - - var reportControls = document.Descendants(Scl + "ReportControl").ToArray(); - if (reportControls.Length != 1) - throw new InvalidDataException($"Exact runtime RCB normalization requires one retained ReportControl; found {reportControls.Length}."); + var exact = selections.FirstOrDefault(selection => + NormalizeSelectionKey(selection.SelectionKey).Equals( + NormalizeSelectionKey(retained.SelectionKey), StringComparison.OrdinalIgnoreCase)); + if (exact != null) + return exact; - var retained = reportControls[0]; - retained.SetAttributeValue("name", exactRuntimeName); - retained.SetAttributeValue("indexed", "false"); - - // ExportName already identifies the concrete MMS RCB instance. Keeping - // RptEnabled max=1 makes some legacy clients instantiate that exact name - // again and append another "01" (for example A_BRCB_1201 -> - // A_BRCB_120101). A non-indexed exact instance must therefore not carry - // the indexed-instantiation element in this legacy interoperability CID. - foreach (var rptEnabled in retained.Elements(Scl + "RptEnabled").ToArray()) - rptEnabled.Remove(); + var byExportName = selections.FirstOrDefault(selection => + !string.IsNullOrWhiteSpace(selection.ExportName) && + retained.Name.Equals(selection.ExportName, StringComparison.OrdinalIgnoreCase)); + return byExportName ?? selections[Math.Min(fallbackIndex, selections.Count - 1)]; } - private static void ValidateExactRuntimeReportControlIdentity( + private static string NormalizeSelectionKey(string value) + => (value ?? string.Empty).Trim().Replace('\\', '/'); + + private static void ValidateExactRuntimeReportControlIdentities( XDocument document, - SclReportControlSelection selection) + IReadOnlyList selections) { - var exactRuntimeName = (selection.ExportName ?? string.Empty).Trim(); - if (exactRuntimeName.Length == 0) - return; - - var retained = document.Descendants(Scl + "ReportControl").Single(); - var actualName = (string?)retained.Attribute("name") ?? string.Empty; - if (!actualName.Equals(exactRuntimeName, StringComparison.Ordinal)) - throw new InvalidDataException($"Filtered SCL changed exact runtime RCB name '{exactRuntimeName}' to '{actualName}'."); - if (!string.Equals((string?)retained.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException($"Exact runtime RCB '{exactRuntimeName}' must be exported as non-indexed."); - if (retained.Elements(Scl + "RptEnabled").Any()) - throw new InvalidDataException($"Exact runtime RCB '{exactRuntimeName}' must not contain RptEnabled because that can append a second instance suffix."); + var retained = document.Descendants(Scl + "ReportControl").ToArray(); + foreach (var selection in selections.Where(item => !string.IsNullOrWhiteSpace(item.ExportName))) + { + var exactRuntimeName = selection.ExportName.Trim(); + var matches = retained.Where(element => + string.Equals((string?)element.Attribute("name"), exactRuntimeName, StringComparison.Ordinal)).ToArray(); + if (matches.Length != 1) + throw new InvalidDataException($"Filtered SCL must contain exact runtime RCB '{exactRuntimeName}' exactly once; found {matches.Length}."); + var reportControl = matches[0]; + if (!string.Equals((string?)reportControl.Attribute("indexed"), "false", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Exact runtime RCB '{exactRuntimeName}' must be exported as non-indexed."); + if (reportControl.Elements(Scl + "RptEnabled").Any()) + throw new InvalidDataException($"Exact runtime RCB '{exactRuntimeName}' must not contain RptEnabled because that can append a second instance suffix."); + } } private static string ExactRetainedReference( @@ -208,14 +243,18 @@ private static string ExactRetainedReference( : retained.DisplayReference[..(separator + 1)] + exactRuntimeName; } - private static SclReportControlDescriptor AssertSingleRetained(SclReportControlFilterResult filtered) + private static IReadOnlyList AssertRetained( + SclReportControlFilterResult filtered, + int expectedCount) { - if (filtered.RetainedReportControls.Count != 1) - throw new InvalidDataException($"Legacy SAS export must retain exactly one ReportControl; found {filtered.RetainedReportControls.Count}."); - var retained = filtered.RetainedReportControls[0]; - if (!retained.HasPopulatedDataSet) - throw new InvalidDataException("The retained ReportControl does not reference a populated DataSet."); - return retained; + if (filtered.RetainedReportControls.Count != expectedCount) + throw new InvalidDataException($"Legacy SAS export must retain {expectedCount} selected ReportControl(s); found {filtered.RetainedReportControls.Count}."); + foreach (var retained in filtered.RetainedReportControls) + { + if (!retained.HasPopulatedDataSet) + throw new InvalidDataException($"Retained ReportControl '{retained.DisplayReference}' does not reference a populated DataSet."); + } + return filtered.RetainedReportControls; } private static void ApplySchemaProfile(XElement root, SclSchemaProfileDescriptor schema) @@ -244,16 +283,18 @@ private static void ApplySchemaProfile(XElement root, SclSchemaProfileDescriptor } } - private static void Validate(XDocument document, string iedName) + private static void Validate(XDocument document, string iedName, int expectedCount) { var parsed = new SclParser().Parse(document, "legacy-sas.cid"); if (!parsed.Ieds.Any(item => item.Name.Equals(iedName, StringComparison.OrdinalIgnoreCase))) throw new InvalidDataException($"Filtered SCL validation lost IED '{iedName}'."); - if (parsed.ReportControls.Count != 1) - throw new InvalidDataException($"Filtered SCL validation expected one ReportControl, found {parsed.ReportControls.Count}."); - var retained = parsed.ReportControls[0]; - if (retained.DataSetBindingStatus != SclDataSetBindingStatus.Resolved || retained.Entries.Count == 0) - throw new InvalidDataException($"Filtered SCL validation found an unresolved or empty DataSet for '{retained.ControlBlockReference}'."); + if (parsed.ReportControls.Count != expectedCount) + throw new InvalidDataException($"Filtered SCL validation expected {expectedCount} ReportControl(s), found {parsed.ReportControls.Count}."); + foreach (var retained in parsed.ReportControls) + { + if (retained.DataSetBindingStatus != SclDataSetBindingStatus.Resolved || retained.Entries.Count == 0) + throw new InvalidDataException($"Filtered SCL validation found an unresolved or empty DataSet for '{retained.ControlBlockReference}'."); + } } private static string BuildMarkdown(LegacySasSclExportResult result) @@ -266,8 +307,10 @@ private static string BuildMarkdown(LegacySasSclExportResult result) builder.AppendLine($"- Output: `{result.OutputPath}`"); builder.AppendLine($"- IED / AccessPoint: `{result.IedName}` / `{result.AccessPointName}`"); builder.AppendLine($"- Schema: `{result.SclSchema}`"); - builder.AppendLine($"- Retained RCB: `{result.RetainedReportControlReference}`"); - builder.AppendLine($"- DataSet: `{result.RetainedDataSetName}` ({result.RetainedDataSetMemberCount} FCDA)"); + builder.AppendLine($"- Retained RCBs: {result.RetainedReportControlCount}"); + foreach (var retained in result.RetainedReportControls) + builder.AppendLine($" - `{retained.Reference}` → `{retained.DataSetName}` ({retained.DataSetMemberCount} FCDA)"); + builder.AppendLine($"- Total retained DataSet members: {result.RetainedDataSetMemberCount}"); builder.AppendLine($"- Removed RCBs: {result.RemovedReportControlCount}"); builder.AppendLine($"- Removed unreferenced DataSets: {result.RemovedDataSetCount}"); builder.AppendLine(); From cdcaaa8f98116f399d99db9a92fc52df7e4dc902 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 11:31:51 +0700 Subject: [PATCH 21/23] Preserve exact single-RCB export behavior --- src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs index d6f25429..3cbd73c2 100644 --- a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs @@ -98,7 +98,9 @@ public static LegacySasSclExportResult Build( IedName = normalized.SelectedIedName, AccessPointName = options.AccessPointName, SelectedReportControls = selections, - RequireExactlyOneReportControl = false, + // Preserve the already field-proven exact single-RCB collapse contract. + // Multi-select only relaxes this guard when the operator actually chose >1 RCB. + RequireExactlyOneReportControl = selections.Count == 1, RemoveUnreferencedDataSets = options.RemoveUnreferencedDataSets, CollapseIndexedSelectionToSingleInstance = true }, From f1fa176158fd459c32f29379d1e474ff8004f1a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 11:33:16 +0700 Subject: [PATCH 22/23] Cover separate analog and digital RCB export --- .../Scl/LegacySasMultiRcbExportTests.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/AR.Iec61850.Tests/Scl/LegacySasMultiRcbExportTests.cs diff --git a/tests/AR.Iec61850.Tests/Scl/LegacySasMultiRcbExportTests.cs b/tests/AR.Iec61850.Tests/Scl/LegacySasMultiRcbExportTests.cs new file mode 100644 index 00000000..ea501955 --- /dev/null +++ b/tests/AR.Iec61850.Tests/Scl/LegacySasMultiRcbExportTests.cs @@ -0,0 +1,67 @@ +using System.Xml.Linq; +using AR.Iec61850.Scl.Export; + +namespace AR.Iec61850.Tests.Scl; + +public sealed class LegacySasMultiRcbExportTests +{ + private static readonly XNamespace Scl = "http://www.iec.ch/61850/2003/SCL"; + + [Fact] + public void Build_Retains_Selected_Analog_And_Digital_Rcbs_With_Separate_DataSets() + { + var source = XDocument.Parse(Fixture()); + var inventory = SclReportControlFilter.Inspect(source, "IED1.cid", "IED1", "AP1"); + var analog = inventory.ReportControls.Single(item => item.Name == "URCB_ANALOG"); + var digital = inventory.ReportControls.Single(item => item.Name == "BRCB_DIGITAL"); + + var result = LegacySasSclExporter.Build( + source, + "IED1.cid", + new LegacySasSclExportOptions + { + IedName = "IED1", + AccessPointName = "AP1", + SchemaProfile = SclSchemaProfile.Edition1V16, + SelectedReportControls = new[] + { + new SclReportControlSelection(analog.SelectionKey), + new SclReportControlSelection(digital.SelectionKey) + } + }); + + Assert.Equal(2, result.RetainedReportControlCount); + Assert.Equal(2, result.Document.Descendants(Scl + "ReportControl").Count()); + Assert.Contains(result.RetainedReportControls, item => item.DataSetName == "Analog" && item.DataSetMemberCount == 2); + Assert.Contains(result.RetainedReportControls, item => item.DataSetName == "Digital" && item.DataSetMemberCount == 3); + Assert.Equal(5, result.RetainedDataSetMemberCount); + Assert.Equal(1, result.RemovedReportControlCount); + Assert.Contains("Analog", result.RetainedDataSetName, StringComparison.Ordinal); + Assert.Contains("Digital", result.RetainedDataSetName, StringComparison.Ordinal); + } + + private static string Fixture() + => """ + + +
+ + + + + + + + + + + + + + + + + + + """; +} From 9fa571a994c32c67e12dbc5b532bf48979b63abe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 6 Sep 2026 11:36:16 +0700 Subject: [PATCH 23/23] Normalize exact runtime identities for multi-RCB export --- .../Scl/Export/LegacySasSclExporter.cs | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs index 3cbd73c2..e0ec7372 100644 --- a/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs +++ b/src/AR.Iec61850/Scl/Export/LegacySasSclExporter.cs @@ -10,9 +10,7 @@ public sealed class LegacySasSclExportOptions public string IedName { get; init; } = string.Empty; public string AccessPointName { get; init; } = string.Empty; public SclSchemaProfile SchemaProfile { get; init; } = SclSchemaProfile.Edition1V16; - // Backward-compatible single-selection entry point. public SclReportControlSelection SelectedReportControl { get; init; } = new(string.Empty); - // Preferred P0 entry point: retain every selected ReportControl/DataSet pair. public IReadOnlyList SelectedReportControls { get; init; } = Array.Empty(); public bool RemoveUnreferencedDataSets { get; init; } @@ -51,7 +49,6 @@ public sealed record LegacySasSclExportResult public string IedName { get; init; } = string.Empty; public string AccessPointName { get; init; } = string.Empty; public string SclSchema { get; init; } = string.Empty; - // Legacy aggregate fields remain populated for existing callers. public string RetainedReportControlReference { get; init; } = string.Empty; public string RetainedDataSetName { get; init; } = string.Empty; public int RetainedDataSetMemberCount { get; init; } @@ -98,8 +95,6 @@ public static LegacySasSclExportResult Build( IedName = normalized.SelectedIedName, AccessPointName = options.AccessPointName, SelectedReportControls = selections, - // Preserve the already field-proven exact single-RCB collapse contract. - // Multi-select only relaxes this guard when the operator actually chose >1 RCB. RequireExactlyOneReportControl = selections.Count == 1, RemoveUnreferencedDataSets = options.RemoveUnreferencedDataSets, CollapseIndexedSelectionToSingleInstance = true @@ -107,6 +102,7 @@ public static LegacySasSclExportResult Build( sourceName); var document = new XDocument(filtered.Document); + ApplyExactRuntimeReportControlIdentities(document, selections); var root = document.Root ?? throw new InvalidDataException("Filtered SCL document has no root element."); var schema = SclSchemaProfiles.Get(options.SchemaProfile); ApplySchemaProfile(root, schema); @@ -191,6 +187,46 @@ public static LegacySasSclExportResult WriteFiles( return written; } + private static void ApplyExactRuntimeReportControlIdentities( + XDocument document, + IReadOnlyList selections) + { + var retained = document.Descendants(Scl + "ReportControl").ToArray(); + foreach (var selection in selections.Where(item => !string.IsNullOrWhiteSpace(item.ExportName))) + { + var exactRuntimeName = selection.ExportName.Trim(); + var sourceName = SourceNameFromSelectionKey(selection.SelectionKey); + var matches = retained.Where(element => + string.Equals((string?)element.Attribute("name"), exactRuntimeName, StringComparison.Ordinal) || + (!string.IsNullOrWhiteSpace(sourceName) && + string.Equals((string?)element.Attribute("name"), sourceName, StringComparison.Ordinal))) + .Distinct() + .ToArray(); + if (matches.Length != 1) + throw new InvalidDataException($"Exact runtime RCB normalization could not uniquely map '{exactRuntimeName}'; found {matches.Length} retained candidate(s)."); + + var reportControl = matches[0]; + reportControl.SetAttributeValue("name", exactRuntimeName); + reportControl.SetAttributeValue("indexed", "false"); + foreach (var rptEnabled in reportControl.Elements(Scl + "RptEnabled").ToArray()) + rptEnabled.Remove(); + } + } + + private static string SourceNameFromSelectionKey(string selectionKey) + { + var normalized = (selectionKey ?? string.Empty).Trim(); + if (normalized.Length == 0) + return string.Empty; + var pipe = normalized.LastIndexOf('|'); + if (pipe >= 0 && pipe + 1 < normalized.Length) + return normalized[(pipe + 1)..]; + var slash = normalized.LastIndexOf('/'); + if (slash >= 0 && slash + 1 < normalized.Length) + return normalized[(slash + 1)..]; + return string.Empty; + } + private static SclReportControlSelection FindSelection( SclReportControlDescriptor retained, IReadOnlyList selections, @@ -327,4 +363,4 @@ private static string BuildMarkdown(LegacySasSclExportResult result) } return builder.ToString(); } -} \ No newline at end of file +}