Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 81 additions & 32 deletions Models/IedBrowserStructuredDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,71 @@
/// </summary>
public static class IedBrowserStructuredDetails
{
/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>One DO row; child DAs/BDAs materialize only when expanded.</summary>
public static IedBrowserDetailRow DataObject(
LiveIedDataObjectModel obj, string? uniqueProcessFc, int depth = 0)
Expand Down Expand Up @@ -77,20 +142,8 @@
// 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<LiveIedDataObjectModel>();
// 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<LiveIedDataObjectModel>()
: 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.
Expand Down Expand Up @@ -144,7 +197,9 @@
? 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,
Expand All @@ -165,34 +220,28 @@
? 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<IedBrowserDetailRow> 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 + ".";
Expand Down Expand Up @@ -310,7 +359,7 @@
var hasDirect = matching.attr is not null;
var attr = hasDirect ? matching.attr : groupArray[0].attr;
var descendants = groupArray.Where(x => x.parts.Length > pathIndex + 1).ToArray();
var fullPrefix = IedBrowserLiveValues.CanonicalReference(attr.ObjectReference) +

Check warning on line 362 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 362 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 362 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 362 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 362 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.

Check warning on line 362 in Models/IedBrowserStructuredDetails.cs

View workflow job for this annotation

GitHub Actions / Build, test, validate and package Windows application

Dereference of a possibly null reference.
"." + string.Join(".", groupArray[0].parts.Take(pathIndex + 1));
var polar = allowVector
? IedBrowserEngineeringComposite.BuildPolar(
Expand Down
168 changes: 168 additions & 0 deletions tests/ARSAS.Tests/M217GDataSetSclSchemaFallbackTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

/// <summary>
/// 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.
/// </summary>
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<string, string?>(), true, out _));
Assert.True(ledger.PublishForAssociation(association,
Owner + ".phsA.cVal.mag.f", "MX", "11", "", "", "Report"));
Assert.True(IedBrowserEngineeringComposite.TryCompose(
row, ledger, new Dictionary<string, string?>(), 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<string, string?>(), 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);
}


}
Loading