diff --git a/Services/Iec61850MonitorRuntime.cs b/Services/Iec61850MonitorRuntime.cs index 801d3b0d5..613aa6a48 100644 --- a/Services/Iec61850MonitorRuntime.cs +++ b/Services/Iec61850MonitorRuntime.cs @@ -36,16 +36,6 @@ private sealed class RuntimePointState public int ConsecutiveErrors { get; set; } } - private sealed class ReportStreamState - { - public ulong? LastSequenceNumber { get; set; } - public ulong? SegmentedSequenceNumber { get; set; } - public ulong? LastSubSequenceNumber { get; set; } - public bool AwaitingMoreSegments { get; set; } - public ulong? ConfigurationRevision { get; set; } - public string LastEntryIdHex { get; set; } = string.Empty; - } - private sealed class DeviceSession { public required Iec61850MonitorDevice Device { get; init; } @@ -59,7 +49,7 @@ private sealed class DeviceSession public Dictionary PointPlanIds { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary ReportReferenceIndex { get; } = new(StringComparer.OrdinalIgnoreCase); public PriorityQueue PollQueue { get; } = new(); - public Dictionary ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase); public int LastUnroutedReportCount { get; set; } public int ReportPlanCursor { get; set; } public IReadOnlyList PendingReportPlans { get; set; } = Array.Empty(); @@ -1329,105 +1319,22 @@ private void ProcessReportHealth( var streamKey = BuildReportStreamKey(plan, frame); if (!session.ReportStreams.TryGetValue(streamKey, out var state)) { - state = new ReportStreamState(); + state = new Iec61850ReportContinuityState(); session.ReportStreams[streamKey] = state; } - if (frame.BufferOverflow == true) + // IEC 61850-7-2 evidence analysis only: each exact RCB has isolated + // report continuity state. We never discard, rewrite or reorder decoded + // process values, re-arm an RCB or add MMS process polling from warnings. + foreach (var finding in Iec61850ReportContinuityInspector.Observe( + state, frame, plan.Buffered)) { Log("WARN", session.Device.Name, - $"BRCB buffer overflow reported by {ReportName(frame.ReportControlReference, plan.ReportControlReference)}. Buffered event continuity may be incomplete."); - } - - if (frame.ConfRev.HasValue) - { - if (state.ConfigurationRevision.HasValue && state.ConfigurationRevision.Value != frame.ConfRev.Value) - { - Log("WARN", session.Device.Name, - $"Report ConfRev changed on {ReportName(frame.ReportControlReference, plan.ReportControlReference)}: {state.ConfigurationRevision.Value} → {frame.ConfRev.Value}. DataSet coverage is being treated as changed and should be revalidated."); - } - state.ConfigurationRevision = frame.ConfRev; + $"{ReportName(frame.ReportControlReference, plan.ReportControlReference)}: {finding}"); } - - ValidateReportSequence(session, plan, frame, state); - - if (!string.IsNullOrWhiteSpace(frame.EntryIdHex)) - state.LastEntryIdHex = frame.EntryIdHex; } } - private void ValidateReportSequence( - DeviceSession session, - ReportControlPlan plan, - NativeReportFrameMetadata frame, - ReportStreamState state) - { - if (!frame.SequenceNumber.HasValue) - return; - - var current = frame.SequenceNumber.Value; - var reportName = ReportName(frame.ReportControlReference, plan.ReportControlReference); - if (frame.SubSequenceNumber.HasValue) - { - var currentSub = frame.SubSequenceNumber.Value; - if (state.SegmentedSequenceNumber.HasValue) - { - var expectedSub = state.LastSubSequenceNumber.GetValueOrDefault() + 1; - if (state.SegmentedSequenceNumber.Value != current || currentSub != expectedSub) - { - Log("WARN", session.Device.Name, - $"Segmented report discontinuity on {reportName}: expected sqNum={state.SegmentedSequenceNumber.Value}, subSqNum={expectedSub}; received sqNum={current}, subSqNum={currentSub}."); - } - } - else if (state.LastSequenceNumber.HasValue && - !IsExpectedReportSequence(state.LastSequenceNumber.Value, current)) - { - Log("WARN", session.Device.Name, - $"Report sequence discontinuity on {reportName}: previous={state.LastSequenceNumber.Value}, current={current}."); - } - - state.SegmentedSequenceNumber = current; - state.LastSubSequenceNumber = currentSub; - state.AwaitingMoreSegments = frame.MoreSegmentsFollow == true; - if (!state.AwaitingMoreSegments) - { - state.LastSequenceNumber = current; - state.SegmentedSequenceNumber = null; - state.LastSubSequenceNumber = null; - } - - return; - } - - if (state.AwaitingMoreSegments) - { - Log("WARN", session.Device.Name, - $"Segmented report on {reportName} ended without the expected continuation before sqNum={current}."); - state.SegmentedSequenceNumber = null; - state.LastSubSequenceNumber = null; - state.AwaitingMoreSegments = false; - } - - if (state.LastSequenceNumber.HasValue && - !IsExpectedReportSequence(state.LastSequenceNumber.Value, current)) - { - Log("WARN", session.Device.Name, - $"Report sequence discontinuity on {reportName}: previous={state.LastSequenceNumber.Value}, current={current}."); - } - - state.LastSequenceNumber = current; - } - - private static bool IsExpectedReportSequence(ulong previous, ulong current) - { - if (current == previous + 1) - return true; - - // Report sequence counters are vendor/RCB dependent and commonly wrap. - // A reset to zero is accepted; duplicate or skipped non-zero values are not. - return current == 0; - } - private static string BuildReportStreamKey(ReportControlPlan plan, NativeReportFrameMetadata frame) { var rcb = string.IsNullOrWhiteSpace(frame.ReportControlReference) ? plan.ReportControlReference : frame.ReportControlReference; diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs new file mode 100644 index 000000000..37819f75c --- /dev/null +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -0,0 +1,157 @@ +using ArIED61850Tester.Models; + +namespace ArIED61850Tester.Services; + +/// +/// Association-local, exact-RCB report-stream evidence. Observational only: +/// the decoder and acquisition pipeline remain authoritative for process values. +/// SqNum/SubSqNum are 16-bit unsigned counters per IEC 61850-7-2, §17.2.3.2; +/// EntryID is an opaque BRCB resume cursor, NOT a numeric monotonic counter. +/// +internal sealed class Iec61850ReportContinuityState +{ + public ulong? LastSequenceNumber { get; set; } + public ulong? SegmentedSequenceNumber { get; set; } + public ulong? LastSubSequenceNumber { get; set; } + public bool AwaitingMoreSegments { get; set; } + public ulong? ConfigurationRevision { get; set; } + public string LastEntryIdHex { get; set; } = string.Empty; +} + +internal static class Iec61850ReportContinuityInspector +{ + private const ulong MaxSequenceNumber = ushort.MaxValue; + + /// + /// A gap/backward move is an OBSERVED discontinuity, not proof of a specific + /// lost event: resubscription, replay, server restart and report queueing may + /// be relevant. Do not mistake a GI or a new EntryID for a validated reset. + /// + internal static IReadOnlyList Observe( + Iec61850ReportContinuityState state, + NativeReportFrameMetadata frame, + bool buffered) + { + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(frame); + + List? findings = null; + void Warn(string message) => (findings ??= new List(2)).Add(message); + + var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex); + var entryIdPresent = !string.IsNullOrEmpty(frame.EntryIdHex); + var entryIdChanged = priorEntryIdPresent && entryIdPresent && + !string.Equals(state.LastEntryIdHex, frame.EntryIdHex, StringComparison.OrdinalIgnoreCase); + // Avoid formatting provenance on every healthy InformationReport: + // materialize it only when there is an actual continuity finding. + string EntryContext() => buffered + ? $"; BRCB EntryID previousPresent={priorEntryIdPresent}, currentPresent={entryIdPresent}, changed={entryIdChanged}" + : string.Empty; + + if (frame.BufferOverflow == true) + { + Warn(buffered + ? "BRCB BufOvfl=true: possible loss of buffered entries; continuity cannot be certified from SqNum alone" + : "URCB carried unexpected BufOvfl=true; inspect report OptionFields/decoder attribution"); + } + + if (frame.ConfRev.HasValue) + { + if (state.ConfigurationRevision.HasValue && + state.ConfigurationRevision.Value != frame.ConfRev.Value) + { + Warn($"Report ConfRev changed: previous={state.ConfigurationRevision.Value}, " + + $"current={frame.ConfRev.Value}; DataSet schema and member bindings require revalidation"); + } + state.ConfigurationRevision = frame.ConfRev; + } + + if (frame.SequenceNumber is { } current) + { + if (current > MaxSequenceNumber) + { + Warn($"Report SqNum={current} exceeds IEC 61850 INT16U range; " + + "decoder/metadata requires inspection; continuity unverified"); + // Keep prior baseline, since accepting an out-of-range counter would + // make subsequent legitimate 16-bit values spuriously comparable. + } + else if (frame.SubSequenceNumber.HasValue) + { + var sub = frame.SubSequenceNumber.Value; + if (sub > MaxSequenceNumber) + Warn($"Report SubSqNum={sub} exceeds IEC 61850 INT16U range; continuity unverified"); + + if (state.AwaitingMoreSegments) + { + var previousSub = state.LastSubSequenceNumber.GetValueOrDefault(); + if (state.SegmentedSequenceNumber != current || + previousSub == MaxSequenceNumber || + sub != previousSub + 1) + Warn($"Segmented report discontinuity: expected sqNum={state.SegmentedSequenceNumber}, " + + $"subSqNum={previousSub + 1}; received sqNum={current}, subSqNum={sub}" + EntryContext()); + } + else + { + if (sub != 0) + Warn($"Segmented report starts at SubSqNum={sub}, expected=0; " + + "prior segment evidence is absent" + EntryContext()); + var discontinuity = DescribeSequenceAnomaly(state.LastSequenceNumber, current); + if (discontinuity is not null) + Warn(discontinuity + EntryContext()); + } + + if (!frame.MoreSegmentsFollow.HasValue) + Warn("Segmented report has SubSqNum but MoreSegmentsFollow is absent; continuity unverified"); + + state.SegmentedSequenceNumber = current; + state.LastSubSequenceNumber = sub; + state.AwaitingMoreSegments = frame.MoreSegmentsFollow == true; + if (!state.AwaitingMoreSegments) + { + state.LastSequenceNumber = current; + state.SegmentedSequenceNumber = null; + state.LastSubSequenceNumber = null; + } + } + else + { + if (state.AwaitingMoreSegments) + Warn($"Segmented report was interrupted before continuation of " + + $"sqNum={state.SegmentedSequenceNumber}, subSqNum={state.LastSubSequenceNumber}" + + EntryContext()); + state.AwaitingMoreSegments = false; + state.SegmentedSequenceNumber = null; + state.LastSubSequenceNumber = null; + var discontinuity = DescribeSequenceAnomaly(state.LastSequenceNumber, current); + if (discontinuity is not null) + Warn(discontinuity + EntryContext()); + state.LastSequenceNumber = current; + } + } + else if (frame.SubSequenceNumber.HasValue || frame.MoreSegmentsFollow == true) + { + Warn("Segmented report metadata has no SqNum; segment continuity cannot be checked"); + } + + if (entryIdPresent) + state.LastEntryIdHex = frame.EntryIdHex; + + return findings is null ? Array.Empty() : findings; + } + + private static string? DescribeSequenceAnomaly(ulong? previous, ulong current) + { + if (!previous.HasValue) + return null; // First observed report is not necessarily the first server report. + + var prior = previous.Value; + if ((prior < MaxSequenceNumber && current == prior + 1) || + (prior == MaxSequenceNumber && current == 0)) + return null; + + var classification = current == prior ? "duplicate/replay" : + current < prior ? "backward/reset/replay" : "forward gap"; + return $"Report sequence discontinuity ({classification}): previous={prior}, " + + $"current={current}; RptEna/reconnect/GI context is not yet verified; no event-loss conclusion"; + } +} diff --git a/tests/ARSAS.Tests/ReportContinuityP76CTests.cs b/tests/ARSAS.Tests/ReportContinuityP76CTests.cs new file mode 100644 index 000000000..58be68aa8 --- /dev/null +++ b/tests/ARSAS.Tests/ReportContinuityP76CTests.cs @@ -0,0 +1,178 @@ +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ReportContinuityP76CTests +{ + [Fact] + public void StandardIncrementAndUint16Rollover_AreNotFalseDiscontinuities() + { + var s = new Iec61850ReportContinuityState(); + Assert.Empty(Observe(s, ushort.MaxValue)); + Assert.Empty(Observe(s, 0)); + Assert.Empty(Observe(s, 1)); + Assert.Equal(1UL, s.LastSequenceNumber); + } + + [Fact] + public void ArbitraryResetToZero_IsNotMistakenForCounterRollover() + { + var s = new Iec61850ReportContinuityState(); + Assert.Empty(Observe(s, 4)); + var warnings = Observe(s, 0); + Assert.Contains(warnings, w => w.Contains("backward/reset/replay", StringComparison.Ordinal)); + Assert.Contains(warnings, w => w.Contains("no event-loss conclusion", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(4, 1, "backward/reset/replay")] + [InlineData(3, 1, "backward/reset/replay")] + [InlineData(2, 5, "forward gap")] + [InlineData(8, 8, "duplicate/replay")] + public void RealFieldDiscontinuityShapes_ArePreservedAsUnverifiedEvidence( + ulong previous, ulong current, string expected) + { + var s = new Iec61850ReportContinuityState(); + Observe(s, previous, buffered: true, entryId: "aabb"); + var warnings = Observe(s, current, buffered: true, entryId: "ccdd"); + Assert.Contains(warnings, w => w.Contains(expected, StringComparison.Ordinal)); + Assert.Contains(warnings, w => w.Contains("previousPresent=True", StringComparison.Ordinal)); + Assert.Contains(warnings, w => w.Contains("changed=True", StringComparison.Ordinal)); + Assert.DoesNotContain(warnings, w => w.Contains("events were lost", StringComparison.Ordinal)); + } + + [Fact] + public void FirstObservedNonzeroReport_IsNotAssumedMissing() + { + var s = new Iec61850ReportContinuityState(); + Assert.Empty(Observe(s, 203, buffered: true, entryId: "010203")); + Assert.Equal(203UL, s.LastSequenceNumber); + } + + [Fact] + public void FullSegmentedReport_SharesSqNumAndStartsAtSubSqNumZero() + { + var s = new Iec61850ReportContinuityState(); + Assert.Empty(Observe(s, 4, sub: 0, more: true)); + Assert.Empty(Observe(s, 4, sub: 1, more: true)); + Assert.Empty(Observe(s, 4, sub: 2, more: false)); + Assert.Empty(Observe(s, 5)); + Assert.Equal(5UL, s.LastSequenceNumber); + } + + [Fact] + public void MissingFirstSegmentAndMissingContinuation_AreBothVisible() + { + var first = new Iec61850ReportContinuityState(); + Assert.Contains(Observe(first, 5, sub: 1, more: true), + w => w.Contains("starts at SubSqNum=1", StringComparison.Ordinal)); + + var s = new Iec61850ReportContinuityState(); + Assert.Empty(Observe(s, 6, sub: 0, more: true)); + Assert.Contains(Observe(s, 6, sub: 2, more: false), + w => w.Contains("Segmented report discontinuity", StringComparison.Ordinal)); + } + + [Fact] + public void UnexpectedUnsegmentedFrame_ReportsInterruptedSegment() + { + var s = new Iec61850ReportContinuityState(); + Assert.Empty(Observe(s, 7, sub: 0, more: true)); + Assert.Contains(Observe(s, 8), w => + w.Contains("Segmented report was interrupted", StringComparison.Ordinal)); + } + + [Fact] + public void BufferOverflowAndConfRevChange_AreNotErasedBySequentialSqNum() + { + var s = new Iec61850ReportContinuityState(); + var initial = new NativeReportFrameMetadata + { + SequenceNumber = 0, ConfRev = 1, EntryIdHex = "aa" + }; + Assert.Empty(Iec61850ReportContinuityInspector.Observe(s, initial, true)); + var next = new NativeReportFrameMetadata + { + SequenceNumber = 1, ConfRev = 2, BufferOverflow = true, EntryIdHex = "bb" + }; + var warnings = Iec61850ReportContinuityInspector.Observe(s, next, true); + Assert.Contains(warnings, w => w.Contains("BufOvfl=true", StringComparison.Ordinal)); + Assert.Contains(warnings, w => w.Contains("ConfRev changed", StringComparison.Ordinal)); + Assert.Equal("bb", s.LastEntryIdHex); + } + + [Fact] + public void OutOfRangeSqNum_DoesNotPoisonValidBaseline() + { + var s = new Iec61850ReportContinuityState(); + Observe(s, 11); + var warnings = Observe(s, 65536); + Assert.Contains(warnings, w => w.Contains("exceeds IEC 61850 INT16U", StringComparison.Ordinal)); + Assert.Equal(11UL, s.LastSequenceNumber); + Assert.Empty(Observe(s, 12)); + } + + [Fact] + public void MissingOptionalSqNum_DoesNotManufactureSequenceEvidence() + { + var s = new Iec61850ReportContinuityState(); + Assert.Empty(Iec61850ReportContinuityInspector.Observe(s, + new NativeReportFrameMetadata { EntryIdHex = "0102" }, true)); + Assert.Null(s.LastSequenceNumber); + Assert.Equal("0102", s.LastEntryIdHex); + } + + [Fact] + public void SeparateRcbAndAssociationStates_DoNotBorrowContinuity() + { + var a = new Iec61850ReportContinuityState(); + var b = new Iec61850ReportContinuityState(); + Observe(a, 32); + Assert.Empty(Observe(b, 1)); + Assert.Null(new Iec61850ReportContinuityState().LastSequenceNumber); + Assert.Equal(32UL, a.LastSequenceNumber); + Assert.Equal(1UL, b.LastSequenceNumber); + } + + [Fact] + public void ReadOnlyInspector_DoesNotChangeReportingOrPollingContracts() + { + var runtime = Read("Services/Iec61850MonitorRuntime.cs"); + Assert.Contains("Iec61850ReportContinuityInspector.Observe", runtime, StringComparison.Ordinal); + Assert.Contains("session.ReportStreams.Clear()", runtime, StringComparison.Ordinal); + Assert.Contains("StaticAcquisitionParityTracker.TryRecordRoutedReport", runtime, StringComparison.Ordinal); + var helper = Read("Services/Iec61850ReportContinuityInspector.cs"); + Assert.DoesNotContain("ReadMms", helper, StringComparison.Ordinal); + Assert.DoesNotContain("WriteMms", helper, StringComparison.Ordinal); + Assert.DoesNotContain("StartReport", helper, StringComparison.Ordinal); + } + + private static IReadOnlyList Observe( + Iec61850ReportContinuityState s, + ulong sequence, + bool buffered = false, + ulong? sub = null, + bool? more = null, + string entryId = "") + => Iec61850ReportContinuityInspector.Observe(s, new NativeReportFrameMetadata + { + SequenceNumber = sequence, + SubSequenceNumber = sub, + MoreSegmentsFollow = more, + EntryIdHex = entryId + }, buffered); + + private static string Read(string path) + { + DirectoryInfo? dir = new(AppContext.BaseDirectory); + while (dir is not null) + { + var found = Path.Combine(dir.FullName, path); + if (File.Exists(found)) + return File.ReadAllText(found); + dir = dir.Parent; + } + throw new FileNotFoundException(path); + } +}