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('/'); diff --git a/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs b/src/AR.Iec61850/Discovery/Iec61850DataSetSignalInventoryProjection.cs index b982a800..b9b1c4ce 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 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 without guessing a runtime leaf."; 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( @@ -196,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." } } }; @@ -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); 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 + }; + } +}