diff --git a/.github/workflows/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml
index 11ad9ab5a..03fa37b49 100644
--- a/.github/workflows/scl-interoperability-r7.yml
+++ b/.github/workflows/scl-interoperability-r7.yml
@@ -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"
diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs
index 7cc255814..b15e731c9 100644
--- a/Services/Iec61850MonitorRuntime.cs
+++ b/Services/Iec61850MonitorRuntime.cs
@@ -2062,7 +2062,19 @@ private void ProcessReportHealth(
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)
@@ -2115,14 +2127,6 @@ private void ProcessReportHealth(
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;
diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs
index 4f7d44c14..0802bcf90 100644
--- a/Services/Iec61850ReportContinuityInspector.cs
+++ b/Services/Iec61850ReportContinuityInspector.cs
@@ -181,6 +181,23 @@ internal static class Iec61850ReportContinuityInspector
private const int RecentChronologyLimit = 8;
private const int AnomalyChronologyLimit = 8;
+ ///
+ /// One concrete RCB owns its sequence history within this association.
+ /// Optional RptID/DataSet aliases do NOT identify independent streams.
+ ///
+ 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("..", ".");
+
///
/// A gap/backward move is an OBSERVED discontinuity, not proof of a specific
/// lost event: resubscription, replay, server restart and report queueing may
@@ -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);
diff --git a/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs
new file mode 100644
index 000000000..9649309cc
--- /dev/null
+++ b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs
@@ -0,0 +1,115 @@
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+///
+/// Synthetic metadata only. This is not a physical SOE lossless claim.
+///
+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 { [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);
+ }
+}