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); + } +}