diff --git a/MainWindow.IedBrowser.cs b/MainWindow.IedBrowser.cs
index 21a055141..5a03abc5c 100644
--- a/MainWindow.IedBrowser.cs
+++ b/MainWindow.IedBrowser.cs
@@ -1707,7 +1707,13 @@ void Put(string name, string value, string authority)
var wireSlots = _iedBrowserDetails.Rows
.Where(row => row.GooseSlot >= 0 && row.GooseReference.Length == 0)
.ToArray();
- var limit = Math.Min(frameView.Leaves.Count, 128);
+ // Unbound allData is evidence only if the ENTIRE received PDU
+ // has complete, contiguous wire slots. Do not project a partial
+ // decoder/model mismatch as real GOOSE values or as FCDA identity.
+ var unbound = ordered is null
+ ? IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView)
+ : null;
+ var limit = Math.Min(unbound?.Count ?? 0, 128);
if (ordered is not null)
{
foreach (var slot in wireSlots)
@@ -1751,9 +1757,9 @@ _iedBrowserGooseDirectory is { } directory &&
{
if (row.GooseSlot < 0) continue;
if (row.GooseReference.Length == 0 &&
- ordered is null && row.GooseSlot < frameView.Leaves.Count)
+ ordered is null && unbound is not null && row.GooseSlot < unbound.Count)
{
- row.Value = frameView.Leaves[row.GooseSlot].Value;
+ row.Value = unbound[row.GooseSlot].Value;
row.ObservationSource = "GOOSE raw allData · unbound";
row.ObservationTooltip =
$"allData[{row.GooseSlot + 1}] from {frameView.GoCbRef} " +
diff --git a/Models/IedBrowserGooseInspection.cs b/Models/IedBrowserGooseInspection.cs
index 246677872..d3fe77ffa 100644
--- a/Models/IedBrowserGooseInspection.cs
+++ b/Models/IedBrowserGooseInspection.cs
@@ -90,22 +90,52 @@ public static bool IsFrameCurrent(
return matches.Length == 1 ? matches[0] : null;
}
+ ///
+ /// Admit only a complete and contiguous received allData vector when no
+ /// exact FCDA directory can be verified. The returned entries remain
+ /// explicitly unbound: their position never proves an IEC reference or FC.
+ /// Validate the WHOLE PDU before showing even the first bounded UI rows.
+ ///
+ public static IReadOnlyList? OrderedUnboundWireEntries(
+ GooseStreamSnapshot frame)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ var count = frame.WireDataSetEntryCount;
+ if (count <= 0 || count != frame.Leaves.Count)
+ return null;
+ for (var index = 0; index < count; index++)
+ {
+ var leaf = frame.Leaves[index];
+ if (leaf.DataSetIndex != index || leaf.Order != index + 1 ||
+ string.IsNullOrWhiteSpace(leaf.Value) ||
+ leaf.Value == "")
+ return null;
+ }
+ return frame.Leaves;
+ }
+
public static IReadOnlyList? VerifiedMembers(
GooseStreamSnapshot frame, LiveIedDataSetModel directory)
{
ArgumentNullException.ThrowIfNull(frame);
ArgumentNullException.ThrowIfNull(directory);
var members = directory.Members.OrderBy(x => x.Index).ToArray();
+ // A cached directory is not evidence for a different received datSet.
+ // Bind its complete reference before interpreting any FCDA leaf.
if (members.Length == 0 || frame.WireDataSetEntryCount != members.Length ||
frame.Leaves.Count != members.Length ||
- members.Select(x => x.Index).Distinct().Count() != members.Length)
+ string.IsNullOrWhiteSpace(directory.Reference) ||
+ !string.Equals(Canonical(frame.DataSetReference),
+ Canonical(directory.Reference), StringComparison.Ordinal))
return null;
- // Validate entire ordered vector before exposing any member value.
+ // Validate the full zero-based directory, one-based presentation order,
+ // and each exact wire reference/FC before publishing any member value.
for (var slot = 0; slot < members.Length; slot++)
{
var declared = members[slot];
var wire = frame.Leaves[slot];
- if (wire.DataSetIndex != slot ||
+ if (declared.Index != slot || wire.DataSetIndex != slot ||
+ wire.Order != slot + 1 ||
Canonical(wire.SignalReference) != Canonical(declared.Reference) ||
!string.Equals(wire.FunctionalConstraint, declared.FunctionalConstraint,
StringComparison.OrdinalIgnoreCase) ||
diff --git a/Models/IedBrowserReadPlan.cs b/Models/IedBrowserReadPlan.cs
index 69bb7157e..0cc15916c 100644
--- a/Models/IedBrowserReadPlan.cs
+++ b/Models/IedBrowserReadPlan.cs
@@ -440,7 +440,15 @@ public static IReadOnlyList BuildInitialPriority(
// must be admitted through the same bounded read plan, not skipped
// because the generic attribute walker cannot see the control block.
// Only read-only NumOfSG/ActSG/LActTm; never EditSG/CnfEdit/SE.
+ // A syntactically valid SGCB/GoCB from an unrelated MMS domain is
+ // not an authority for this live association. Reuse one exact domain
+ // index for both bounded read-only control-state inventories.
+ var liveDomains = verifiedLiveModel.LogicalDevices
+ .Select(ld => ld.MmsDomain)
+ .Where(domain => !string.IsNullOrWhiteSpace(domain))
+ .ToHashSet(StringComparer.Ordinal);
var settings = verifiedLiveModel.SettingGroupControls
+ .Where(block => liveDomains.Contains(block.Domain))
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "SGCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
@@ -460,6 +468,7 @@ public static IReadOnlyList BuildInitialPriority(
// Limit background status reads for dense GoCB catalogs; unvisited
// objects retain honest Not read evidence.
var goose = verifiedLiveModel.GooseControlBlocks
+ .Where(block => liveDomains.Contains(block.Domain))
.SelectMany(block => IedBrowserControlStateEvidence.ReadTargets(block, "GoCB"))
.GroupBy(target => IedBrowserLiveValues.CanonicalReference(target.Reference) +
"|" + target.FunctionalConstraint,
diff --git a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
index 81873c29b..1218bd76f 100644
--- a/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
+++ b/tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
@@ -50,6 +50,51 @@ public void UnverifiedOrConflictingSgcbNeverTriggersProbe(
Assert.Empty(IedBrowserControlStateEvidence.ReadTargets(block, "SGCB"));
}
+ [Fact]
+ public void InitialControlStateHydrationRejectsForeignMmsDomain()
+ {
+ var valid = Block(fields: ["ActSG"]);
+ var foreign = new LiveIedControlBlockModel
+ {
+ Name = "SGCB", Kind = "SettingGroupControl",
+ Domain = "FOREIGN", LogicalNode = "LLN0",
+ Reference = "FOREIGN/LLN0.SP.SGCB",
+ FunctionalConstraint = "SP", DiscoveryStatus = "AttributeInventoryOnly",
+ Attributes = ["ActSG"]
+ };
+ var localGoose = Block("GSEControl", "GO", "BCU",
+ fields: ["GoEna"]);
+ var foreignGoose = new LiveIedControlBlockModel
+ {
+ Name = "BCU", Kind = "GSEControl",
+ Domain = "FOREIGN", LogicalNode = "LLN0",
+ Reference = "FOREIGN/LLN0.GO.BCU",
+ FunctionalConstraint = "GO", DiscoveryStatus = "AttributeInventoryOnly",
+ Attributes = ["GoEna"]
+ };
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls = [foreign, valid],
+ GooseControlBlocks = [foreignGoose, localGoose]
+ };
+ var ledger = new IedBrowserLiveValues();
+ ledger.BeginAssociation();
+ var admitted = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32);
+ Assert.Contains(admitted, target => target.Reference == "LD/LLN0.SGCB.ActSG");
+ Assert.Contains(admitted, target => target.Reference == "LD/LLN0.BCU.GoEna");
+ Assert.DoesNotContain(admitted, target =>
+ target.Reference.StartsWith("FOREIGN/", StringComparison.Ordinal));
+
+ model = new LiveIedModelDiscoveryDocument
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls = [foreign],
+ GooseControlBlocks = [foreignGoose]
+ };
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32));
+ }
+
[Fact]
public void GocbGoEnaAndNdsComAreMmsReads_NotPassiveFrameAssumptions()
{
diff --git a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
index 70148c3f8..f734c2590 100644
--- a/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
+++ b/tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
@@ -33,6 +33,49 @@ public void ExactCanonicalControlIdentityAndOnePublisherRequired()
Assert.Null(IedBrowserGooseInspection.Select(Expected(), [right, Frame()]));
}
+ [Fact]
+ public void UnboundAllDataRejectsIncompleteDuplicateOrMissingRealWireSlots()
+ {
+ var first = new GooseLeafValueSnapshot(1, 0, "wire[1]", "", "", "",
+ "", "Closed [10]", "", false, "Unbound");
+ var second = new GooseLeafValueSnapshot(2, 1, "wire[2]", "", "", "",
+ "", "Good", "", false, "Unbound");
+ var frame = Frame(leaves: [first, second]);
+ var slots = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame);
+ Assert.NotNull(slots);
+ Assert.Equal(2, slots.Count);
+ Assert.Same(first, slots[0]);
+ Assert.All(slots, slot => Assert.Empty(slot.SignalReference));
+ Assert.All(slots, slot => Assert.Empty(slot.FunctionalConstraint));
+
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { WireDataSetEntryCount = 3 }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [second, first] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, first] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, second with { Order = 7 }] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, second with { Value = "" }] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [first, second with { Value = "" }] }));
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ frame with { Leaves = [] }));
+
+ // The UI displays at most 128 entries; an invalid *129th* wire
+ // slot must still invalidate the entire PDU, including the first 128.
+ var large = Enumerable.Range(0, 129)
+ .Select(i => new GooseLeafValueSnapshot(i + 1, i,
+ $"wire[{i + 1}]", "", "", "", "", "Good", "", false, "Unbound"))
+ .ToArray();
+ Assert.Equal(129, IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ Frame(leaves: large))?.Count);
+ large[128] = large[128] with { DataSetIndex = 127 };
+ Assert.Null(IedBrowserGooseInspection.OrderedUnboundWireEntries(
+ Frame(leaves: large)));
+ }
+
[Fact]
public void ExactSclGoCbMetadata_ProjectsOnlyConfiguredVlanAndTimingFields()
{
@@ -78,8 +121,28 @@ public void WireDataSetValuesOnlyIfWholeOrderedMemberIdentityMatches()
{ Leaves = [b, a] }, set));
Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
{ WireDataSetEntryCount = 1 }, set));
+ Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
+ { DataSetReference = "LD/LLN0$DIFFERENT" }, set));
+ Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
+ { Leaves = [a, b with { Order = 1 }] }, set));
Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame with
{ Leaves = [a, b with { FunctionalConstraint = "MX" }] }, set));
+ var nonContiguous = new LiveIedDataSetModel
+ {
+ Reference = set.Reference,
+ Members =
+ [
+ new LiveIedDataSetMemberModel
+ {
+ Index = 1, Reference = first.Reference, FunctionalConstraint = "ST"
+ },
+ new LiveIedDataSetMemberModel
+ {
+ Index = 2, Reference = second.Reference, FunctionalConstraint = "ST"
+ }
+ ]
+ };
+ Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, nonContiguous));
var ambiguous = new LiveIedDataSetModel { Reference = set.Reference,
Members = [first, first] };
Assert.Null(IedBrowserGooseInspection.VerifiedMembers(frame, ambiguous));
@@ -229,6 +292,7 @@ public void BrowserHasUnboundWireAllDataFallbackWithoutRewritingLedger()
"MainWindow.IedBrowser.cs"));
Assert.Contains("ResolveWireDataSet(", source);
Assert.Contains("allData[{slot + 1}] · unbound", source);
+ Assert.Contains("IedBrowserGooseInspection.OrderedUnboundWireEntries(frameView)", source);
Assert.Contains("GOOSE raw allData · unbound", source);
Assert.Contains("Not current", source);
Assert.Contains("Received GOOSE PDU datSet", source);
diff --git a/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs
new file mode 100644
index 000000000..b2db7c9a1
--- /dev/null
+++ b/tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs
@@ -0,0 +1,118 @@
+using AR.Iec61850.Discovery;
+using ArIED61850Tester.Models;
+
+namespace ARSAS.Tests;
+
+///
+/// Retains the unique live-wire and initial-SGCB admission contracts of
+/// historical #606 against the CURRENT RC API. No obsolete UI navigation
+/// assertions, no second GOOSE decoder, no MMS mutation and no device I/O.
+///
+public sealed class M217DCurrentTreeEvidenceContinuityTests
+{
+ [Fact]
+ public void ExactWireGoCbCanBeReadUnboundWithoutAnMmsDataSetInventory()
+ {
+ var first = new GooseLeafValueSnapshot(1, 0, "raw-1", "", "", "", "",
+ "Open [01]", "", false, "Unbound");
+ var second = new GooseLeafValueSnapshot(2, 1, "raw-2", "", "", "", "",
+ "Good", "", false, "Unbound");
+ var frame = new GooseStreamSnapshot(
+ "received-wire", "0x0021", "LD/LLN0$GO$BCU", "BCUGE03",
+ "LD/LLN0$GOOSE1", "02:00:4C:4F:5B:50",
+ "01:0C:CD:01:00:33", "VID 0 / PCP 4",
+ "1", "42", "Healthy", "20000 ms", "1",
+ "", "Unbound", "", "12:00:00.000", 1, 0,
+ false, false, [first, second],
+ "0", "4", 2);
+ // There is no MMS/SCL DatSet inventory and publisher IED label is
+ // absent. Only the full received GoCB reference authorizes a match.
+ var expected = new IedBrowserGooseExpectedIdentity(
+ "BCUGE", "LD/LLN0.GO.BCU", "", "BCUGE03",
+ 33, 1, "", "", null);
+ Assert.Same(frame, IedBrowserGooseInspection.Select(expected, [frame]));
+ var raw = IedBrowserGooseInspection.OrderedUnboundWireEntries(frame);
+ Assert.NotNull(raw);
+ Assert.Equal(2, raw.Count);
+ Assert.Equal("Good", raw[1].Value);
+ Assert.All(raw, slot => Assert.Empty(slot.SignalReference));
+ Assert.All(raw, slot => Assert.Empty(slot.FunctionalConstraint));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected with { GoCbRef = "" }, [frame]));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected, [frame with { GoCbRef = "LD/LLN0$GO$OTHER" }]));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected, [frame, frame]));
+ Assert.Null(IedBrowserGooseInspection.Select(
+ expected, [frame with { AppIdText = "0x0022" }]));
+ }
+
+ [Fact]
+ public void ExactLiveSgcbInitialReadIsBoundedReadOnlyAndSkipsObservedFields()
+ {
+ var block = new LiveIedControlBlockModel
+ {
+ Kind = "SettingGroupControl",
+ Domain = "LD", LogicalNode = "LLN0", Name = "SGCB",
+ FunctionalConstraint = "SP",
+ DiscoveryStatus = "AttributeInventoryOnly",
+ Reference = "LD/LLN0.SP.SGCB",
+ Attributes = ["NumOfSG", "ActSG", "LActTm", "EditSG", "CnfEdit"]
+ };
+ var model = new LiveIedModelDiscoveryDocument
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls = [block]
+ };
+ var ledger = new IedBrowserLiveValues();
+ var association = ledger.BeginAssociation();
+ var original = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32);
+ Assert.Equal(3, original.Count);
+ Assert.All(original, entry => Assert.Equal("SP", entry.FunctionalConstraint));
+ Assert.Equal(new[] { "ActSG", "LActTm", "NumOfSG" },
+ original.Select(target => target.Reference.Split('.')[^1])
+ .OrderBy(name => name, StringComparer.Ordinal).ToArray());
+ Assert.DoesNotContain(original, target =>
+ target.Reference.Contains("EditSG", StringComparison.Ordinal) ||
+ target.Reference.Contains("CnfEdit", StringComparison.Ordinal));
+
+ Assert.True(ledger.PublishForAssociation(association,
+ "LD/LLN0.SGCB.ActSG", "SP", "1", "", "", "MMS Read"));
+ var pending = IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32);
+ Assert.Equal(2, pending.Count);
+ Assert.DoesNotContain(pending, target =>
+ target.Reference == "LD/LLN0.SGCB.ActSG");
+ Assert.Single(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 1));
+ ledger.MarkOffline();
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(model, ledger, 32));
+ }
+
+ [Fact]
+ public void UnverifiedSclOrWrongFcNeverBecomesAnInitialSgcbRead()
+ {
+ LiveIedModelDiscoveryDocument Create(string status, string fc) => new()
+ {
+ LogicalDevices = [new LiveIedLogicalDeviceModel { MmsDomain = "LD" }],
+ SettingGroupControls =
+ [
+ new LiveIedControlBlockModel
+ {
+ Kind = "SettingGroupControl", Domain = "LD",
+ LogicalNode = "LLN0", Name = "SGCB",
+ FunctionalConstraint = fc,
+ DiscoveryStatus = status,
+ Reference = $"LD/LLN0.{fc}.SGCB",
+ Attributes = ["NumOfSG", "ActSG", "EditSG", "CnfEdit"]
+ }
+ ]
+ };
+ var ledger = new IedBrowserLiveValues();
+ ledger.BeginAssociation();
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(
+ Create("SCL", "SP"), ledger, 32));
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(
+ Create("AttributeInventoryOnly", "CF"), ledger, 32));
+ Assert.Empty(IedBrowserReadPlan.BuildInitialPriority(
+ Create("AttributeInventoryOnly", "SP"), ledger, 0));
+ }
+}