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
25 changes: 19 additions & 6 deletions src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@
return null;

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

Check warning on line 268 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 268 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 268 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 269 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 269 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 269 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 @@ -1470,9 +1470,12 @@
var slots = new ScopeSampleFrame?[window];
foreach (var frame in frames)
{
var slot = frame.SampleCounter.HasValue
? frame.SampleCounter.Value % window
: (int)(frame.Index % window);
// Use the monotonic unwrapped sample index, not the on-wire
// UInt16 smpCnt. A diagnostic stream may wrap 65535 -> 0,
// which is not a multiple of the 2-cycle window (160 @ 50 Hz).
// Using raw smpCnt here splices two different phases together
// for one window and appears as a periodic scope flicker.
var slot = (int)(frame.Index % window);
Comment thread
masarray marked this conversation as resolved.
slots[slot] = frame;
}

Expand Down Expand Up @@ -2237,9 +2240,19 @@
if (SmpRate is > 0)
return SmpRate.Value;

// Adaptive fallback: after one full second of traffic the highest counter seen
// is rate-1. Require a plausible floor so early traffic cannot fake a tiny base.
return _maxSeenSequenceCounter >= 799 ? _maxSeenSequenceCounter + 1 : 0;
// 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.
// Only accept the supported nominal 50/60 Hz wrap boundaries if
// their final counter was actually observed. Unknown rollovers
// invalidate scope acquisition instead of silently shifting phase.
return _maxSeenSequenceCounter switch
{
3999 => 4000,
4799 => 4800,
65535 => 65536,
_ => 0
};
}

private static SequenceObservation AnalyzeSequence(ushort previous, ushort current, int rateWrapBase)
Expand Down
74 changes: 74 additions & 0 deletions tests/ProcessBus.Tests/ScopeStabilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,80 @@ public void Scope_OptionalRateAbsentAndNoCounterWrap_StaysUnverified()
Assert.NotEmpty(snapshot.Waveform.VoltageSeries); // raw samples still usable without SCL
}

[Fact]
public void Scope_UnknownRateRolloverAfterMissingTail_DoesNotHideGapOrPublishSplice()
{
// Only the last 3998 samples are seen. The canonical stream then jumps
// from 3998 to 0, with 3999 missing and smpRate absent. Inferring
// maxSeen+1=3999 would falsely call this a valid rollover.
var analyzer = new RawProcessBusAnalyzer();
const int start = 3999 - 319;
for (var i = 0; i < 319; i++)
{
var absolute = start + i;
analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples(
(ushort)absolute, ChannelValuesAt(absolute, Math.Sin),
"ARSTACK_SV01", 0x4000));
}

var before = UaSamples(analyzer).ToArray();
Assert.Equal(160, before.Length);

// Missing sample 3999. The analyzer must reject this discontinuity,
// holding its last complete window rather than silently compressing
// time by a sample on the unwrapped timeline.
analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples(
0, ChannelValuesAt(4000, Math.Sin), "ARSTACK_SV01", 0x4000));
var after = analyzer.GetAnalyzerSnapshot();
Assert.Equal(1, after.Diagnostics.SequenceErrors);
Assert.Equal(before, after.Waveform.VoltageSeries.Single(
x => x.Name == "Ua").Samples.ToArray());

// Reacquire from subsequent valid samples; never stitch a partial
// pre-gap view together with a post-gap view.
for (var i = 1; i <= 180; i++)
{
analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples(
(ushort)i, ChannelValuesAt(4000 + i, Math.Sin),
"ARSTACK_SV01", 0x4000));
}
Assert.Equal(160, UaSamples(analyzer).Count);
}

[Fact]
public void Scope_DiagnosticMirror16BitSmpCntWrap_DoesNotSplicePhase()
{
// The capture mirror intentionally uses a continuously incrementing 16-bit
// smpCnt, unlike canonical 4000-count rollover. At 65535->0, the old
// raw-smpCnt % 160 slotting overwrote slot 0 with a sample belonging
// to a different phase, despite zero actual sample loss.
var analyzer = new RawProcessBusAnalyzer();
const int start = 65536 - 320;
for (var i = 0; i < 320; i++)
{
var absolute = start + i;
analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples(
(ushort)(absolute % 65536),
ChannelValuesAt(absolute, Math.Sin),
"AR_DIAG_SV1", 0x4F01));
}

var before = UaSamples(analyzer).ToArray();
Assert.Equal(160, before.Length);

// Exactly one sample after rollover; the stable 80 samples/cycle signal
// has identical values at the same unwrapped scope slots.
analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples(
0, ChannelValuesAt(start + 320, Math.Sin),
"AR_DIAG_SV1", 0x4F01));
var snapshot = analyzer.GetAnalyzerSnapshot();
var after = snapshot.Waveform.VoltageSeries.Single(
s => string.Equals(s.Name, "Ua", StringComparison.OrdinalIgnoreCase)).Samples.ToArray();
Assert.Equal(0, snapshot.Diagnostics.SequenceErrors);
Assert.Equal(0, snapshot.Diagnostics.MissingSamples);
Assert.Equal(before, after);
}

[Fact]
public void Scope_WindowIsPhaseLocked_AcrossWholeCycleAdvances()
{
Expand Down
Loading