diff --git a/Services/Iec61850DataSetSignalInventoryService.cs b/Services/Iec61850DataSetSignalInventoryService.cs index 28674fa94..f3c956350 100644 --- a/Services/Iec61850DataSetSignalInventoryService.cs +++ b/Services/Iec61850DataSetSignalInventoryService.cs @@ -78,6 +78,7 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( { if (ApplyEngineDataSetAuthority(current, descriptor, inventoryReference, runtimeBinding)) enriched++; + AddPhaseRows(signals, model, descriptor, inventoryReference, added); continue; } @@ -88,11 +89,64 @@ public static Iec61850DataSetSignalInventoryMergeResult EnsureMandatorySignals( var signal = CreateSignal(descriptor, runtimeBinding, inventoryReference); signals.Add(signal); added.Add(signal); + AddPhaseRows(signals, model, descriptor, inventoryReference, added); } return new Iec61850DataSetSignalInventoryMergeResult(added, enriched, mandatory.Count); } + private static void AddPhaseRows( + ICollection signals, + LiveIedModelDiscoveryDocument model, + Iec61850SignalDescriptor descriptor, + string staticMember, + List added) + { + // Only schema-proven phase descendants of an unresolved whole-DO FCD. + // Keep the original mandatory FCD row; never replace its identity. + if (descriptor.ResolutionStatus != Iec61850SignalCatalogResolutionStatus.Unresolved) + return; + + var membership = FirstMembership(descriptor); + if (membership is null || string.IsNullOrWhiteSpace(membership.DataSetReference)) + return; + + foreach (var leaf in Iec61850StaticPhaseLeafProjection.Resolve( + model, staticMember, descriptor.FunctionalConstraint)) + { + if (signals.Any(signal => + ReferenceEquals(signal.ObjectReference, leaf.Reference) && + ReferenceEquals(signal.DisplayReference, leaf.Reference) && + ReferenceEquals(signal.DataSetReference, membership.DataSetReference))) + continue; + + var row = new SignalDefinition + { + Name = descriptor.DataObject + " " + leaf.Phase, + ObjectReference = leaf.Reference, + DisplayReference = leaf.Reference, + FunctionalConstraint = "MX", + DataType = leaf.DataType, + Category = "Measurement", + Confidence = "High", + DataSetReference = membership.DataSetReference, + ReportControlReference = descriptor.ReportMemberships.FirstOrDefault()?.ReportControlReference ?? "", + QualityReference = leaf.QualityReference, + TimestampReference = leaf.TimestampReference, + Source = "ARIEC61850 signal inventory • mandatory static DataSet member • schema named phase", + IsReportCapable = true, + ReportCoverage = "Static DataSet phase leaf • awaiting configured report", + ReportCoverageReason = "Exact schema-proven phase of the configured static FCD; requires actual report ingress.", + ProbeStatus = "Exact phase schema; report pending", + Value = "-", + Quality = "Unknown", + DeviceTimestamp = "-" + }; + signals.Add(row); + added.Add(row); + } + } + private static SignalDefinition? FindExistingMembershipSignal( IEnumerable signals, Iec61850SignalDescriptor descriptor, diff --git a/Services/Iec61850StaticDataSetAuthoritySelection.cs b/Services/Iec61850StaticDataSetAuthoritySelection.cs index 8f4b43f09..eb94e455f 100644 --- a/Services/Iec61850StaticDataSetAuthoritySelection.cs +++ b/Services/Iec61850StaticDataSetAuthoritySelection.cs @@ -72,6 +72,22 @@ public static IReadOnlySet Build(Iec61850MonitorDevice device) selected.Add(control); } + // A single configured WYE/DEL whole-DO membership can own several + // independent schema-proven phase leaves. Retain the original static + // membership and select every exact leaf with this DataSet owner. + foreach (var leaf in Iec61850StaticPhaseLeafProjection.Resolve( + authorityModel, memberReference, descriptor.FunctionalConstraint)) + { + var matches = signals + .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) + .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) + .Where(signal => LiteralEquals(signal.ObjectReference, leaf.Reference)) + .Where(signal => LiteralEquals(signal.DisplayReference, leaf.Reference)) + .ToArray(); + if (matches.Length == 1) + selected.Add(matches[0]); + } + var candidates = signals .Where(signal => !signal.IsControlSignal && signal.CanPublishToRuntime) .Where(signal => LiteralEquals(signal.DataSetReference, membership.DataSetReference)) diff --git a/Services/Iec61850StaticPhaseLeafProjection.cs b/Services/Iec61850StaticPhaseLeafProjection.cs new file mode 100644 index 000000000..2b83b096e --- /dev/null +++ b/Services/Iec61850StaticPhaseLeafProjection.cs @@ -0,0 +1,74 @@ +using AR.Iec61850.Discovery; + +namespace ArIED61850Tester.Services; + +/// +/// Maps one typed WYE/DEL whole-DO static FCDA into named phase magnitudes. +/// No MMS reads, numeric-child selection, or additional DataSet/RCB are created. +/// +public static class Iec61850StaticPhaseLeafProjection +{ + public sealed record Leaf( + string Reference, string Phase, string DataType, + string QualityReference, string TimestampReference); + + public static IReadOnlyList Resolve( + LiveIedModelDiscoveryDocument? model, string? memberReference, string? fc) + { + if (model is null || string.IsNullOrWhiteSpace(memberReference) || + !string.Equals(fc, "MX", StringComparison.OrdinalIgnoreCase)) + return Array.Empty(); + + var normalized = Normalize(memberReference); + var owners = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects) + .Where(obj => string.Equals(Normalize(obj.Reference), normalized, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (owners.Length != 1) + return Array.Empty(); + + var owner = owners[0]; + var phases = (owner.InferredCdc ?? string.Empty).Trim().ToUpperInvariant() switch + { + "WYE" => new[] { "phsA", "phsB", "phsC", "neut", "net", "res" }, + "DEL" => new[] { "phsAB", "phsBC", "phsCA" }, + _ => Array.Empty() + }; + var result = new List(phases.Length); + foreach (var phase in phases) + { + var prefix = normalized + "." + phase; + if (!SchemaSafeAggregateProjectionService.TryResolveStaticDataSetPrimaryLeaf( + model, prefix, "MX", out var leaf, out _) || + !IsNumeric(leaf.DataType) || + !Normalize(leaf.Reference).StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + continue; + + result.Add(new Leaf( + leaf.Reference, phase, leaf.DataType, + HasAttribute(owner, prefix + ".q") ? prefix + ".q" : "", + HasAttribute(owner, prefix + ".t") ? prefix + ".t" : "")); + } + return result; + } + + private static bool HasAttribute(LiveIedDataObjectModel owner, string reference) + => owner.Attributes.Any(attribute => + string.Equals(attribute.FunctionalConstraint, "MX", StringComparison.OrdinalIgnoreCase) && + string.Equals(Normalize(string.IsNullOrWhiteSpace(attribute.ObjectReference) + ? owner.Reference + "." + attribute.AttributePath : attribute.ObjectReference), + reference, StringComparison.OrdinalIgnoreCase)); + + private static bool IsNumeric(string? type) + { + var value = (type ?? string.Empty).Trim(); + return value.Equals("FLOAT32", StringComparison.OrdinalIgnoreCase) || + value.Equals("FLOAT64", StringComparison.OrdinalIgnoreCase) || + value.Equals("floating-point", StringComparison.OrdinalIgnoreCase) || + value.Equals("INT32", StringComparison.OrdinalIgnoreCase) || + value.Equals("INT64", StringComparison.OrdinalIgnoreCase); + } + + private static string Normalize(string? reference) + => (reference ?? string.Empty).Trim().Replace('$', '.'); +} diff --git a/Services/SchemaSafeAggregateProjectionService.cs b/Services/SchemaSafeAggregateProjectionService.cs index 905cbf411..65183fd23 100644 --- a/Services/SchemaSafeAggregateProjectionService.cs +++ b/Services/SchemaSafeAggregateProjectionService.cs @@ -158,10 +158,16 @@ public static bool TryResolveStaticDataSetPrimaryLeaf( var requested = NormalizeReference(requestedReference).TrimEnd('.'); var dataObjectReference = NormalizeReference(dataObject.Reference).TrimEnd('.'); - if (requested.Length <= dataObjectReference.Length || - !requested.StartsWith(dataObjectReference + ".", StringComparison.OrdinalIgnoreCase)) + // A top-level WYE/DEL has multiple phases and must never be collapsed. + // Top-level scalar CMV/MV FCDs (such as AuxV) can resolve their unique + // typed cVal.mag.f or mag.f using the same schema-safe leaf tiers. + var topLevel = requested.Equals(dataObjectReference, StringComparison.OrdinalIgnoreCase); + var scalarCdc = string.Equals(dataObject.InferredCdc, "CMV", StringComparison.OrdinalIgnoreCase) || + string.Equals(dataObject.InferredCdc, "MV", StringComparison.OrdinalIgnoreCase); + if ((topLevel && !scalarCdc) || + (!topLevel && !requested.StartsWith(dataObjectReference + ".", StringComparison.OrdinalIgnoreCase))) { - status = $"Static DataSet primary-leaf resolution does not own top-level DataObject {requestedReference}."; + status = $"Static DataSet primary-leaf resolution cannot collapse {requestedReference} to one scalar."; return false; } diff --git a/tests/ARSAS.Tests/StaticWholeFcdPhaseMeasurementTests.cs b/tests/ARSAS.Tests/StaticWholeFcdPhaseMeasurementTests.cs new file mode 100644 index 000000000..e5089993f --- /dev/null +++ b/tests/ARSAS.Tests/StaticWholeFcdPhaseMeasurementTests.cs @@ -0,0 +1,147 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class StaticWholeFcdPhaseMeasurementTests +{ + [Fact] + public void WholeFcd_ThirteenExactPhaseMagnitudes_AndScalarAuxV_AreReportEligible() + { + var model = Model(); + var device = new Iec61850MonitorDevice { Name = "IED", LiveDiscoveryModel = model }; + var inventory = Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + Assert.Equal(4, inventory.MandatoryCatalogCount); + + var expected = new[] + { + "A.phsA", "A.phsB", "A.phsC", "A.neut", "A.net", "A.res", + "PhV.phsA", "PhV.phsB", "PhV.phsC", "PhV.neut", + "PPV.phsAB", "PPV.phsBC", "PPV.phsCA" + }.Select(p => "IEDLD0/MMXU1." + p + ".cVal.mag.f").ToArray(); + + var selected = Iec61850StaticDataSetAuthoritySelection.Build(device); + foreach (var reference in expected) + { + var signal = Assert.Single(device.Signals, signal => + signal.ObjectReference.Equals(reference, StringComparison.OrdinalIgnoreCase) && + signal.DataSetReference.Equals("IEDLD0/LLN0.Analog", StringComparison.OrdinalIgnoreCase)); + Assert.True(signal.CanPublishToRuntime, signal.ObjectReference); + Assert.Equal("FLOAT32", signal.DataType); + Assert.EndsWith(".q", signal.QualityReference, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(".t", signal.TimestampReference, StringComparison.OrdinalIgnoreCase); + Assert.Contains(selected, chosen => ReferenceEquals(signal, chosen)); + } + Assert.Equal(13, selected.Count(signal => expected.Contains(signal.ObjectReference, StringComparer.OrdinalIgnoreCase))); + Assert.Contains(selected, signal => + signal.ObjectReference.Equals("IEDLD0/MMXU1.AuxV.cVal.mag.f", StringComparison.OrdinalIgnoreCase)); + + Iec61850DataSetSignalInventoryService.EnsureMandatorySignals(device); + foreach (var reference in expected) + Assert.Single(device.Signals, signal => + signal.ObjectReference.Equals(reference, StringComparison.OrdinalIgnoreCase) && + signal.DataSetReference.Equals("IEDLD0/LLN0.Analog", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void WholeFcd_ReorderedTypedAttributes_MissingPhase_NotInvented() + { + var original = Model(); + var objectA = original.LogicalDevices[0].LogicalNodes[0].DataObjects[0]; + var sparse = new LiveIedDataObjectModel + { + Reference = objectA.Reference, Name = objectA.Name, InferredCdc = objectA.InferredCdc, + Attributes = objectA.Attributes + .Where(attribute => !attribute.AttributePath.StartsWith("phsB.", StringComparison.OrdinalIgnoreCase)) + .Reverse() + .ToArray() + }; + var rebuilt = Model(sparse); + var resolved = Iec61850StaticPhaseLeafProjection.Resolve(rebuilt, sparse.Reference, "MX"); + Assert.Equal(5, resolved.Count); + Assert.DoesNotContain(resolved, leaf => leaf.Phase == "phsB"); + Assert.Empty(Iec61850StaticPhaseLeafProjection.Resolve(rebuilt, sparse.Reference, "CO")); + Assert.Empty(Iec61850StaticPhaseLeafProjection.Resolve(rebuilt, sparse.Reference + ".phsA", "MX")); + } + + private static LiveIedModelDiscoveryDocument Model(LiveIedDataObjectModel? overrideA = null) + { + const string prefix = "IEDLD0/MMXU1."; + LiveIedDataObjectModel Object(string name, string cdc, params string[] phases) => new() + { + Reference = prefix + name, Name = name, InferredCdc = cdc, + Attributes = phases.SelectMany(phase => new[] + { + Attribute(prefix + name + "." + phase + ".instCVal.mag.f", phase + ".instCVal.mag.f"), + Attribute(prefix + name + "." + phase + ".q", phase + ".q", "Quality"), + Attribute(prefix + name + "." + phase + ".cVal.mag.f", phase + ".cVal.mag.f"), + Attribute(prefix + name + "." + phase + ".t", phase + ".t", "Timestamp") + }).ToArray() + }; + var objects = new[] + { + overrideA ?? Object("A", "WYE", "phsA", "phsB", "phsC", "neut", "net", "res"), + Object("PhV", "WYE", "phsA", "phsB", "phsC", "neut"), + Object("PPV", "DEL", "phsAB", "phsBC", "phsCA"), + new LiveIedDataObjectModel + { + Reference = prefix + "AuxV", Name = "AuxV", InferredCdc = "CMV", + Attributes = new[] + { + Attribute(prefix + "AuxV.instCVal.mag.f", "instCVal.mag.f"), + Attribute(prefix + "AuxV.cVal.mag.f", "cVal.mag.f") + } + } + }; + return new LiveIedModelDiscoveryDocument + { + Source = "Synthetic SCL", IedName = "IED", + LogicalDevices = new[] + { + new LiveIedLogicalDeviceModel + { + MmsDomain = "IEDLD0", Inst = "LD0", + LogicalNodes = new[] + { + new LiveIedLogicalNodeModel + { + Name = "MMXU1", LnClass = "MMXU", LnInst = "1", DataObjects = objects + } + } + } + }, + DataSets = new[] + { + new LiveIedDataSetModel + { + Reference = "IEDLD0/LLN0.Analog", Domain = "IEDLD0", + LogicalNode = "LLN0", Name = "Analog", MemberCount = 4, + Members = objects.Select((obj, index) => new LiveIedDataSetMemberModel + { + Index = index, Reference = obj.Reference, FunctionalConstraint = "MX", + MmsReference = "IEDLD0/MMXU1$MX$" + obj.Name, + Confidence = LiveIedDiscoveryConfidenceLevel.Exact + }).ToArray() + } + }, + ReportControls = new[] + { + new LiveIedReportControlModel + { + Reference = "IEDLD0/LLN0.RP.Unbuffer01", + DataSetReference = "IEDLD0/LLN0.Analog", Buffered = false + } + } + }; + } + + private static LiveIedDataAttributeModel Attribute(string reference, string path, string type = "FLOAT32") + => new() + { + ObjectReference = reference, AttributePath = path, FunctionalConstraint = "MX", + SclBType = type, MmsType = type == "FLOAT32" ? "floating-point" : "", + TypeConfidence = LiveIedDiscoveryConfidenceLevel.Exact, + TypeSource = "SCL.DataTypeTemplates", Source = "SCL.DataTypeTemplates" + }; +}