Skip to content
Closed
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
12 changes: 9 additions & 3 deletions MainWindow.IedBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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} " +
Expand Down
36 changes: 33 additions & 3 deletions Models/IedBrowserGooseInspection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,22 +90,52 @@ public static bool IsFrameCurrent(
return matches.Length == 1 ? matches[0] : null;
}

/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<GooseLeafValueSnapshot>? 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 == "<missing in frame>")
return null;
}
return frame.Leaves;
}

public static IReadOnlyList<GooseLeafValueSnapshot>? 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) ||
Expand Down
9 changes: 9 additions & 0 deletions Models/IedBrowserReadPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,15 @@ public static IReadOnlyList<IedBrowserReadTarget> 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,
Expand All @@ -460,6 +468,7 @@ public static IReadOnlyList<IedBrowserReadTarget> 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,
Expand Down
45 changes: 45 additions & 0 deletions tests/ARSAS.Tests/IedBrowserControlStateEvidenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
64 changes: 64 additions & 0 deletions tests/ARSAS.Tests/IedBrowserGooseInspectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<missing in frame>" }] }));
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()
{
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
118 changes: 118 additions & 0 deletions tests/ARSAS.Tests/M217DCurrentTreeEvidenceContinuityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using AR.Iec61850.Discovery;
using ArIED61850Tester.Models;

namespace ARSAS.Tests;

/// <summary>
/// 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.
/// </summary>
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));
}
}
Loading