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
36 changes: 28 additions & 8 deletions Services/StaticAcquisitionParityTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,18 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence(
(kind == "static-brcb") != plan.Buffered)
evidenceIssues.Add($"DataSet {dataSetReference} has inconsistent static RCB family evidence");

var members = dataSet?.Members
// Absolute member indexes are ingress-specific: imported SCL uses 1-based
// FCDA ordinals while MMS discovery uses 0-based list positions.
// Only the ordered semantic *position* is part of the parity fingerprint.
// Keep source indexes unchanged for the authoritative model and guard them
// independently so missing/duplicate positions cannot become false MATCH.
var orderedMembers = dataSet?.Members
.OrderBy(member => member.Index)
.Select(member =>
$"{member.Index}:{NormalizeReference(member.Reference)}@{NormalizeFc(member.FunctionalConstraint)}")
.ToArray()
?? Array.Empty<string>();
.ToArray() ?? Array.Empty<LiveIedDataSetMemberModel>();
var members = orderedMembers
.Select((member, position) =>
$"{position}:{NormalizeReference(member.Reference)}@{NormalizeFc(member.FunctionalConstraint)}")
.ToArray();

if (dataSet is null)
evidenceIssues.Add($"DataSet {dataSetReference} missing from {ingress} model");
Expand All @@ -180,10 +186,24 @@ internal static StaticAcquisitionIngressEvidence BuildEvidence(

if (dataSet is not null)
{
if (dataSet.Members.Select(member => member.Index).Distinct().Count() != members.Length ||
dataSet.Members.Any(member => member.Index < 0))
var invalidOrDuplicateIndex = orderedMembers.Length > 0 && orderedMembers[0].Index < 0;
var hasIndexGap = false;
for (var position = 1; position < orderedMembers.Length; position++)
{
// long arithmetic avoids overflow near int.MaxValue, and sorted
// index comparisons prevent duplicate/gap information being erased.
var delta = (long)orderedMembers[position].Index - orderedMembers[position - 1].Index;
if (delta <= 0)
invalidOrDuplicateIndex = true;
else if (delta != 1)
hasIndexGap = true;
}

if (invalidOrDuplicateIndex)
evidenceIssues.Add($"DataSet {dataSetReference} has invalid or duplicate ordered member indices");
if (dataSet.Members.Any(member =>
if (hasIndexGap)
evidenceIssues.Add($"DataSet {dataSetReference} has noncontiguous ordered member indices");
if (orderedMembers.Any(member =>
string.IsNullOrWhiteSpace(member.Reference) ||
string.IsNullOrWhiteSpace(member.FunctionalConstraint)))
evidenceIssues.Add($"DataSet {dataSetReference} has incomplete member identity or FC evidence");
Expand Down
207 changes: 207 additions & 0 deletions tests/ARSAS.Tests/StaticAcquisitionCanonicalOrdinalP76BTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
using AR.Iec61850.Discovery;
using AR.Iec61850.Scl.Workspace;
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;

public sealed class StaticAcquisitionCanonicalOrdinalP76BTests
{
private const string Indications = "IED-A/LLN0.Indications";
private const string Measurements = "IED-A/LLN0.Measurements";

// Exact field failure shape, fully synthetic: SCL FCDA ordinals 1..N versus
// MMS DataSet-directory ordinals 0..N-1 on two different static RCB families.
[Fact]
public void OneBasedSclAndZeroBasedDiscovery_AreSemanticallyEquivalentWithoutRewritingSourceModels()
{
var liveModel = Model(
DataSet(Indications, 0, ("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI1.Pos", "ST")),
DataSet(Measurements, 0, ("IED-A/MMXU1.A.phsA", "MX"), ("IED-A/MMXU1.TotW", "MX")));
var sclModel = Model(
DataSet(Indications, 1, ("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI1.Pos", "ST")),
DataSet(Measurements, 1, ("IED-A/MMXU1.A.phsA", "MX"), ("IED-A/MMXU1.TotW", "MX")));

var discoveryDevice = Device(liveModel);
var sclDevice = Device(sclModel, isOpenScl: true);
var discovered = Build(discoveryDevice, Planning("Rpt_ind02"), StaticAcquisitionIngressKind.LiveDiscovery);
var opened = Build(sclDevice, Planning("Rpt_ind01"), StaticAcquisitionIngressKind.OpenScl);

Assert.True(discovered.IsComparable, discovered.IncomparableReason);
Assert.True(opened.IsComparable, opened.IncomparableReason);
Assert.Equal(discovered.SemanticLines, opened.SemanticLines);
Assert.Equal(discovered.SemanticFingerprint, opened.SemanticFingerprint);
Assert.NotEqual(discovered.RuntimeTargets[0], opened.RuntimeTargets[0]);
Assert.Equal(StaticAcquisitionParityStatus.Equivalent,
StaticAcquisitionParityTracker.Compare(discovered, opened).Status);
Assert.StartsWith("static-brcb|dataset=", discovered.SemanticLines[1], StringComparison.Ordinal);
Assert.Contains("members=2[0:ied-a/xcbr1.pos@ST,1:ied-a/xswi1.pos@ST]", discovered.SemanticLines[1], StringComparison.Ordinal);
Assert.Equal(0, liveModel.DataSets[0].Members[0].Index);
Assert.Equal(1, sclModel.DataSets[0].Members[0].Index);
}

[Fact]
public void ReorderedMembers_RemainMismatchEvenAcrossDifferentIndexBases()
{
var discovered = Build(Device(Model(DataSet(Indications, 0,
("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI1.Pos", "ST")))),
PlanningOneDataSet(), StaticAcquisitionIngressKind.LiveDiscovery);
var opened = Build(Device(Model(DataSet(Indications, 1,
("IED-A/XSWI1.Pos", "ST"), ("IED-A/XCBR1.Pos", "ST"))), true),
PlanningOneDataSet(), StaticAcquisitionIngressKind.OpenScl);

Assert.True(discovered.IsComparable);
Assert.True(opened.IsComparable);
Assert.Equal(StaticAcquisitionParityStatus.Mismatch,
StaticAcquisitionParityTracker.Compare(discovered, opened).Status);
}

[Fact]
public void FunctionalConstraintAndReferenceDrift_RemainMismatch()
{
var baseline = Build(Device(Model(DataSet(Indications, 0,
("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI1.Pos", "ST")))),
PlanningOneDataSet(), StaticAcquisitionIngressKind.LiveDiscovery);
var fcDrift = Build(Device(Model(DataSet(Indications, 1,
("IED-A/XCBR1.Pos", "MX"), ("IED-A/XSWI1.Pos", "ST"))), true),
PlanningOneDataSet(), StaticAcquisitionIngressKind.OpenScl);
var referenceDrift = Build(Device(Model(DataSet(Indications, 1,
("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI2.Pos", "ST"))), true),
PlanningOneDataSet(), StaticAcquisitionIngressKind.OpenScl);

Assert.Equal(StaticAcquisitionParityStatus.Mismatch,
StaticAcquisitionParityTracker.Compare(baseline, fcDrift).Status);
Assert.Equal(StaticAcquisitionParityStatus.Mismatch,
StaticAcquisitionParityTracker.Compare(baseline, referenceDrift).Status);
}

[Theory]
[InlineData(0, 2)]
[InlineData(1, 3)]
[InlineData(100, 102)]
public void NoncontiguousIndex_IsNotNormalizedIntoFalseMatch(int first, int second)
{
var valid = Build(Device(Model(DataSet(Indications, 0,
("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI1.Pos", "ST")))),
PlanningOneDataSet(), StaticAcquisitionIngressKind.LiveDiscovery);
var brokenModel = Model(new LiveIedDataSetModel
{
Reference = Indications,
Domain = "IED-A",
LogicalNode = "LLN0",
Name = "Indications",
MemberCount = 2,
Members =
[
Member(first, "IED-A/XCBR1.Pos", "ST"),
Member(second, "IED-A/XSWI1.Pos", "ST")
]
});
var broken = Build(Device(brokenModel, true), PlanningOneDataSet(), StaticAcquisitionIngressKind.OpenScl);

Assert.Equal(valid.SemanticFingerprint, broken.SemanticFingerprint);
Assert.False(broken.IsComparable);
Assert.Contains("noncontiguous", broken.IncomparableReason, StringComparison.Ordinal);
Assert.Equal(StaticAcquisitionParityStatus.InsufficientEvidence,
StaticAcquisitionParityTracker.Compare(valid, broken).Status);
}

[Fact]
public void ArbitraryContiguousIndexOrigin_IsSourceNeutralWithoutOverflow()
{
var original = Build(Device(Model(DataSet(Indications, 0,
("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI1.Pos", "ST")))),
PlanningOneDataSet(), StaticAcquisitionIngressKind.LiveDiscovery);
var shifted = Build(Device(Model(DataSet(Indications, int.MaxValue - 1,
("IED-A/XCBR1.Pos", "ST"), ("IED-A/XSWI1.Pos", "ST"))), true),
PlanningOneDataSet(), StaticAcquisitionIngressKind.OpenScl);
Assert.True(shifted.IsComparable, shifted.IncomparableReason);
Assert.Equal(original.SemanticFingerprint, shifted.SemanticFingerprint);
}

private static StaticAcquisitionIngressEvidence Build(
Iec61850MonitorDevice device, NativeHybridReportPlanningResult plan, StaticAcquisitionIngressKind ingress)
=> StaticAcquisitionParityTracker.BuildEvidence(device, plan, ingress);

private static Iec61850MonitorDevice Device(LiveIedModelDiscoveryDocument model, bool isOpenScl = false)
{
var device = new Iec61850MonitorDevice { Name = "IED-A", IpAddress = "192.0.2.20", LiveDiscoveryModel = model };
if (isOpenScl)
device.SclWorkspace = new SclIedWorkspace
{
IedName = "IED-A",
AccessPointName = "AP1",
DesignModel = model
};
return device;
}

private static LiveIedModelDiscoveryDocument Model(params LiveIedDataSetModel[] dataSets)
=> new() { IedName = "IED-A", Source = "SyntheticFixture", DataSets = dataSets };

private static LiveIedDataSetModel DataSet(
string reference, int indexOrigin, params (string Reference, string Fc)[] members)
=> new()
{
Reference = reference,
Domain = "IED-A",
LogicalNode = "LLN0",
Name = reference.Split('.').Last(),
MemberCount = members.Length,
Members = members.Select((member, ordinal) =>
Member(checked(indexOrigin + ordinal), member.Reference, member.Fc)).ToArray()
};

private static LiveIedDataSetMemberModel Member(int index, string reference, string fc)
=> new() { Index = index, Reference = reference, FunctionalConstraint = fc };

private static NativeHybridReportPlanningResult Planning(string brcbSlot)
=> new()
{
IsAuthoritative = true,
RequestedPointCount = 4,
CatalogMappedPointCount = 4,
StaticBrcbSignalCount = 2,
StaticUrcbSignalCount = 2,
UncoveredSignalCount = 0,
ReportPlans =
[
Report("StaticBrcb", Indications, "IED-A/LLN0.BR." + brcbSlot,
("IED-A/XCBR1.Pos.stVal", "ST"), ("IED-A/XSWI1.Pos.stVal", "ST")),
Report("StaticUrcb", Measurements, "IED-A/LLN0.RP.Rpt_meas01",
("IED-A/MMXU1.A.phsA.cVal.mag.f", "MX"), ("IED-A/MMXU1.TotW.mag.f", "MX"))
]
};

private static NativeHybridReportPlanningResult PlanningOneDataSet()
=> new()
{
IsAuthoritative = true,
RequestedPointCount = 2,
CatalogMappedPointCount = 2,
StaticBrcbSignalCount = 2,
UncoveredSignalCount = 0,
ReportPlans =
[
Report("StaticBrcb", Indications, "IED-A/LLN0.BR.Rpt_ind01",
("IED-A/XCBR1.Pos.stVal", "ST"), ("IED-A/XSWI1.Pos.stVal", "ST"))
]
};

private static ReportControlPlan Report(
string kind, string dataSet, string rcb, params (string Reference, string Fc)[] points)
=> new()
{
IsEngineAuthoritative = true,
EngineAcquisitionKind = kind,
Buffered = string.Equals(kind, "StaticBrcb", StringComparison.Ordinal),
DataSetReference = dataSet,
ReportControlReference = rcb,
Bindings = points.Select(point => new Iec61850MonitorPoint
{
DeviceId = "device-a",
IecReference = point.Reference,
FunctionalConstraint = point.Fc
}).ToList()
};
}