Skip to content

Commit d671f3b

Browse files
authored
Merge pull request #24 from masarray/fix/sv-optional-smp-rate-pcap-evidence-20261016
fix(raw-sv): recover 4I4V rate from verified smpCnt rollover without SCL
2 parents 30fde2b + 65d3e21 commit d671f3b

2 files changed

Lines changed: 74 additions & 6 deletions

File tree

‎src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,7 @@ private static readonly (string Channel, int ElementIndex)[] OmicronInstMagQuali
798798
private double _totalDeltaMicroseconds;
799799
private long _deltaCount;
800800
private ushort? _previousSmpCnt;
801+
private int _counterVerifiedSampleRateHz; // never advertised as an on-wire smpRate
801802
private long? _previousCaptureTicks;
802803
private string? _pendingJitterEvidence;
803804
private string? _activeMappingSignature;
@@ -952,7 +953,10 @@ public StreamDetailsModel ToDetails()
952953
SourceMac = SourceMac,
953954
DestinationMac = DestinationMac,
954955
VlanText = VlanText,
955-
SmpRateText = SmpRate.HasValue ? $"{SmpRate} ({ResolveSmpModText(SmpMod)})" : "N/A",
956+
SmpRateText = SmpRate.HasValue ? $"{SmpRate} ({ResolveSmpModText(SmpMod)})"
957+
: _counterVerifiedSampleRateHz > 0
958+
? $"{_counterVerifiedSampleRateHz} (estimated: smpCnt wrap + host arrival rate, not on-wire)"
959+
: "N/A (optional smpRate absent; awaiting verified wrap)",
956960
ConfRevText = ConfRev?.ToString() ?? "N/A",
957961
SampleValueMappingText = MappingProfileName,
958962
SampleValueCountText = DecodedElementCount > 0 ? $"{DecodedElementCount} element(s)" : "0",
@@ -1568,8 +1572,17 @@ private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort sm
15681572
acceptForDisplay = !sequence.IsError;
15691573
if (sequence.IsError)
15701574
{
1575+
// A gap invalidates the evidence used for a prior rate estimate.
1576+
_counterVerifiedSampleRateHz = 0;
15711577
ClearScopeAcquisition(clearPublishedSnapshot: false);
15721578
}
1579+
else if (!SmpRate.HasValue && SmpCntWrapIsSupportedRate(_previousSmpCnt.Value, smpCnt, RecentPacketRatePps))
1580+
{
1581+
// Only after a full counter rollover AND independent host-rate screening.
1582+
// No SCL is needed and neither counter nor arrival timestamp is
1583+
// represented as certified synchronization/timing accuracy.
1584+
_counterVerifiedSampleRateHz = _previousSmpCnt.Value + 1;
1585+
}
15731586

15741587
var actualUs = (captureTicks - _previousCaptureTicks.Value) * 1_000_000.0 / Stopwatch.Frequency;
15751588
CurrentDeltaMicroseconds = actualUs;
@@ -1632,10 +1645,20 @@ private bool ObserveTiming(long captureTicks, DateTime captureTimeUtc, ushort sm
16321645

16331646
private double? ResolveExpectedDeltaUs(int sampleStep)
16341647
{
1635-
if (SmpRate is > 0 && SmpMod == 1)
1636-
return sampleStep * 1_000_000.0 / SmpRate.Value;
1648+
var rate = ResolveWaveformSampleRate();
1649+
return rate > 0 ? sampleStep * 1_000_000.0 / rate : null;
1650+
}
16371651

1638-
return null;
1652+
// smpRate/smpMod are optional fields in real IEC 61850-9-2LE ASDU streams.
1653+
// The observed rollover alone is not a clock measurement. Gate narrowly on
1654+
// two supported nominal modes plus a rolling host-arrival packet-rate check.
1655+
private static bool SmpCntWrapIsSupportedRate(ushort previous, ushort current, double? observedFps)
1656+
{
1657+
if (current != 0 || (previous != 3999 && previous != 4799) ||
1658+
!observedFps.HasValue || !double.IsFinite(observedFps.Value))
1659+
return false;
1660+
var expected = previous + 1;
1661+
return Math.Abs(observedFps.Value - expected) <= expected * 0.10;
16391662
}
16401663

16411664
private void AddJitter(double absJitterUs)
@@ -1814,13 +1837,15 @@ private double ResolveWaveformSampleRate()
18141837
if (SmpRate is > 0 && SmpMod == 1)
18151838
return SmpRate.Value;
18161839

1817-
return 0;
1840+
return _counterVerifiedSampleRateHz;
18181841
}
18191842

18201843
private double ResolveNominalFrequency()
18211844
{
18221845
var sampleRate = ResolveWaveformSampleRate();
1823-
return sampleRate >= 4000 ? 50.0 : 0;
1846+
if (sampleRate == 4000) return 50.0;
1847+
if (sampleRate == 4800) return 60.0;
1848+
return 0;
18241849
}
18251850

18261851
private int ResolveSamplesPerCycle()

‎tests/ProcessBus.Tests/ScopeStabilityTests.cs‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,49 @@ private static IReadOnlyList<double> UaSamples(RawProcessBusAnalyzer analyzer)
6363
return ua!.Samples;
6464
}
6565

66+
67+
[Fact]
68+
public void Scope_ArstackPrimaryWithOptionalRateOmitted_RecoversWaveAnd50HzWithoutScl()
69+
{
70+
// Synthetic 64-byte 4I4V instMag/q frames model the operator's October
71+
// ESP32-P4 PCAP: svID ARSTACK_SV01, APPID 0x4000, no ASDU smpRate,
72+
// observed 4000 fps and smpCnt 3999 -> 0. No customer packet is committed.
73+
var analyzer = new RawProcessBusAnalyzer();
74+
var started = new DateTime(2026, 10, 16, 0, 0, 0, DateTimeKind.Utc);
75+
for (var i = 0; i < 4200; i++)
76+
{
77+
var absoluteIndex = 1108 + i;
78+
analyzer.ObserveFrame(
79+
GoldenFrames.SvFrameWithChannelSamples(
80+
(ushort)(absoluteIndex % 4000),
81+
ChannelValuesAt(absoluteIndex, Math.Sin),
82+
"ARSTACK_SV01", 0x4000),
83+
started.AddTicks(i * TimeSpan.TicksPerSecond / 4000),
84+
i * System.Diagnostics.Stopwatch.Frequency / 4000);
85+
}
86+
87+
var snapshot = analyzer.GetAnalyzerSnapshot();
88+
Assert.Equal(0, snapshot.Diagnostics.SequenceErrors);
89+
Assert.Equal(0, snapshot.Diagnostics.MissingSamples);
90+
Assert.NotEmpty(snapshot.Waveform.VoltageSeries);
91+
Assert.NotEmpty(snapshot.Waveform.CurrentSeries);
92+
Assert.Equal(4000, snapshot.Waveform.SampleRateHz);
93+
Assert.Equal(50.0, snapshot.Waveform.MeasuredFrequencyHz);
94+
Assert.Contains("estimated", snapshot.SelectedStreamDetails!.SmpRateText,
95+
StringComparison.OrdinalIgnoreCase);
96+
Assert.Contains("Raw 4I4V", snapshot.SelectedStreamDetails.SampleValueMappingText);
97+
}
98+
99+
[Fact]
100+
public void Scope_OptionalRateAbsentAndNoCounterWrap_StaysUnverified()
101+
{
102+
var analyzer = FeedSine(SamplesPerCycle * 10);
103+
var snapshot = analyzer.GetAnalyzerSnapshot();
104+
Assert.Equal(0, snapshot.Waveform.SampleRateHz);
105+
Assert.Contains("awaiting verified wrap", snapshot.SelectedStreamDetails!.SmpRateText);
106+
Assert.NotEmpty(snapshot.Waveform.VoltageSeries); // raw samples still usable without SCL
107+
}
108+
66109
[Fact]
67110
public void Scope_WindowIsPhaseLocked_AcrossWholeCycleAdvances()
68111
{

0 commit comments

Comments
 (0)