diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml
index 5f28a995..3c27643e 100644
--- a/src/ProcessBus.App.Wpf/MainWindow.xaml
+++ b/src/ProcessBus.App.Wpf/MainWindow.xaml
@@ -2494,92 +2494,98 @@
-
-
+
-
+
+
-
-
+
-
+
+
-
+
+
-
-
-
+
+
+
+
-
+
+
-
-
-
+
+
+
+
-
+
+
-
-
-
+
+
+
+
+
+
+
@@ -2831,29 +2837,48 @@
-
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
-
-
+
+
+
-
-
-
+
+
diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
index 84958b2d..d8bc7f4c 100644
--- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
+++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
@@ -144,6 +144,7 @@ private set
private readonly ObservableCollection _diagnosticTargets = new();
private TrafficHealthTargetRow? _selectedDiagnosticTarget;
+ private bool _nonSvDiagnosticTargetPinnedByOperator;
private DiagnosticEventItem? _selectedDiagnosticEvent;
public DiagnosticEventItem? SelectedDiagnosticEvent
@@ -181,6 +182,7 @@ public TrafficHealthTargetRow? SelectedDiagnosticTarget
BeginWorkspaceTargetTransition();
_selectedDiagnosticTarget = value;
+ _nonSvDiagnosticTargetPinnedByOperator = value is { Protocol: "PTP" or "GOOSE" };
ApplyDiagnosticTargetSelection(value);
OnPropertyChanged();
RaiseDiagnosticScopeProperties();
@@ -2077,7 +2079,7 @@ private async Task RefreshAsync()
? int.MaxValue
: (int)Diagnostics.SequenceErrors;
- _smcCntOk = Diagnostics.LastSampleCount > 0;
+ _smcCntOk = Diagnostics.LastSampleCount.HasValue;
_frequencyStable = Diagnostics.FrequencyEstimateValid;
SynchronizeSelectedStreamRow();
@@ -2357,14 +2359,21 @@ private void RebuildDiagnosticTargets()
SyncDiagnosticTargetRows(rows);
- var next = !string.IsNullOrWhiteSpace(previousKey)
+ var previouslySelected = !string.IsNullOrWhiteSpace(previousKey)
? _diagnosticTargets.FirstOrDefault(x => string.Equals(x.TargetKey, previousKey, StringComparison.OrdinalIgnoreCase))
: null;
-
- next ??= _diagnosticTargets.FirstOrDefault(x => x.SeverityRank > 0);
- next ??= SelectedStream is null
+ var analyzerStreamTarget = SelectedStream is null
? null
: _diagnosticTargets.FirstOrDefault(x => string.Equals(x.TargetKey, $"SV|{SelectedStream.StreamId}", StringComparison.OrdinalIgnoreCase));
+
+ // Automatically prefer the actual selected SV stream when it appears;
+ // the previous PTP placeholder must not hijack Diagnostics forever.
+ // Preserve only a user-requested PTP/GOOSE target until they select SV.
+ var next = _nonSvDiagnosticTargetPinnedByOperator &&
+ previouslySelected is { Protocol: "PTP" or "GOOSE" }
+ ? previouslySelected
+ : analyzerStreamTarget ?? previouslySelected;
+ next ??= _diagnosticTargets.FirstOrDefault(x => x.SeverityRank > 0);
next ??= _diagnosticTargets.FirstOrDefault();
if (!ReferenceEquals(_selectedDiagnosticTarget, next))
diff --git a/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs b/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs
index e60d56ed..1f7639ba 100644
--- a/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs
+++ b/src/ProcessBus.Core/Models/SvDiagnosticsSnapshot.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+
namespace ProcessBus.Core.Models;
public sealed class SvDiagnosticsSnapshot
@@ -64,4 +66,48 @@ public sealed class SvDiagnosticsSnapshot
public string TimingReferenceText { get; set; } = "Timing Reference: not observed";
public string TimestampSourceText { get; set; } = "Timestamp Source: Npcap software timestamp";
public string TimingMetricText { get; set; } = "Metric: arrival timing variation";
+ // Read-only operator telemetry computed from the selected stream snapshot.
+ // Arrival timing is host/Npcap evidence, never publisher jitter proof.
+ public string SvJitterHeadlineText => CurrentJitterMicroseconds is { } current && double.IsFinite(current)
+ ? current.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture) + " µs"
+ : "N/A";
+
+ public string SvJitterStatisticsText =>
+ $"Mean |Δ| {FormatMicroseconds(AverageAbsJitterMicroseconds)} · Max |Δ| {FormatMicroseconds(MaxAbsJitterMicroseconds)}";
+
+ public string SvJitterExcursionsText =>
+ $"≥300 µs: {RecentJitterOver300MicrosecondsCount} / 5s · {JitterOver300MicrosecondsCount} total";
+
+ // SequenceErrors means discontinuity events, not the number of missing sample values.
+ public string SvSequenceJumpsText => SequenceErrors.ToString("N0", CultureInfo.InvariantCulture);
+ public string SvMissingSamplesText => MissingSamples.ToString("N0", CultureInfo.InvariantCulture);
+ public string SvSequenceEvidenceText => LastSampleCount.HasValue
+ ? $"Last smpCnt: {LastSampleCount.Value.ToString(CultureInfo.InvariantCulture)}"
+ : "smpCnt: waiting for SV";
+ public string SvDecodeRejectText =>
+ $"Decoder rejects: {DecodeErrors.ToString("N0", CultureInfo.InvariantCulture)} (capture-wide)";
+
+ public string SvQualityHeadlineText => ValidityText switch
+ {
+ "4I+4V quality good" => "GOOD",
+ "4I+4V TEST data (laboratory stimulus)" => "TEST",
+ "4I+4V operator-blocked data" => "BLOCKED",
+ "4I+4V quality invalid: measurement withheld" => "INVALID",
+ _ => "UNVERIFIED"
+ };
+
+ public string SvQualityEvidenceText => $"{ValidityText} · smpSynch: {SmpSynchText}" +
+ (SmpSynchText == "N/A" ? " (unavailable)" : " (observed)");
+ public string SvArrivalIntervalText =>
+ $"Actual Δ {FormatMicroseconds(CurrentDeltaMicroseconds)} · Expected Δ {FormatMicroseconds(ExpectedDeltaMicroseconds)}";
+ public string SvArrivalAuthorityText =>
+ CurrentJitterMicroseconds.HasValue
+ ? "Host/Npcap arrival timestamps · capture-path screening only"
+ : "Arrival interval unavailable · no hardware timing or clock-lock claim";
+
+ private static string FormatMicroseconds(double? value) =>
+ value is { } number && double.IsFinite(number)
+ ? number.ToString("0.0", CultureInfo.InvariantCulture) + " µs"
+ : "N/A";
+
}
diff --git a/tests/ProcessBus.Tests/SvDiagnosticsReadoutTests.cs b/tests/ProcessBus.Tests/SvDiagnosticsReadoutTests.cs
new file mode 100644
index 00000000..61ed1ff9
--- /dev/null
+++ b/tests/ProcessBus.Tests/SvDiagnosticsReadoutTests.cs
@@ -0,0 +1,83 @@
+using ProcessBus.Core.Models;
+using Xunit;
+
+namespace ProcessBus.Tests;
+
+public sealed class SvDiagnosticsReadoutTests
+{
+ [Fact]
+ public void SelectedStreamShowsSignedArrivalJitterAndBoundedEvidence()
+ {
+ var d = new SvDiagnosticsSnapshot
+ {
+ CurrentJitterMicroseconds = -413.24,
+ AverageAbsJitterMicroseconds = 77.12,
+ MaxAbsJitterMicroseconds = 813.14,
+ RecentJitterOver300MicrosecondsCount = 5,
+ JitterOver300MicrosecondsCount = 1530,
+ CurrentDeltaMicroseconds = 663.24,
+ ExpectedDeltaMicroseconds = 250
+ };
+ Assert.Equal("-413.2 µs", d.SvJitterHeadlineText);
+ Assert.Equal("Mean |Δ| 77.1 µs · Max |Δ| 813.1 µs", d.SvJitterStatisticsText);
+ Assert.Equal("≥300 µs: 5 / 5s · 1530 total", d.SvJitterExcursionsText);
+ Assert.Equal("Actual Δ 663.2 µs · Expected Δ 250.0 µs", d.SvArrivalIntervalText);
+ Assert.Contains("Host/Npcap", d.SvArrivalAuthorityText);
+ Assert.DoesNotContain("locked", d.SvArrivalAuthorityText, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void MissingArrivalTimingDoesNotBecomeInventedZero()
+ {
+ var d = new SvDiagnosticsSnapshot { CurrentJitterMicroseconds = null };
+ Assert.Equal("N/A", d.SvJitterHeadlineText);
+ Assert.Contains("N/A", d.SvJitterStatisticsText);
+ Assert.Contains("unavailable", d.SvArrivalAuthorityText);
+ }
+
+ [Fact]
+ public void NonFiniteInputRemainsUnavailable()
+ {
+ var d = new SvDiagnosticsSnapshot
+ {
+ CurrentJitterMicroseconds = double.NaN,
+ AverageAbsJitterMicroseconds = double.PositiveInfinity,
+ MaxAbsJitterMicroseconds = double.NegativeInfinity
+ };
+ Assert.Equal("N/A", d.SvJitterHeadlineText);
+ Assert.Equal("Mean |Δ| N/A · Max |Δ| N/A", d.SvJitterStatisticsText);
+ }
+
+ [Fact]
+ public void SequenceJumpEventsAndMissingSamplesAreNotInterchanged()
+ {
+ var d = new SvDiagnosticsSnapshot
+ {
+ SequenceErrors = 5, MissingSamples = 27, LastSampleCount = 0,
+ DecodeErrors = 3
+ };
+ Assert.Equal("5", d.SvSequenceJumpsText);
+ Assert.Equal("27", d.SvMissingSamplesText);
+ Assert.Equal("Last smpCnt: 0", d.SvSequenceEvidenceText);
+ Assert.Equal("Decoder rejects: 3 (capture-wide)", d.SvDecodeRejectText);
+ }
+
+ [Theory]
+ [InlineData("4I+4V quality good", "GOOD")]
+ [InlineData("4I+4V TEST data (laboratory stimulus)", "TEST")]
+ [InlineData("4I+4V operator-blocked data", "BLOCKED")]
+ [InlineData("4I+4V quality invalid: measurement withheld", "INVALID")]
+ [InlineData("Quality mapping not verified", "UNVERIFIED")]
+ public void QualityVerdictTracksObservedQualityWithoutInferringGood(string evidence, string expected)
+ {
+ var d = new SvDiagnosticsSnapshot { ValidityText = evidence, SmpSynchText = "0" };
+ Assert.Equal(expected, d.SvQualityHeadlineText);
+ Assert.Contains("smpSynch: 0 (observed)", d.SvQualityEvidenceText);
+ }
+
+ [Fact]
+ public void NoSmpCntObservationIsPendingNotZero()
+ {
+ Assert.Equal("smpCnt: waiting for SV", new SvDiagnosticsSnapshot().SvSequenceEvidenceText);
+ }
+}