Skip to content
3 changes: 3 additions & 0 deletions .github/workflows/scl-interoperability-r7.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ on:
- "Models/IedBrowserReadEvidenceBundle.cs"
- "Models/StaticAcquisitionParityModels.cs"
- "MainWindow.IedBrowser.cs"
- "Services/Iec61850MonitorRuntime.cs"
- "Services/Iec61850ReportContinuityInspector.cs"
- "tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs"
- "tests/ARSAS.Tests/P0D*.cs"
- "evidence/interoperability-reference-target.json"
- "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md"
Expand Down
22 changes: 13 additions & 9 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1822,7 +1822,7 @@
{
if (update.HasValue &&
IedBrowserReportValueAuthority.SafeScalar(update.Value) &&
!LooksLikeReferenceEcho(update.Value, update.Reference, update.Reference))

Check warning on line 1825 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

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

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1825 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

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

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.

Check warning on line 1825 in Services/Iec61850MonitorRuntime.cs

View workflow job for this annotation

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

Possible null reference argument for parameter 'value' in 'bool Iec61850MonitorRuntime.LooksLikeReferenceEcho(string value, string updateReference, string pointReference)'.
session.Device.LiveValues.PublishForAssociation(
session.BrowserObservationAssociation,
update.Reference, reportFc, update.Value,
Expand Down Expand Up @@ -2062,7 +2062,19 @@
const int maxTrackedStreams = 64;
foreach (var frame in slice.ReportFrames)
{
var streamKey = BuildReportStreamKey(plan, frame);
var streamKey = Iec61850ReportContinuityInspector.StreamKey(
plan.ReportControlReference, frame);
// A missing RCB identity cannot be used to prove a stream; do not
// invent one from an optional RptID/DataSet spelling.
if (streamKey.Length == 0)
{
if (session.ContinuityUntrackedStreams < int.MaxValue)
session.ContinuityUntrackedStreams++;
if (session.ContinuityUntrackedStreams == 1)
Log("WARN", session.Device.Name,
"Report has no exact RCB identity; continuity evidence is untracked.");
continue;
}
if (!session.ReportStreams.TryGetValue(streamKey, out var state))
{
if (session.ReportStreams.Count >= maxTrackedStreams)
Expand Down Expand Up @@ -2115,14 +2127,6 @@
session.ContinuityUntrackedStreams);
}

private static string BuildReportStreamKey(ReportControlPlan plan, NativeReportFrameMetadata frame)
{
var rcb = string.IsNullOrWhiteSpace(frame.ReportControlReference) ? plan.ReportControlReference : frame.ReportControlReference;
var reportId = string.IsNullOrWhiteSpace(frame.ReportId) ? "-" : frame.ReportId;
var dataSet = string.IsNullOrWhiteSpace(frame.DataSetReference) ? plan.DataSetReference : frame.DataSetReference;
return $"{NormalizeReference(rcb)}|{NormalizeReference(reportId)}|{NormalizeReference(dataSet)}";
}

private static string ReportName(string actualReference, string plannedReference)
{
var reference = string.IsNullOrWhiteSpace(actualReference) ? plannedReference : actualReference;
Expand Down
34 changes: 34 additions & 0 deletions Services/Iec61850ReportContinuityInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,23 @@ internal static class Iec61850ReportContinuityInspector
private const int RecentChronologyLimit = 8;
private const int AnomalyChronologyLimit = 8;

/// <summary>
/// One concrete RCB owns its sequence history within this association.
/// Optional RptID/DataSet aliases do NOT identify independent streams.
/// </summary>
internal static string StreamKey(string? plannedRcb, NativeReportFrameMetadata frame)
{
ArgumentNullException.ThrowIfNull(frame);
var rcb = string.IsNullOrWhiteSpace(frame.ReportControlReference)
? plannedRcb : frame.ReportControlReference;
return (rcb ?? string.Empty).Trim().Replace('$', '.')
.Replace("..", ".").ToLowerInvariant();
}

private static string CanonicalDataSet(string? reference)
=> (reference ?? string.Empty).Trim().Replace('$', '.')
.Replace("..", ".");

/// <summary>
/// A gap/backward move is an OBSERVED discontinuity, not proof of a specific
/// lost event: resubscription, replay, server restart and report queueing may
Expand Down Expand Up @@ -227,6 +244,23 @@ void Warn(string message)
if (state.AnomalyFindings < long.MaxValue) state.AnomalyFindings++;
}

// Optional metadata variations cannot create another independent RCB.
if (!string.IsNullOrWhiteSpace(frame.ReportId))
{
if (!string.IsNullOrWhiteSpace(state.ReportId) &&
!string.Equals(state.ReportId, frame.ReportId, StringComparison.Ordinal))
Warn("RptID changed under the same RCB; continuity must be requalified");
state.ReportId = frame.ReportId;
}
if (!string.IsNullOrWhiteSpace(frame.DataSetReference))
{
if (!string.IsNullOrWhiteSpace(state.DataSetReference) &&
!string.Equals(CanonicalDataSet(state.DataSetReference),
CanonicalDataSet(frame.DataSetReference), StringComparison.OrdinalIgnoreCase))
Warn("DataSet changed under the same RCB; schema and continuity must be requalified");
state.DataSetReference = frame.DataSetReference;
}

var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex);
var priorEntryIdProven = state.LastEntryIdProvenByOptFlds;
var entryIdPresent = !string.IsNullOrEmpty(frame.EntryIdHex);
Expand Down
115 changes: 115 additions & 0 deletions tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using ArIED61850Tester.Models;
using ArIED61850Tester.Services;

namespace ARSAS.Tests;

/// <summary>
/// Synthetic metadata only. This is not a physical SOE lossless claim.
/// </summary>
public sealed class P0FReportStreamIdentityTests
{
private const string Rcb = "LABCTRL/LLN0.BR.Buffer01";
private const string Dataset = "LABCTRL/LLN0.DataSet_1";

[Fact]
public void SameConcreteRcb_DoesNotSplitWhenRptIdIsMissingOrDataSetUsesMmsSeparator()
{
var metadataOnly = new NativeReportFrameMetadata
{
ReportControlReference = Rcb,
DataSetReference = Dataset,
ReportId = "",
SequenceNumber = null
};
var decoded = new NativeReportFrameMetadata
{
ReportControlReference = "LABCTRL/LLN0$BR$Buffer01",
DataSetReference = "LABCTRL/LLN0$DataSet_1",
ReportId = "LAB/CTRL/LLN0$BR$Buffer",
SequenceNumber = 1
};

var key = Iec61850ReportContinuityInspector.StreamKey(Rcb, metadataOnly);
Assert.Equal(key, Iec61850ReportContinuityInspector.StreamKey(Rcb, decoded));

var state = new Iec61850ReportContinuityState
{
ReportControlReference = Rcb,
DataSetReference = Dataset,
Buffered = true
};
Iec61850ReportContinuityInspector.Observe(
state, new NativeReportFrameMetadata
{
SequenceNumber = 0, BufferOverflow = true,
GeneralInterrogationReasonSeen = true
}, buffered: true);
for (var i = 0; i < 4; i++)
Iec61850ReportContinuityInspector.Observe(state, metadataOnly, buffered: true);
for (ulong sq = 1; sq <= 12; sq++)
Iec61850ReportContinuityInspector.Observe(
state, new NativeReportFrameMetadata
{
ReportControlReference = decoded.ReportControlReference,
DataSetReference = decoded.DataSetReference,
ReportId = decoded.ReportId,
SequenceNumber = sq
}, buffered: true);

var snapshot = Iec61850ReportContinuityInspector.Snapshot(
new Dictionary<string, Iec61850ReportContinuityState> { [key] = state },
processUpdatesSeen: 126, untrackedStreamCount: 0);
Assert.Equal(1, snapshot.StreamCount);
Assert.Equal(17, snapshot.Frames);
Assert.Equal(13, snapshot.Sequenced);
Assert.Equal(1, snapshot.Overflows);
Assert.Equal(4, Assert.Single(snapshot.Streams).SequenceMissing);
Assert.Equal(decoded.ReportId, state.ReportId);
Assert.Equal(12UL, state.LastSequenceNumber);
Assert.Contains("DataSet_1", state.DataSetReference);
}

[Fact]
public void DistinctRcbsStayIndependentEvenWhenRptIdAndDataSetMatch()
{
var first = new NativeReportFrameMetadata
{
ReportControlReference = Rcb,
DataSetReference = Dataset,
ReportId = "same"
};
var second = new NativeReportFrameMetadata
{
ReportControlReference = "LABCTRL/LLN0.BR.Buffer02",
DataSetReference = Dataset,
ReportId = "same"
};
Assert.NotEqual(Iec61850ReportContinuityInspector.StreamKey(Rcb, first),
Iec61850ReportContinuityInspector.StreamKey(Rcb, second));
Assert.Equal(string.Empty, Iec61850ReportContinuityInspector.StreamKey(
null, new NativeReportFrameMetadata { DataSetReference = Dataset, ReportId = "rpt" }));
}

[Fact]
public void ActualRptIdAndDataSetDriftAreReportedAsContinuityFindings()
{
var state = new Iec61850ReportContinuityState
{
ReportControlReference = Rcb,
DataSetReference = Dataset,
ReportId = "initial",
Buffered = true
};
var warnings = Iec61850ReportContinuityInspector.Observe(
state, new NativeReportFrameMetadata
{
ReportId = "changed",
DataSetReference = "LABCTRL/LLN0.OtherDataSet",
SequenceNumber = 0
}, buffered: true);
Assert.Contains(warnings, msg => msg.Contains("RptID changed", StringComparison.Ordinal));
Assert.Contains(warnings, msg => msg.Contains("DataSet changed", StringComparison.Ordinal));
Assert.True(state.AnomalousFrames >= 1);
Assert.Equal("changed", state.ReportId);
}
}
Loading