diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 7fc650d0..5747c0db 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -798,6 +798,7 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali private double _totalDeltaMicroseconds; private long _deltaCount; private ushort? _previousSmpCnt; + private int _counterVerifiedSampleRateHz; // never advertised as an on-wire smpRate private long? _previousCaptureTicks; private string? _pendingJitterEvidence; private string? _activeMappingSignature; @@ -952,7 +953,10 @@ public StreamDetailsModel ToDetails() SourceMac = SourceMac, DestinationMac = DestinationMac, VlanText = VlanText, - SmpRateText = SmpRate.HasValue ? $"{SmpRate} ({ResolveSmpModText(SmpMod)})" : "N/A", + SmpRateText = SmpRate.HasValue ? $"{SmpRate} ({ResolveSmpModText(SmpMod)})" + : _counterVerifiedSampleRateHz > 0 + ? $"{_counterVerifiedSampleRateHz} (estimated: smpCnt wrap + host arrival rate, not on-wire)" + : "N/A (optional smpRate absent; awaiting verified wrap)", ConfRevText = ConfRev?.ToString() ?? "N/A", SampleValueMappingText = MappingProfileName, SampleValueCountText = DecodedElementCount > 0 ? $"{DecodedElementCount} element(s)" : "0", @@ -1568,8 +1572,17 @@ private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort sm acceptForDisplay = !sequence.IsError; if (sequence.IsError) { + // A gap invalidates the evidence used for a prior rate estimate. + _counterVerifiedSampleRateHz = 0; ClearScopeAcquisition(clearPublishedSnapshot: false); } + else if (!SmpRate.HasValue && SmpCntWrapIsSupportedRate(_previousSmpCnt.Value, smpCnt, RecentPacketRatePps)) + { + // Only after a full counter rollover AND independent host-rate screening. + // No SCL is needed and neither counter nor arrival timestamp is + // represented as certified synchronization/timing accuracy. + _counterVerifiedSampleRateHz = _previousSmpCnt.Value + 1; + } var actualUs = (captureTicks - _previousCaptureTicks.Value) * 1_000_000.0 / Stopwatch.Frequency; CurrentDeltaMicroseconds = actualUs; @@ -1632,10 +1645,20 @@ private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort sm private double? ResolveExpectedDeltaUs(int sampleStep) { - if (SmpRate is > 0 && SmpMod == 1) - return sampleStep * 1_000_000.0 / SmpRate.Value; + var rate = ResolveWaveformSampleRate(); + return rate > 0 ? sampleStep * 1_000_000.0 / rate : null; + } - return null; + // smpRate/smpMod are optional fields in real IEC 61850-9-2LE ASDU streams. + // The observed rollover alone is not a clock measurement. Gate narrowly on + // two supported nominal modes plus a rolling host-arrival packet-rate check. + private static bool SmpCntWrapIsSupportedRate(ushort previous, ushort current, double? observedFps) + { + if (current != 0 || (previous != 3999 && previous != 4799) || + !observedFps.HasValue || !double.IsFinite(observedFps.Value)) + return false; + var expected = previous + 1; + return Math.Abs(observedFps.Value - expected) <= expected * 0.10; } private void AddJitter(double absJitterUs) @@ -1814,13 +1837,15 @@ private double ResolveWaveformSampleRate() if (SmpRate is > 0 && SmpMod == 1) return SmpRate.Value; - return 0; + return _counterVerifiedSampleRateHz; } private double ResolveNominalFrequency() { var sampleRate = ResolveWaveformSampleRate(); - return sampleRate >= 4000 ? 50.0 : 0; + if (sampleRate == 4000) return 50.0; + if (sampleRate == 4800) return 60.0; + return 0; } private int ResolveSamplesPerCycle() diff --git a/tests/ProcessBus.Tests/ScopeStabilityTests.cs b/tests/ProcessBus.Tests/ScopeStabilityTests.cs index 1e8a7b1e..edcd9862 100644 --- a/tests/ProcessBus.Tests/ScopeStabilityTests.cs +++ b/tests/ProcessBus.Tests/ScopeStabilityTests.cs @@ -63,6 +63,49 @@ private static IReadOnlyList UaSamples(RawProcessBusAnalyzer analyzer) return ua!.Samples; } + + [Fact] + public void Scope_ArstackPrimaryWithOptionalRateOmitted_RecoversWaveAnd50HzWithoutScl() + { + // Synthetic 64-byte 4I4V instMag/q frames model the operator's October + // ESP32-P4 PCAP: svID ARSTACK_SV01, APPID 0x4000, no ASDU smpRate, + // observed 4000 fps and smpCnt 3999 -> 0. No customer packet is committed. + var analyzer = new RawProcessBusAnalyzer(); + var started = new DateTime(2026, 10, 16, 0, 0, 0, DateTimeKind.Utc); + for (var i = 0; i < 4200; i++) + { + var absoluteIndex = 1108 + i; + analyzer.ObserveFrame( + GoldenFrames.SvFrameWithChannelSamples( + (ushort)(absoluteIndex % 4000), + ChannelValuesAt(absoluteIndex, Math.Sin), + "ARSTACK_SV01", 0x4000), + started.AddTicks(i * TimeSpan.TicksPerSecond / 4000), + i * System.Diagnostics.Stopwatch.Frequency / 4000); + } + + var snapshot = analyzer.GetAnalyzerSnapshot(); + Assert.Equal(0, snapshot.Diagnostics.SequenceErrors); + Assert.Equal(0, snapshot.Diagnostics.MissingSamples); + Assert.NotEmpty(snapshot.Waveform.VoltageSeries); + Assert.NotEmpty(snapshot.Waveform.CurrentSeries); + Assert.Equal(4000, snapshot.Waveform.SampleRateHz); + Assert.Equal(50.0, snapshot.Waveform.MeasuredFrequencyHz); + Assert.Contains("estimated", snapshot.SelectedStreamDetails!.SmpRateText, + StringComparison.OrdinalIgnoreCase); + Assert.Contains("Raw 4I4V", snapshot.SelectedStreamDetails.SampleValueMappingText); + } + + [Fact] + public void Scope_OptionalRateAbsentAndNoCounterWrap_StaysUnverified() + { + var analyzer = FeedSine(SamplesPerCycle * 10); + var snapshot = analyzer.GetAnalyzerSnapshot(); + Assert.Equal(0, snapshot.Waveform.SampleRateHz); + Assert.Contains("awaiting verified wrap", snapshot.SelectedStreamDetails!.SmpRateText); + Assert.NotEmpty(snapshot.Waveform.VoltageSeries); // raw samples still usable without SCL + } + [Fact] public void Scope_WindowIsPhaseLocked_AcrossWholeCycleAdvances() {