diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs index 31ab1c23b..928ac777b 100644 --- a/MainWindow.IedBrowser.cs +++ b/MainWindow.IedBrowser.cs @@ -719,6 +719,20 @@ private void RefreshIedBrowserVisibleValues() row.ValueReference, row.FunctionalConstraint, live.Value); else display = "Not exposed"; + // An actually observed q/t leaf with no verified raw bit-level + // metadata is not an unexposed IEC attribute. Report/MMS value + // ingress succeeded; only the expansion into individual flags + // is unavailable. Preserve this distinction to guide BCUGE + // diagnosis without inventing false quality/timeQuality bits. + if (row.EvidenceField.Length > 0 && + display == "Not exposed" && + row.EvidenceKind is "Quality" or "Timestamp") + { + display = "Not decoded"; + row.ObservationTooltip += + " · Exact q/t leaf observed, but raw typed bit evidence " + + "was not verified; do not infer flags from text."; + } // IEC UTC wire timestamps are UTC instants, unlike offsetless // vendor display strings that may already be operator-local. // Convert only when explicit UTC/offset evidence exists. diff --git a/Models/IedBrowserStructuredDetails.cs b/Models/IedBrowserStructuredDetails.cs index 8e66e57ad..985c1d7bb 100644 --- a/Models/IedBrowserStructuredDetails.cs +++ b/Models/IedBrowserStructuredDetails.cs @@ -8,6 +8,71 @@ namespace ArIED61850Tester.Models; /// public static class IedBrowserStructuredDetails { + /// + /// DataSet presentation may use the selected IED's exact SCL schema + /// when live discovery did not expose any declarations for this member FC. + /// This never authorizes an MMS Read or supplies an observation: the + /// Browser value remains keyed to the original member reference and FC. + /// Ambiguous live ownership must NOT silently fall back to SCL. + /// + private static LiveIedDataObjectModel? PresentationOwner( + string memberReference, string memberFc, + LiveIedModelDiscoveryDocument? liveModel, + LiveIedModelDiscoveryDocument? sclModel, + out bool sclOnly) + { + sclOnly = false; + if (memberReference.Length == 0 || string.IsNullOrWhiteSpace(memberFc)) + return null; + + static (LiveIedDataObjectModel? Owner, bool Ambiguous) Find( + LiveIedModelDiscoveryDocument? source, string reference) + { + var owners = source?.LogicalDevices.SelectMany(ld => ld.LogicalNodes) + .SelectMany(ln => ln.DataObjects) + .Where(obj => reference == IedBrowserLiveValues.CanonicalReference(obj.Reference) || + reference.StartsWith( + IedBrowserLiveValues.CanonicalReference(obj.Reference) + ".", + StringComparison.Ordinal)) + .OrderByDescending(obj => obj.Reference.Length).ToArray(); + if (owners is not { Length: > 0 }) + return (null, false); + var best = IedBrowserLiveValues.CanonicalReference(owners[0].Reference); + var grouped = IedBrowserEngineeringObjects.GroupExact( + owners.Where(o => IedBrowserLiveValues.CanonicalReference(o.Reference) == best) + .ToArray()); + return grouped.Count == 1 + ? (grouped[0], false) : (null, true); + } + + var live = Find(liveModel, memberReference); + if (live.Ambiguous) return null; + if (live.Owner is { } proven && + proven.Attributes.Any(a => + string.Equals(a.FunctionalConstraint, memberFc, + StringComparison.OrdinalIgnoreCase) && + (string.Equals(IedBrowserLiveValues.CanonicalReference(a.MmsReference), + memberReference, StringComparison.Ordinal) || + IedBrowserLiveValues.CanonicalReference(a.MmsReference) + .StartsWith(memberReference + ".", StringComparison.Ordinal)))) + return proven; + + // Do not merge two competing SCL/type inventories or borrow an LN + // from a different domain: the member's fully-qualified owner must + // be present in this selected-IED SCL projection. + var scl = Find(sclModel, memberReference); + if (scl.Ambiguous || scl.Owner is null) + return live.Owner; + if (!scl.Owner.Attributes.Any(a => + string.Equals(a.FunctionalConstraint, memberFc, + StringComparison.OrdinalIgnoreCase) && + IedBrowserLiveValues.CanonicalReference(a.MmsReference) + .StartsWith(memberReference + ".", StringComparison.Ordinal))) + return live.Owner; + sclOnly = true; + return scl.Owner; + } + /// One DO row; child DAs/BDAs materialize only when expanded. public static IedBrowserDetailRow DataObject( LiveIedDataObjectModel obj, string? uniqueProcessFc, int depth = 0) @@ -77,20 +142,8 @@ public static IedBrowserDetailRow Member(LiveIedDataSetMemberModel member, // Static FCD reports can expose only a scalar semantic descendant. // Resolve a unique IEC process leaf once from the exact model owner. var identity = IedBrowserLiveValues.CanonicalReference(member.Reference); - var objectCandidates = model?.LogicalDevices.SelectMany(ld => ld.LogicalNodes) - .SelectMany(ln => ln.DataObjects) - .Where(obj => identity == IedBrowserLiveValues.CanonicalReference(obj.Reference) || - identity.StartsWith(IedBrowserLiveValues.CanonicalReference(obj.Reference) + ".", - StringComparison.Ordinal)) - .OrderByDescending(obj => obj.Reference.Length).ToArray() - ?? Array.Empty(); - // Discovery may list the same DO once per FC. Reuse the exact - // engineering grouping; conflicting names fail closed. - var matchingOwners = objectCandidates.Length == 0 - ? Array.Empty() - : IedBrowserEngineeringObjects.GroupExact(objectCandidates - .Where(o => o.Reference == objectCandidates[0].Reference).ToArray()); - var exactObject = matchingOwners.Count == 1 ? matchingOwners[0] : null; + var exactObject = PresentationOwner(identity, member.FunctionalConstraint, + model, sclModel, out var usingSclSchema); // A DataSet FCDA can identify a structured subobject (A.phsA) // while the discovered DO owner is A. Resolve only descendants of // that exact member and matching FC. @@ -144,7 +197,9 @@ public static IedBrowserDetailRow Member(LiveIedDataSetMemberModel member, ? IedBrowserEngineeringComposite.BuildPolar(matchingMembers, identity) : null; return new IedBrowserDetailRow("Data", $"{name} [{member.FunctionalConstraint}]", "Not read", - depth, "DataSet directory; observed live values or explicit MMS read") + depth, usingSclSchema + ? "Selected-IED SCL structure only; values require exact live evidence" + : "DataSet directory; observed live values or explicit MMS read") { ScalarComponents = scalarComponents, PolarPlan = memberPolarPlan, @@ -165,34 +220,28 @@ public static IedBrowserDetailRow Member(LiveIedDataSetMemberModel member, ? IedBrowserLiveValues.CanonicalReference(a.MmsReference)[(identity.Length + 1)..] : "")), member.FunctionalConstraint), - LoadChildren = () => DataSetMemberChildren(member, model, depth + 1) + LoadChildren = () => DataSetMemberChildren( + member, model, depth + 1, sclModel) }; } public static IReadOnlyList DataSetMemberChildren( - LiveIedDataSetMemberModel member, LiveIedModelDiscoveryDocument? model, int depth) + LiveIedDataSetMemberModel member, LiveIedModelDiscoveryDocument? model, int depth, + LiveIedModelDiscoveryDocument? sclModel = null) { - if (model is null) return [new("Message", "No model type hierarchy is available.", Depth: depth)]; + if (model is null && sclModel is null) + return [new("Message", "No model type hierarchy is available.", Depth: depth)]; // MMS $FC$ and canonical IEC dotted references must resolve to the // SAME case-sensitive object identity. A string prefix alone is never // enough: Pos.stVal must not expose Pos.stValue/vendor siblings. var memberReference = IedBrowserLiveValues.CanonicalReference(member.Reference); - var objectModels = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes) - .SelectMany(ln => ln.DataObjects) - .Where(obj => string.Equals(obj.Reference, memberReference, StringComparison.Ordinal) || - memberReference.StartsWith(obj.Reference + ".", StringComparison.Ordinal)) - .OrderByDescending(obj => obj.Reference.Length) - .ToArray(); - if (objectModels.Length == 0) - return [new("Message", "No exact attribute schema for this DataSet member.", Depth: depth)]; - - var exactOwners = IedBrowserEngineeringObjects.GroupExact(objectModels - .Where(o => o.Reference == objectModels[0].Reference).ToArray()); - if (exactOwners.Count != 1) + var parent = PresentationOwner(memberReference, member.FunctionalConstraint, + model, sclModel, out var sclOnly); + if (parent is null) return [new("Message", - "Ambiguous model ownership for this exact DataSet member.", Depth: depth)]; - var parent = exactOwners[0]; + "No unique matching live/SCL attribute schema for this DataSet member.", + Depth: depth)]; var memberCoversObject = string.Equals( memberReference, parent.Reference, StringComparison.Ordinal); var exactDescendantPrefix = memberReference + "."; diff --git a/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs new file mode 100644 index 000000000..12b1a6756 --- /dev/null +++ b/tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs @@ -0,0 +1,168 @@ +using AR.Iec61850.Discovery; +using ArIED61850Tester.Models; + +namespace ARSAS.Tests; + +/// +/// The trusted selected-IED SCL may complete *presentation schema* for a +/// live DataSet. It never supplies a process value, a new MMS Read target, +/// or authority for a differently spelled object/FC. +/// +public sealed class M217GDataSetSclSchemaFallbackTests +{ + private const string Owner = "LD/MMXU1.PhV"; + + private static LiveIedDataAttributeModel Attr(string fc, string relative) + => new() + { + ObjectReference = Owner, + AttributePath = "PhV." + relative, + MmsReference = "LD/MMXU1$" + fc + "$PhV$" + + relative.Replace('.', '$'), + FunctionalConstraint = fc, + SclBType = relative.EndsWith(".f", StringComparison.Ordinal) + ? "FLOAT32" : "Quality" + }; + + private static LiveIedDataObjectModel Obj( + params LiveIedDataAttributeModel[] attrs) + => new() + { + Name = "PhV", Reference = Owner, InferredCdc = "WYE", + Attributes = attrs + }; + + private static LiveIedModelDiscoveryDocument Model( + params LiveIedDataObjectModel[] objects) + => new() + { + LogicalDevices = [new LiveIedLogicalDeviceModel + { + MmsDomain = "LD", LogicalNodes = [new LiveIedLogicalNodeModel + { + Name = "MMXU1", DataObjects = objects + }] + }] + }; + + private static LiveIedDataAttributeModel[] ThreePhase() + => new[] { "phsA", "phsB", "phsC" }.SelectMany(phase => + new[] { Attr("MX", phase + ".cVal.mag.f"), + Attr("MX", phase + ".cVal.ang.f"), + Attr("MX", phase + ".q"), + Attr("MX", phase + ".t") }).ToArray(); + + private static LiveIedDataSetMemberModel Member(string fc = "MX", + string reference = Owner) + => new() { Reference = reference, FunctionalConstraint = fc }; + + [Fact] + public void ExactSelectedIedSclMxSchemaRendersOnlyActuallyObservedValues() + { + var scl = Model(Obj(ThreePhase())); + var live = Model(); // the live DataSet member exists but DA hierarchy is sparse + var member = Member(); + var row = IedBrowserStructuredDetails.Member(member, live, sclModel: scl); + Assert.Equal(3, row.PhasePlans.Count); + Assert.Contains("SCL structure only", row.Tooltip); + + var children = IedBrowserStructuredDetails.DataSetMemberChildren( + member, live, 1, scl); + Assert.Contains(children, x => x.Name == "phsA [MX]"); + Assert.DoesNotContain(children, x => x.FunctionalConstraint == "ST"); + + var ledger = new IedBrowserLiveValues(); + var association = ledger.BeginAssociation(); + Assert.False(IedBrowserEngineeringComposite.TryCompose( + row, ledger, new Dictionary(), true, out _)); + Assert.True(ledger.PublishForAssociation(association, + Owner + ".phsA.cVal.mag.f", "MX", "11", "", "", "Report")); + Assert.True(IedBrowserEngineeringComposite.TryCompose( + row, ledger, new Dictionary(), true, out var actual)); + Assert.Contains("phsA: 11", actual.Text); + Assert.Contains("phsB: not observed", actual.Text); + Assert.DoesNotContain("∠ 0", actual.Text); + Assert.False(ledger.TryGet(Owner + ".phsB.cVal.mag.f", "MX", out _)); + ledger.MarkOffline(); + Assert.True(IedBrowserEngineeringComposite.TryCompose( + row, ledger, new Dictionary(), true, out var stale)); + Assert.True(stale.IsStale); + } + + [Fact] + public void LiveMxSchemaWinsOverConflictingSclPresentation() + { + var live = Model(Obj(Attr("MX", "phsA.cVal.mag.f"), + Attr("MX", "phsA.cVal.ang.f"))); + var scl = Model(Obj(ThreePhase())); + var row = IedBrowserStructuredDetails.Member(Member(), live, sclModel: scl); + // A single live phase is not a full three-phase vector, but it + // must remain live-only: SCL cannot manufacture phsB/phsC leaves. + Assert.Empty(row.PhasePlans); + Assert.DoesNotContain("SCL structure only", row.Tooltip); + var children = IedBrowserStructuredDetails.DataSetMemberChildren( + Member(), live, 1, scl); + Assert.Contains(children, x => x.Name == "phsA [MX]"); + Assert.DoesNotContain(children, x => x.Name == "phsB [MX]"); + } + + [Fact] + public void OtherFcAndDifferentIedReferenceCannotSupplyMxDirectory() + { + var scl = Model(Obj(ThreePhase())); + var noLive = Model(); + var st = IedBrowserStructuredDetails.Member(Member("ST"), noLive, + sclModel: scl); + Assert.Empty(st.PhasePlans); + var otherLn = IedBrowserStructuredDetails.Member( + Member(reference: "LD/MMXU2.PhV"), noLive, sclModel: scl); + Assert.Empty(otherLn.PhasePlans); + var otherDomain = IedBrowserStructuredDetails.Member( + Member(reference: "OTHER/MMXU1.PhV"), noLive, sclModel: scl); + Assert.Empty(otherDomain.PhasePlans); + var children = IedBrowserStructuredDetails.DataSetMemberChildren( + Member("ST"), noLive, 1, scl); + Assert.Contains(children, x => x.Kind == "Message"); + } + + [Fact] + public void SclPresentationCannotAuthorizeMmsReadTargets() + { + var scl = Model(Obj(ThreePhase())); + var live = Model(); + var member = Member(); + var row = IedBrowserStructuredDetails.Member(member, live, sclModel: scl); + Assert.Equal(3, row.PhasePlans.Count); + // Existing acquisition is still strictly built from LIVE discovery. + var dataSet = new LiveIedDataSetModel + { + Reference = "LD/LLN0.Measurements", + Domain = "LD", LogicalNode = "LLN0", + Name = "Measurements", MemberCount = 1, + Members = [member] + }; + var liveWithSet = new LiveIedModelDiscoveryDocument + { + LogicalDevices = live.LogicalDevices, DataSets = [dataSet] + }; + var page = new IedBrowserTreeRow("datasets/set/" + dataSet.Reference, + dataSet.Name, "DataSet", 3, dataSet); + Assert.Empty(IedBrowserReadPlan.Build(liveWithSet, page, readAll: false)); + } [Fact] + public void ObservedQualityWithoutTypedBitsIsNotMislabeledNotExposed() + { + DirectoryInfo? folder = new(AppContext.BaseDirectory); + while (folder is not null && + !File.Exists(Path.Combine(folder.FullName, "MainWindow.IedBrowser.cs"))) + folder = folder.Parent; + Assert.NotNull(folder); + var code = File.ReadAllText(Path.Combine(folder!.FullName, + "MainWindow.IedBrowser.cs")); + Assert.Contains("display == \"Not exposed\"", code); + Assert.Contains("display = \"Not decoded\";", code); + Assert.Contains("raw typed bit evidence", code); + Assert.Contains("row.EvidenceKind is \"Quality\" or \"Timestamp\"", code); + } + + +}