From bba1953c969933d4ce1ac01d8f087a842de981b8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:23:19 +0700 Subject: [PATCH 01/79] perf(sv): add bounded amortized O(1) jitter ring statistics --- .../Analysis/RollingJitterWindow.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 src/ProcessBus.Iec61850.Raw/Analysis/RollingJitterWindow.cs diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RollingJitterWindow.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RollingJitterWindow.cs new file mode 100644 index 00000000..5d9422a4 --- /dev/null +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RollingJitterWindow.cs @@ -0,0 +1,80 @@ +namespace ProcessBus.Iec61850.Raw.Analysis; + +/// +/// Bounded, allocation-free-after-construction rolling jitter statistics. +/// Maintains an amortized O(1) maximum with a monotonically decreasing ring deque. +/// One instance belongs to exactly one SV stream; callers synchronize ownership. +/// +public sealed class RollingJitterWindow +{ + private readonly double[] _values; + private readonly MaximumEntry[] _maxima; + private int _head; + private int _count; + private int _maximumHead; + private int _maximumCount; + private long _sequence; + private double _sum; + + private readonly record struct MaximumEntry(long Sequence, double Value); + + public RollingJitterWindow(int capacity) + { + if (capacity <= 0) + throw new ArgumentOutOfRangeException(nameof(capacity)); + _values = new double[capacity]; + _maxima = new MaximumEntry[capacity]; + } + + public int Count => _count; + public double? Average => _count == 0 ? null : _sum / _count; + public double? Maximum => _maximumCount == 0 ? null : _maxima[_maximumHead].Value; + + public void Add(double absoluteMicroseconds) + { + if (!double.IsFinite(absoluteMicroseconds) || absoluteMicroseconds < 0) + throw new ArgumentOutOfRangeException(nameof(absoluteMicroseconds)); + + if (_count == _values.Length) + { + _sum -= _values[_head]; + _head = (_head + 1) % _values.Length; + _count--; + } + + _values[(_head + _count) % _values.Length] = absoluteMicroseconds; + _count++; + _sum += absoluteMicroseconds; + var sequence = _sequence++; + var oldestSequence = _sequence - _count; + + // Expire first: the candidate deque must never exceed the fixed capacity. + while (_maximumCount > 0 && _maxima[_maximumHead].Sequence < oldestSequence) + { + _maximumHead = (_maximumHead + 1) % _maxima.Length; + _maximumCount--; + } + + while (_maximumCount > 0) + { + var last = (_maximumHead + _maximumCount - 1) % _maxima.Length; + if (_maxima[last].Value > absoluteMicroseconds) + break; + _maximumCount--; + } + + _maxima[(_maximumHead + _maximumCount) % _maxima.Length] = + new MaximumEntry(sequence, absoluteMicroseconds); + _maximumCount++; + } + + public void Clear() + { + _head = 0; + _count = 0; + _maximumHead = 0; + _maximumCount = 0; + _sequence = 0; + _sum = 0; + } +} From f7f1331c9bf7b9005a82f410d3be32384d495622 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:24:34 +0700 Subject: [PATCH 02/79] perf(sv): eliminate O(window) per-sample jitter scans and cache SCL matching per stream --- .../Analysis/RawProcessBusAnalyzer.cs | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index d256329c..1551f24f 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -17,6 +17,9 @@ public sealed class RawProcessBusAnalyzer private readonly Dictionary _svStreams = new(StringComparer.OrdinalIgnoreCase); // Per-stream operator evidence, never inferred from svID, APPID or payload count. private readonly Dictionary _svScales = new(StringComparer.OrdinalIgnoreCase); + // Mapping eligibility is stable until the operator reloads SCL/mapping context. + // Avoid scoring + LINQ allocations for every 4,000/4,800 Hz ASDU. + private readonly Dictionary _resolvedSvMappings = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _gooseStates = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _aggregatedEvents = new(StringComparer.OrdinalIgnoreCase); private readonly PtpHealthTracker _ptpTracker = new(); @@ -144,6 +147,7 @@ public void Reset() { _svStreams.Clear(); _svScales.Clear(); + _resolvedSvMappings.Clear(); _gooseStates.Clear(); _events.Clear(); _aggregatedEvents.Clear(); @@ -206,6 +210,7 @@ public void SetSvChannelMappings(IReadOnlyList profiles lock (_gate) { var nextMappings = profiles.Where(x => x.HasRenderableChannels).ToArray(); + _resolvedSvMappings.Clear(); var nextSignature = BuildMappingSetSignature(nextMappings); if (string.Equals(_svChannelMappingSetSignature, nextSignature, StringComparison.Ordinal)) { @@ -246,7 +251,12 @@ private void ObserveSampledValues(SampledValuePacket packet) AddEvent("Info", $"Raw SV stream detected: {DescribeSv(packet.Frame, asdu)}."); } - state.Observe(packet.Frame, asdu, ResolveSvMappingProfile(packet.Frame, asdu)); + if (!_resolvedSvMappings.TryGetValue(key, out var mapping)) + { + mapping = ResolveSvMappingProfile(packet.Frame, asdu); + _resolvedSvMappings[key] = mapping; + } + state.Observe(packet.Frame, asdu, mapping); if (state.ConsumeJitterEvidence() is { } jitterEvidence) { AddAggregatedEvent( @@ -804,7 +814,7 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali ("Un", 14) ]; - private readonly Queue _absJitterWindow = new(); + private readonly RollingJitterWindow _jitterWindow = new(JitterWindowCapacity); private readonly Queue _recentJitterExcursions = new(); private readonly Queue _recentPacketTimes = new(); private readonly Queue _scopeFrames = new(WaveformSampleCapacity + 8); @@ -816,7 +826,6 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali private ushort _maxSeenScopeSampleCounter; private WaveformSnapshot? _lastScopeWaveform; private AnalogValuesSnapshot? _lastAnalogValues; - private double _absJitterWindowTotalMicroseconds; private double _totalDeltaMicroseconds; private long _deltaCount; private ushort? _previousSmpCnt; @@ -1643,10 +1652,8 @@ private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort sm var absJitter = Math.Abs(signedJitter); CurrentJitterMicroseconds = signedJitter; AddJitter(absJitter); - AverageAbsJitterMicroseconds = _absJitterWindow.Count == 0 - ? null - : _absJitterWindowTotalMicroseconds / _absJitterWindow.Count; - MaxAbsJitterMicroseconds = _absJitterWindow.Count == 0 ? null : _absJitterWindow.Max(); + AverageAbsJitterMicroseconds = _jitterWindow.Average; + MaxAbsJitterMicroseconds = _jitterWindow.Maximum; if (absJitter >= JitterAlertThresholdMicroseconds) { @@ -1707,11 +1714,7 @@ private static bool SmpCntWrapIsSupportedRate(ushort previous, ushort current, d private void AddJitter(double absJitterUs) { - _absJitterWindow.Enqueue(absJitterUs); - _absJitterWindowTotalMicroseconds += absJitterUs; - - while (_absJitterWindow.Count > JitterWindowCapacity) - _absJitterWindowTotalMicroseconds -= _absJitterWindow.Dequeue(); + _jitterWindow.Add(absJitterUs); } private void TrackRecentJitterExcursion(DateTime nowUtc) From 6a15aa2c253fbba23dee53c7d08e97e27a85fe03 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:24:47 +0700 Subject: [PATCH 03/79] test(sv): verify bounded O(1) jitter rolling max under sustained stream --- .../RollingJitterWindowTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/ProcessBus.Tests/RollingJitterWindowTests.cs diff --git a/tests/ProcessBus.Tests/RollingJitterWindowTests.cs b/tests/ProcessBus.Tests/RollingJitterWindowTests.cs new file mode 100644 index 00000000..a921d265 --- /dev/null +++ b/tests/ProcessBus.Tests/RollingJitterWindowTests.cs @@ -0,0 +1,62 @@ +using ProcessBus.Iec61850.Raw.Analysis; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class RollingJitterWindowTests +{ + [Fact] + public void ExpiredMaximumNeverLeaksAcrossWindow() + { + var stats = new RollingJitterWindow(3); + stats.Add(100); + stats.Add(3); + stats.Add(2); + Assert.Equal(100, stats.Maximum); + stats.Add(1); + Assert.Equal(3, stats.Maximum); + Assert.Equal(2.0, stats.Average); + stats.Add(0); + Assert.Equal(2, stats.Maximum); + Assert.Equal(1.0, stats.Average); + } + + [Fact] + public void StreamingMaximumAndMeanMatchBoundedReference() + { + const int capacity = 80; + var stats = new RollingJitterWindow(capacity); + var reference = new Queue(); + for (var i = 0; i < 30_000; i++) + { + var value = (i % 181) * (i % 3 == 0 ? 1.25 : 0.4); + stats.Add(value); + reference.Enqueue(value); + if (reference.Count > capacity) + reference.Dequeue(); + + if (i % 73 == 0) + { + Assert.Equal(reference.Count, stats.Count); + Assert.Equal(reference.Max(), stats.Maximum); + Assert.Equal(reference.Average(), stats.Average!.Value, 8); + } + } + stats.Clear(); + Assert.Equal(0, stats.Count); + Assert.Null(stats.Maximum); + Assert.Null(stats.Average); + stats.Add(7); + Assert.Equal(7, stats.Maximum); + } + + [Fact] + public void InvalidInputAndCapacityFailClosed() + { + Assert.Throws(() => new RollingJitterWindow(0)); + var stats = new RollingJitterWindow(2); + Assert.Throws(() => stats.Add(double.NaN)); + Assert.Throws(() => stats.Add(-1)); + Assert.Equal(0, stats.Count); + } +} From 70f7f9bbe27efa67ef4169b1678ffcf630107297 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:25:31 +0700 Subject: [PATCH 04/79] feat(sv): classify ARStack 4I4V quality without guessing arbitrary SCL layouts --- .../Sv/FourI4VQuality.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs diff --git a/src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs b/src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs new file mode 100644 index 00000000..fc9369ea --- /dev/null +++ b/src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs @@ -0,0 +1,34 @@ +using System.Buffers.Binary; + +namespace ProcessBus.Iec61850.Raw.Sv; + +/// +/// ARStack/9-2LE-style 4I+4V payload: eight (INT32 instMag, UINT32 q) pairs. +/// Do not apply this decoder to arbitrary SCL data layouts. +/// +public static class FourI4VQuality +{ + public enum MeasurementState { Good, Test, OperatorBlocked, Untrusted, UnknownLayout } + + public static MeasurementState Assess(ReadOnlySpan payload) + { + if (payload.Length != 64) + return MeasurementState.UnknownLayout; + + bool test = false; + bool blocked = false; + for (var offset = 4; offset < payload.Length; offset += 8) + { + var q = BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(offset, 4)); + // Validity and detail-quality bits (validity, overflow, outOfRange, + // badReference, oscillatory, failure, oldData, inconsistent, inaccurate). + if ((q & 0x03FFU) != 0) + return MeasurementState.Untrusted; + test |= (q & (1U << 11)) != 0; + blocked |= (q & (1U << 12)) != 0; + } + + if (blocked) return MeasurementState.OperatorBlocked; + return test ? MeasurementState.Test : MeasurementState.Good; + } +} From 00ef925bd22b1de68dcdb75c34517758b1ff5e96 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:27:06 +0700 Subject: [PATCH 05/79] feat(sv): gate invalid ARStack sample quality, coalesce mapping and defer debug formatting --- .../Analysis/RawProcessBusAnalyzer.cs | 78 +++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 1551f24f..d3f8f413 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -558,7 +558,7 @@ private SvDiagnosticsSnapshot BuildDiagnostics(SvStreamState? selected) JitterStatusText = selected.JitterStatusText, LastPacketTimestampUtc = selected.LastSeenUtc, SmpSynchText = selected.SmpSynchText, - ValidityText = "Raw payload not quality-mapped yet", + ValidityText = selected.QualityStatusText, DecodeStatusText = "Raw SV APDU decoded", PacketRateMeaningText = selected.RecentPacketRatePps.HasValue ? "Raw SV frames observed in a rolling capture window" @@ -818,6 +818,13 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali private readonly Queue _recentJitterExcursions = new(); private readonly Queue _recentPacketTimes = new(); private readonly Queue _scopeFrames = new(WaveformSampleCapacity + 8); + private ReadOnlyMemory _latestSamplePayload; + private SvChannelMappingProfile? _lastDecodedMapping; + private int _lastDecodedElementCount = -1; + private bool _qualityAccepted = true; + private long _cachedWindowIndex = -1; + private int _cachedWindowCycles; + private IReadOnlyList _cachedWindow = Array.Empty(); private readonly Dictionary _channelSamples = CreateChannelBuffers(); private readonly Dictionary _displayAngles = CreateAngleState(); private long _scopeFrameIndex; @@ -895,7 +902,10 @@ public void SetScopeCycles(int cycles) public int DecodedElementCount { get; private set; } public string MappingProfileName { get; private set; } = "Unmapped"; public string MappedChannelNamesText { get; private set; } = "Not mapped yet"; - public string RawValuesText { get; private set; } = "[]"; + // Format debug evidence on snapshot reads, never for every high-rate ASDU. + public string RawValuesText => BuildRawValuesText(DecodeInt32Elements(_latestSamplePayload.Span)); + public long QualityRejectedSamples { get; private set; } + public string QualityStatusText { get; private set; } = "Quality mapping not verified"; public string JitterStatusText { get; private set; } = "Arrival variation pending"; public string SequenceStatusText { get; private set; } = "First sample"; public DateTime LastSeenUtc { get; private set; } @@ -904,6 +914,8 @@ public void SetScopeCycles(int cycles) public void ResetRenderMappingState() { _activeMappingSignature = null; + _lastDecodedMapping = null; + _lastDecodedElementCount = -1; ClearRenderBuffers(); } @@ -1131,10 +1143,48 @@ public WaveformSnapshot ToWaveform() private void ObserveSamples(ReadOnlyMemory samplePayload, ushort? smpCnt, SvChannelMappingProfile? mappingProfile, bool acceptForDisplay) { - var decoded = DecodeInt32Elements(samplePayload.Span); - DecodedElementCount = decoded.Count; - RawValuesText = BuildRawValuesText(decoded); - ApplyMappingSignature(BuildMappingSignature(mappingProfile, decoded.Count)); + _latestSamplePayload = samplePayload; + var payload = samplePayload.Span; + var elementCount = payload.Length / sizeof(int); + DecodedElementCount = elementCount; + + // Never assume an arbitrary 64-byte SCL DataSet is eight instMag/q pairs. + var knownFourI4VLayout = payload.Length == 64 && + (mappingProfile is null || + (mappingProfile.Elements.Count >= 3 && mappingProfile.Elements.All(e => + e.ElementIndex >= 0 && e.ElementIndex < 16 && e.ElementIndex % 2 == 0 && + (e.SignalReference.Contains("instMag", StringComparison.OrdinalIgnoreCase) || + e.TypeText.Contains("instMag", StringComparison.OrdinalIgnoreCase))))); + var quality = knownFourI4VLayout + ? FourI4VQuality.Assess(payload) + : FourI4VQuality.MeasurementState.UnknownLayout; + QualityStatusText = quality switch + { + FourI4VQuality.MeasurementState.Good => "4I+4V quality good", + FourI4VQuality.MeasurementState.Test => "4I+4V TEST data (laboratory stimulus)", + FourI4VQuality.MeasurementState.OperatorBlocked => "4I+4V operator-blocked data", + FourI4VQuality.MeasurementState.Untrusted => "4I+4V quality invalid: measurement withheld", + _ => "Quality mapping not verified" + }; + if (quality == FourI4VQuality.MeasurementState.Untrusted) + { + QualityRejectedSamples++; + if (_qualityAccepted) + { + _qualityAccepted = false; + // Invalidate immediately, rather than leaving apparently valid stale RMS. + ClearRenderBuffers(); + } + return; + } + _qualityAccepted = true; + + if (!ReferenceEquals(_lastDecodedMapping, mappingProfile) || _lastDecodedElementCount != elementCount) + { + ApplyMappingSignature(BuildMappingSignature(mappingProfile, elementCount)); + _lastDecodedMapping = mappingProfile; + _lastDecodedElementCount = elementCount; + } var displayValues = new Dictionary(StringComparer.OrdinalIgnoreCase); if (mappingProfile?.HasRenderableChannels == true) @@ -1144,15 +1194,15 @@ private void ObserveSamples(ReadOnlyMemory samplePayload, ushort? smpCnt, foreach (var element in mappingProfile.Elements) { - if (element.ElementIndex < 0 || element.ElementIndex >= decoded.Count) + if (element.ElementIndex < 0 || element.ElementIndex >= elementCount) continue; if (!_channelSamples.TryGetValue(element.ChannelName, out var samples)) continue; if (acceptForDisplay) { - samples.Add(smpCnt, decoded[element.ElementIndex].Value, ResolveSamplesPerCycle()); - displayValues[element.ChannelName] = decoded[element.ElementIndex].Value; + samples.Add(smpCnt, BinaryPrimitives.ReadInt32BigEndian(payload.Slice(element.ElementIndex * 4, 4)), ResolveSamplesPerCycle()); + displayValues[element.ChannelName] = BinaryPrimitives.ReadInt32BigEndian(payload.Slice(element.ElementIndex * 4, 4)); } sclMapped.Add($"{element.ChannelName}<-element[{element.ElementIndex}]"); @@ -1171,7 +1221,7 @@ private void ObserveSamples(ReadOnlyMemory samplePayload, ushort? smpCnt, } } - if (decoded.Count < 15) + if (elementCount < 15) { MappingProfileName = "Raw SV payload evidence"; MappedChannelNamesText = "Not mapped yet"; @@ -1183,13 +1233,13 @@ private void ObserveSamples(ReadOnlyMemory samplePayload, ushort? smpCnt, foreach (var (channel, elementIndex) in OmicronInstMagQualityMap) { - if (elementIndex >= decoded.Count) + if (elementIndex >= elementCount) continue; if (acceptForDisplay) { - _channelSamples[channel].Add(smpCnt, decoded[elementIndex].Value, ResolveSamplesPerCycle()); - displayValues[channel] = decoded[elementIndex].Value; + _channelSamples[channel].Add(smpCnt, BinaryPrimitives.ReadInt32BigEndian(payload.Slice(elementIndex * 4, 4)), ResolveSamplesPerCycle()); + displayValues[channel] = BinaryPrimitives.ReadInt32BigEndian(payload.Slice(elementIndex * 4, 4)); } mapped.Add(channel); @@ -1248,6 +1298,8 @@ private void ClearScopeAcquisition(bool clearPublishedSnapshot) { _scopeFrames.Clear(); _scopeFrameIndex = 0; + _cachedWindowIndex = -1; + _cachedWindow = Array.Empty(); _lastScopeSampleCounter = null; _maxSeenScopeSampleCounter = 0; if (clearPublishedSnapshot) From d465f6d5217cad13a863458a680c8d9714387b0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:27:53 +0700 Subject: [PATCH 06/79] test(sv): support precise 4I4V wire quality word fixtures --- tests/ProcessBus.Tests/GoldenFrames.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ProcessBus.Tests/GoldenFrames.cs b/tests/ProcessBus.Tests/GoldenFrames.cs index bb8ffab1..e628d39a 100644 --- a/tests/ProcessBus.Tests/GoldenFrames.cs +++ b/tests/ProcessBus.Tests/GoldenFrames.cs @@ -129,14 +129,14 @@ public static byte[] GooseApdu( /// matches the analyzer's raw 4I4V candidate profile, so streams built with it decode to /// scope channels without any SCL mapping. /// - public static byte[] SvApduWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1") + public static byte[] SvApduWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1", uint qualityWord = 0) { var payload = new byte[16 * 4]; for (var element = 0; element < 8; element++) { var value = element < channelValues.Count ? channelValues[element] : 0; BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(element * 8, 4), value); - // quality word (element*8 + 4) stays 0x00000000 = good + BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(element * 8 + 4, 4), qualityWord); } var asdu = Tlv(0x30, @@ -151,9 +151,9 @@ public static byte[] SvApduWithChannelSamples(ushort smpCnt, IReadOnlyList Tlv(0xA2, asdu)); } - public static byte[] SvFrameWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1", ushort appId = 0x4000) + public static byte[] SvFrameWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1", ushort appId = 0x4000, uint qualityWord = 0) { - return EthernetFrame(SvEtherType, ProcessBusPayload(appId, SvApduWithChannelSamples(smpCnt, channelValues, svId))); + return EthernetFrame(SvEtherType, ProcessBusPayload(appId, SvApduWithChannelSamples(smpCnt, channelValues, svId, qualityWord))); } public static byte[] SvFrame(ushort appId = 0x4000, ushort? vlanTci = null) From 59a0d5ce34cc4d0894a008259390409bca2558a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:28:07 +0700 Subject: [PATCH 07/79] test(sv): reject invalid 4I4V quality, recover coherently and label test stimuli --- tests/ProcessBus.Tests/FourI4VQualityTests.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 tests/ProcessBus.Tests/FourI4VQualityTests.cs diff --git a/tests/ProcessBus.Tests/FourI4VQualityTests.cs b/tests/ProcessBus.Tests/FourI4VQualityTests.cs new file mode 100644 index 00000000..ac9febe5 --- /dev/null +++ b/tests/ProcessBus.Tests/FourI4VQualityTests.cs @@ -0,0 +1,64 @@ +using ProcessBus.Iec61850.Raw.Analysis; +using ProcessBus.Iec61850.Raw.Sv; +using Xunit; + +namespace ProcessBus.Tests; + +public class FourI4VQualityTests +{ + [Theory] + [InlineData(0u, FourI4VQuality.MeasurementState.Good)] + [InlineData(1u, FourI4VQuality.MeasurementState.Untrusted)] + [InlineData(3u, FourI4VQuality.MeasurementState.Untrusted)] + [InlineData(1u << 6, FourI4VQuality.MeasurementState.Untrusted)] + [InlineData(1u << 11, FourI4VQuality.MeasurementState.Test)] + [InlineData(1u << 12, FourI4VQuality.MeasurementState.OperatorBlocked)] + public void QualityInterpretationMatchesArstackWireFlags( + uint quality, FourI4VQuality.MeasurementState state) + { + var payload = new byte[64]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(payload.AsSpan(4, 4), quality); + Assert.Equal(state, FourI4VQuality.Assess(payload)); + Assert.Equal(FourI4VQuality.MeasurementState.UnknownLayout, + FourI4VQuality.Assess(new byte[60])); + } + + [Fact] + public void BadQualityInvalidatesMeasurementsUntilNewCoherentWindow() + { + var analyzer = new RawProcessBusAnalyzer(); + var values = new[] { 1000, -500, -500, 0, 8000, -4000, -4000, 0 }; + for (var i = 0; i < 400; i++) + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples((ushort)i, values)); + + var before = analyzer.GetAnalyzerSnapshot(); + Assert.NotNull(before.AnalogValues.Ia.RmsValue); + Assert.NotEmpty(before.Waveform.VoltageSeries); + + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples(400, values, qualityWord: 1)); + var invalid = analyzer.GetAnalyzerSnapshot(); + Assert.Contains("invalid", invalid.Diagnostics.ValidityText, StringComparison.OrdinalIgnoreCase); + Assert.Null(invalid.AnalogValues.Ia.RmsValue); + Assert.Empty(invalid.Waveform.VoltageSeries); + + // Do not cross-stitch the pre-failure window to the new valid samples. + for (var i = 401; i < 1100; i++) + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples((ushort)i, values)); + var recovered = analyzer.GetAnalyzerSnapshot(); + Assert.Equal("4I+4V quality good", recovered.Diagnostics.ValidityText); + Assert.NotNull(recovered.AnalogValues.Ia.RmsValue); + Assert.NotEmpty(recovered.Waveform.VoltageSeries); + } + + [Fact] + public void TestStimulusRemainsVisibleButMustBeExplicitlyLabeled() + { + var analyzer = new RawProcessBusAnalyzer(); + var values = new[] { 1000, -500, -500, 0, 8000, -4000, -4000, 0 }; + for (var i = 0; i < 400; i++) + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples((ushort)i, values, qualityWord: 1u << 11)); + var snap = analyzer.GetAnalyzerSnapshot(); + Assert.Contains("TEST", snap.Diagnostics.ValidityText); + Assert.NotNull(snap.AnalogValues.Ia.RmsValue); + } +} From 01625bf8869228e9e01e4b99dee95ceb9ce16823 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:28:20 +0700 Subject: [PATCH 08/79] perf(snapshot): reuse a single coherent SV window across waveform and RMS projection --- .../Analysis/RawProcessBusAnalyzer.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index d3f8f413..c78e32ec 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -1516,6 +1516,12 @@ private IReadOnlyList BuildCoherentScopeWindow(int samplesPerC // window is visually stretched into a long, slow line. Keep the ARSVIN slot-locking // rule, but publish only when the whole two-cycle slot window is available. Until // then ToWaveform()/ToAnalogValues() hold the last published snapshot. + // Analog/RMS and waveform projections from the same publication generation + // reuse one immutable coherent window. Avoid duplicate frame copies and slot + // reconstruction during the same UI tick, without sacrificing stream ownership. + if (_cachedWindowIndex == _scopeFrameIndex && _cachedWindowCycles == _scopeCycles) + return _cachedWindow; + var frames = _scopeFrames.ToArray(); if (frames.Length == 0) return Array.Empty(); @@ -1549,7 +1555,10 @@ private IReadOnlyList BuildCoherentScopeWindow(int samplesPerC result.Add(frame with { Index = slot }); } - return result.ToArray(); + _cachedWindow = result.ToArray(); + _cachedWindowIndex = _scopeFrameIndex; + _cachedWindowCycles = _scopeCycles; + return _cachedWindow; } private static int ResolveArsvinPointsPerCycle(int samplesPerCycle) From 1b991b71eb34b1d8d5b1bf54e21636ddb168ded0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:29:28 +0700 Subject: [PATCH 09/79] feat(ptp): bound and isolate passive source/domain evidence with truthful freshness --- .../Ptp/PtpHealthTracker.cs | 249 ++++++++++-------- 1 file changed, 144 insertions(+), 105 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Ptp/PtpHealthTracker.cs b/src/ProcessBus.Iec61850.Raw/Ptp/PtpHealthTracker.cs index c1dc6987..43cef242 100644 --- a/src/ProcessBus.Iec61850.Raw/Ptp/PtpHealthTracker.cs +++ b/src/ProcessBus.Iec61850.Raw/Ptp/PtpHealthTracker.cs @@ -1,162 +1,199 @@ namespace ProcessBus.Iec61850.Raw.Ptp; +/// +/// Passive PTP evidence, isolated by domain + sourcePortIdentity + transport. +/// A received Announce is evidence of a source, never evidence of servo LOCKED. +/// The source catalog is bounded to avoid memory growth on noisy/malicious links. +/// public sealed class PtpHealthTracker { private static readonly TimeSpan RateWindow = TimeSpan.FromSeconds(5); - private readonly Queue _syncTimes = new(); - private readonly Queue _announceTimes = new(); - private readonly Queue _followUpTimes = new(); - private readonly Dictionary _messageCounts = new(StringComparer.OrdinalIgnoreCase); + private static readonly TimeSpan AnnounceFreshness = TimeSpan.FromSeconds(6); + private static readonly TimeSpan SyncFreshness = TimeSpan.FromSeconds(2); + private const int MaximumObservedSources = 64; + + private readonly Dictionary _sources = new(StringComparer.Ordinal); private readonly HashSet _transports = new(StringComparer.OrdinalIgnoreCase); private long _totalMessages; private long _grandmasterChangeCount; - private string? _lastGrandmasterIdentity; - private byte? _lastDomainNumber; - private DateTime? _lastMessageUtc; - private DateTime? _lastSyncUtc; - private DateTime? _lastAnnounceUtc; - private DateTime? _lastFollowUpUtc; - private PtpMessage? _lastAnnounce; private string? _pendingEvent; public void Observe(PtpMessage message) { _totalMessages++; - _lastMessageUtc = message.CaptureTimeUtc; - _lastDomainNumber = message.DomainNumber; - - if (!_messageCounts.TryAdd(message.MessageType, 1)) - _messageCounts[message.MessageType]++; - if (!string.IsNullOrWhiteSpace(message.TransportText)) _transports.Add(message.TransportText); - if (message.IsSync) + var key = $"{message.DomainNumber}|{message.SourcePortIdentity}|{message.TransportText}"; + if (!_sources.TryGetValue(key, out var source)) { - _lastSyncUtc = message.CaptureTimeUtc; - Track(_syncTimes, message.CaptureTimeUtc); + if (_sources.Count == MaximumObservedSources) + { + // Preserve currently active sources; remove the least recent identity. + var oldest = _sources.OrderBy(x => x.Value.LastMessageUtc).First().Key; + _sources.Remove(oldest); + } + + source = new SourceState(message.DomainNumber, message.SourcePortIdentity); + _sources.Add(key, source); } - else if (message.IsFollowUp) + + if (message.IsAnnounce && !string.IsNullOrWhiteSpace(message.GrandmasterIdentity) && + !string.IsNullOrWhiteSpace(source.GrandmasterIdentity) && + !string.Equals(source.GrandmasterIdentity, message.GrandmasterIdentity, StringComparison.OrdinalIgnoreCase)) { - _lastFollowUpUtc = message.CaptureTimeUtc; - Track(_followUpTimes, message.CaptureTimeUtc); + _grandmasterChangeCount++; + _pendingEvent = $"PTP grandmaster changed within source {source.Identity}, domain {source.Domain}: " + + $"{source.GrandmasterIdentity} -> {message.GrandmasterIdentity}."; } - else if (message.IsAnnounce) - { - _lastAnnounceUtc = message.CaptureTimeUtc; - _lastAnnounce = message; - Track(_announceTimes, message.CaptureTimeUtc); - if (!string.IsNullOrWhiteSpace(message.GrandmasterIdentity)) - { - if (!string.IsNullOrWhiteSpace(_lastGrandmasterIdentity) && - !string.Equals(_lastGrandmasterIdentity, message.GrandmasterIdentity, StringComparison.OrdinalIgnoreCase)) - { - _grandmasterChangeCount++; - _pendingEvent = $"PTP grandmaster changed: {_lastGrandmasterIdentity} -> {message.GrandmasterIdentity}."; - } - - _lastGrandmasterIdentity = message.GrandmasterIdentity; - } - } + source.Observe(message); } public void Reset() { - _syncTimes.Clear(); - _announceTimes.Clear(); - _followUpTimes.Clear(); - _messageCounts.Clear(); + _sources.Clear(); _transports.Clear(); _totalMessages = 0; _grandmasterChangeCount = 0; - _lastGrandmasterIdentity = null; - _lastDomainNumber = null; - _lastMessageUtc = null; - _lastSyncUtc = null; - _lastAnnounceUtc = null; - _lastFollowUpUtc = null; - _lastAnnounce = null; _pendingEvent = null; } public string? ConsumeEvent() { - var result = _pendingEvent; + var eventText = _pendingEvent; _pendingEvent = null; - return result; + return eventText; } public PtpHealthSnapshot CreateSnapshot(DateTime nowUtc) { - Trim(_syncTimes, nowUtc); - Trim(_announceTimes, nowUtc); - Trim(_followUpTimes, nowUtc); - - var observed = _totalMessages > 0; - var announceAge = _lastAnnounceUtc.HasValue ? nowUtc - _lastAnnounceUtc.Value : (TimeSpan?)null; - var syncAge = _lastSyncUtc.HasValue ? nowUtc - _lastSyncUtc.Value : (TimeSpan?)null; - - var status = !observed - ? "Not observed" - : announceAge is { TotalSeconds: > 6 } - ? "Announce timeout" - : syncAge is { TotalSeconds: > 2 } && _syncTimes.Count > 0 - ? "Sync timeout" - : _announceTimes.Count > 0 || _syncTimes.Count > 0 - ? "Observed" - : "Observed message flow"; - - var gm = _lastGrandmasterIdentity ?? _lastAnnounce?.GrandmasterIdentity ?? "N/A"; - var clockClass = _lastAnnounce?.GrandmasterClockClass; - var clockAccuracy = _lastAnnounce?.GrandmasterClockAccuracy is { } acc ? $"0x{acc:X2}" : "N/A"; - var stepsRemoved = _lastAnnounce?.StepsRemoved; - var profileHint = _lastAnnounce?.ProfileHintText ?? (observed ? "PTP v2 observed" : "PTP not observed"); + // Latest *fresh* Announce takes precedence over unrelated Sync traffic. + // Deterministic tie breaking is important with interleaved sources. + var source = _sources.Values + .OrderByDescending(x => x.LastAnnounceUtc.HasValue && + IsFresh(nowUtc, x.LastAnnounceUtc.Value, AnnounceFreshness)) + .ThenByDescending(x => x.LastAnnounceUtc) + .ThenByDescending(x => x.LastMessageUtc) + .ThenBy(x => x.Identity, StringComparer.Ordinal) + .FirstOrDefault(); + + if (source is null) + { + return new PtpHealthSnapshot + { + Observed = false, + StatusText = "Not observed", + TotalMessages = _totalMessages, + GrandmasterChangeCount = _grandmasterChangeCount, + ProfileHintText = "PTP not observed" + }; + } + + source.Trim(nowUtc); + var announceFresh = source.LastAnnounceUtc.HasValue && + IsFresh(nowUtc, source.LastAnnounceUtc.Value, AnnounceFreshness); + var syncFresh = source.LastSyncUtc.HasValue && + IsFresh(nowUtc, source.LastSyncUtc.Value, SyncFreshness); + var status = source.LastAnnounceUtc.HasValue && !announceFresh + ? "Announce timeout" + : source.LastSyncUtc.HasValue && !syncFresh + ? "Sync timeout" + : announceFresh || syncFresh + ? "Observed" + : "Observed message flow (unverified)"; return new PtpHealthSnapshot { - Observed = observed, + Observed = true, StatusText = status, TotalMessages = _totalMessages, - DomainNumber = _lastDomainNumber, - GrandmasterIdentity = gm, - ClockClass = clockClass, - ClockAccuracyText = clockAccuracy, - StepsRemoved = stepsRemoved, - SyncRatePerSecond = CalculateRate(_syncTimes, nowUtc), - AnnounceRatePerSecond = CalculateRate(_announceTimes, nowUtc), - FollowUpRatePerSecond = CalculateRate(_followUpTimes, nowUtc), - LastMessageUtc = _lastMessageUtc, - LastSyncUtc = _lastSyncUtc, - LastAnnounceUtc = _lastAnnounceUtc, - LastFollowUpUtc = _lastFollowUpUtc, + DomainNumber = source.Domain, + SourcePortIdentity = source.Identity, + ObservedSourceCount = _sources.Count, + GrandmasterIdentity = source.GrandmasterIdentity ?? "N/A", + ClockClass = source.LastAnnounce?.GrandmasterClockClass, + ClockAccuracyText = source.LastAnnounce?.ClockAccuracyText ?? "N/A", + StepsRemoved = source.LastAnnounce?.StepsRemoved, + SyncRatePerSecond = source.Rate(source.SyncTimes), + AnnounceRatePerSecond = source.Rate(source.AnnounceTimes), + FollowUpRatePerSecond = source.Rate(source.FollowUpTimes), + LastMessageUtc = source.LastMessageUtc, + LastSyncUtc = source.LastSyncUtc, + LastAnnounceUtc = source.LastAnnounceUtc, + LastFollowUpUtc = source.LastFollowUpUtc, GrandmasterChangeCount = _grandmasterChangeCount, - ProfileHintText = profileHint, + ProfileHintText = $"{source.LastAnnounce?.ProfileHintText ?? "PTP v2 observed"}; " + + $"{_sources.Count} source(s); no passive clock-lock claim", TransportText = _transports.Count == 0 ? "N/A" : string.Join(", ", _transports.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) }; } - private static void Track(Queue queue, DateTime timestampUtc) - { - queue.Enqueue(timestampUtc); - Trim(queue, timestampUtc); - } + private static bool IsFresh(DateTime now, DateTime sample, TimeSpan duration) => + sample <= now && now - sample <= duration; - private static void Trim(Queue queue, DateTime nowUtc) + private sealed class SourceState { - while (queue.Count > 0 && nowUtc - queue.Peek() > RateWindow) - queue.Dequeue(); - } + public SourceState(byte domain, string identity) + { + Domain = domain; + Identity = identity; + } - private static double? CalculateRate(Queue queue, DateTime nowUtc) - { - if (queue.Count == 0) - return null; + public byte Domain { get; } + public string Identity { get; } + public DateTime LastMessageUtc { get; private set; } + public DateTime? LastSyncUtc { get; private set; } + public DateTime? LastAnnounceUtc { get; private set; } + public DateTime? LastFollowUpUtc { get; private set; } + public string? GrandmasterIdentity { get; private set; } + public PtpMessage? LastAnnounce { get; private set; } + public Queue SyncTimes { get; } = new(); + public Queue AnnounceTimes { get; } = new(); + public Queue FollowUpTimes { get; } = new(); + + public void Observe(PtpMessage message) + { + LastMessageUtc = message.CaptureTimeUtc; + if (message.IsSync) + { + LastSyncUtc = message.CaptureTimeUtc; + SyncTimes.Enqueue(message.CaptureTimeUtc); + Trim(SyncTimes, message.CaptureTimeUtc); + } + else if (message.IsFollowUp) + { + LastFollowUpUtc = message.CaptureTimeUtc; + FollowUpTimes.Enqueue(message.CaptureTimeUtc); + Trim(FollowUpTimes, message.CaptureTimeUtc); + } + else if (message.IsAnnounce) + { + LastAnnounceUtc = message.CaptureTimeUtc; + LastAnnounce = message; + GrandmasterIdentity = message.GrandmasterIdentity; + AnnounceTimes.Enqueue(message.CaptureTimeUtc); + Trim(AnnounceTimes, message.CaptureTimeUtc); + } + } + + public void Trim(DateTime now) + { + Trim(SyncTimes, now); + Trim(AnnounceTimes, now); + Trim(FollowUpTimes, now); + } + + private static void Trim(Queue queue, DateTime now) + { + while (queue.Count > 0 && now - queue.Peek() > RateWindow) + queue.Dequeue(); + } - Trim(queue, nowUtc); - return queue.Count / Math.Max(0.001, RateWindow.TotalSeconds); + public double? Rate(Queue timestamps) => + timestamps.Count == 0 ? null : timestamps.Count / RateWindow.TotalSeconds; } } @@ -166,6 +203,8 @@ public sealed class PtpHealthSnapshot public string StatusText { get; init; } = "Not observed"; public long TotalMessages { get; init; } public byte? DomainNumber { get; init; } + public string SourcePortIdentity { get; init; } = "N/A"; + public int ObservedSourceCount { get; init; } public string GrandmasterIdentity { get; init; } = "N/A"; public byte? ClockClass { get; init; } public string ClockAccuracyText { get; init; } = "N/A"; From f6f53fa3a9317111085bfc4b9c9ad7a27dc52552 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:29:48 +0700 Subject: [PATCH 10/79] test(ptp): isolate interleaved domain sources and expire stale Announce truthfully --- .../ProcessBus.Tests/PtpHealthTrackerTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/ProcessBus.Tests/PtpHealthTrackerTests.cs diff --git a/tests/ProcessBus.Tests/PtpHealthTrackerTests.cs b/tests/ProcessBus.Tests/PtpHealthTrackerTests.cs new file mode 100644 index 00000000..0c62be45 --- /dev/null +++ b/tests/ProcessBus.Tests/PtpHealthTrackerTests.cs @@ -0,0 +1,72 @@ +using ProcessBus.Iec61850.Raw.Ptp; +using Xunit; + +namespace ProcessBus.Tests; + +public class PtpHealthTrackerTests +{ + private static PtpMessage Message( + DateTime when, byte domain, string source, byte type, string? gm = null) => + new() + { + CaptureTimeUtc = when, + DomainNumber = domain, + SourceClockIdentity = source, + SourcePortNumber = 1, + MessageType = type switch { 0x0B => "Announce", 0 => "Sync", _ => "Follow_Up" }, + MessageTypeValue = type, + GrandmasterIdentity = gm, + GrandmasterClockClass = 248, + TransportText = "Ethernet / IEEE 1588" + }; + + [Fact] + public void InterleavedDomainsMustNotBlendGrandmasters() + { + var tracker = new PtpHealthTracker(); + var now = new DateTime(2026, 10, 17, 0, 0, 0, DateTimeKind.Utc); + tracker.Observe(Message(now, 0, "CLOCK-A", 0x0B, "GM-A")); + tracker.Observe(Message(now.AddMilliseconds(100), 1, "CLOCK-B", 0x0B, "GM-B")); + tracker.Observe(Message(now.AddMilliseconds(150), 1, "CLOCK-B", 0)); + var selected = tracker.CreateSnapshot(now.AddMilliseconds(200)); + Assert.Equal(1, selected.DomainNumber); + Assert.Equal("GM-B", selected.GrandmasterIdentity); + Assert.Equal(2, selected.ObservedSourceCount); + Assert.Equal(0, selected.GrandmasterChangeCount); + + tracker.Observe(Message(now.AddMilliseconds(300), 0, "CLOCK-A", 0x0B, "GM-A")); + selected = tracker.CreateSnapshot(now.AddMilliseconds(350)); + Assert.Equal(0, selected.DomainNumber); + Assert.Equal("GM-A", selected.GrandmasterIdentity); + Assert.Equal(0, selected.GrandmasterChangeCount); + + tracker.Observe(Message(now.AddMilliseconds(400), 0, "CLOCK-A", 0x0B, "GM-X")); + Assert.Equal(1, tracker.CreateSnapshot(now.AddMilliseconds(500)).GrandmasterChangeCount); + Assert.Contains("CLOCK-A", tracker.ConsumeEvent()!); + } + + [Fact] + public void OldAnnounceIsObservedButNotActiveOrLocked() + { + var tracker = new PtpHealthTracker(); + var now = new DateTime(2026, 10, 17, 0, 0, 0, DateTimeKind.Utc); + tracker.Observe(Message(now, 0, "CLOCK-A", 0x0B, "GM-A")); + var expired = tracker.CreateSnapshot(now.AddSeconds(9)); + Assert.True(expired.Observed); + Assert.Equal("Announce timeout", expired.StatusText); + Assert.Contains("no passive clock-lock claim", expired.ProfileHintText); + Assert.Null(expired.AnnounceRatePerSecond); + } + + [Fact] + public void SourceCatalogStaysBounded() + { + var tracker = new PtpHealthTracker(); + var now = new DateTime(2026, 10, 17, 0, 0, 0, DateTimeKind.Utc); + for (var i = 0; i < 90; i++) + tracker.Observe(Message(now.AddMilliseconds(i), (byte)(i % 3), $"CLOCK-{i:000}", 0x0B, $"GM-{i:000}")); + var result = tracker.CreateSnapshot(now.AddSeconds(1)); + Assert.Equal(64, result.ObservedSourceCount); + Assert.Equal(90, result.TotalMessages); + } +} From c5bba177b958a46abdd1bc28a7a822918e3134b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:30:04 +0700 Subject: [PATCH 11/79] fix(ptp): preserve transportSpecific and avoid unsupported profile claims --- src/ProcessBus.Iec61850.Raw/Ptp/PtpMessage.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Ptp/PtpMessage.cs b/src/ProcessBus.Iec61850.Raw/Ptp/PtpMessage.cs index 094bc3f1..566748d2 100644 --- a/src/ProcessBus.Iec61850.Raw/Ptp/PtpMessage.cs +++ b/src/ProcessBus.Iec61850.Raw/Ptp/PtpMessage.cs @@ -5,6 +5,7 @@ public sealed class PtpMessage public string MessageType { get; init; } = "Unknown"; public byte MessageTypeValue { get; init; } public byte Version { get; init; } + public byte TransportSpecific { get; init; } public ushort MessageLength { get; init; } public byte DomainNumber { get; init; } public ushort Flags { get; init; } @@ -52,10 +53,8 @@ public string ProfileHintText if (!IsAnnounce) return "PTP v2 message"; - if (GrandmasterClockClass is 6 or 7 or 13 or 14 or 52 or 187 or 248) - return "PTP power-utility profile candidate"; - - return "PTP v2 announce observed"; + // ClockClass alone is insufficient to identify IEC/IEEE 61850-9-3. + return "PTP v2 Announce (power profile unverified)"; } } } From 17166c9508c2f0bad266d8672803ee8728af2c2b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:30:16 +0700 Subject: [PATCH 12/79] fix(ptp): strictly validate PTPv2 lengths/version before accepting source evidence --- src/ProcessBus.Iec61850.Raw/Ptp/PtpPacketParser.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Ptp/PtpPacketParser.cs b/src/ProcessBus.Iec61850.Raw/Ptp/PtpPacketParser.cs index 05af56bb..a83a3e68 100644 --- a/src/ProcessBus.Iec61850.Raw/Ptp/PtpPacketParser.cs +++ b/src/ProcessBus.Iec61850.Raw/Ptp/PtpPacketParser.cs @@ -18,8 +18,11 @@ public static bool TryParse(ProcessBusFrame frame, out PtpMessage message) var payload = frame.Apdu.Span; var messageTypeValue = (byte)(payload[0] & 0x0F); var messageLength = BinaryPrimitives.ReadUInt16BigEndian(payload.Slice(2, 2)); - if (messageLength < CommonHeaderLength || messageLength > payload.Length) - messageLength = (ushort)payload.Length; + // Do not turn a truncated/malformed PTP message into trusted source evidence. + if ((payload[1] & 0x0F) != 2 || messageLength < CommonHeaderLength || + messageLength > payload.Length || + (messageTypeValue == 0x0B && messageLength < AnnounceMinimumLength)) + return false; var sourceClockIdentity = FormatClockIdentity(payload.Slice(20, 8)); var sourcePortNumber = BinaryPrimitives.ReadUInt16BigEndian(payload.Slice(28, 2)); @@ -29,6 +32,7 @@ public static bool TryParse(ProcessBusFrame frame, out PtpMessage message) MessageType = ResolveMessageType(messageTypeValue), MessageTypeValue = messageTypeValue, Version = (byte)(payload[1] & 0x0F), + TransportSpecific = (byte)(payload[0] >> 4), MessageLength = messageLength, DomainNumber = payload[4], Flags = BinaryPrimitives.ReadUInt16BigEndian(payload.Slice(6, 2)), @@ -48,13 +52,14 @@ public static bool TryParse(ProcessBusFrame frame, out PtpMessage message) CaptureTicks = frame.CaptureTicks }; - if (messageTypeValue == 0x0B && payload.Length >= AnnounceMinimumLength) + if (messageTypeValue == 0x0B) { builder = new PtpMessage { MessageType = builder.MessageType, MessageTypeValue = builder.MessageTypeValue, Version = builder.Version, + TransportSpecific = builder.TransportSpecific, MessageLength = builder.MessageLength, DomainNumber = builder.DomainNumber, Flags = builder.Flags, From 634a590bca2d27b990725b7916836125380a4f79 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:30:26 +0700 Subject: [PATCH 13/79] test(ptp): reject truncated Announce and preserve transportSpecific --- .../PtpPacketValidationTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/ProcessBus.Tests/PtpPacketValidationTests.cs diff --git a/tests/ProcessBus.Tests/PtpPacketValidationTests.cs b/tests/ProcessBus.Tests/PtpPacketValidationTests.cs new file mode 100644 index 00000000..fa88cee4 --- /dev/null +++ b/tests/ProcessBus.Tests/PtpPacketValidationTests.cs @@ -0,0 +1,37 @@ +using System.Buffers.Binary; +using ProcessBus.Iec61850.Raw.Protocol; +using ProcessBus.Iec61850.Raw.Ptp; +using Xunit; + +namespace ProcessBus.Tests; + +public class PtpPacketValidationTests +{ + private static byte[] PtpFrame(ushort length, byte version = 2, byte transportSpecific = 0) + { + var message = new byte[64]; + message[0] = (byte)((transportSpecific << 4) | 0x0B); // Announce + message[1] = version; + BinaryPrimitives.WriteUInt16BigEndian(message.AsSpan(2, 2), length); + message[4] = 0; // domain + return GoldenFrames.EthernetFrame(0x88F7, message); + } + + [Fact] + public void PtpAnnounceRequiresValidVersionAndDeclaredMessageLength() + { + Assert.True(ProcessBusFrameParser.TryParse(PtpFrame(64, transportSpecific: 1), out var frame)); + Assert.True(PtpPacketParser.TryParse(frame, out var valid)); + Assert.Equal((byte)1, valid.TransportSpecific); + Assert.Equal((ushort)64, valid.MessageLength); + + Assert.True(ProcessBusFrameParser.TryParse(PtpFrame(80), out var tooLong)); + Assert.False(PtpPacketParser.TryParse(tooLong, out _)); + + Assert.True(ProcessBusFrameParser.TryParse(PtpFrame(34), out var shortAnnounce)); + Assert.False(PtpPacketParser.TryParse(shortAnnounce, out _)); + + Assert.True(ProcessBusFrameParser.TryParse(PtpFrame(64, version: 1), out var oldVersion)); + Assert.False(PtpPacketParser.TryParse(oldVersion, out _)); + } +} From 1dc91b993f7adeba8d1ad0899fd9111ba3948b6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:32:26 +0700 Subject: [PATCH 14/79] perf(sv): stack-local 4I4V channel projection and change-driven mapping labels --- .../Analysis/RawProcessBusAnalyzer.cs | 162 ++++++++++-------- 1 file changed, 93 insertions(+), 69 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index c78e32ec..bf4a31fe 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -822,6 +822,8 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali private SvChannelMappingProfile? _lastDecodedMapping; private int _lastDecodedElementCount = -1; private bool _qualityAccepted = true; + private bool _knownFourI4VMapping; + private bool _lastMappingDisplayAccepted = true; private long _cachedWindowIndex = -1; private int _cachedWindowCycles; private IReadOnlyList _cachedWindow = Array.Empty(); @@ -1148,14 +1150,20 @@ private void ObserveSamples(ReadOnlyMemory samplePayload, ushort? smpCnt, var elementCount = payload.Length / sizeof(int); DecodedElementCount = elementCount; - // Never assume an arbitrary 64-byte SCL DataSet is eight instMag/q pairs. - var knownFourI4VLayout = payload.Length == 64 && - (mappingProfile is null || - (mappingProfile.Elements.Count >= 3 && mappingProfile.Elements.All(e => - e.ElementIndex >= 0 && e.ElementIndex < 16 && e.ElementIndex % 2 == 0 && - (e.SignalReference.Contains("instMag", StringComparison.OrdinalIgnoreCase) || - e.TypeText.Contains("instMag", StringComparison.OrdinalIgnoreCase))))); - var quality = knownFourI4VLayout + var mappingChanged = !ReferenceEquals(_lastDecodedMapping, mappingProfile) || + _lastDecodedElementCount != elementCount; + if (mappingChanged) + { + // Quality semantics are layout-specific. Never infer pair order for an + // arbitrary DataSet merely because it occupies 64 bytes. + _knownFourI4VMapping = mappingProfile is null || + (mappingProfile.Elements.Count >= 3 && mappingProfile.Elements.All(e => + e.ElementIndex >= 0 && e.ElementIndex < 16 && e.ElementIndex % 2 == 0 && + (e.SignalReference.Contains("instMag", StringComparison.OrdinalIgnoreCase) || + e.TypeText.Contains("instMag", StringComparison.OrdinalIgnoreCase)))); + } + + var quality = payload.Length == 64 && _knownFourI4VMapping ? FourI4VQuality.Assess(payload) : FourI4VQuality.MeasurementState.UnknownLayout; QualityStatusText = quality switch @@ -1166,95 +1174,100 @@ private void ObserveSamples(ReadOnlyMemory samplePayload, ushort? smpCnt, FourI4VQuality.MeasurementState.Untrusted => "4I+4V quality invalid: measurement withheld", _ => "Quality mapping not verified" }; + if (quality == FourI4VQuality.MeasurementState.Untrusted) { QualityRejectedSamples++; if (_qualityAccepted) { _qualityAccepted = false; - // Invalidate immediately, rather than leaving apparently valid stale RMS. ClearRenderBuffers(); } return; } _qualityAccepted = true; - if (!ReferenceEquals(_lastDecodedMapping, mappingProfile) || _lastDecodedElementCount != elementCount) + if (mappingChanged) { ApplyMappingSignature(BuildMappingSignature(mappingProfile, elementCount)); _lastDecodedMapping = mappingProfile; _lastDecodedElementCount = elementCount; } - var displayValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // This stack-local value record replaces a per-frame Dictionary + eight + // dynamic entries. Stream ordering and strong identity remain unchanged. + var values = new ScopeSampleValues(); + var samplesPerCycle = acceptForDisplay ? ResolveSamplesPerCycle() : 0; if (mappingProfile?.HasRenderableChannels == true) { - var sclMapped = new List(mappingProfile.Elements.Count); - var sclUsed = new List(mappingProfile.Elements.Count); - + var mappedCount = 0; foreach (var element in mappingProfile.Elements) { - if (element.ElementIndex < 0 || element.ElementIndex >= elementCount) - continue; - if (!_channelSamples.TryGetValue(element.ChannelName, out var samples)) + if (element.ElementIndex < 0 || element.ElementIndex >= elementCount || + !_channelSamples.TryGetValue(element.ChannelName, out var samples)) continue; + mappedCount++; if (acceptForDisplay) { - samples.Add(smpCnt, BinaryPrimitives.ReadInt32BigEndian(payload.Slice(element.ElementIndex * 4, 4)), ResolveSamplesPerCycle()); - displayValues[element.ChannelName] = BinaryPrimitives.ReadInt32BigEndian(payload.Slice(element.ElementIndex * 4, 4)); + var sample = BinaryPrimitives.ReadInt32BigEndian(payload.Slice(element.ElementIndex * 4, 4)); + samples.Add(smpCnt, sample, samplesPerCycle); + values.Set(element.ChannelName, sample); } - - sclMapped.Add($"{element.ChannelName}<-element[{element.ElementIndex}]"); - sclUsed.Add(element.ElementIndex); } - if (sclMapped.Count > 0) + if (mappedCount > 0) { if (acceptForDisplay) - AddScopeFrame(smpCnt, displayValues); + AddScopeFrame(smpCnt, values); - MappingProfileName = $"{mappingProfile.SourceText} / {mappingProfile.ControlBlockReference}"; - var displayGateText = acceptForDisplay ? string.Empty : " | display held by SV sequence gate"; - MappedChannelNamesText = $"{string.Join(", ", sclMapped)} | SCL DataSet {mappingProfile.DataSetReference} | Used elements: {string.Join(", ", sclUsed)}{displayGateText}"; + if (mappingChanged || _lastMappingDisplayAccepted != acceptForDisplay) + { + var eligible = mappingProfile.Elements.Where(e => + e.ElementIndex >= 0 && e.ElementIndex < elementCount && + _channelSamples.ContainsKey(e.ChannelName)).ToArray(); + MappingProfileName = $"{mappingProfile.SourceText} / {mappingProfile.ControlBlockReference}"; + MappedChannelNamesText = $"{string.Join(", ", eligible.Select(e => $"{e.ChannelName}<-element[{e.ElementIndex}]"))} | " + + $"SCL DataSet {mappingProfile.DataSetReference} | Used elements: {string.Join(", ", eligible.Select(e => e.ElementIndex))}" + + (acceptForDisplay ? "" : " | display held by SV sequence gate"); + _lastMappingDisplayAccepted = acceptForDisplay; + } return; } } - if (elementCount < 15) + // Without an authoritative SCL binding, recognize ONLY the exact 64-byte + // eight instMag/q layout, not a truncated or arbitrary larger payload. + if (elementCount != 16) { - MappingProfileName = "Raw SV payload evidence"; - MappedChannelNamesText = "Not mapped yet"; + if (mappingChanged) + { + MappingProfileName = "Raw SV payload evidence"; + MappedChannelNamesText = "Not mapped yet"; + } return; } - var mapped = new List(OmicronInstMagQualityMap.Length); - var used = new List(OmicronInstMagQualityMap.Length); - foreach (var (channel, elementIndex) in OmicronInstMagQualityMap) { - if (elementIndex >= elementCount) + if (!acceptForDisplay) continue; - - if (acceptForDisplay) - { - _channelSamples[channel].Add(smpCnt, BinaryPrimitives.ReadInt32BigEndian(payload.Slice(elementIndex * 4, 4)), ResolveSamplesPerCycle()); - displayValues[channel] = BinaryPrimitives.ReadInt32BigEndian(payload.Slice(elementIndex * 4, 4)); - } - - mapped.Add(channel); - used.Add(elementIndex); + var sample = BinaryPrimitives.ReadInt32BigEndian(payload.Slice(elementIndex * 4, 4)); + _channelSamples[channel].Add(smpCnt, sample, samplesPerCycle); + values.Set(channel, sample); } - if (mapped.Count > 0 && acceptForDisplay) - AddScopeFrame(smpCnt, displayValues); + if (acceptForDisplay) + AddScopeFrame(smpCnt, values); - MappingProfileName = IsOmicronStream(SvId) - ? $"OMICRON raw 4I4V instMag/q profile / {SvId}" - : "Raw 4I4V instMag/q candidate profile"; - MappedChannelNamesText = mapped.Count == 0 - ? "Not mapped yet" - : $"{string.Join(", ", mapped)} | Used elements: {string.Join(", ", used)}"; + if (mappingChanged) + { + MappingProfileName = IsOmicronStream(SvId) + ? $"OMICRON raw 4I4V instMag/q profile / {SvId}" + : "Raw 4I4V instMag/q candidate profile"; + MappedChannelNamesText = "Ia, Ib, Ic, In, Ua, Ub, Uc, Un | Used elements: 0, 2, 4, 6, 8, 10, 12, 14"; + } } private static string BuildMappingSignature(SvChannelMappingProfile? mappingProfile, int decodedElementCount) @@ -1268,7 +1281,7 @@ private static string BuildMappingSignature(SvChannelMappingProfile? mappingProf return $"scl|{mappingProfile.ProfileKey}|{elementSignature}"; } - return decodedElementCount >= 15 + return decodedElementCount == 16 ? "raw|4i4v-instmag-q" : "raw|unmapped"; } @@ -1430,7 +1443,7 @@ private static WaveformShapeAnalysis PickMoreSevereWaveformShape(WaveformShapeAn return lockedShape; } - private void AddScopeFrame(ushort? smpCnt, IReadOnlyDictionary displayValues) + private void AddScopeFrame(ushort? smpCnt, ScopeSampleValues displayValues) { if (displayValues.Count == 0) return; @@ -1486,27 +1499,12 @@ private void AddScopeFrame(ushort? smpCnt, IReadOnlyDictionary d _lastScopeSampleCounter = null; } - _scopeFrames.Enqueue(new ScopeSampleFrame( - Index: _scopeFrameIndex, - SampleCounter: smpCnt, - Ia: TryGetDisplayValue(displayValues, "Ia"), - Ib: TryGetDisplayValue(displayValues, "Ib"), - Ic: TryGetDisplayValue(displayValues, "Ic"), - In: TryGetDisplayValue(displayValues, "In"), - Ua: TryGetDisplayValue(displayValues, "Ua"), - Ub: TryGetDisplayValue(displayValues, "Ub"), - Uc: TryGetDisplayValue(displayValues, "Uc"), - Un: TryGetDisplayValue(displayValues, "Un"))); + _scopeFrames.Enqueue(displayValues.ToFrame(_scopeFrameIndex, smpCnt)); while (_scopeFrames.Count > WaveformSampleCapacity) _scopeFrames.Dequeue(); } - private static double? TryGetDisplayValue(IReadOnlyDictionary values, string channel) - { - return values.TryGetValue(channel, out var value) ? value : null; - } - private IReadOnlyList BuildCoherentScopeWindow(int samplesPerCycle, int visibleSamples) { // ARSVIN's OscilloscopePlot has an X index per WaveformPoint, so a partially-filled @@ -2351,6 +2349,32 @@ private static string ResolveSmpModText(ushort? smpMod) }; } + // Fixed-size struct, no per-frame heap dictionary for the eight logical channels. + private struct ScopeSampleValues + { + public int Count { get; private set; } + public double? Ia, Ib, Ic, In, Ua, Ub, Uc, Un; + public void Set(string channel, double value) + { + switch (channel) + { + case "Ia": Ia = value; break; + case "Ib": Ib = value; break; + case "Ic": Ic = value; break; + case "In": In = value; break; + case "Ua": Ua = value; break; + case "Ub": Ub = value; break; + case "Uc": Uc = value; break; + case "Un": Un = value; break; + default: return; + } + Count++; + } + + public ScopeSampleFrame ToFrame(long index, ushort? count) => + new(index, count, Ia, Ib, Ic, In, Ua, Ub, Uc, Un); + } + private readonly record struct ScopeSampleFrame( long Index, ushort? SampleCounter, From dba8c4e07da4477354333c464213c6a74f09435d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:33:00 +0700 Subject: [PATCH 15/79] fix(test): compare nullable PTP domain using precise byte type --- tests/ProcessBus.Tests/PtpHealthTrackerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ProcessBus.Tests/PtpHealthTrackerTests.cs b/tests/ProcessBus.Tests/PtpHealthTrackerTests.cs index 0c62be45..765f8ebd 100644 --- a/tests/ProcessBus.Tests/PtpHealthTrackerTests.cs +++ b/tests/ProcessBus.Tests/PtpHealthTrackerTests.cs @@ -29,14 +29,14 @@ public void InterleavedDomainsMustNotBlendGrandmasters() tracker.Observe(Message(now.AddMilliseconds(100), 1, "CLOCK-B", 0x0B, "GM-B")); tracker.Observe(Message(now.AddMilliseconds(150), 1, "CLOCK-B", 0)); var selected = tracker.CreateSnapshot(now.AddMilliseconds(200)); - Assert.Equal(1, selected.DomainNumber); + Assert.Equal((byte)1, selected.DomainNumber); Assert.Equal("GM-B", selected.GrandmasterIdentity); Assert.Equal(2, selected.ObservedSourceCount); Assert.Equal(0, selected.GrandmasterChangeCount); tracker.Observe(Message(now.AddMilliseconds(300), 0, "CLOCK-A", 0x0B, "GM-A")); selected = tracker.CreateSnapshot(now.AddMilliseconds(350)); - Assert.Equal(0, selected.DomainNumber); + Assert.Equal((byte)0, selected.DomainNumber); Assert.Equal("GM-A", selected.GrandmasterIdentity); Assert.Equal(0, selected.GrandmasterChangeCount); From c583d07dd3cc2b40c3b0a04d3266a8e3d571d7e1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:33:48 +0700 Subject: [PATCH 16/79] perf(sv): remove unused per-channel counter dictionary and 1024-element scan per sample --- .../Analysis/RawProcessBusAnalyzer.cs | 77 +------------------ 1 file changed, 3 insertions(+), 74 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index bf4a31fe..776a2c5d 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -2434,8 +2434,6 @@ private sealed class RollingChannelSamples private const double RmsAttackAlpha = 0.02; private const double RmsReleaseAlpha = 0.006; private readonly Queue _samples = new(); - private readonly Queue _sampleCounters = new(); - private readonly Dictionary _samplesByCounter = new(); private readonly int _capacity; private double _sumSquares; private double? _displayRms; @@ -2454,25 +2452,12 @@ public void Add(ushort? sampleCounter, double value, int minSamplesForRms) _sumSquares += value * value; LastValue = value; - if (sampleCounter.HasValue) - { - _sampleCounters.Enqueue(sampleCounter.Value); - _samplesByCounter[sampleCounter.Value] = value; - } - while (_samples.Count > _capacity) { var removed = _samples.Dequeue(); _sumSquares -= removed * removed; } - while (_sampleCounters.Count > _capacity) - { - var removedCounter = _sampleCounters.Dequeue(); - if (!_sampleCounters.Contains(removedCounter)) - _samplesByCounter.Remove(removedCounter); - } - UpdateDisplayRms(Math.Max(1, minSamplesForRms)); } @@ -2524,8 +2509,6 @@ public IReadOnlyList GetLatestSamples(int maxSamples) public void Clear() { _samples.Clear(); - _sampleCounters.Clear(); - _samplesByCounter.Clear(); _sumSquares = 0; _displayRms = null; LastValue = null; @@ -2573,63 +2556,9 @@ private static double NormalizeAngleDegrees(double angle) return angle; } - public IReadOnlyList GetScopeWindow(ushort? lastSampleCounter, int samplesPerCycle, int visibleSamples) - { - if (!lastSampleCounter.HasValue || samplesPerCycle <= 0 || visibleSamples <= 0 || _sampleCounters.Count == 0) - return GetTail(visibleSamples); - - // ARSVIN-style phase-locked scope: - // - Do NOT draw a sliding tail as the primary oscilloscope view. A sliding tail moves the - // waveform horizontally at every refresh and looks glitchy even when SV data is stable. - // - Each smpCnt maps to a fixed modulo slot in the visible window, so new samples update - // the same phase position instead of shifting the whole trace. - var slotValues = new double?[visibleSamples]; - var filled = 0; - - foreach (var counter in _sampleCounters) - { - if (!_samplesByCounter.TryGetValue(counter, out var value)) - continue; - - var slot = counter % visibleSamples; - if (!slotValues[slot].HasValue) - filled++; - - slotValues[slot] = value; - } - - // Do not forward-fill missing modulo slots. Forward-fill creates artificial flat - // segments and visible flicker while the UI samples a partially refreshed ring. ARSVIN - // keeps only real decoded SV points in slot order; use a locked window only after it is - // mostly populated, otherwise fall back to the latest contiguous tail. - var minimumUsefulFill = Math.Max(samplesPerCycle * 2, (int)Math.Ceiling(visibleSamples * 0.85)); - if (filled < minimumUsefulFill) - return GetTail(visibleSamples); - - return BuildStableSlotWindow(slotValues); - } - - private static IReadOnlyList BuildStableSlotWindow(IReadOnlyList slotValues) - { - var result = new List(slotValues.Count); - - for (var i = 0; i < slotValues.Count; i++) - { - if (slotValues[i].HasValue) - result.Add(slotValues[i]!.Value); - } - - return result.Count == 0 ? Array.Empty() : result.ToArray(); - } - - private IReadOnlyList GetTail(int visibleSamples) - { - var all = _samples.ToArray(); - if (visibleSamples <= 0 || all.Length <= visibleSamples) - return all; - - return all.Skip(all.Length - visibleSamples).ToArray(); - } + // The active scope is built from the stream-owned monotonic ScopeSampleFrame ring. + // The previous sampleCounter -> value dictionary was no longer consumed, and its + // per-sample Queue.Contains scan caused O(1024) work per channel at full capacity. } private sealed class GooseState From a2ad81fdbb080e6a7d0cbf299d1c5c01f5e7fab5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:35:37 +0700 Subject: [PATCH 17/79] fix(sv): distinguish multi-ASDU frame rate from samples and suppress fabricated arrival jitter --- .../Analysis/RawProcessBusAnalyzer.cs | 107 +++++++++++------- 1 file changed, 66 insertions(+), 41 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 776a2c5d..8d29b57e 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -238,9 +238,14 @@ private void ObserveSampledValues(SampledValuePacket packet) return; } + // Multiple ASDUs share one Ethernet capture timestamp. Track sequence per ASDU, + // but never treat intra-frame sample intervals as observed RX timing. + var multipleAsdus = packet.Asdus.Count > 1; + HashSet? frameStreams = multipleAsdus ? new(StringComparer.OrdinalIgnoreCase) : null; foreach (var asdu in packet.Asdus) { var key = BuildSvKey(packet.Frame, asdu); + var firstForStreamInFrame = frameStreams?.Add(key) ?? true; if (!_svStreams.TryGetValue(key, out var state)) { @@ -256,7 +261,7 @@ private void ObserveSampledValues(SampledValuePacket packet) mapping = ResolveSvMappingProfile(packet.Frame, asdu); _resolvedSvMappings[key] = mapping; } - state.Observe(packet.Frame, asdu, mapping); + state.Observe(packet.Frame, asdu, mapping, firstForStreamInFrame, multipleAsdus); if (state.ConsumeJitterEvidence() is { } jitterEvidence) { AddAggregatedEvent( @@ -541,8 +546,8 @@ private SvDiagnosticsSnapshot BuildDiagnostics(SvStreamState? selected) { IsRunning = true, StreamStatusText = "Raw SV stream active", - PacketRatePps = selected.RecentPacketRatePps ?? selected.PacketCount / elapsedSeconds, - TotalPackets = selected.PacketCount, + PacketRatePps = selected.RecentPacketRatePps ?? selected.FrameCount / elapsedSeconds, + TotalPackets = selected.FrameCount, DecodeErrors = _decodeErrors, SequenceErrors = selected.SequenceErrors, MissingSamples = selected.MissingSamples, @@ -822,6 +827,7 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali private SvChannelMappingProfile? _lastDecodedMapping; private int _lastDecodedElementCount = -1; private bool _qualityAccepted = true; + private bool _previousMultiAsduFrame; private bool _knownFourI4VMapping; private bool _lastMappingDisplayAccepted = true; private long _cachedWindowIndex = -1; @@ -888,7 +894,8 @@ public void SetScopeCycles(int cycles) public ushort? SmpMod { get; private set; } public byte? SmpSynch { get; private set; } public int LastPayloadBytes { get; private set; } - public long PacketCount { get; private set; } + public long PacketCount { get; private set; } // decoded ASDUs + public long FrameCount { get; private set; } // observed Ethernet frames for this SV identity public long SequenceErrors { get; private set; } public long MissingSamples { get; private set; } public double? CurrentDeltaMicroseconds { get; private set; } @@ -921,9 +928,12 @@ public void ResetRenderMappingState() ClearRenderBuffers(); } - public void Observe(ProcessBusFrame frame, SampledValueAsdu asdu, SvChannelMappingProfile? mappingProfile) + public void Observe(ProcessBusFrame frame, SampledValueAsdu asdu, SvChannelMappingProfile? mappingProfile, + bool firstAsduInFrame, bool multipleAsdus) { PacketCount++; + if (firstAsduInFrame) + FrameCount++; SvId = ValueOrFallback(asdu.SvId, "N/A"); DataSet = ValueOrFallback(asdu.DataSet, "N/A"); AppId = FormatAppId(frame.AppId); @@ -939,11 +949,12 @@ public void Observe(ProcessBusFrame frame, SampledValueAsdu asdu, SvChannelMappi SmpSynch = asdu.SmpSynch; LastPayloadBytes = asdu.SamplePayload.Length; LastSeenUtc = frame.CaptureTimeUtc; - TrackRecentPacketRate(frame.CaptureTimeUtc); + if (firstAsduInFrame) + TrackRecentPacketRate(frame.CaptureTimeUtc); var acceptForDisplay = true; if (asdu.SmpCnt.HasValue) - acceptForDisplay = ObserveTiming(frame.CaptureTicks, frame.CaptureTimeUtc, asdu.SmpCnt.Value); + acceptForDisplay = ObserveTiming(frame.CaptureTicks, frame.CaptureTimeUtc, asdu.SmpCnt.Value, multipleAsdus); ObserveSamples(asdu.SamplePayload, asdu.SmpCnt, mappingProfile, acceptForDisplay); } @@ -1670,7 +1681,7 @@ private static IReadOnlyList CreateReconstructedWaveformSer return result; } - private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort smpCnt) + private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort smpCnt, bool multipleAsdus) { TrimRecentJitterExcursions(captureTimeUtc); var acceptForDisplay = true; @@ -1696,45 +1707,58 @@ private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort sm _counterVerifiedSampleRateHz = _previousSmpCnt.Value + 1; } - var actualUs = (captureTicks - _previousCaptureTicks.Value) * 1_000_000.0 / Stopwatch.Frequency; - CurrentDeltaMicroseconds = actualUs; - _totalDeltaMicroseconds += actualUs; - _deltaCount++; - AverageDeltaMicroseconds = _totalDeltaMicroseconds / _deltaCount; - - var expectedUs = ResolveExpectedDeltaUs(sequence.SampleStep); - ExpectedDeltaMicroseconds = expectedUs; - - if (expectedUs.HasValue) + if (!multipleAsdus && !_previousMultiAsduFrame) { - var signedJitter = actualUs - expectedUs.Value; - var absJitter = Math.Abs(signedJitter); - CurrentJitterMicroseconds = signedJitter; - AddJitter(absJitter); - AverageAbsJitterMicroseconds = _jitterWindow.Average; - MaxAbsJitterMicroseconds = _jitterWindow.Maximum; - - if (absJitter >= JitterAlertThresholdMicroseconds) + var actualUs = (captureTicks - _previousCaptureTicks.Value) * 1_000_000.0 / Stopwatch.Frequency; + CurrentDeltaMicroseconds = actualUs; + _totalDeltaMicroseconds += actualUs; + _deltaCount++; + AverageDeltaMicroseconds = _totalDeltaMicroseconds / _deltaCount; + + var expectedUs = ResolveExpectedDeltaUs(sequence.SampleStep); + ExpectedDeltaMicroseconds = expectedUs; + + if (expectedUs.HasValue) { - JitterOver300MicrosecondsCount++; - TrackRecentJitterExcursion(captureTimeUtc); - _pendingJitterEvidence = BuildJitterEvidence( - previousSmpCnt: _previousSmpCnt.Value, - currentSmpCnt: smpCnt, - actualUs: actualUs, - expectedUs: expectedUs.Value, - signedJitterUs: signedJitter, - absJitterUs: absJitter, - sampleStep: sequence.SampleStep); + var signedJitter = actualUs - expectedUs.Value; + var absJitter = Math.Abs(signedJitter); + CurrentJitterMicroseconds = signedJitter; + AddJitter(absJitter); + AverageAbsJitterMicroseconds = _jitterWindow.Average; + MaxAbsJitterMicroseconds = _jitterWindow.Maximum; + + if (absJitter >= JitterAlertThresholdMicroseconds) + { + JitterOver300MicrosecondsCount++; + TrackRecentJitterExcursion(captureTimeUtc); + _pendingJitterEvidence = BuildJitterEvidence( + previousSmpCnt: _previousSmpCnt.Value, + currentSmpCnt: smpCnt, + actualUs: actualUs, + expectedUs: expectedUs.Value, + signedJitterUs: signedJitter, + absJitterUs: absJitter, + sampleStep: sequence.SampleStep); + } + + JitterStatusText = absJitter >= JitterAlertThresholdMicroseconds + ? $"Arrival variation alert: {absJitter:0.#} us >= {JitterAlertThresholdMicroseconds:0} us" + : $"Arrival variation nominal: {absJitter:0.#} us"; + } + else + { + JitterStatusText = "Arrival variation pending: sample rate unknown"; } - - JitterStatusText = absJitter >= JitterAlertThresholdMicroseconds - ? $"Arrival variation alert: {absJitter:0.#} us >= {JitterAlertThresholdMicroseconds:0} us" - : $"Arrival variation nominal: {absJitter:0.#} us"; } else { - JitterStatusText = "Arrival variation pending: sample rate unknown"; + // One RX timestamp represents the entire frame, not each ASDU. + // Fake zero deltas and frame-duration deltas must not pollute + // jitter, latency or current sample-timing diagnostics. + CurrentDeltaMicroseconds = null; + CurrentJitterMicroseconds = null; + ExpectedDeltaMicroseconds = null; + JitterStatusText = "Multi-ASDU: per-sample arrival timing not observable"; } } @@ -1743,6 +1767,7 @@ private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort sm _previousSmpCnt = smpCnt; _previousCaptureTicks = captureTicks; + _previousMultiAsduFrame = multipleAsdus; return acceptForDisplay; } From 798a53371b8f3cb4f90c270ec5e059bc4d61b4c0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:36:05 +0700 Subject: [PATCH 18/79] test(sv): preserve multi-ASDU continuity without false per-sample arrival timing --- .../ProcessBus.Tests/MultiAsduTimingTests.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/ProcessBus.Tests/MultiAsduTimingTests.cs diff --git a/tests/ProcessBus.Tests/MultiAsduTimingTests.cs b/tests/ProcessBus.Tests/MultiAsduTimingTests.cs new file mode 100644 index 00000000..8896c49e --- /dev/null +++ b/tests/ProcessBus.Tests/MultiAsduTimingTests.cs @@ -0,0 +1,53 @@ +using System.Buffers.Binary; +using ProcessBus.Iec61850.Raw.Analysis; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class MultiAsduTimingTests +{ + private static byte[] Frame(ushort first, ushort second) + { + static byte[] Asdu(ushort count) + { + var payload = new byte[64]; + for (var channel = 0; channel < 8; channel++) + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(channel * 8, 4), 1000 + channel); + + return GoldenFrames.Tlv(0x30, + GoldenFrames.Tlv(0x80, GoldenFrames.Ascii("ARSTACK_2ASDU")), + GoldenFrames.Tlv(0x82, GoldenFrames.U16(count)), + GoldenFrames.Tlv(0x83, new byte[] { 1 }), + GoldenFrames.Tlv(0x85, new byte[] { 0 }), + GoldenFrames.Tlv(0x87, payload)); + } + + var sav = GoldenFrames.Tlv(0x60, + GoldenFrames.Tlv(0x80, new byte[] { 2 }), + GoldenFrames.Tlv(0xA2, Asdu(first), Asdu(second))); + return GoldenFrames.EthernetFrame(GoldenFrames.SvEtherType, + GoldenFrames.ProcessBusPayload(0x4000, sav)); + } + + [Fact] + public void TwoAsdusOneFramePreserveSequenceWithoutInventingPerSampleJitter() + { + var analyzer = new RawProcessBusAnalyzer(); + var epoch = new DateTime(2026, 10, 17, 0, 0, 0, DateTimeKind.Utc); + for (var frame = 0; frame < 200; frame++) + { + var sample = frame * 2; + analyzer.ObserveFrame(Frame((ushort)sample, (ushort)(sample + 1)), + epoch.AddTicks(frame * 5000), + (long)(frame * (System.Diagnostics.Stopwatch.Frequency / 2000.0))); + } + + var snapshot = analyzer.GetAnalyzerSnapshot(); + Assert.Equal(200, snapshot.Diagnostics.TotalPackets); // Ethernet frames, not ASDUs + Assert.Equal(0, snapshot.Diagnostics.SequenceErrors); + Assert.Equal(0, snapshot.Diagnostics.MissingSamples); + Assert.Null(snapshot.Diagnostics.CurrentJitterMicroseconds); + Assert.Contains("Multi-ASDU", snapshot.Diagnostics.JitterStatusText); + Assert.NotEmpty(snapshot.Waveform.VoltageSeries); + } +} From 08303a8189caf1d381ad57314fdcc80c15d1c837 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:37:03 +0700 Subject: [PATCH 19/79] perf(ui): coalesce SCL joins and show stale PTP sources without misleading green status --- .../ViewModels/MainWindowViewModel.cs | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 36d98070..6230c066 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -69,6 +69,7 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private int _currentWorkspaceTabIndex; private bool _refreshInFlight; private DateTime _lastRefreshUtc = DateTime.MinValue; + private DateTime _lastSclEvaluationUtc = DateTime.MinValue; private long _lastObservedPacketCount = -1; private DateTime _lastPacketAdvanceUtc = DateTime.MinValue; private DateTime _lastGlobalRaiseUtc = DateTime.MinValue; @@ -2244,18 +2245,23 @@ private void RebuildDiagnosticTargets() } var ptpObserved = Diagnostics.PtpObserved || _state.ProtocolMonitor.PtpFrames > 0; + var ptpStale = ptpObserved && + (Diagnostics.PtpStatusText.Contains("timeout", StringComparison.OrdinalIgnoreCase) || + (Diagnostics.LastPtpMessageTimestampUtc is { } lastPtp && + DateTime.UtcNow - lastPtp > TimeSpan.FromSeconds(6))); rows.Add(new TrafficHealthTargetRow { Protocol = "PTP", TargetKey = "PTP|reference", DisplayName = ptpObserved ? Diagnostics.PtpGrandmasterIdentity : "Timing reference", Subtitle = ptpObserved ? $"{Diagnostics.PtpTransportText} - {PtpDomainText}" : "No PTP observed", - StatusText = ptpObserved ? "OBSERVED" : "WAIT", - StatusBrush = ptpObserved ? "#70D7A7" : "#8FA8BF", - StatusSoftBrush = ptpObserved ? "#173528" : "#1C2A38", - IssueSummaryText = ptpObserved ? PtpRateText : "No PTP packets detected in current capture", + StatusText = ptpStale ? "STALE" : ptpObserved ? "OBSERVED" : "WAIT", + StatusBrush = ptpStale ? "#F0B533" : ptpObserved ? "#70D7A7" : "#8FA8BF", + StatusSoftBrush = ptpStale ? "#3A2B12" : ptpObserved ? "#173528" : "#1C2A38", + IssueSummaryText = ptpStale ? "PTP evidence expired; check source/domain and capture path" : + ptpObserved ? PtpRateText : "No PTP packets detected in current capture", LastSeenUtc = Diagnostics.LastPtpMessageTimestampUtc, - SeverityRank = ptpObserved ? 0 : 1, + SeverityRank = ptpStale || !ptpObserved ? 1 : 0, SourceId = "PTP" }); @@ -2949,6 +2955,13 @@ private static int ParseLnInst(string value) private void RebuildSclBindingMatrix(bool force = false) { + // SCL expected-vs-observed joins can be O(expected * observed) and do not + // belong on every 8 FPS oscilloscope UI tick. Explicit SCL edits bypass the + // coalescing interval; passive evidence updates remain bounded to 500 ms. + var now = DateTime.UtcNow; + if (!force && now - _lastSclEvaluationUtc < TimeSpan.FromMilliseconds(PassiveUiRefreshMs)) + return; + _lastSclEvaluationUtc = now; var previousKey = _selectedSclBindingMatrixRow?.BindingKey; var rows = BuildSclBindingRows(); var signature = string.Join("|", rows.Select(x => x.SignatureText)); From 4080764606548e7f5118357fe275463bf327e700 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:37:21 +0700 Subject: [PATCH 20/79] perf(capture): prevent hot idle spin on immediate-mode empty Npcap reads --- src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs b/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs index 8f01a534..5ffd8cd3 100644 --- a/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs +++ b/src/ProcessBus.Iec61850.Raw/Capture/NpcapRawFrameSource.cs @@ -45,7 +45,12 @@ public async IAsyncEnumerable ReadFramesAsync( yield break; if (status == ReadStatus.Timeout) - await Task.Yield(); + { + // A driver/adapter may return empty immediately even with a configured + // timeout. Task.Yield alone can spin at full CPU while the link is idle. + // The delay is only on empty reads; available frames remain uninterrupted. + await Task.Delay(1, cancellationToken).ConfigureAwait(false); + } } } From 47ff03d4b3c126813417351cb2584b7616a6352a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:40:48 +0700 Subject: [PATCH 21/79] perf(memory): bound SV stream state with safe stale-only eviction and capacity evidence --- .../Analysis/RawProcessBusAnalyzer.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 8d29b57e..8c67a26c 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -12,6 +12,8 @@ namespace ProcessBus.Iec61850.Raw.Analysis; public sealed class RawProcessBusAnalyzer { private static readonly TimeSpan MalformedEventInterval = TimeSpan.FromSeconds(2); + private const int MaximumTrackedSvStreams = 256; + private static readonly TimeSpan StaleStreamRetention = TimeSpan.FromSeconds(10); private static readonly TimeSpan JitterEventInterval = TimeSpan.FromSeconds(5); private readonly object _gate = new(); private readonly Dictionary _svStreams = new(StringComparer.OrdinalIgnoreCase); @@ -249,6 +251,30 @@ private void ObserveSampledValues(SampledValuePacket packet) if (!_svStreams.TryGetValue(key, out var state)) { + if (_svStreams.Count >= MaximumTrackedSvStreams) + { + // Keep active and operator-selected streams. Under identity churn, + // reclaim only stale unselected state rather than leaking indefinitely. + var oldestStale = _svStreams.Values + .Where(candidate => + !string.Equals(candidate.Key, _selectedSvKey, StringComparison.OrdinalIgnoreCase) && + packet.Frame.CaptureTimeUtc - candidate.LastSeenUtc > StaleStreamRetention) + .OrderBy(candidate => candidate.LastSeenUtc) + .FirstOrDefault(); + if (oldestStale is null) + { + AddAggregatedEvent( + "sv.stream-capacity", "Warning", "SV stream capacity reached", + "256 active identities retained; new publisher ignored until stale state expires.", + TimeSpan.FromSeconds(10)); + continue; + } + + _svStreams.Remove(oldestStale.Key); + _resolvedSvMappings.Remove(oldestStale.Key); + _aggregatedEvents.Remove($"sv.jitter.{oldestStale.Key}"); + } + state = new SvStreamState(key, ++_svFirstSeenSequence, _requestedScopeCycles); if (_svScales.TryGetValue(key, out var scale)) state.SetEngineeringScale(scale); From 9f2beb37a1f4ec95dce93322386a6cb4743c4983 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:41:03 +0700 Subject: [PATCH 22/79] test(memory): cap synthetic SV identity churn and preserve operator selection --- .../BoundedStreamCatalogTests.cs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/ProcessBus.Tests/BoundedStreamCatalogTests.cs diff --git a/tests/ProcessBus.Tests/BoundedStreamCatalogTests.cs b/tests/ProcessBus.Tests/BoundedStreamCatalogTests.cs new file mode 100644 index 00000000..07223def --- /dev/null +++ b/tests/ProcessBus.Tests/BoundedStreamCatalogTests.cs @@ -0,0 +1,39 @@ +using ProcessBus.Iec61850.Raw.Analysis; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class BoundedStreamCatalogTests +{ + [Fact] + public void IdentityChurnCannotGrowCatalogForeverOrEvictSelectedActiveStream() + { + var analyzer = new RawProcessBusAnalyzer(); + var epoch = DateTime.UtcNow; + var signals = new int[8]; + + for (var i = 0; i < 300; i++) + { + analyzer.ObserveFrame( + GoldenFrames.SvFrameWithChannelSamples(0, signals, $"MU_{i:000}"), + epoch.AddMilliseconds(i), + i); + } + + var first = analyzer.GetAnalyzerSnapshot(); + Assert.Equal(256, first.Streams.Count); + Assert.Contains(first.Events, x => x.Message.Contains("capacity", StringComparison.OrdinalIgnoreCase)); + + var selected = first.Streams[0].StreamId; + analyzer.SelectStream(selected); + + analyzer.ObserveFrame( + GoldenFrames.SvFrameWithChannelSamples(0, signals, "MU_NEW"), + epoch.AddSeconds(12), + 12000); + var after = analyzer.GetAnalyzerSnapshot(); + Assert.Equal(256, after.Streams.Count); + Assert.Contains(after.Streams, x => x.SvId == "MU_NEW"); + Assert.Contains(after.Streams, x => x.StreamId == selected); + } +} From 011178190dc52f57dc66b95881e6037b2e703884 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:41:50 +0700 Subject: [PATCH 23/79] fix(sv): never treat ASDU smpRate as authoritative smpCnt modulus --- .../Analysis/RawProcessBusAnalyzer.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 8c67a26c..95edeacd 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -2350,9 +2350,8 @@ private static double NormalizeAngleDegrees(double angle) /// private int ResolveSequenceWrapBase() { - if (SmpRate is > 0) - return SmpRate.Value; - + // SmpRate/SmpMod express sampling semantics, NOT a universal counter + // modulus. Never reinterpret an 80-samples/cycle field as 80-count wrap. // A highest *observed* counter is not a verified wrap modulus. // Missing the last packet(s) of a second makes maxSeen+1 one or // more counts too small and falsely classifies a gap as contiguous. From 96bc89167581367b8b1af3d0128743d1ff3325ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:42:14 +0700 Subject: [PATCH 24/79] test(sv): allow explicit smpRate/smpMod fixtures independent of counter policy --- tests/ProcessBus.Tests/GoldenFrames.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/ProcessBus.Tests/GoldenFrames.cs b/tests/ProcessBus.Tests/GoldenFrames.cs index e628d39a..3345a753 100644 --- a/tests/ProcessBus.Tests/GoldenFrames.cs +++ b/tests/ProcessBus.Tests/GoldenFrames.cs @@ -129,7 +129,7 @@ public static byte[] GooseApdu( /// matches the analyzer's raw 4I4V candidate profile, so streams built with it decode to /// scope channels without any SCL mapping. /// - public static byte[] SvApduWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1", uint qualityWord = 0) + public static byte[] SvApduWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1", uint qualityWord = 0, ushort? smpRate = null, ushort? smpMod = null) { var payload = new byte[16 * 4]; for (var element = 0; element < 8; element++) @@ -144,16 +144,18 @@ public static byte[] SvApduWithChannelSamples(ushort smpCnt, IReadOnlyList Tlv(0x82, U16(smpCnt)), Tlv(0x83, new byte[] { 0x01 }), Tlv(0x85, new byte[] { 0x02 }), - Tlv(0x87, payload)); + smpRate.HasValue ? Tlv(0x86, U16(smpRate.Value)) : Array.Empty(), + Tlv(0x87, payload), + smpMod.HasValue ? Tlv(0x88, U16(smpMod.Value)) : Array.Empty()); return Tlv(0x60, Tlv(0x80, new byte[] { 0x01 }), Tlv(0xA2, asdu)); } - public static byte[] SvFrameWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1", ushort appId = 0x4000, uint qualityWord = 0) + public static byte[] SvFrameWithChannelSamples(ushort smpCnt, IReadOnlyList channelValues, string svId = "MU01_SV1", ushort appId = 0x4000, uint qualityWord = 0, ushort? smpRate = null, ushort? smpMod = null) { - return EthernetFrame(SvEtherType, ProcessBusPayload(appId, SvApduWithChannelSamples(smpCnt, channelValues, svId, qualityWord))); + return EthernetFrame(SvEtherType, ProcessBusPayload(appId, SvApduWithChannelSamples(smpCnt, channelValues, svId, qualityWord, smpRate, smpMod))); } public static byte[] SvFrame(ushort appId = 0x4000, ushort? vlanTci = null) From 1ea1323bb349eb9106afcdfbf4a06f08a2ac76c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:42:35 +0700 Subject: [PATCH 25/79] test(sv): reject naive samples-per-period counter modulus inference --- tests/ProcessBus.Tests/SequencePolicyTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/ProcessBus.Tests/SequencePolicyTests.cs diff --git a/tests/ProcessBus.Tests/SequencePolicyTests.cs b/tests/ProcessBus.Tests/SequencePolicyTests.cs new file mode 100644 index 00000000..a617a10a --- /dev/null +++ b/tests/ProcessBus.Tests/SequencePolicyTests.cs @@ -0,0 +1,40 @@ +using ProcessBus.Iec61850.Raw.Analysis; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class SequencePolicyTests +{ + [Fact] + public void SamplesPerPeriodMetadataMustNotInventCounterModulus() + { + var analyzer = new RawProcessBusAnalyzer(); + var values = new[] { 10, 20, 30, 0, 100, 200, 300, 0 }; + for (ushort count = 0; count < 80; count++) + { + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + count, values, smpRate: 80, smpMod: 2)); + } + + // Counter suddenly starts over; 80 samples/period does not authorize an + // 80-count modulo. Do not quietly classify the restart as a valid wrap. + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + 0, values, smpRate: 80, smpMod: 2)); + + var snapshot = analyzer.GetAnalyzerSnapshot(); + Assert.Equal(1, snapshot.Diagnostics.SequenceErrors); + } + + [Fact] + public void VerifiedObserved4000BoundaryRetainsContinuityDespiteOptionalRateMismatch() + { + var analyzer = new RawProcessBusAnalyzer(); + var values = new int[8]; + for (var i = 3950; i < 4060; i++) + { + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + (ushort)(i % 4000), values, smpRate: 96, smpMod: 2)); + } + Assert.Equal(0, analyzer.GetAnalyzerSnapshot().Diagnostics.SequenceErrors); + } +} From fc4f69661ac40a6c876fc0449b038797d1cba300 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:43:21 +0700 Subject: [PATCH 26/79] docs: record M1/M2 invariants, tested software scope and outstanding physical evidence --- docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md | 78 ++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md diff --git a/docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md b/docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md new file mode 100644 index 00000000..c2f07e69 --- /dev/null +++ b/docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md @@ -0,0 +1,78 @@ +# M1/M2 receiver hardening — 17 October 2026 + +Scope: Process Bus Insight / DigSubAnalyzer receive-only application. This record +describes the bounded software tranche proposed in PR #29. Physical interoperability +and timestamp accuracy require separate evidence. + +## Non-negotiable IEC 61850 boundaries + +- The ARStack injector's canonical SCL-derived multicast/VLAN/APPID/svID identity and + broadcast `AR_DIAG_SV1` mirror are distinct streams. A mirror observation cannot + establish canonical multicast/VLAN acceptance. +- One frame may contain multiple SV ASDUs; each ASDU contributes to sample sequence, + but the frame has only one host receive timestamp. Do not invent intra-frame jitter. +- `SmpRate/SmpMod` are sampling metadata, not universal sample-counter modulo rules. + Counter wrap conclusions remain bounded by observed and profile-supported evidence. +- The eight (INT32 instMag, UINT32 q) pairs are a 64-byte 4I+4V *profile-specific* + decoding authority. Never apply their quality interpretation to arbitrary SCL + layouts solely because the payload is 64 bytes. +- Quality with unusable validity/detail flags immediately invalidates displayed + engineering measurements; wait for a new complete coherent window before resuming. + A TEST or operator-blocked quality remains visible with its truthful context. +- PTP observations are scoped to domain/sourcePortIdentity/transport; observation + does not imply clock lock, grandmaster traceability, power-profile compliance, + or reliable software receive timing. + +## Data plane and UI rules + +- One dedicated capture pump preserves input ordering. Do not introduce independent, + unordered parallel SV workers or silent lossy queues merely to increase thread count. +- Npcap owns raw frame memory only until its next API read. The source transfers + a newly allocated owned frame to the analyzer. The latest payload is retained for + debug-on-demand; indiscriminate ArrayPool return would corrupt this evidence. +- Per-ASDU projections use direct bounded INT32 reads and stack-local eight-channel + values. SCL eligibility and explanatory labels refresh when mapping/sequence state + changes, not for each SV frame. +- Rolling jitter maximum uses a bounded monotonic ring rather than scanning the + entire window for each sample. Retire the unused counter dictionary/linear scan. +- The active per-stream coherent waveform buffer is the single snapshot authority. + RMS/phasor/waveform projections of the same generation reuse that window; an + invalid quality state must invalidate prior measurements, never show stale values + as live. +- User-visible SCL comparisons may be coalesced to 500 ms, with explicit user SCL + reload forcing immediate recomputation. +- Stream history has a 256-identity guard. Reclaim only stale unselected state; + when at active capacity, report rejection rather than silently evicting a stream + currently being inspected. +- PTP source history is bounded and stale sources are labeled STALE instead of + green OBSERVED; the interface should emphasize the fault and next diagnostic + action rather than flooding raw details. + +## Automated acceptance + +`dotnet build ProcessBusSuite.sln -c Release` and `dotnet test +ProcessBusSuite.sln -c Release` plus repeated RuntimeStability acceptance must pass. + +New regressions include: + +- O(1) rolling jitter reference equivalence under a 30,000-sample run; +- invalid-quality suppression, complete coherent recovery, and TEST labeling; +- multi-ASDU continuity with a single frame timestamp and independent frame count; +- per-domain PTP source isolation and stale Announce; +- strict PTPv2 length/transportSpecific parsing; +- bounded stream identity churn and preserved selected stream; +- rejection of `SmpRate` as an unverified counter modulus. + +## Evidence still required before field/product claims + +1. Trusted independent canonical multicast/VLAN capture from ARStack ESP32-P4. +2. Same-run canonical SV (4000/4800 samples/s) plus Announce, Sync, Follow_Up, + Pdelay and `smpSynch` transition stimuli. +3. Npcap/adapter drop counters, host CPU/GC/memory, sustained capture throughput, + and 60+ minute bench soak with no cross-stream contamination. +4. Hardware timestamp validation for any measurement-grade jitter/offset statement. +5. Multi-vendor profile/SCL fixtures, arbitrary `nofASDU` and vendor DataSet layouts, + and receiver/IED behavior with real devices. + +The software tranche avoids claiming those field results. A green CI result means +the bounded software regressions passed, not that an IED accepted the traffic. From 2d5fc8e893dc463dc80f0f9740ebdce641f89df8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:45:36 +0700 Subject: [PATCH 27/79] fix(sv): treat unknown quality bits as untrusted, preserving explicit TEST flags --- src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs b/src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs index fc9369ea..e3e3c419 100644 --- a/src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs +++ b/src/ProcessBus.Iec61850.Raw/Sv/FourI4VQuality.cs @@ -22,7 +22,10 @@ public static MeasurementState Assess(ReadOnlySpan payload) var q = BinaryPrimitives.ReadUInt32BigEndian(payload.Slice(offset, 4)); // Validity and detail-quality bits (validity, overflow, outOfRange, // badReference, oscillatory, failure, oldData, inconsistent, inaccurate). - if ((q & 0x03FFU) != 0) + // Only explicitly modeled TEST/operatorBlocked flags may accompany + // a trusted normal value. Unknown/reserved flags fail closed instead + // of silently treating ambiguous quality bits as "good". + if ((q & ~((1U << 11) | (1U << 12))) != 0) return MeasurementState.Untrusted; test |= (q & (1U << 11)) != 0; blocked |= (q & (1U << 12)) != 0; From 10c5be19df4fa0402371370ce0c1a4d183afefe1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:45:47 +0700 Subject: [PATCH 28/79] test(sv): fail closed for substituted and reserved unknown quality bits --- tests/ProcessBus.Tests/FourI4VQualityTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ProcessBus.Tests/FourI4VQualityTests.cs b/tests/ProcessBus.Tests/FourI4VQualityTests.cs index ac9febe5..709c9a8d 100644 --- a/tests/ProcessBus.Tests/FourI4VQualityTests.cs +++ b/tests/ProcessBus.Tests/FourI4VQualityTests.cs @@ -11,6 +11,8 @@ public class FourI4VQualityTests [InlineData(1u, FourI4VQuality.MeasurementState.Untrusted)] [InlineData(3u, FourI4VQuality.MeasurementState.Untrusted)] [InlineData(1u << 6, FourI4VQuality.MeasurementState.Untrusted)] + [InlineData(1u << 10, FourI4VQuality.MeasurementState.Untrusted)] + [InlineData(1u << 30, FourI4VQuality.MeasurementState.Untrusted)] [InlineData(1u << 11, FourI4VQuality.MeasurementState.Test)] [InlineData(1u << 12, FourI4VQuality.MeasurementState.OperatorBlocked)] public void QualityInterpretationMatchesArstackWireFlags( From 4892f00c4b377f3b7753e16fc89709c4de76659e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:53:04 +0700 Subject: [PATCH 29/79] feat(m3): compare complete engineering frame values to suppress redundant WPF paints without quantizing transients --- .../Presentation/LiveRenderCoalescer.cs | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs diff --git a/src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs b/src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs new file mode 100644 index 00000000..c255e86d --- /dev/null +++ b/src/ProcessBus.Core/Presentation/LiveRenderCoalescer.cs @@ -0,0 +1,95 @@ +using ProcessBus.Core.Models; + +namespace ProcessBus.Core.Presentation; + +/// +/// Compares published engineering values, not object references. A capture snapshot +/// may be reconstructed at every tick even when the observable signal is unchanged. +/// Never quantize: small real transients and quality-driven clearing must be rendered. +/// Intended for one WPF dispatcher/UI owner, not shared across threads. +/// +public sealed class LiveRenderCoalescer +{ + private string? _waveformStream; + private WaveformSnapshot? _lastWaveform; + private string? _analogStream; + private AnalogValuesSnapshot? _lastAnalog; + + public bool PublishWaveform(string? streamId, WaveformSnapshot waveform) + { + if (string.Equals(_waveformStream, streamId, StringComparison.Ordinal) && + _lastWaveform is not null && SameWaveform(_lastWaveform, waveform)) + return false; + + _waveformStream = streamId; + _lastWaveform = waveform; + return true; + } + + public bool PublishAnalog(string? streamId, AnalogValuesSnapshot analog) + { + if (string.Equals(_analogStream, streamId, StringComparison.Ordinal) && + _lastAnalog is not null && SameAnalog(_lastAnalog, analog)) + return false; + + _analogStream = streamId; + _lastAnalog = analog; + return true; + } + + public void Reset() + { + _waveformStream = null; + _lastWaveform = null; + _analogStream = null; + _lastAnalog = null; + } + + public static bool SameAnalog(AnalogValuesSnapshot left, AnalogValuesSnapshot right) => + SameChannel(left.Ia, right.Ia) && SameChannel(left.Ib, right.Ib) && + SameChannel(left.Ic, right.Ic) && SameChannel(left.In, right.In) && + SameChannel(left.Ua, right.Ua) && SameChannel(left.Ub, right.Ub) && + SameChannel(left.Uc, right.Uc) && SameChannel(left.Un, right.Un); + + private static bool SameChannel(ChannelValueModel a, ChannelValueModel b) => + a.InstantValue == b.InstantValue && a.RmsValue == b.RmsValue && + a.AngleDegrees == b.AngleDegrees && + string.Equals(a.Unit, b.Unit, StringComparison.Ordinal); + + private static bool SameWaveform(WaveformSnapshot a, WaveformSnapshot b) => + a.SampleRateHz == b.SampleRateHz && + a.MeasuredFrequencyHz == b.MeasuredFrequencyHz && + a.SamplesPerCycle == b.SamplesPerCycle && + a.WindowDurationMilliseconds == b.WindowDurationMilliseconds && + a.HasShapeWarning == b.HasShapeWarning && + a.IsReconstructed == b.IsReconstructed && + string.Equals(a.StatusText, b.StatusText, StringComparison.Ordinal) && + string.Equals(a.ShapeStatusText, b.ShapeStatusText, StringComparison.Ordinal) && + string.Equals(a.ShapeSeverity, b.ShapeSeverity, StringComparison.Ordinal) && + SameSeries(a.VoltageSeries, b.VoltageSeries) && + SameSeries(a.CurrentSeries, b.CurrentSeries); + + private static bool SameSeries(IReadOnlyList a, IReadOnlyList b) + { + if (a.Count != b.Count) return false; + for (var i = 0; i < a.Count; i++) + { + var x = a[i]; + var y = b[i]; + if (!string.Equals(x.Name, y.Name, StringComparison.Ordinal) || + !string.Equals(x.Unit, y.Unit, StringComparison.Ordinal) || + !string.Equals(x.ShapeSeverity, y.ShapeSeverity, StringComparison.Ordinal) || + !string.Equals(x.ShapeStatusText, y.ShapeStatusText, StringComparison.Ordinal) || + x.ShapeResidualPercent != y.ShapeResidualPercent || + x.CrestFactor != y.CrestFactor || + x.HasShapeDistortion != y.HasShapeDistortion || + x.Samples.Count != y.Samples.Count) + return false; + + for (var sample = 0; sample < x.Samples.Count; sample++) + if (x.Samples[sample] != y.Samples[sample]) + return false; + } + return true; + } +} From a55bf07878d2ca23fd90bf68caf195cd2d8dce9d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:53:28 +0700 Subject: [PATCH 30/79] feat(m3): coalesce waveform and phasor UI publication and reuse per-stream workspace caches --- .../ViewModels/MainWindowViewModel.cs | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 6230c066..6cb45ab8 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -7,6 +7,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; +using ProcessBus.Core.Presentation; using System.IO; using System.Runtime.CompilerServices; using System.Text; @@ -29,6 +30,7 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private static readonly TimeSpan GooseInteractionQuietPeriod = TimeSpan.FromMilliseconds(750); private static readonly string LastInterfacePath = Path.Combine(AppContext.BaseDirectory, "raw_capture.last_interface.txt"); private readonly AnalyzerShellState _state = new(); + private readonly LiveRenderCoalescer _renderCoalescer = new(); private readonly IRawCaptureDataSource _rawDataSource = new RawAnalyzerDataSource(); private readonly DispatcherTimer _timer; private IAnalyzerDataSource _dataSource; @@ -47,6 +49,7 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged, IDisposable private WaveformSnapshot _displayedWaveform = new(); private string? _lastRequestedRawStreamId; private readonly Dictionary _streamWorkspaces = new(StringComparer.OrdinalIgnoreCase); + private string? _lastPhasorSourceStreamId; private readonly Dictionary _explicitSvScales = new(StringComparer.OrdinalIgnoreCase); private const string RawScaleText = "Raw counts (uncalibrated)"; private const string NominalScaleText = "4I+4V nominal (1 mA / 10 mV per count)"; @@ -2169,22 +2172,27 @@ private void UpdateSelectedStreamWorkspaceFromSnapshot(string? selectedStreamId, // correctness and responsiveness are more important than suppressing this tiny // allocation. _displayedAnalogValues = analogValues; - _phasors = BuildPhasorItems(_displayedAnalogValues); - _lastPhasorSourceSnapshot = _displayedAnalogValues; + if (_lastPhasorSourceSnapshot is null || + !LiveRenderCoalescer.SameAnalog(_lastPhasorSourceSnapshot, _displayedAnalogValues) || + !string.Equals(_lastPhasorSourceStreamId, selectedStreamId, StringComparison.Ordinal)) + { + _phasors = BuildPhasorItems(_displayedAnalogValues); + _lastPhasorSourceSnapshot = _displayedAnalogValues; + _lastPhasorSourceStreamId = selectedStreamId; + } if (!ReferenceEquals(_displayedWaveform, waveform)) _displayedWaveform = waveform; if (!string.IsNullOrWhiteSpace(selectedStreamId)) { - _streamWorkspaces[selectedStreamId] = new StreamWorkspaceCache - { - Details = _displayedSelectedStreamDetails, - AnalogValues = _displayedAnalogValues, - Waveform = _displayedWaveform, - Phasors = _phasors, - PhasorSource = _lastPhasorSourceSnapshot - }; + if (!_streamWorkspaces.TryGetValue(selectedStreamId, out var cached)) + _streamWorkspaces[selectedStreamId] = cached = new StreamWorkspaceCache(); + cached.Details = _displayedSelectedStreamDetails; + cached.AnalogValues = _displayedAnalogValues; + cached.Waveform = _displayedWaveform; + cached.Phasors = _phasors; + cached.PhasorSource = _lastPhasorSourceSnapshot; } } @@ -2416,7 +2424,7 @@ private void RaiseAll(DateTime now, bool force = false) } if (IsAnalyzerTabActive) - RaiseAnalyzerRenderProperties(); + RaiseAnalyzerRenderProperties(force); if (IsDiagnosticsTabActive && (force || (now - _lastDiagnosticsRaiseUtc).TotalMilliseconds >= PassiveUiRefreshMs)) { _lastDiagnosticsRaiseUtc = now; @@ -2444,21 +2452,29 @@ private void RaiseAll(DateTime now, bool force = false) } } - private void RaiseAnalyzerRenderProperties() + private void RaiseAnalyzerRenderProperties(bool force = false) { + // Separate data-rate, UI-rate and render-rate. An equivalent reconstructed + // snapshot must not trigger full waveform/phasor repaint every timer tick. + // Compare every numeric sample: do not smooth away a real IEC 61850 transient. + var streamId = _selectedStream?.StreamId; + var waveformChanged = _renderCoalescer.PublishWaveform(streamId, _displayedWaveform); + var analogChanged = _renderCoalescer.PublishAnalog(streamId, _displayedAnalogValues); OnPropertyChanged(nameof(SelectedStreamDetails)); OnPropertyChanged(nameof(SvMappingSourceText)); OnPropertyChanged(nameof(SvMappingSourceCompactText)); OnPropertyChanged(nameof(SvSemanticChannelSummaryText)); OnPropertyChanged(nameof(SvSemanticChannelCompactText)); - OnPropertyChanged(nameof(AnalogValues)); + if (force || analogChanged) + OnPropertyChanged(nameof(AnalogValues)); OnPropertyChanged(nameof(SelectedEngineeringScale)); OnPropertyChanged(nameof(EngineeringScaleExplanation)); OnPropertyChanged(nameof(TotalActivePowerText)); OnPropertyChanged(nameof(TotalReactivePowerText)); OnPropertyChanged(nameof(TotalApparentPowerText)); OnPropertyChanged(nameof(PowerFactorText)); - OnPropertyChanged(nameof(Waveform)); + if (force || waveformChanged) + OnPropertyChanged(nameof(Waveform)); OnPropertyChanged(nameof(WaveformStatusText)); OnPropertyChanged(nameof(WaveformHeaderModeText)); OnPropertyChanged(nameof(WaveformHeaderFrequencyText)); @@ -2467,7 +2483,8 @@ private void RaiseAnalyzerRenderProperties() OnPropertyChanged(nameof(WaveformHeaderWindowText)); OnPropertyChanged(nameof(WaveformHeaderStatusText)); OnPropertyChanged(nameof(WaveformShapeStatusText)); - OnPropertyChanged(nameof(Phasors)); + if (force || analogChanged) + OnPropertyChanged(nameof(Phasors)); OnPropertyChanged(nameof(WaveformVoltageScale)); OnPropertyChanged(nameof(WaveformCurrentScale)); } From 167f150945aa84e131ed72ee6a0c93ad81612d7c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:53:45 +0700 Subject: [PATCH 31/79] test(m3): suppress duplicate frames but preserve tiny transients, quality invalidation and stream switches --- .../LiveRenderCoalescerTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/ProcessBus.Tests/LiveRenderCoalescerTests.cs diff --git a/tests/ProcessBus.Tests/LiveRenderCoalescerTests.cs b/tests/ProcessBus.Tests/LiveRenderCoalescerTests.cs new file mode 100644 index 00000000..f3b610a3 --- /dev/null +++ b/tests/ProcessBus.Tests/LiveRenderCoalescerTests.cs @@ -0,0 +1,71 @@ +using ProcessBus.Core.Models; +using ProcessBus.Core.Presentation; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class LiveRenderCoalescerTests +{ + private static WaveformSnapshot Wave(params double[] samples) => new() + { + SampleRateHz = 4000, + MeasuredFrequencyHz = 50, + SamplesPerCycle = 80, + VoltageSeries = [new WaveformSeriesModel + { + Name = "Ua", Unit = "counts", Samples = samples, + ShapeSeverity = "Good", ShapeStatusText = "Stable" + }] + }; + + private static AnalogValuesSnapshot Analog(double rms, double angle = 0) => new() + { + Ua = new ChannelValueModel("Ua") { RmsValue = rms, AngleDegrees = angle, Unit = "counts" } + }; + + [Fact] + public void IdenticalValuesAcrossNewSnapshotsDoNotRepaint() + { + var gate = new LiveRenderCoalescer(); + Assert.True(gate.PublishWaveform("A", Wave(1, 2, 3))); + Assert.False(gate.PublishWaveform("A", Wave(1, 2, 3))); + Assert.True(gate.PublishAnalog("A", Analog(50))); + Assert.False(gate.PublishAnalog("A", Analog(50))); + } + + [Fact] + public void TinyRealTransientStreamSwitchAndInvalidationMustPublish() + { + var gate = new LiveRenderCoalescer(); + Assert.True(gate.PublishWaveform("A", Wave(1, 2, 3))); + Assert.True(gate.PublishWaveform("A", Wave(1, 2.00000000001, 3))); + Assert.True(gate.PublishWaveform("B", Wave(1, 2.00000000001, 3))); + Assert.True(gate.PublishWaveform("B", new WaveformSnapshot + { + StatusText = "Invalid quality: engineering measurements withheld" + })); + Assert.False(gate.PublishWaveform("B", new WaveformSnapshot + { + StatusText = "Invalid quality: engineering measurements withheld" + })); + Assert.True(gate.PublishAnalog("B", Analog(50))); + Assert.True(gate.PublishAnalog("B", Analog(50, 0.0000000001))); + gate.Reset(); + Assert.True(gate.PublishWaveform("B", Wave(1, 2, 3))); + } + + [Fact] + public void ShapeVerdictAndUnitChangesCannotBeHidden() + { + var gate = new LiveRenderCoalescer(); + Assert.True(gate.PublishWaveform("A", Wave(1, 2))); + Assert.True(gate.PublishWaveform("A", new WaveformSnapshot + { + SampleRateHz = 4000, MeasuredFrequencyHz = 50, SamplesPerCycle = 80, + VoltageSeries = [new WaveformSeriesModel { + Name = "Ua", Unit = "V", Samples = new[] { 1d, 2d }, + ShapeSeverity = "Warning", ShapeStatusText = "Harmonic" + }] + })); + } +} From 59c727472aaed4a719c041f1d54b1f72e646d217 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:54:12 +0700 Subject: [PATCH 32/79] refactor(m3): remove dead interpolation/render-loop engine; draw only canonical raw SV snapshot --- .../Controls/WaveformPanelControl.cs | 181 +----------------- 1 file changed, 3 insertions(+), 178 deletions(-) diff --git a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs index 629d561b..35897b3b 100644 --- a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs +++ b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Diagnostics; using System.Windows; using System.Windows.Input; using System.Windows.Media; @@ -42,13 +41,6 @@ public sealed class WaveformPanelControl : FrameworkElement private double _currentAutoPeak; private DateTime _voltagePeakUpdatedUtc = DateTime.MinValue; private DateTime _currentPeakUpdatedUtc = DateTime.MinValue; - private readonly Stopwatch _smoothClock = new(); - private WaveformSnapshot? _fromSnapshot; - private WaveformSnapshot? _targetSnapshot; - private WaveformSnapshot? _visualSnapshot; - private TimeSpan _lastVisualRender = TimeSpan.Zero; - private bool _renderLoopAttached; - private const double AutoScaleHeadroom = 1.28; private const double AutoScaleSmoothing = 0.18; // Steady-state deadband: the observed window peak jitters a little every cycle, and an @@ -57,15 +49,12 @@ public sealed class WaveformPanelControl : FrameworkElement private const double AutoScaleDeadband = 0.02; private const double PeakHoldSeconds = 6.0; private const double SamplesPerPixel = 0.8; - private const double VisualSmoothingMilliseconds = 120.0; - private const double VisualRenderFps = 30.0; public WaveformPanelControl() { Focusable = true; Cursor = Cursors.Cross; - // Raw SV scope renders atomically on Snapshot changes; no CompositionTarget animation loop. - Unloaded += (_, _) => DetachRenderLoop(); + // Render only the latest immutable waveform snapshot; never tween real SV samples. } public static readonly DependencyProperty SnapshotProperty = @@ -73,10 +62,7 @@ public WaveformPanelControl() nameof(Snapshot), typeof(WaveformSnapshot), typeof(WaveformPanelControl), - new FrameworkPropertyMetadata( - null, - FrameworkPropertyMetadataOptions.AffectsRender, - OnSnapshotChanged)); + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty ShowModeProperty = DependencyProperty.Register( @@ -126,32 +112,6 @@ public WaveformSnapshot? Snapshot set => SetValue(SnapshotProperty, value); } - private static void OnSnapshotChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) - { - if (dependencyObject is not WaveformPanelControl control) - return; - - var next = args.NewValue as WaveformSnapshot; - if (next is null) - { - control._fromSnapshot = null; - control._targetSnapshot = null; - control._visualSnapshot = null; - control.DetachRenderLoop(); - control.InvalidateVisual(); - return; - } - - // Raw SV scope must not tween/interpolate between snapshots. Interpolation makes a - // stable phase-locked waveform crawl and creates fake transient shapes between two - // real SV sample windows. Render the latest snapshot atomically, ARSVIN-style. - control.DetachRenderLoop(); - control._fromSnapshot = null; - control._targetSnapshot = next; - control._visualSnapshot = next; - control.InvalidateVisual(); - } - public string ShowMode { get => (string)GetValue(ShowModeProperty); @@ -202,7 +162,7 @@ protected override void OnRender(DrawingContext dc) var bounds = new Rect(new Point(0, 0), RenderSize); dc.DrawRectangle(PanelBrush, null, bounds); - var snapshot = _visualSnapshot ?? Snapshot; + var snapshot = Snapshot; if (snapshot is null || (snapshot.VoltageSeries.Count == 0 && snapshot.CurrentSeries.Count == 0)) { DrawCenteredMessage(dc, bounds, "Waveform data pending"); @@ -238,141 +198,6 @@ protected override void OnRender(DrawingContext dc) } } - private void AttachRenderLoopIfNeeded() - { - if (_renderLoopAttached || !IsLoaded) - return; - - CompositionTarget.Rendering += OnCompositionRendering; - _renderLoopAttached = true; - } - - private void DetachRenderLoop() - { - if (!_renderLoopAttached) - return; - - CompositionTarget.Rendering -= OnCompositionRendering; - _renderLoopAttached = false; - } - - private void OnCompositionRendering(object? sender, EventArgs e) - { - if (_fromSnapshot is null || _targetSnapshot is null) - { - DetachRenderLoop(); - return; - } - - var elapsed = _smoothClock.Elapsed; - if (elapsed - _lastVisualRender < TimeSpan.FromMilliseconds(1000.0 / VisualRenderFps)) - return; - - _lastVisualRender = elapsed; - var progress = Math.Clamp(elapsed.TotalMilliseconds / VisualSmoothingMilliseconds, 0.0, 1.0); - var eased = EaseOutCubic(progress); - _visualSnapshot = progress >= 1.0 - ? _targetSnapshot - : InterpolateSnapshot(_fromSnapshot, _targetSnapshot, eased); - - InvalidateVisual(); - - if (progress >= 1.0) - DetachRenderLoop(); - } - - private static bool HasAnySeries(WaveformSnapshot snapshot) - { - return snapshot.VoltageSeries.Count > 0 || snapshot.CurrentSeries.Count > 0; - } - - private static WaveformSnapshot InterpolateSnapshot(WaveformSnapshot from, WaveformSnapshot to, double amount) - { - return new WaveformSnapshot - { - VoltageSeries = InterpolateSeries(from.VoltageSeries, to.VoltageSeries, amount), - CurrentSeries = InterpolateSeries(from.CurrentSeries, to.CurrentSeries, amount), - SampleRateHz = Lerp(from.SampleRateHz, to.SampleRateHz, amount), - MeasuredFrequencyHz = Lerp(from.MeasuredFrequencyHz, to.MeasuredFrequencyHz, amount), - WindowDurationMilliseconds = Lerp(from.WindowDurationMilliseconds, to.WindowDurationMilliseconds, amount), - StatusText = to.StatusText, - ShapeSeverity = to.ShapeSeverity, - ShapeStatusText = to.ShapeStatusText, - HasShapeWarning = to.HasShapeWarning, - SamplesPerCycle = to.SamplesPerCycle, - IsReconstructed = to.IsReconstructed - }; - } - - private static IReadOnlyList InterpolateSeries( - IReadOnlyList fromSeries, - IReadOnlyList toSeries, - double amount) - { - if (toSeries.Count == 0) - return Array.Empty(); - - var fromByName = fromSeries.ToDictionary(item => item.Name, StringComparer.OrdinalIgnoreCase); - var result = new WaveformSeriesModel[toSeries.Count]; - for (var i = 0; i < toSeries.Count; i++) - { - var target = toSeries[i]; - if (!fromByName.TryGetValue(target.Name, out var source) || source.Samples.Count == 0 || target.Samples.Count == 0) - { - result[i] = target; - continue; - } - - var samples = new double[target.Samples.Count]; - for (var sampleIndex = 0; sampleIndex < samples.Length; sampleIndex++) - { - var fraction = samples.Length <= 1 ? 0.0 : sampleIndex / (samples.Length - 1.0); - var sourceValue = SampleAt(source.Samples, fraction); - samples[sampleIndex] = Lerp(sourceValue, target.Samples[sampleIndex], amount); - } - - result[i] = new WaveformSeriesModel - { - Name = target.Name, - Unit = target.Unit, - Samples = samples, - ShapeSeverity = target.ShapeSeverity, - ShapeStatusText = target.ShapeStatusText, - ShapeResidualPercent = target.ShapeResidualPercent, - CrestFactor = target.CrestFactor, - HasShapeDistortion = target.HasShapeDistortion - }; - } - - return result; - } - - private static double SampleAt(IReadOnlyList samples, double fraction) - { - if (samples.Count == 0) - return 0; - - if (samples.Count == 1) - return samples[0]; - - var position = Math.Clamp(fraction, 0.0, 1.0) * (samples.Count - 1); - var left = (int)Math.Floor(position); - var right = Math.Min(samples.Count - 1, left + 1); - var amount = position - left; - return Lerp(samples[left], samples[right], amount); - } - - private static double Lerp(double from, double to, double amount) - { - return from + ((to - from) * amount); - } - - private static double EaseOutCubic(double amount) - { - var inverse = 1.0 - Math.Clamp(amount, 0.0, 1.0); - return 1.0 - (inverse * inverse * inverse); - } - private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, WaveformSnapshot snapshot) { dc.DrawRoundedRectangle(PlotBackgroundBrush, BorderPen, rect, 10, 10); From 7569eafdc64cc87a4a97f3194c056cce264b23fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:55:11 +0700 Subject: [PATCH 33/79] feat(m3): one calm context-aware capture summary without conflating PTP observation and lock --- .../ViewModels/MainWindowViewModel.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 6cb45ab8..96426a92 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -2398,12 +2398,38 @@ private void UpdateStreamStaleState() _streamStale = (now - _lastPacketAdvanceUtc).TotalSeconds > StreamStaleTimeoutSeconds; } + // One operator-level statement in the primary status strip; detailed wire + // evidence remains in Timing/Diagnostics/Advanced rather than five noisy chips. + public string OperatorCaptureSummaryText + { + get + { + if (!IsRunning) + return "Capture stopped • Select an Ethernet adapter and press Start"; + + var activeSv = Streams.Count(stream => stream.IsActive); + if (activeSv == 0) + return Diagnostics.DecodeErrors > 0 + ? "Listening • Traffic detected, but no valid SV decoded" + : "Listening • Waiting for SV publisher"; + + var ptpActive = Diagnostics.PtpObserved && + !Diagnostics.PtpStatusText.Contains("timeout", StringComparison.OrdinalIgnoreCase) && + Diagnostics.LastPtpMessageTimestampUtc is { } lastPtp && + DateTime.UtcNow - lastPtp <= TimeSpan.FromSeconds(6); + return ptpActive + ? $"Receiving {activeSv} SV stream(s) • PTP observed (sync unverified)" + : $"Receiving {activeSv} SV stream(s) • PTP not verified"; + } + } + private void RaiseAll(DateTime now, bool force = false) { if (force || (now - _lastGlobalRaiseUtc).TotalMilliseconds >= GlobalStatusRefreshMs) { _lastGlobalRaiseUtc = now; OnPropertyChanged(nameof(StreamStatusText)); + OnPropertyChanged(nameof(OperatorCaptureSummaryText)); OnPropertyChanged(nameof(DataSourceName)); OnPropertyChanged(nameof(IsLiveMode)); OnPropertyChanged(nameof(ModeBannerText)); From 160b241b91c55df979e917ae56f9c7f81680fa0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 08:55:32 +0700 Subject: [PATCH 34/79] feat(m3): collapse five duplicate status chips into one operator-centric status strip and remove flow instruction clutter --- src/ProcessBus.App.Wpf/MainWindow.xaml | 84 +++++--------------------- 1 file changed, 15 insertions(+), 69 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index c19ad425..38dc3cb5 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -837,66 +837,18 @@ ROW 2 — QUIET STATUS STRIP ========================================================= --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + MinHeight="32" + Padding="14,7" + Background="#07111D" + CornerRadius="0,0,8,8"> + + + @@ -3623,9 +3575,7 @@ Text="{Binding ValidationSummaryCompactText}" Style="{StaticResource PanelSubtitleTextStyle}" TextWrapping="Wrap"/> - + @@ -3901,9 +3851,7 @@ Text="Import SCD / CID / ICD / IID files, inspect IED cards, and build semantic mapping for SV and GOOSE streams." Style="{StaticResource PanelSubtitleTextStyle}" TextWrapping="Wrap"/> - +