Skip to content
Draft
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
109 changes: 8 additions & 101 deletions Services/Iec61850MonitorRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -59,7 +49,7 @@ private sealed class DeviceSession
public Dictionary<string, string> PointPlanIds { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, Iec61850MonitorPoint> ReportReferenceIndex { get; } = new(StringComparer.OrdinalIgnoreCase);
public PriorityQueue<string, long> PollQueue { get; } = new();
public Dictionary<string, ReportStreamState> ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, Iec61850ReportContinuityState> ReportStreams { get; } = new(StringComparer.OrdinalIgnoreCase);
public int LastUnroutedReportCount { get; set; }
public int ReportPlanCursor { get; set; }
public IReadOnlyList<ReportControlPlan> PendingReportPlans { get; set; } = Array.Empty<ReportControlPlan>();
Expand Down Expand Up @@ -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;
Expand Down
157 changes: 157 additions & 0 deletions Services/Iec61850ReportContinuityInspector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
using ArIED61850Tester.Models;

namespace ArIED61850Tester.Services;

/// <summary>
/// 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.
/// </summary>
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;

/// <summary>
/// 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.
/// </summary>
internal static IReadOnlyList<string> Observe(
Iec61850ReportContinuityState state,
NativeReportFrameMetadata frame,
bool buffered)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(frame);

List<string>? findings = null;
void Warn(string message) => (findings ??= new List<string>(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<string>() : 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";
}
}
Loading