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); + } +}