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);
+ }
+
+
+}