From aafafff536cd15b48f0d10bb4f43dbe62dfdf8b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:04:33 +0700 Subject: [PATCH 1/7] fix(p0-f): classify one report stream per verified RCB and flag metadata drift --- Services/Iec61850ReportContinuityInspector.cs | 1005 +++++++++++++++++ 1 file changed, 1005 insertions(+) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index 4f7d44c14..60471bc6d 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -181,6 +181,1011 @@ internal static class Iec61850ReportContinuityInspector private const int RecentChronologyLimit = 8; private const int AnomalyChronologyLimit = 8; + /// + /// Within one association a concrete RCB owns its sequence history. + /// RptID and DataSet metadata can be omitted or spelled differently, + /// so neither is a stream key. Unproven RCB identity remains untracked. + /// This does not affect control, report routing or process-value admission. + /// + 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(' + + /// + /// 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); + + if (state.FramesSeen < long.MaxValue) state.FramesSeen++; + if (frame.ReceivedAt != default) + { + state.FirstFrameReceivedAtUtc ??= frame.ReceivedAt; + state.LastFrameReceivedAtUtc = frame.ReceivedAt; + } + if (frame.SequenceNumber.HasValue) + { + if (state.SequencedFrames < long.MaxValue) state.SequencedFrames++; + if (frame.SequenceNumber.Value <= MaxSequenceNumber) + state.FirstSequenceNumber ??= frame.SequenceNumber; + } + else if (state.MissingSequenceFrames < long.MaxValue) state.MissingSequenceFrames++; + if (frame.SubSequenceNumber.HasValue && state.SegmentedFrames < long.MaxValue) + state.SegmentedFrames++; + if (!string.IsNullOrEmpty(frame.EntryIdHex) && state.EntryIdPresentFrames < long.MaxValue) + state.EntryIdPresentFrames++; + if (frame.GeneralInterrogationReasonSeen == true && state.GiReasonFrames < long.MaxValue) + state.GiReasonFrames++; + if (frame.IntegrityReasonSeen == true && state.IntegrityReasonFrames < long.MaxValue) + state.IntegrityReasonFrames++; + if (!frame.GeneralInterrogationReasonSeen.HasValue && + !frame.IntegrityReasonSeen.HasValue && + state.ReasonUnavailableFrames < long.MaxValue) + state.ReasonUnavailableFrames++; + + List? findings = null; + void Warn(string message) + { + (findings ??= new List(2)).Add(message); + if (state.AnomalyFindings < long.MaxValue) state.AnomalyFindings++; + } + + // Optional report metadata does not establish a second independent RCB. + // Preserve the latest nonempty identity and explicitly flag real drift. + 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); + 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; + // This context is observational: a successful GI=true write or GI + // inclusion reason may correlate with SqNum reset but can never + // certify replay correctness / absence of lost buffered entries. + string CausalContext() => + $"; activation RptEna={WriteVerdict(state.RptEnaWriteAccepted)}, " + + $"GIwrite={WriteVerdict(state.GiWriteAccepted)}, " + + $"GIreason={WriteVerdict(frame.GeneralInterrogationReasonSeen)}, " + + $"startReturnUtc={state.ActivationReturnedAtUtc?.ToString("O") ?? "unknown"}, " + + $"frameReceivedUtc={frame.ReceivedAt:O}; GI correlation is not proof of a harmless reset"; + + // One causal classification per actually observed SqNum discontinuity. + // EntryID is an opaque octet string, never a numeric continuity proof. + void RecordSequenceCorrelation() + { + if (state.SequenceDiscontinuities < long.MaxValue) + state.SequenceDiscontinuities++; + if (buffered && priorEntryIdPresent && entryIdPresent && + state.LastEntryIdProvenByOptFlds && frame.OptFldsEntryId == true && + IsBoundedHex(frame.OptFldsRawHex)) + { + if (entryIdChanged) + { + if (state.SequenceWithChangedEntryId < long.MaxValue) + state.SequenceWithChangedEntryId++; + } + else if (state.SequenceWithRepeatedEntryId < long.MaxValue) + state.SequenceWithRepeatedEntryId++; + } + else if (state.SequenceWithoutComparableEntryId < long.MaxValue) + state.SequenceWithoutComparableEntryId++; + + if (frame.GeneralInterrogationReasonSeen == true && + state.SequenceWithGiReason < long.MaxValue) + state.SequenceWithGiReason++; + if (frame.BufferOverflow == true && state.SequenceWithOverflow < long.MaxValue) + state.SequenceWithOverflow++; + } + + // Raw OptFlds is authoritative decoder evidence. Unknown stays UNKNOWN; + // a missing value cannot be interpreted as an omitted wire field. + var hasOptFlds = frame.OptFldsSequenceNumber.HasValue && + frame.OptFldsEntryId.HasValue && + IsBoundedHex(frame.OptFldsRawHex); + if (hasOptFlds) + { + if (state.OptFldsDecodedFrames < long.MaxValue) state.OptFldsDecodedFrames++; + state.LastOptFldsHex = frame.OptFldsRawHex; + if (frame.OptFldsSequenceNumber == true) + { + if (state.SqNumAdvertisedFrames < long.MaxValue) state.SqNumAdvertisedFrames++; + if (!frame.SequenceNumber.HasValue) + { + if (state.SqNumAdvertisedMissingFrames < long.MaxValue) + state.SqNumAdvertisedMissingFrames++; + Warn("Report OptFlds advertises SqNum, but ARIEC decoded no SqNum; inspect decoder and on-wire field completeness"); + } + } + else + { + if (state.SqNumOmittedFrames < long.MaxValue) state.SqNumOmittedFrames++; + if (frame.SequenceNumber.HasValue) + Warn("Report OptFlds omits SqNum, but decoded frame supplied SqNum; metadata provenance is inconsistent"); + } + + if (frame.OptFldsEntryId == true) + { + if (state.EntryIdAdvertisedFrames < long.MaxValue) state.EntryIdAdvertisedFrames++; + if (!entryIdPresent && state.EntryIdAdvertisedEmptyFrames < long.MaxValue) + state.EntryIdAdvertisedEmptyFrames++; + } + else + { + if (state.EntryIdOmittedFrames < long.MaxValue) state.EntryIdOmittedFrames++; + if (entryIdPresent) + Warn("Report OptFlds omits EntryID, but decoded frame supplied EntryID; metadata provenance is inconsistent"); + } + + if (frame.OptFldsBufferOverflow == false && frame.BufferOverflow.HasValue) + Warn("Report OptFlds omits BufOvfl, but decoded frame supplied it; metadata provenance is inconsistent"); + if (frame.OptFldsConfRev == false && frame.ConfRev.HasValue) + Warn("Report OptFlds omits ConfRev, but decoded frame supplied it; metadata provenance is inconsistent"); + } + else if (state.OptFldsUnknownFrames < long.MaxValue) + { + state.OptFldsUnknownFrames++; + } + + if (frame.BufferOverflow == true) + { + if (state.BufferOverflows < long.MaxValue) state.BufferOverflows++; + 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) + { + if (state.ConfRevChanges < long.MaxValue) state.ConfRevChanges++; + 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) + { + CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); + Warn(discontinuity + EntryContext() + CausalContext()); + } + } + + 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) + { + CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); + Warn(discontinuity + EntryContext() + CausalContext()); + } + 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; + state.LastEntryIdProvenByOptFlds = hasOptFlds && frame.OptFldsEntryId == true; + } + else + { + // Missing current EntryID invalidates comparisons with future frames; + // never compare across an opaque, unobserved resume cursor. + state.LastEntryIdHex = string.Empty; + state.LastEntryIdProvenByOptFlds = false; + } + + if (buffered) + { + // Observational classification only. Both OptFlds must prove the + // opaque cursor to label successive values as changed or repeated. + var comparableEntryId = priorEntryIdPresent && entryIdPresent && + priorEntryIdProven && hasOptFlds && frame.OptFldsEntryId == true; + var relation = !entryIdPresent ? "absent" : + !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; + var evidence = new Iec61850BrcbChronologyFrame + { + FrameOrdinal = state.FramesSeen, + ReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt, + SequenceNumber = frame.SequenceNumber, + GiReason = frame.GeneralInterrogationReasonSeen, + BufferOverflow = frame.BufferOverflow, + EntryIdRelation = relation, + FindingCount = findings?.Count ?? 0 + }; + RecordChronology(state, evidence); + if (evidence.FindingCount > 0) + RecordAnomalyChronology(state, evidence); + } + + if (findings is not null) + { + if (state.AnomalousFrames < long.MaxValue) + state.AnomalousFrames++; + state.FramesAfterLastFinding = 0; + // Missing decoder timestamps remain unknown; do not reuse older time. + state.LastFindingReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; + } + else if (state.AnomalousFrames > 0 && state.FramesAfterLastFinding < long.MaxValue) + { + // Receive-order observation only; no claim of event delivery completeness. + state.FramesAfterLastFinding++; + } + return findings is null ? Array.Empty() : findings; + } + + internal static Iec61850ReportContinuitySnapshot Snapshot( + IReadOnlyDictionary streams, + long processUpdatesSeen, + int untrackedStreamCount, + int maxShown = 12) + { + ArgumentNullException.ThrowIfNull(streams); + var ordered = streams.Values + .OrderBy(state => state.ReportControlReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(state => state.ReportId, StringComparer.OrdinalIgnoreCase) + .ThenBy(state => state.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var shown = ordered.Take(Math.Clamp(maxShown, 0, 32)).Select(state => + new Iec61850ReportContinuityStreamSnapshot + { + Rcb = state.ReportControlReference, + DataSet = state.DataSetReference, + ReportId = state.ReportId, + Buffered = state.Buffered, + Frames = state.FramesSeen, + Sequenced = state.SequencedFrames, + SequenceMissing = state.MissingSequenceFrames, + Segmented = state.SegmentedFrames, + Findings = state.AnomalyFindings, + AnomalousFrames = state.AnomalousFrames, + FramesAfterLastFinding = state.FramesAfterLastFinding, + FirstFrameReceivedAtUtc = state.FirstFrameReceivedAtUtc, + LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, + LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, + Chronology = CopyChronology(state), + RecentAnomalies = CopyAnomalyChronology(state), + ChronologyInitialCount = state.InitialChronologyCount, + ChronologyRecentCount = state.RecentChronologyCount, + ChronologyOmittedFrames = state.Buffered + ? Math.Max(0, state.FramesSeen - state.InitialChronologyCount - state.RecentChronologyCount) + : 0, + SequenceDiscontinuities = state.SequenceDiscontinuities, + SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, + SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, + SequenceWithoutComparableEntryId = state.SequenceWithoutComparableEntryId, + SequenceWithGiReason = state.SequenceWithGiReason, + SequenceWithOverflow = state.SequenceWithOverflow, + Overflow = state.BufferOverflows, + ConfRevChanges = state.ConfRevChanges, + EntryIdPresent = state.EntryIdPresentFrames, + FirstSqNum = state.FirstSequenceNumber, + LastSqNum = state.LastSequenceNumber, + OptFldsDecoded = state.OptFldsDecodedFrames, + OptFldsUnknown = state.OptFldsUnknownFrames, + SqNumAdvertised = state.SqNumAdvertisedFrames, + SqNumOmitted = state.SqNumOmittedFrames, + SqNumAdvertisedMissing = state.SqNumAdvertisedMissingFrames, + EntryIdAdvertised = state.EntryIdAdvertisedFrames, + EntryIdOmitted = state.EntryIdOmittedFrames, + EntryIdEmptyOrUnprojected = state.EntryIdAdvertisedEmptyFrames, + LastOptFldsHex = state.LastOptFldsHex, + RptEnaWriteAccepted = state.RptEnaWriteAccepted, + GiWriteAccepted = state.GiWriteAccepted, + ActivationReturnedAtUtc = state.ActivationReturnedAtUtc, + GiReasonFrames = state.GiReasonFrames, + IntegrityReasonFrames = state.IntegrityReasonFrames, + ReasonUnavailableFrames = state.ReasonUnavailableFrames, + LastAnomalyPriorSqNum = state.LastAnomalyPriorSqNum, + LastAnomalyCurrentSqNum = state.LastAnomalyCurrentSqNum, + LastAnomalyGiReason = state.LastAnomalyGiReason, + LastAnomalyReceivedAtUtc = state.LastAnomalyReceivedAtUtc + }).ToArray(); + return new Iec61850ReportContinuitySnapshot + { + ProcessUpdatesSeen = processUpdatesSeen, + StreamCount = ordered.Length, + UntrackedStreamCount = untrackedStreamCount, + Frames = ordered.Sum(state => state.FramesSeen), + Sequenced = ordered.Sum(state => state.SequencedFrames), + Findings = ordered.Sum(state => state.AnomalyFindings), + AnomalousFrames = ordered.Sum(state => state.AnomalousFrames), + SequenceDiscontinuities = ordered.Sum(state => state.SequenceDiscontinuities), + Overflows = ordered.Sum(state => state.BufferOverflows), + OptFldsDecoded = ordered.Sum(state => state.OptFldsDecodedFrames), + OptFldsUnknown = ordered.Sum(state => state.OptFldsUnknownFrames), + Streams = shown + }; + } + + private static void RecordChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + if (state.InitialChronologyCount < InitialChronologyLimit) + { + var initial = state.InitialChronology ??= new Iec61850BrcbChronologyFrame[InitialChronologyLimit]; + initial[state.InitialChronologyCount++] = item; + } + else + { + var recent = state.RecentChronology ??= new Iec61850BrcbChronologyFrame[RecentChronologyLimit]; + recent[state.RecentChronologyNext] = item; + state.RecentChronologyNext = (state.RecentChronologyNext + 1) % RecentChronologyLimit; + if (state.RecentChronologyCount < RecentChronologyLimit) + state.RecentChronologyCount++; + } + } + + private static void RecordAnomalyChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + var ring = state.AnomalyChronology ??= + new Iec61850BrcbChronologyFrame[AnomalyChronologyLimit]; + ring[state.AnomalyChronologyNext] = item; + state.AnomalyChronologyNext = + (state.AnomalyChronologyNext + 1) % AnomalyChronologyLimit; + if (state.AnomalyChronologyCount < AnomalyChronologyLimit) + state.AnomalyChronologyCount++; + } + + private static IReadOnlyList CopyAnomalyChronology( + Iec61850ReportContinuityState state) + { + var count = state.AnomalyChronologyCount; + if (count == 0) + return Array.Empty(); + var copy = new Iec61850BrcbChronologyFrame[count]; + for (var index = 0; index < count; index++) + { + var source = (state.AnomalyChronologyNext - count + index + + AnomalyChronologyLimit) % AnomalyChronologyLimit; + copy[index] = state.AnomalyChronology![source]; + } + return copy; + } + + private static IReadOnlyList CopyChronology( + Iec61850ReportContinuityState state) + { + var initialCount = state.InitialChronologyCount; + var recentCount = state.RecentChronologyCount; + if (initialCount + recentCount == 0) + return Array.Empty(); + + var copy = new Iec61850BrcbChronologyFrame[initialCount + recentCount]; + if (initialCount > 0) + Array.Copy(state.InitialChronology!, 0, copy, 0, initialCount); + for (var index = 0; index < recentCount; index++) + { + var source = (state.RecentChronologyNext - recentCount + index + RecentChronologyLimit) + % RecentChronologyLimit; + copy[initialCount + index] = state.RecentChronology![source]; + } + return copy; + } + + private static string WriteVerdict(bool? value) + => value is true ? "accepted/yes" : value is false ? "rejected/no" : "UNKNOWN"; + + private static void CaptureAnomaly( + Iec61850ReportContinuityState state, NativeReportFrameMetadata frame) + { + state.LastAnomalyPriorSqNum = state.LastSequenceNumber; + state.LastAnomalyCurrentSqNum = frame.SequenceNumber; + state.LastAnomalyGiReason = frame.GeneralInterrogationReasonSeen; + state.LastAnomalyReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; + } + + private static bool IsBoundedHex(string? value) + { + if (string.IsNullOrEmpty(value) || value.Length > 16 || (value.Length & 1) != 0) + return false; + foreach (var character in value) + { + if (!((character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'))) + return false; + } + return true; + } + + 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"; + } +} +, '.') + .Replace("..", ".").ToLowerInvariant(); + } + + private static string CanonicalDataSet(string? reference) + => (reference ?? string.Empty).Trim().Replace(' + + /// + /// 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); + + if (state.FramesSeen < long.MaxValue) state.FramesSeen++; + if (frame.ReceivedAt != default) + { + state.FirstFrameReceivedAtUtc ??= frame.ReceivedAt; + state.LastFrameReceivedAtUtc = frame.ReceivedAt; + } + if (frame.SequenceNumber.HasValue) + { + if (state.SequencedFrames < long.MaxValue) state.SequencedFrames++; + if (frame.SequenceNumber.Value <= MaxSequenceNumber) + state.FirstSequenceNumber ??= frame.SequenceNumber; + } + else if (state.MissingSequenceFrames < long.MaxValue) state.MissingSequenceFrames++; + if (frame.SubSequenceNumber.HasValue && state.SegmentedFrames < long.MaxValue) + state.SegmentedFrames++; + if (!string.IsNullOrEmpty(frame.EntryIdHex) && state.EntryIdPresentFrames < long.MaxValue) + state.EntryIdPresentFrames++; + if (frame.GeneralInterrogationReasonSeen == true && state.GiReasonFrames < long.MaxValue) + state.GiReasonFrames++; + if (frame.IntegrityReasonSeen == true && state.IntegrityReasonFrames < long.MaxValue) + state.IntegrityReasonFrames++; + if (!frame.GeneralInterrogationReasonSeen.HasValue && + !frame.IntegrityReasonSeen.HasValue && + state.ReasonUnavailableFrames < long.MaxValue) + state.ReasonUnavailableFrames++; + + List? findings = null; + void Warn(string message) + { + (findings ??= new List(2)).Add(message); + if (state.AnomalyFindings < long.MaxValue) state.AnomalyFindings++; + } + + var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex); + var priorEntryIdProven = state.LastEntryIdProvenByOptFlds; + 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; + // This context is observational: a successful GI=true write or GI + // inclusion reason may correlate with SqNum reset but can never + // certify replay correctness / absence of lost buffered entries. + string CausalContext() => + $"; activation RptEna={WriteVerdict(state.RptEnaWriteAccepted)}, " + + $"GIwrite={WriteVerdict(state.GiWriteAccepted)}, " + + $"GIreason={WriteVerdict(frame.GeneralInterrogationReasonSeen)}, " + + $"startReturnUtc={state.ActivationReturnedAtUtc?.ToString("O") ?? "unknown"}, " + + $"frameReceivedUtc={frame.ReceivedAt:O}; GI correlation is not proof of a harmless reset"; + + // One causal classification per actually observed SqNum discontinuity. + // EntryID is an opaque octet string, never a numeric continuity proof. + void RecordSequenceCorrelation() + { + if (state.SequenceDiscontinuities < long.MaxValue) + state.SequenceDiscontinuities++; + if (buffered && priorEntryIdPresent && entryIdPresent && + state.LastEntryIdProvenByOptFlds && frame.OptFldsEntryId == true && + IsBoundedHex(frame.OptFldsRawHex)) + { + if (entryIdChanged) + { + if (state.SequenceWithChangedEntryId < long.MaxValue) + state.SequenceWithChangedEntryId++; + } + else if (state.SequenceWithRepeatedEntryId < long.MaxValue) + state.SequenceWithRepeatedEntryId++; + } + else if (state.SequenceWithoutComparableEntryId < long.MaxValue) + state.SequenceWithoutComparableEntryId++; + + if (frame.GeneralInterrogationReasonSeen == true && + state.SequenceWithGiReason < long.MaxValue) + state.SequenceWithGiReason++; + if (frame.BufferOverflow == true && state.SequenceWithOverflow < long.MaxValue) + state.SequenceWithOverflow++; + } + + // Raw OptFlds is authoritative decoder evidence. Unknown stays UNKNOWN; + // a missing value cannot be interpreted as an omitted wire field. + var hasOptFlds = frame.OptFldsSequenceNumber.HasValue && + frame.OptFldsEntryId.HasValue && + IsBoundedHex(frame.OptFldsRawHex); + if (hasOptFlds) + { + if (state.OptFldsDecodedFrames < long.MaxValue) state.OptFldsDecodedFrames++; + state.LastOptFldsHex = frame.OptFldsRawHex; + if (frame.OptFldsSequenceNumber == true) + { + if (state.SqNumAdvertisedFrames < long.MaxValue) state.SqNumAdvertisedFrames++; + if (!frame.SequenceNumber.HasValue) + { + if (state.SqNumAdvertisedMissingFrames < long.MaxValue) + state.SqNumAdvertisedMissingFrames++; + Warn("Report OptFlds advertises SqNum, but ARIEC decoded no SqNum; inspect decoder and on-wire field completeness"); + } + } + else + { + if (state.SqNumOmittedFrames < long.MaxValue) state.SqNumOmittedFrames++; + if (frame.SequenceNumber.HasValue) + Warn("Report OptFlds omits SqNum, but decoded frame supplied SqNum; metadata provenance is inconsistent"); + } + + if (frame.OptFldsEntryId == true) + { + if (state.EntryIdAdvertisedFrames < long.MaxValue) state.EntryIdAdvertisedFrames++; + if (!entryIdPresent && state.EntryIdAdvertisedEmptyFrames < long.MaxValue) + state.EntryIdAdvertisedEmptyFrames++; + } + else + { + if (state.EntryIdOmittedFrames < long.MaxValue) state.EntryIdOmittedFrames++; + if (entryIdPresent) + Warn("Report OptFlds omits EntryID, but decoded frame supplied EntryID; metadata provenance is inconsistent"); + } + + if (frame.OptFldsBufferOverflow == false && frame.BufferOverflow.HasValue) + Warn("Report OptFlds omits BufOvfl, but decoded frame supplied it; metadata provenance is inconsistent"); + if (frame.OptFldsConfRev == false && frame.ConfRev.HasValue) + Warn("Report OptFlds omits ConfRev, but decoded frame supplied it; metadata provenance is inconsistent"); + } + else if (state.OptFldsUnknownFrames < long.MaxValue) + { + state.OptFldsUnknownFrames++; + } + + if (frame.BufferOverflow == true) + { + if (state.BufferOverflows < long.MaxValue) state.BufferOverflows++; + 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) + { + if (state.ConfRevChanges < long.MaxValue) state.ConfRevChanges++; + 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) + { + CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); + Warn(discontinuity + EntryContext() + CausalContext()); + } + } + + 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) + { + CaptureAnomaly(state, frame); + RecordSequenceCorrelation(); + Warn(discontinuity + EntryContext() + CausalContext()); + } + 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; + state.LastEntryIdProvenByOptFlds = hasOptFlds && frame.OptFldsEntryId == true; + } + else + { + // Missing current EntryID invalidates comparisons with future frames; + // never compare across an opaque, unobserved resume cursor. + state.LastEntryIdHex = string.Empty; + state.LastEntryIdProvenByOptFlds = false; + } + + if (buffered) + { + // Observational classification only. Both OptFlds must prove the + // opaque cursor to label successive values as changed or repeated. + var comparableEntryId = priorEntryIdPresent && entryIdPresent && + priorEntryIdProven && hasOptFlds && frame.OptFldsEntryId == true; + var relation = !entryIdPresent ? "absent" : + !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; + var evidence = new Iec61850BrcbChronologyFrame + { + FrameOrdinal = state.FramesSeen, + ReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt, + SequenceNumber = frame.SequenceNumber, + GiReason = frame.GeneralInterrogationReasonSeen, + BufferOverflow = frame.BufferOverflow, + EntryIdRelation = relation, + FindingCount = findings?.Count ?? 0 + }; + RecordChronology(state, evidence); + if (evidence.FindingCount > 0) + RecordAnomalyChronology(state, evidence); + } + + if (findings is not null) + { + if (state.AnomalousFrames < long.MaxValue) + state.AnomalousFrames++; + state.FramesAfterLastFinding = 0; + // Missing decoder timestamps remain unknown; do not reuse older time. + state.LastFindingReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; + } + else if (state.AnomalousFrames > 0 && state.FramesAfterLastFinding < long.MaxValue) + { + // Receive-order observation only; no claim of event delivery completeness. + state.FramesAfterLastFinding++; + } + return findings is null ? Array.Empty() : findings; + } + + internal static Iec61850ReportContinuitySnapshot Snapshot( + IReadOnlyDictionary streams, + long processUpdatesSeen, + int untrackedStreamCount, + int maxShown = 12) + { + ArgumentNullException.ThrowIfNull(streams); + var ordered = streams.Values + .OrderBy(state => state.ReportControlReference, StringComparer.OrdinalIgnoreCase) + .ThenBy(state => state.ReportId, StringComparer.OrdinalIgnoreCase) + .ThenBy(state => state.DataSetReference, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var shown = ordered.Take(Math.Clamp(maxShown, 0, 32)).Select(state => + new Iec61850ReportContinuityStreamSnapshot + { + Rcb = state.ReportControlReference, + DataSet = state.DataSetReference, + ReportId = state.ReportId, + Buffered = state.Buffered, + Frames = state.FramesSeen, + Sequenced = state.SequencedFrames, + SequenceMissing = state.MissingSequenceFrames, + Segmented = state.SegmentedFrames, + Findings = state.AnomalyFindings, + AnomalousFrames = state.AnomalousFrames, + FramesAfterLastFinding = state.FramesAfterLastFinding, + FirstFrameReceivedAtUtc = state.FirstFrameReceivedAtUtc, + LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, + LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, + Chronology = CopyChronology(state), + RecentAnomalies = CopyAnomalyChronology(state), + ChronologyInitialCount = state.InitialChronologyCount, + ChronologyRecentCount = state.RecentChronologyCount, + ChronologyOmittedFrames = state.Buffered + ? Math.Max(0, state.FramesSeen - state.InitialChronologyCount - state.RecentChronologyCount) + : 0, + SequenceDiscontinuities = state.SequenceDiscontinuities, + SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, + SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, + SequenceWithoutComparableEntryId = state.SequenceWithoutComparableEntryId, + SequenceWithGiReason = state.SequenceWithGiReason, + SequenceWithOverflow = state.SequenceWithOverflow, + Overflow = state.BufferOverflows, + ConfRevChanges = state.ConfRevChanges, + EntryIdPresent = state.EntryIdPresentFrames, + FirstSqNum = state.FirstSequenceNumber, + LastSqNum = state.LastSequenceNumber, + OptFldsDecoded = state.OptFldsDecodedFrames, + OptFldsUnknown = state.OptFldsUnknownFrames, + SqNumAdvertised = state.SqNumAdvertisedFrames, + SqNumOmitted = state.SqNumOmittedFrames, + SqNumAdvertisedMissing = state.SqNumAdvertisedMissingFrames, + EntryIdAdvertised = state.EntryIdAdvertisedFrames, + EntryIdOmitted = state.EntryIdOmittedFrames, + EntryIdEmptyOrUnprojected = state.EntryIdAdvertisedEmptyFrames, + LastOptFldsHex = state.LastOptFldsHex, + RptEnaWriteAccepted = state.RptEnaWriteAccepted, + GiWriteAccepted = state.GiWriteAccepted, + ActivationReturnedAtUtc = state.ActivationReturnedAtUtc, + GiReasonFrames = state.GiReasonFrames, + IntegrityReasonFrames = state.IntegrityReasonFrames, + ReasonUnavailableFrames = state.ReasonUnavailableFrames, + LastAnomalyPriorSqNum = state.LastAnomalyPriorSqNum, + LastAnomalyCurrentSqNum = state.LastAnomalyCurrentSqNum, + LastAnomalyGiReason = state.LastAnomalyGiReason, + LastAnomalyReceivedAtUtc = state.LastAnomalyReceivedAtUtc + }).ToArray(); + return new Iec61850ReportContinuitySnapshot + { + ProcessUpdatesSeen = processUpdatesSeen, + StreamCount = ordered.Length, + UntrackedStreamCount = untrackedStreamCount, + Frames = ordered.Sum(state => state.FramesSeen), + Sequenced = ordered.Sum(state => state.SequencedFrames), + Findings = ordered.Sum(state => state.AnomalyFindings), + AnomalousFrames = ordered.Sum(state => state.AnomalousFrames), + SequenceDiscontinuities = ordered.Sum(state => state.SequenceDiscontinuities), + Overflows = ordered.Sum(state => state.BufferOverflows), + OptFldsDecoded = ordered.Sum(state => state.OptFldsDecodedFrames), + OptFldsUnknown = ordered.Sum(state => state.OptFldsUnknownFrames), + Streams = shown + }; + } + + private static void RecordChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + if (state.InitialChronologyCount < InitialChronologyLimit) + { + var initial = state.InitialChronology ??= new Iec61850BrcbChronologyFrame[InitialChronologyLimit]; + initial[state.InitialChronologyCount++] = item; + } + else + { + var recent = state.RecentChronology ??= new Iec61850BrcbChronologyFrame[RecentChronologyLimit]; + recent[state.RecentChronologyNext] = item; + state.RecentChronologyNext = (state.RecentChronologyNext + 1) % RecentChronologyLimit; + if (state.RecentChronologyCount < RecentChronologyLimit) + state.RecentChronologyCount++; + } + } + + private static void RecordAnomalyChronology( + Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) + { + var ring = state.AnomalyChronology ??= + new Iec61850BrcbChronologyFrame[AnomalyChronologyLimit]; + ring[state.AnomalyChronologyNext] = item; + state.AnomalyChronologyNext = + (state.AnomalyChronologyNext + 1) % AnomalyChronologyLimit; + if (state.AnomalyChronologyCount < AnomalyChronologyLimit) + state.AnomalyChronologyCount++; + } + + private static IReadOnlyList CopyAnomalyChronology( + Iec61850ReportContinuityState state) + { + var count = state.AnomalyChronologyCount; + if (count == 0) + return Array.Empty(); + var copy = new Iec61850BrcbChronologyFrame[count]; + for (var index = 0; index < count; index++) + { + var source = (state.AnomalyChronologyNext - count + index + + AnomalyChronologyLimit) % AnomalyChronologyLimit; + copy[index] = state.AnomalyChronology![source]; + } + return copy; + } + + private static IReadOnlyList CopyChronology( + Iec61850ReportContinuityState state) + { + var initialCount = state.InitialChronologyCount; + var recentCount = state.RecentChronologyCount; + if (initialCount + recentCount == 0) + return Array.Empty(); + + var copy = new Iec61850BrcbChronologyFrame[initialCount + recentCount]; + if (initialCount > 0) + Array.Copy(state.InitialChronology!, 0, copy, 0, initialCount); + for (var index = 0; index < recentCount; index++) + { + var source = (state.RecentChronologyNext - recentCount + index + RecentChronologyLimit) + % RecentChronologyLimit; + copy[initialCount + index] = state.RecentChronology![source]; + } + return copy; + } + + private static string WriteVerdict(bool? value) + => value is true ? "accepted/yes" : value is false ? "rejected/no" : "UNKNOWN"; + + private static void CaptureAnomaly( + Iec61850ReportContinuityState state, NativeReportFrameMetadata frame) + { + state.LastAnomalyPriorSqNum = state.LastSequenceNumber; + state.LastAnomalyCurrentSqNum = frame.SequenceNumber; + state.LastAnomalyGiReason = frame.GeneralInterrogationReasonSeen; + state.LastAnomalyReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; + } + + private static bool IsBoundedHex(string? value) + { + if (string.IsNullOrEmpty(value) || value.Length > 16 || (value.Length & 1) != 0) + return false; + foreach (var character in value) + { + if (!((character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'))) + return false; + } + return true; + } + + 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"; + } +} +, '.') + .Replace("..", "."); + /// /// A gap/backward move is an OBSERVED discontinuity, not proof of a specific /// lost event: resubscription, replay, server restart and report queueing may From 89e4f18ee38a082149cdc57b5966356bbf6dd81b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:05:40 +0700 Subject: [PATCH 2/7] fix(p0-f): restore exact source and use bounded RCB identity metadata changes --- Services/Iec61850ReportContinuityInspector.cs | 991 +----------------- 1 file changed, 10 insertions(+), 981 deletions(-) diff --git a/Services/Iec61850ReportContinuityInspector.cs b/Services/Iec61850ReportContinuityInspector.cs index 60471bc6d..0802bcf90 100644 --- a/Services/Iec61850ReportContinuityInspector.cs +++ b/Services/Iec61850ReportContinuityInspector.cs @@ -182,17 +182,21 @@ internal static class Iec61850ReportContinuityInspector private const int AnomalyChronologyLimit = 8; /// - /// Within one association a concrete RCB owns its sequence history. - /// RptID and DataSet metadata can be omitted or spelled differently, - /// so neither is a stream key. Unproven RCB identity remains untracked. - /// This does not affect control, report routing or process-value admission. + /// 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(' + 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 @@ -240,8 +244,7 @@ void Warn(string message) if (state.AnomalyFindings < long.MaxValue) state.AnomalyFindings++; } - // Optional report metadata does not establish a second independent RCB. - // Preserve the latest nonempty identity and explicitly flag real drift. + // Optional metadata variations cannot create another independent RCB. if (!string.IsNullOrWhiteSpace(frame.ReportId)) { if (!string.IsNullOrWhiteSpace(state.ReportId) && @@ -694,977 +697,3 @@ private static bool IsBoundedHex(string? value) $"current={current}; RptEna/reconnect/GI context is not yet verified; no event-loss conclusion"; } } -, '.') - .Replace("..", ".").ToLowerInvariant(); - } - - private static string CanonicalDataSet(string? reference) - => (reference ?? string.Empty).Trim().Replace(' - - /// - /// 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); - - if (state.FramesSeen < long.MaxValue) state.FramesSeen++; - if (frame.ReceivedAt != default) - { - state.FirstFrameReceivedAtUtc ??= frame.ReceivedAt; - state.LastFrameReceivedAtUtc = frame.ReceivedAt; - } - if (frame.SequenceNumber.HasValue) - { - if (state.SequencedFrames < long.MaxValue) state.SequencedFrames++; - if (frame.SequenceNumber.Value <= MaxSequenceNumber) - state.FirstSequenceNumber ??= frame.SequenceNumber; - } - else if (state.MissingSequenceFrames < long.MaxValue) state.MissingSequenceFrames++; - if (frame.SubSequenceNumber.HasValue && state.SegmentedFrames < long.MaxValue) - state.SegmentedFrames++; - if (!string.IsNullOrEmpty(frame.EntryIdHex) && state.EntryIdPresentFrames < long.MaxValue) - state.EntryIdPresentFrames++; - if (frame.GeneralInterrogationReasonSeen == true && state.GiReasonFrames < long.MaxValue) - state.GiReasonFrames++; - if (frame.IntegrityReasonSeen == true && state.IntegrityReasonFrames < long.MaxValue) - state.IntegrityReasonFrames++; - if (!frame.GeneralInterrogationReasonSeen.HasValue && - !frame.IntegrityReasonSeen.HasValue && - state.ReasonUnavailableFrames < long.MaxValue) - state.ReasonUnavailableFrames++; - - List? findings = null; - void Warn(string message) - { - (findings ??= new List(2)).Add(message); - if (state.AnomalyFindings < long.MaxValue) state.AnomalyFindings++; - } - - var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex); - var priorEntryIdProven = state.LastEntryIdProvenByOptFlds; - 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; - // This context is observational: a successful GI=true write or GI - // inclusion reason may correlate with SqNum reset but can never - // certify replay correctness / absence of lost buffered entries. - string CausalContext() => - $"; activation RptEna={WriteVerdict(state.RptEnaWriteAccepted)}, " + - $"GIwrite={WriteVerdict(state.GiWriteAccepted)}, " + - $"GIreason={WriteVerdict(frame.GeneralInterrogationReasonSeen)}, " + - $"startReturnUtc={state.ActivationReturnedAtUtc?.ToString("O") ?? "unknown"}, " + - $"frameReceivedUtc={frame.ReceivedAt:O}; GI correlation is not proof of a harmless reset"; - - // One causal classification per actually observed SqNum discontinuity. - // EntryID is an opaque octet string, never a numeric continuity proof. - void RecordSequenceCorrelation() - { - if (state.SequenceDiscontinuities < long.MaxValue) - state.SequenceDiscontinuities++; - if (buffered && priorEntryIdPresent && entryIdPresent && - state.LastEntryIdProvenByOptFlds && frame.OptFldsEntryId == true && - IsBoundedHex(frame.OptFldsRawHex)) - { - if (entryIdChanged) - { - if (state.SequenceWithChangedEntryId < long.MaxValue) - state.SequenceWithChangedEntryId++; - } - else if (state.SequenceWithRepeatedEntryId < long.MaxValue) - state.SequenceWithRepeatedEntryId++; - } - else if (state.SequenceWithoutComparableEntryId < long.MaxValue) - state.SequenceWithoutComparableEntryId++; - - if (frame.GeneralInterrogationReasonSeen == true && - state.SequenceWithGiReason < long.MaxValue) - state.SequenceWithGiReason++; - if (frame.BufferOverflow == true && state.SequenceWithOverflow < long.MaxValue) - state.SequenceWithOverflow++; - } - - // Raw OptFlds is authoritative decoder evidence. Unknown stays UNKNOWN; - // a missing value cannot be interpreted as an omitted wire field. - var hasOptFlds = frame.OptFldsSequenceNumber.HasValue && - frame.OptFldsEntryId.HasValue && - IsBoundedHex(frame.OptFldsRawHex); - if (hasOptFlds) - { - if (state.OptFldsDecodedFrames < long.MaxValue) state.OptFldsDecodedFrames++; - state.LastOptFldsHex = frame.OptFldsRawHex; - if (frame.OptFldsSequenceNumber == true) - { - if (state.SqNumAdvertisedFrames < long.MaxValue) state.SqNumAdvertisedFrames++; - if (!frame.SequenceNumber.HasValue) - { - if (state.SqNumAdvertisedMissingFrames < long.MaxValue) - state.SqNumAdvertisedMissingFrames++; - Warn("Report OptFlds advertises SqNum, but ARIEC decoded no SqNum; inspect decoder and on-wire field completeness"); - } - } - else - { - if (state.SqNumOmittedFrames < long.MaxValue) state.SqNumOmittedFrames++; - if (frame.SequenceNumber.HasValue) - Warn("Report OptFlds omits SqNum, but decoded frame supplied SqNum; metadata provenance is inconsistent"); - } - - if (frame.OptFldsEntryId == true) - { - if (state.EntryIdAdvertisedFrames < long.MaxValue) state.EntryIdAdvertisedFrames++; - if (!entryIdPresent && state.EntryIdAdvertisedEmptyFrames < long.MaxValue) - state.EntryIdAdvertisedEmptyFrames++; - } - else - { - if (state.EntryIdOmittedFrames < long.MaxValue) state.EntryIdOmittedFrames++; - if (entryIdPresent) - Warn("Report OptFlds omits EntryID, but decoded frame supplied EntryID; metadata provenance is inconsistent"); - } - - if (frame.OptFldsBufferOverflow == false && frame.BufferOverflow.HasValue) - Warn("Report OptFlds omits BufOvfl, but decoded frame supplied it; metadata provenance is inconsistent"); - if (frame.OptFldsConfRev == false && frame.ConfRev.HasValue) - Warn("Report OptFlds omits ConfRev, but decoded frame supplied it; metadata provenance is inconsistent"); - } - else if (state.OptFldsUnknownFrames < long.MaxValue) - { - state.OptFldsUnknownFrames++; - } - - if (frame.BufferOverflow == true) - { - if (state.BufferOverflows < long.MaxValue) state.BufferOverflows++; - 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) - { - if (state.ConfRevChanges < long.MaxValue) state.ConfRevChanges++; - 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) - { - CaptureAnomaly(state, frame); - RecordSequenceCorrelation(); - Warn(discontinuity + EntryContext() + CausalContext()); - } - } - - 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) - { - CaptureAnomaly(state, frame); - RecordSequenceCorrelation(); - Warn(discontinuity + EntryContext() + CausalContext()); - } - 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; - state.LastEntryIdProvenByOptFlds = hasOptFlds && frame.OptFldsEntryId == true; - } - else - { - // Missing current EntryID invalidates comparisons with future frames; - // never compare across an opaque, unobserved resume cursor. - state.LastEntryIdHex = string.Empty; - state.LastEntryIdProvenByOptFlds = false; - } - - if (buffered) - { - // Observational classification only. Both OptFlds must prove the - // opaque cursor to label successive values as changed or repeated. - var comparableEntryId = priorEntryIdPresent && entryIdPresent && - priorEntryIdProven && hasOptFlds && frame.OptFldsEntryId == true; - var relation = !entryIdPresent ? "absent" : - !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; - var evidence = new Iec61850BrcbChronologyFrame - { - FrameOrdinal = state.FramesSeen, - ReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt, - SequenceNumber = frame.SequenceNumber, - GiReason = frame.GeneralInterrogationReasonSeen, - BufferOverflow = frame.BufferOverflow, - EntryIdRelation = relation, - FindingCount = findings?.Count ?? 0 - }; - RecordChronology(state, evidence); - if (evidence.FindingCount > 0) - RecordAnomalyChronology(state, evidence); - } - - if (findings is not null) - { - if (state.AnomalousFrames < long.MaxValue) - state.AnomalousFrames++; - state.FramesAfterLastFinding = 0; - // Missing decoder timestamps remain unknown; do not reuse older time. - state.LastFindingReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; - } - else if (state.AnomalousFrames > 0 && state.FramesAfterLastFinding < long.MaxValue) - { - // Receive-order observation only; no claim of event delivery completeness. - state.FramesAfterLastFinding++; - } - return findings is null ? Array.Empty() : findings; - } - - internal static Iec61850ReportContinuitySnapshot Snapshot( - IReadOnlyDictionary streams, - long processUpdatesSeen, - int untrackedStreamCount, - int maxShown = 12) - { - ArgumentNullException.ThrowIfNull(streams); - var ordered = streams.Values - .OrderBy(state => state.ReportControlReference, StringComparer.OrdinalIgnoreCase) - .ThenBy(state => state.ReportId, StringComparer.OrdinalIgnoreCase) - .ThenBy(state => state.DataSetReference, StringComparer.OrdinalIgnoreCase) - .ToArray(); - var shown = ordered.Take(Math.Clamp(maxShown, 0, 32)).Select(state => - new Iec61850ReportContinuityStreamSnapshot - { - Rcb = state.ReportControlReference, - DataSet = state.DataSetReference, - ReportId = state.ReportId, - Buffered = state.Buffered, - Frames = state.FramesSeen, - Sequenced = state.SequencedFrames, - SequenceMissing = state.MissingSequenceFrames, - Segmented = state.SegmentedFrames, - Findings = state.AnomalyFindings, - AnomalousFrames = state.AnomalousFrames, - FramesAfterLastFinding = state.FramesAfterLastFinding, - FirstFrameReceivedAtUtc = state.FirstFrameReceivedAtUtc, - LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, - LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, - Chronology = CopyChronology(state), - RecentAnomalies = CopyAnomalyChronology(state), - ChronologyInitialCount = state.InitialChronologyCount, - ChronologyRecentCount = state.RecentChronologyCount, - ChronologyOmittedFrames = state.Buffered - ? Math.Max(0, state.FramesSeen - state.InitialChronologyCount - state.RecentChronologyCount) - : 0, - SequenceDiscontinuities = state.SequenceDiscontinuities, - SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, - SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, - SequenceWithoutComparableEntryId = state.SequenceWithoutComparableEntryId, - SequenceWithGiReason = state.SequenceWithGiReason, - SequenceWithOverflow = state.SequenceWithOverflow, - Overflow = state.BufferOverflows, - ConfRevChanges = state.ConfRevChanges, - EntryIdPresent = state.EntryIdPresentFrames, - FirstSqNum = state.FirstSequenceNumber, - LastSqNum = state.LastSequenceNumber, - OptFldsDecoded = state.OptFldsDecodedFrames, - OptFldsUnknown = state.OptFldsUnknownFrames, - SqNumAdvertised = state.SqNumAdvertisedFrames, - SqNumOmitted = state.SqNumOmittedFrames, - SqNumAdvertisedMissing = state.SqNumAdvertisedMissingFrames, - EntryIdAdvertised = state.EntryIdAdvertisedFrames, - EntryIdOmitted = state.EntryIdOmittedFrames, - EntryIdEmptyOrUnprojected = state.EntryIdAdvertisedEmptyFrames, - LastOptFldsHex = state.LastOptFldsHex, - RptEnaWriteAccepted = state.RptEnaWriteAccepted, - GiWriteAccepted = state.GiWriteAccepted, - ActivationReturnedAtUtc = state.ActivationReturnedAtUtc, - GiReasonFrames = state.GiReasonFrames, - IntegrityReasonFrames = state.IntegrityReasonFrames, - ReasonUnavailableFrames = state.ReasonUnavailableFrames, - LastAnomalyPriorSqNum = state.LastAnomalyPriorSqNum, - LastAnomalyCurrentSqNum = state.LastAnomalyCurrentSqNum, - LastAnomalyGiReason = state.LastAnomalyGiReason, - LastAnomalyReceivedAtUtc = state.LastAnomalyReceivedAtUtc - }).ToArray(); - return new Iec61850ReportContinuitySnapshot - { - ProcessUpdatesSeen = processUpdatesSeen, - StreamCount = ordered.Length, - UntrackedStreamCount = untrackedStreamCount, - Frames = ordered.Sum(state => state.FramesSeen), - Sequenced = ordered.Sum(state => state.SequencedFrames), - Findings = ordered.Sum(state => state.AnomalyFindings), - AnomalousFrames = ordered.Sum(state => state.AnomalousFrames), - SequenceDiscontinuities = ordered.Sum(state => state.SequenceDiscontinuities), - Overflows = ordered.Sum(state => state.BufferOverflows), - OptFldsDecoded = ordered.Sum(state => state.OptFldsDecodedFrames), - OptFldsUnknown = ordered.Sum(state => state.OptFldsUnknownFrames), - Streams = shown - }; - } - - private static void RecordChronology( - Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) - { - if (state.InitialChronologyCount < InitialChronologyLimit) - { - var initial = state.InitialChronology ??= new Iec61850BrcbChronologyFrame[InitialChronologyLimit]; - initial[state.InitialChronologyCount++] = item; - } - else - { - var recent = state.RecentChronology ??= new Iec61850BrcbChronologyFrame[RecentChronologyLimit]; - recent[state.RecentChronologyNext] = item; - state.RecentChronologyNext = (state.RecentChronologyNext + 1) % RecentChronologyLimit; - if (state.RecentChronologyCount < RecentChronologyLimit) - state.RecentChronologyCount++; - } - } - - private static void RecordAnomalyChronology( - Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) - { - var ring = state.AnomalyChronology ??= - new Iec61850BrcbChronologyFrame[AnomalyChronologyLimit]; - ring[state.AnomalyChronologyNext] = item; - state.AnomalyChronologyNext = - (state.AnomalyChronologyNext + 1) % AnomalyChronologyLimit; - if (state.AnomalyChronologyCount < AnomalyChronologyLimit) - state.AnomalyChronologyCount++; - } - - private static IReadOnlyList CopyAnomalyChronology( - Iec61850ReportContinuityState state) - { - var count = state.AnomalyChronologyCount; - if (count == 0) - return Array.Empty(); - var copy = new Iec61850BrcbChronologyFrame[count]; - for (var index = 0; index < count; index++) - { - var source = (state.AnomalyChronologyNext - count + index + - AnomalyChronologyLimit) % AnomalyChronologyLimit; - copy[index] = state.AnomalyChronology![source]; - } - return copy; - } - - private static IReadOnlyList CopyChronology( - Iec61850ReportContinuityState state) - { - var initialCount = state.InitialChronologyCount; - var recentCount = state.RecentChronologyCount; - if (initialCount + recentCount == 0) - return Array.Empty(); - - var copy = new Iec61850BrcbChronologyFrame[initialCount + recentCount]; - if (initialCount > 0) - Array.Copy(state.InitialChronology!, 0, copy, 0, initialCount); - for (var index = 0; index < recentCount; index++) - { - var source = (state.RecentChronologyNext - recentCount + index + RecentChronologyLimit) - % RecentChronologyLimit; - copy[initialCount + index] = state.RecentChronology![source]; - } - return copy; - } - - private static string WriteVerdict(bool? value) - => value is true ? "accepted/yes" : value is false ? "rejected/no" : "UNKNOWN"; - - private static void CaptureAnomaly( - Iec61850ReportContinuityState state, NativeReportFrameMetadata frame) - { - state.LastAnomalyPriorSqNum = state.LastSequenceNumber; - state.LastAnomalyCurrentSqNum = frame.SequenceNumber; - state.LastAnomalyGiReason = frame.GeneralInterrogationReasonSeen; - state.LastAnomalyReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; - } - - private static bool IsBoundedHex(string? value) - { - if (string.IsNullOrEmpty(value) || value.Length > 16 || (value.Length & 1) != 0) - return false; - foreach (var character in value) - { - if (!((character >= '0' && character <= '9') || - (character >= 'a' && character <= 'f') || - (character >= 'A' && character <= 'F'))) - return false; - } - return true; - } - - 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"; - } -} -, '.') - .Replace("..", "."); - - /// - /// 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); - - if (state.FramesSeen < long.MaxValue) state.FramesSeen++; - if (frame.ReceivedAt != default) - { - state.FirstFrameReceivedAtUtc ??= frame.ReceivedAt; - state.LastFrameReceivedAtUtc = frame.ReceivedAt; - } - if (frame.SequenceNumber.HasValue) - { - if (state.SequencedFrames < long.MaxValue) state.SequencedFrames++; - if (frame.SequenceNumber.Value <= MaxSequenceNumber) - state.FirstSequenceNumber ??= frame.SequenceNumber; - } - else if (state.MissingSequenceFrames < long.MaxValue) state.MissingSequenceFrames++; - if (frame.SubSequenceNumber.HasValue && state.SegmentedFrames < long.MaxValue) - state.SegmentedFrames++; - if (!string.IsNullOrEmpty(frame.EntryIdHex) && state.EntryIdPresentFrames < long.MaxValue) - state.EntryIdPresentFrames++; - if (frame.GeneralInterrogationReasonSeen == true && state.GiReasonFrames < long.MaxValue) - state.GiReasonFrames++; - if (frame.IntegrityReasonSeen == true && state.IntegrityReasonFrames < long.MaxValue) - state.IntegrityReasonFrames++; - if (!frame.GeneralInterrogationReasonSeen.HasValue && - !frame.IntegrityReasonSeen.HasValue && - state.ReasonUnavailableFrames < long.MaxValue) - state.ReasonUnavailableFrames++; - - List? findings = null; - void Warn(string message) - { - (findings ??= new List(2)).Add(message); - if (state.AnomalyFindings < long.MaxValue) state.AnomalyFindings++; - } - - var priorEntryIdPresent = !string.IsNullOrEmpty(state.LastEntryIdHex); - var priorEntryIdProven = state.LastEntryIdProvenByOptFlds; - 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; - // This context is observational: a successful GI=true write or GI - // inclusion reason may correlate with SqNum reset but can never - // certify replay correctness / absence of lost buffered entries. - string CausalContext() => - $"; activation RptEna={WriteVerdict(state.RptEnaWriteAccepted)}, " + - $"GIwrite={WriteVerdict(state.GiWriteAccepted)}, " + - $"GIreason={WriteVerdict(frame.GeneralInterrogationReasonSeen)}, " + - $"startReturnUtc={state.ActivationReturnedAtUtc?.ToString("O") ?? "unknown"}, " + - $"frameReceivedUtc={frame.ReceivedAt:O}; GI correlation is not proof of a harmless reset"; - - // One causal classification per actually observed SqNum discontinuity. - // EntryID is an opaque octet string, never a numeric continuity proof. - void RecordSequenceCorrelation() - { - if (state.SequenceDiscontinuities < long.MaxValue) - state.SequenceDiscontinuities++; - if (buffered && priorEntryIdPresent && entryIdPresent && - state.LastEntryIdProvenByOptFlds && frame.OptFldsEntryId == true && - IsBoundedHex(frame.OptFldsRawHex)) - { - if (entryIdChanged) - { - if (state.SequenceWithChangedEntryId < long.MaxValue) - state.SequenceWithChangedEntryId++; - } - else if (state.SequenceWithRepeatedEntryId < long.MaxValue) - state.SequenceWithRepeatedEntryId++; - } - else if (state.SequenceWithoutComparableEntryId < long.MaxValue) - state.SequenceWithoutComparableEntryId++; - - if (frame.GeneralInterrogationReasonSeen == true && - state.SequenceWithGiReason < long.MaxValue) - state.SequenceWithGiReason++; - if (frame.BufferOverflow == true && state.SequenceWithOverflow < long.MaxValue) - state.SequenceWithOverflow++; - } - - // Raw OptFlds is authoritative decoder evidence. Unknown stays UNKNOWN; - // a missing value cannot be interpreted as an omitted wire field. - var hasOptFlds = frame.OptFldsSequenceNumber.HasValue && - frame.OptFldsEntryId.HasValue && - IsBoundedHex(frame.OptFldsRawHex); - if (hasOptFlds) - { - if (state.OptFldsDecodedFrames < long.MaxValue) state.OptFldsDecodedFrames++; - state.LastOptFldsHex = frame.OptFldsRawHex; - if (frame.OptFldsSequenceNumber == true) - { - if (state.SqNumAdvertisedFrames < long.MaxValue) state.SqNumAdvertisedFrames++; - if (!frame.SequenceNumber.HasValue) - { - if (state.SqNumAdvertisedMissingFrames < long.MaxValue) - state.SqNumAdvertisedMissingFrames++; - Warn("Report OptFlds advertises SqNum, but ARIEC decoded no SqNum; inspect decoder and on-wire field completeness"); - } - } - else - { - if (state.SqNumOmittedFrames < long.MaxValue) state.SqNumOmittedFrames++; - if (frame.SequenceNumber.HasValue) - Warn("Report OptFlds omits SqNum, but decoded frame supplied SqNum; metadata provenance is inconsistent"); - } - - if (frame.OptFldsEntryId == true) - { - if (state.EntryIdAdvertisedFrames < long.MaxValue) state.EntryIdAdvertisedFrames++; - if (!entryIdPresent && state.EntryIdAdvertisedEmptyFrames < long.MaxValue) - state.EntryIdAdvertisedEmptyFrames++; - } - else - { - if (state.EntryIdOmittedFrames < long.MaxValue) state.EntryIdOmittedFrames++; - if (entryIdPresent) - Warn("Report OptFlds omits EntryID, but decoded frame supplied EntryID; metadata provenance is inconsistent"); - } - - if (frame.OptFldsBufferOverflow == false && frame.BufferOverflow.HasValue) - Warn("Report OptFlds omits BufOvfl, but decoded frame supplied it; metadata provenance is inconsistent"); - if (frame.OptFldsConfRev == false && frame.ConfRev.HasValue) - Warn("Report OptFlds omits ConfRev, but decoded frame supplied it; metadata provenance is inconsistent"); - } - else if (state.OptFldsUnknownFrames < long.MaxValue) - { - state.OptFldsUnknownFrames++; - } - - if (frame.BufferOverflow == true) - { - if (state.BufferOverflows < long.MaxValue) state.BufferOverflows++; - 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) - { - if (state.ConfRevChanges < long.MaxValue) state.ConfRevChanges++; - 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) - { - CaptureAnomaly(state, frame); - RecordSequenceCorrelation(); - Warn(discontinuity + EntryContext() + CausalContext()); - } - } - - 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) - { - CaptureAnomaly(state, frame); - RecordSequenceCorrelation(); - Warn(discontinuity + EntryContext() + CausalContext()); - } - 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; - state.LastEntryIdProvenByOptFlds = hasOptFlds && frame.OptFldsEntryId == true; - } - else - { - // Missing current EntryID invalidates comparisons with future frames; - // never compare across an opaque, unobserved resume cursor. - state.LastEntryIdHex = string.Empty; - state.LastEntryIdProvenByOptFlds = false; - } - - if (buffered) - { - // Observational classification only. Both OptFlds must prove the - // opaque cursor to label successive values as changed or repeated. - var comparableEntryId = priorEntryIdPresent && entryIdPresent && - priorEntryIdProven && hasOptFlds && frame.OptFldsEntryId == true; - var relation = !entryIdPresent ? "absent" : - !comparableEntryId ? "unverified" : entryIdChanged ? "changed" : "repeated"; - var evidence = new Iec61850BrcbChronologyFrame - { - FrameOrdinal = state.FramesSeen, - ReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt, - SequenceNumber = frame.SequenceNumber, - GiReason = frame.GeneralInterrogationReasonSeen, - BufferOverflow = frame.BufferOverflow, - EntryIdRelation = relation, - FindingCount = findings?.Count ?? 0 - }; - RecordChronology(state, evidence); - if (evidence.FindingCount > 0) - RecordAnomalyChronology(state, evidence); - } - - if (findings is not null) - { - if (state.AnomalousFrames < long.MaxValue) - state.AnomalousFrames++; - state.FramesAfterLastFinding = 0; - // Missing decoder timestamps remain unknown; do not reuse older time. - state.LastFindingReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; - } - else if (state.AnomalousFrames > 0 && state.FramesAfterLastFinding < long.MaxValue) - { - // Receive-order observation only; no claim of event delivery completeness. - state.FramesAfterLastFinding++; - } - return findings is null ? Array.Empty() : findings; - } - - internal static Iec61850ReportContinuitySnapshot Snapshot( - IReadOnlyDictionary streams, - long processUpdatesSeen, - int untrackedStreamCount, - int maxShown = 12) - { - ArgumentNullException.ThrowIfNull(streams); - var ordered = streams.Values - .OrderBy(state => state.ReportControlReference, StringComparer.OrdinalIgnoreCase) - .ThenBy(state => state.ReportId, StringComparer.OrdinalIgnoreCase) - .ThenBy(state => state.DataSetReference, StringComparer.OrdinalIgnoreCase) - .ToArray(); - var shown = ordered.Take(Math.Clamp(maxShown, 0, 32)).Select(state => - new Iec61850ReportContinuityStreamSnapshot - { - Rcb = state.ReportControlReference, - DataSet = state.DataSetReference, - ReportId = state.ReportId, - Buffered = state.Buffered, - Frames = state.FramesSeen, - Sequenced = state.SequencedFrames, - SequenceMissing = state.MissingSequenceFrames, - Segmented = state.SegmentedFrames, - Findings = state.AnomalyFindings, - AnomalousFrames = state.AnomalousFrames, - FramesAfterLastFinding = state.FramesAfterLastFinding, - FirstFrameReceivedAtUtc = state.FirstFrameReceivedAtUtc, - LastFrameReceivedAtUtc = state.LastFrameReceivedAtUtc, - LastFindingReceivedAtUtc = state.LastFindingReceivedAtUtc, - Chronology = CopyChronology(state), - RecentAnomalies = CopyAnomalyChronology(state), - ChronologyInitialCount = state.InitialChronologyCount, - ChronologyRecentCount = state.RecentChronologyCount, - ChronologyOmittedFrames = state.Buffered - ? Math.Max(0, state.FramesSeen - state.InitialChronologyCount - state.RecentChronologyCount) - : 0, - SequenceDiscontinuities = state.SequenceDiscontinuities, - SequenceWithChangedEntryId = state.SequenceWithChangedEntryId, - SequenceWithRepeatedEntryId = state.SequenceWithRepeatedEntryId, - SequenceWithoutComparableEntryId = state.SequenceWithoutComparableEntryId, - SequenceWithGiReason = state.SequenceWithGiReason, - SequenceWithOverflow = state.SequenceWithOverflow, - Overflow = state.BufferOverflows, - ConfRevChanges = state.ConfRevChanges, - EntryIdPresent = state.EntryIdPresentFrames, - FirstSqNum = state.FirstSequenceNumber, - LastSqNum = state.LastSequenceNumber, - OptFldsDecoded = state.OptFldsDecodedFrames, - OptFldsUnknown = state.OptFldsUnknownFrames, - SqNumAdvertised = state.SqNumAdvertisedFrames, - SqNumOmitted = state.SqNumOmittedFrames, - SqNumAdvertisedMissing = state.SqNumAdvertisedMissingFrames, - EntryIdAdvertised = state.EntryIdAdvertisedFrames, - EntryIdOmitted = state.EntryIdOmittedFrames, - EntryIdEmptyOrUnprojected = state.EntryIdAdvertisedEmptyFrames, - LastOptFldsHex = state.LastOptFldsHex, - RptEnaWriteAccepted = state.RptEnaWriteAccepted, - GiWriteAccepted = state.GiWriteAccepted, - ActivationReturnedAtUtc = state.ActivationReturnedAtUtc, - GiReasonFrames = state.GiReasonFrames, - IntegrityReasonFrames = state.IntegrityReasonFrames, - ReasonUnavailableFrames = state.ReasonUnavailableFrames, - LastAnomalyPriorSqNum = state.LastAnomalyPriorSqNum, - LastAnomalyCurrentSqNum = state.LastAnomalyCurrentSqNum, - LastAnomalyGiReason = state.LastAnomalyGiReason, - LastAnomalyReceivedAtUtc = state.LastAnomalyReceivedAtUtc - }).ToArray(); - return new Iec61850ReportContinuitySnapshot - { - ProcessUpdatesSeen = processUpdatesSeen, - StreamCount = ordered.Length, - UntrackedStreamCount = untrackedStreamCount, - Frames = ordered.Sum(state => state.FramesSeen), - Sequenced = ordered.Sum(state => state.SequencedFrames), - Findings = ordered.Sum(state => state.AnomalyFindings), - AnomalousFrames = ordered.Sum(state => state.AnomalousFrames), - SequenceDiscontinuities = ordered.Sum(state => state.SequenceDiscontinuities), - Overflows = ordered.Sum(state => state.BufferOverflows), - OptFldsDecoded = ordered.Sum(state => state.OptFldsDecodedFrames), - OptFldsUnknown = ordered.Sum(state => state.OptFldsUnknownFrames), - Streams = shown - }; - } - - private static void RecordChronology( - Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) - { - if (state.InitialChronologyCount < InitialChronologyLimit) - { - var initial = state.InitialChronology ??= new Iec61850BrcbChronologyFrame[InitialChronologyLimit]; - initial[state.InitialChronologyCount++] = item; - } - else - { - var recent = state.RecentChronology ??= new Iec61850BrcbChronologyFrame[RecentChronologyLimit]; - recent[state.RecentChronologyNext] = item; - state.RecentChronologyNext = (state.RecentChronologyNext + 1) % RecentChronologyLimit; - if (state.RecentChronologyCount < RecentChronologyLimit) - state.RecentChronologyCount++; - } - } - - private static void RecordAnomalyChronology( - Iec61850ReportContinuityState state, Iec61850BrcbChronologyFrame item) - { - var ring = state.AnomalyChronology ??= - new Iec61850BrcbChronologyFrame[AnomalyChronologyLimit]; - ring[state.AnomalyChronologyNext] = item; - state.AnomalyChronologyNext = - (state.AnomalyChronologyNext + 1) % AnomalyChronologyLimit; - if (state.AnomalyChronologyCount < AnomalyChronologyLimit) - state.AnomalyChronologyCount++; - } - - private static IReadOnlyList CopyAnomalyChronology( - Iec61850ReportContinuityState state) - { - var count = state.AnomalyChronologyCount; - if (count == 0) - return Array.Empty(); - var copy = new Iec61850BrcbChronologyFrame[count]; - for (var index = 0; index < count; index++) - { - var source = (state.AnomalyChronologyNext - count + index + - AnomalyChronologyLimit) % AnomalyChronologyLimit; - copy[index] = state.AnomalyChronology![source]; - } - return copy; - } - - private static IReadOnlyList CopyChronology( - Iec61850ReportContinuityState state) - { - var initialCount = state.InitialChronologyCount; - var recentCount = state.RecentChronologyCount; - if (initialCount + recentCount == 0) - return Array.Empty(); - - var copy = new Iec61850BrcbChronologyFrame[initialCount + recentCount]; - if (initialCount > 0) - Array.Copy(state.InitialChronology!, 0, copy, 0, initialCount); - for (var index = 0; index < recentCount; index++) - { - var source = (state.RecentChronologyNext - recentCount + index + RecentChronologyLimit) - % RecentChronologyLimit; - copy[initialCount + index] = state.RecentChronology![source]; - } - return copy; - } - - private static string WriteVerdict(bool? value) - => value is true ? "accepted/yes" : value is false ? "rejected/no" : "UNKNOWN"; - - private static void CaptureAnomaly( - Iec61850ReportContinuityState state, NativeReportFrameMetadata frame) - { - state.LastAnomalyPriorSqNum = state.LastSequenceNumber; - state.LastAnomalyCurrentSqNum = frame.SequenceNumber; - state.LastAnomalyGiReason = frame.GeneralInterrogationReasonSeen; - state.LastAnomalyReceivedAtUtc = frame.ReceivedAt == default ? null : frame.ReceivedAt; - } - - private static bool IsBoundedHex(string? value) - { - if (string.IsNullOrEmpty(value) || value.Length > 16 || (value.Length & 1) != 0) - return false; - foreach (var character in value) - { - if (!((character >= '0' && character <= '9') || - (character >= 'a' && character <= 'f') || - (character >= 'A' && character <= 'F'))) - return false; - } - return true; - } - - 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"; - } -} From e50a5a1408a7a27a96c9728b26b4fb49152ef916 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:06:04 +0700 Subject: [PATCH 3/7] fix(p0-f): track continuity per exact RCB and mark missing identity unverified --- Services/Iec61850MonitorRuntime.cs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) 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; From 26a32e1819b1b725ccdd691e691f7f9910e759a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:06:55 +0700 Subject: [PATCH 4/7] test(p0-f): one RCB stream across metadata aliases; drift and missing identity --- .../P0FReportStreamIdentityTests.cs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs diff --git a/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs new file mode 100644 index 000000000..13a9e3983 --- /dev/null +++ b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs @@ -0,0 +1,107 @@ +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, decoded with { 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); + } +} From 27a01ceddf68638235a208594dc79a39fb9d4914 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:07:51 +0700 Subject: [PATCH 5/7] test(p0-f): create synthetic class frames without record copy assumption --- tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs index 13a9e3983..e9115c4e4 100644 --- a/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs +++ b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs @@ -46,7 +46,14 @@ public void SameConcreteRcb_DoesNotSplitWhenRptIdIsMissingOrDataSetUsesMmsSepara for (var i = 0; i < 4; i++) Iec61850ReportContinuityInspector.Observe(state, metadataOnly, buffered: true); for (ulong sq = 1; sq <= 12; sq++) - Iec61850ReportContinuityInspector.Observe(state, decoded with { SequenceNumber = sq }, buffered: true); + 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 }, From 2ed051931d0337fc7d5ad8013315400daec589a4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:08:39 +0700 Subject: [PATCH 6/7] test(p0-f): import typed report metadata from consumer models --- tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs index e9115c4e4..9649309cc 100644 --- a/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs +++ b/tests/ARSAS.Tests/P0FReportStreamIdentityTests.cs @@ -1,3 +1,4 @@ +using ArIED61850Tester.Models; using ArIED61850Tester.Services; namespace ARSAS.Tests; From 1d2ccfd6d3928d33fba2f68966c0f6eae3410a6c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:09:55 +0700 Subject: [PATCH 7/7] ci(p0-f): run R7 exact-head regression for report continuity changes --- .github/workflows/scl-interoperability-r7.yml | 3 +++ 1 file changed, 3 insertions(+) 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"