Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@
return null;

var appId = FormatAppId(frame.AppId);
var vlanId = frame.Ethernet.Vlan?.VlanId.ToString() ?? "Untagged";

Check warning on line 246 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 246 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Deterministic multi-stream stability gate

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 246 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 246 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Deterministic multi-stream stability gate

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 246 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
var confRev = asdu.ConfRev?.ToString() ?? "N/A";

Check warning on line 247 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 247 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Deterministic multi-stream stability gate

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 247 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 247 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Deterministic multi-stream stability gate

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 247 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build verified Windows x64 candidate

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.ResolveSvMappingProfile(ProcessBusFrame, SampledValueAsdu)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

return _svChannelMappings
.Where(profile => IsSvProfileIdentityCompatible(profile, frame, asdu, appId, vlanId, confRev))
Expand Down Expand Up @@ -395,7 +395,7 @@
Source = source,
Destination = destination,
DomainText = $"D{message.DomainNumber}",
SequenceIdText = message.SequenceId.ToString(),

Check warning on line 398 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.TrackPtpEvent(PtpMessage)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 398 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Deterministic multi-stream stability gate

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.TrackPtpEvent(PtpMessage)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 398 in src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Build, test, and repository health

The behavior of 'ushort.ToString()' could vary based on the current user's locale settings. Replace this call in 'RawProcessBusAnalyzer.TrackPtpEvent(PtpMessage)' with a call to 'ushort.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
ClockIdentity = message.SourceClockIdentity
});

Expand Down Expand Up @@ -798,6 +798,7 @@
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;
Expand Down Expand Up @@ -952,7 +953,10 @@
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",
Expand Down Expand Up @@ -1568,8 +1572,17 @@
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;
Expand Down Expand Up @@ -1632,10 +1645,20 @@

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)
Expand Down Expand Up @@ -1814,13 +1837,15 @@
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()
Expand Down
43 changes: 43 additions & 0 deletions tests/ProcessBus.Tests/ScopeStabilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,49 @@ private static IReadOnlyList<double> 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()
{
Expand Down
Loading