diff --git a/docs/FINAL_OPERATOR_UX_ACCEPTANCE_2026-10-17.md b/docs/FINAL_OPERATOR_UX_ACCEPTANCE_2026-10-17.md
new file mode 100644
index 0000000..889c059
--- /dev/null
+++ b/docs/FINAL_OPERATOR_UX_ACCEPTANCE_2026-10-17.md
@@ -0,0 +1,26 @@
+# Operator UX release-candidate acceptance — 2026-10-17
+
+## Source and scope
+
+Base: `main@232015b4b0d8986e11af313ad31fdb7d8b35ca90`.
+Candidate work: `fix/final-premium-ux-20261017`.
+
+This change is **read-only presentation and binding hardening**. No SV/GOOSE/PTP wire decoder, raw capture path, SCL authority, measurement algorithm, or waveform sample interpolation has been changed. Real PTP lock, physical timing accuracy, and calibrated electrical units remain unverified unless independently substantiated.
+
+## Problem → root cause → implementation → acceptance
+
+| Problem | Root cause | Implementation | Acceptance |
+| --- | --- | --- | --- |
+| PTP matched two-step headline 0 while evidence count advances | `Diagnostics` object replacement did not notify bindings in normal Diagnostics/Timing refresh; derived `PtpTwoStepEvidenceText` was notified | Notify `Diagnostics` in both scoped UI refreshes | Headline and passive evidence read the same snapshot on every refresh |
+| Six-phase metering cramped with RMS and unit as one string | 3-column fixed-angle ledger, ad-hoc row styling and generic formatted strings | Four-column phase/RMS/unit/angle ledger with shared WPF styles, invariant numeric formatting, group readability and more height for meter | At 1366×768 all phases legible with no numeric overlap |
+| Uncalibrated/unknown or non-finite numeric metadata could appear as a physical measurement | Value formatter inferred V/A for unspecified units and did not reject non-finite values | Display only source-provided units; render unavailable/nonfinite as N/A; keep counts separate from calibrated units | Unit authority never invented; no NaN/Infinity shown as valid |
+| Diagnostics findings become a text wall | Long `Message` rendered directly for each event; list virtualization not explicit; header incorrectly implied target filtering | Bounded existing evidence retained, concise summary/source columns, recycling virtualization, selected-event full evidence under expander; label all-source collection accurately | Short rows with full technical evidence available on selection |
+| Metering difficult to see below phasor at reduced window height | Phasor minimum height consumed the constrained right column | Rebalance phasor/meter height while preserving diagram | No key phase values hidden at 1366×768 |
+
+## Automated and manual gates
+
+The repository's existing CI must confirm Release build, xUnit regression suite, runtime stability, CodeQL and dependency review. Windows candidate packaging must confirm ZIP publish, verification, checksum/manifest and artifact upload. New model regression tests cover raw count formatting, scaled engineering units, non-finite/unverified readings and diagnostic evidence preservation.
+
+**Manual visual QA required** at 1366×768 (100%), 1600×900 (100%), 1920×1080 (125%), and 150% Windows DPI if possible, including resized windows; zero/one/many streams; uncalibrated/explicit scaling; invalid quality; PTP observed/absent; and rapid selected-stream switching. No pixel-level GUI pass can be inferred from CI alone.
+
+**Physical acceptance remains OPEN** under issue #30: VLAN TAP/SPAN, 60-minute SV/PTP hardware soak with measured drops/CPU/GC, independent IED receiver, actual clock discipline evidence. Passing CI/package is only **Software Release Candidate Ready for Physical Acceptance**, not a certified or field-accepted release.
diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml
index 2879d53..5f28a99 100644
--- a/src/ProcessBus.App.Wpf/MainWindow.xaml
+++ b/src/ProcessBus.App.Wpf/MainWindow.xaml
@@ -2132,9 +2132,9 @@
Grid.RowSpan="3"
Opacity="0.94">
-
+
-
+
@@ -2182,20 +2182,11 @@
-
+
-
-
-
@@ -2207,172 +2198,196 @@
Text="{Binding WaveformHeaderFrequencyText}"
FontSize="11" Foreground="#8CBDE4"/>
-
+
+ ToolTip="Copy original engineering values and passive capture evidence"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2854,7 +2869,23 @@
Style="{StaticResource PanelTitleTextStyle}"
Margin="0,0,0,8"/>
+
+
+
+
+
-
-
+
+
+
@@ -2890,12 +2922,16 @@
FontSize="10.5"/>
-
+
+
diff --git a/src/ProcessBus.App.Wpf/Themes/Controls.xaml b/src/ProcessBus.App.Wpf/Themes/Controls.xaml
index 674355f..6bb3b3c 100644
--- a/src/ProcessBus.App.Wpf/Themes/Controls.xaml
+++ b/src/ProcessBus.App.Wpf/Themes/Controls.xaml
@@ -851,4 +851,36 @@
+
+
+
+
+
+
diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
index bccedbc..84958b2 100644
--- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
+++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
@@ -144,6 +144,19 @@ private set
private readonly ObservableCollection _diagnosticTargets = new();
private TrafficHealthTargetRow? _selectedDiagnosticTarget;
+ private DiagnosticEventItem? _selectedDiagnosticEvent;
+
+ public DiagnosticEventItem? SelectedDiagnosticEvent
+ {
+ get => _selectedDiagnosticEvent;
+ set
+ {
+ if (ReferenceEquals(_selectedDiagnosticEvent, value))
+ return;
+ _selectedDiagnosticEvent = value;
+ OnPropertyChanged();
+ }
+ }
public ObservableCollection GooseHistory => _gooseHistory;
public ICollectionView GooseHistoryView => _gooseHistoryView;
@@ -297,9 +310,9 @@ public string DiagnosticAffectedSummaryText
}
}
- public string DiagnosticFindingsHeaderText => SelectedDiagnosticTarget is null
- ? "Recent Findings"
- : $"Findings for {SelectedDiagnosticTarget.Protocol} target";
+ // This collection contains capture-wide evidence; do not label it as
+ // filtered to the diagnostic target when the backend has not supplied such a filter.
+ public string DiagnosticFindingsHeaderText => "Recent capture findings · all sources";
public string AdvancedTargetTitle => SelectedDiagnosticTarget is null
@@ -2588,6 +2601,10 @@ private void RaiseAnalyzerRenderProperties(bool force = false)
private void RaiseDiagnosticsProperties()
{
+ // Diagnostics is a replaced snapshot, not an observable mutable object.
+ // Without this notification direct bindings (e.g. PTP matched pair count)
+ // froze at zero while independently-notified summary text kept advancing.
+ OnPropertyChanged(nameof(Diagnostics));
OnPropertyChanged(nameof(HealthIcon));
OnPropertyChanged(nameof(HealthBackgroundBrush));
OnPropertyChanged(nameof(IsHealthy));
@@ -2651,6 +2668,7 @@ private void RaiseGooseProperties()
private void RaiseTimingProperties()
{
+ OnPropertyChanged(nameof(Diagnostics));
OnPropertyChanged(nameof(ProtocolMonitor));
OnPropertyChanged(nameof(PtpEvents));
OnPropertyChanged(nameof(ProtocolSummaryText));
diff --git a/src/ProcessBus.Core/Models/ChannelValueModel.cs b/src/ProcessBus.Core/Models/ChannelValueModel.cs
index c7d483a..e29cc90 100644
--- a/src/ProcessBus.Core/Models/ChannelValueModel.cs
+++ b/src/ProcessBus.Core/Models/ChannelValueModel.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+
namespace ProcessBus.Core.Models;
public sealed class ChannelValueModel
@@ -12,23 +14,52 @@ public ChannelValueModel(string name)
public double? RmsValue { get; init; }
public double? AngleDegrees { get; init; }
public string Unit { get; init; } = string.Empty;
+
+ // The operator ledger keeps the numerical readout and its unit in separate columns.
+ // Do not infer V/A when engineering scale authority has not supplied a unit.
+ public string RmsNumberText => FormatNumber(RmsValue);
+ public string RmsUnitText => !RmsValue.HasValue || !double.IsFinite(RmsValue.Value)
+ ? "—"
+ : DisplayUnit(RmsValue.Value);
+ public string AngleNumberText => AngleDegrees is { } angle && double.IsFinite(angle)
+ ? angle.ToString("0.00", CultureInfo.InvariantCulture) + "°"
+ : "N/A";
+
+ // Preserve existing display properties for the advanced inspector and clipboard.
public string DisplayText => FormatEngineeringValue(InstantValue);
public string RmsDisplayText => FormatEngineeringValue(RmsValue);
- public string AngleDisplayText => AngleDegrees.HasValue ? $"{AngleDegrees.Value:0.00} deg" : "N/A";
+ public string AngleDisplayText => AngleDegrees is { } angle && double.IsFinite(angle)
+ ? angle.ToString("0.00", CultureInfo.InvariantCulture) + " deg"
+ : "N/A";
- private string DisplayUnit => !string.IsNullOrWhiteSpace(Unit)
- ? Unit
- : Name.StartsWith("U", StringComparison.OrdinalIgnoreCase) ? "V" : "A";
+ private string DisplayUnit(double value)
+ {
+ if (string.IsNullOrWhiteSpace(Unit))
+ return "—";
- private string FormatEngineeringValue(double? value)
+ return string.Equals(Unit, "V", StringComparison.OrdinalIgnoreCase) &&
+ Math.Abs(value) >= 1000.0
+ ? "kV"
+ : Unit;
+ }
+
+ private string FormatNumber(double? value)
{
- if (!value.HasValue)
+ if (value is not { } number || !double.IsFinite(number))
return "N/A";
- var unit = DisplayUnit;
- if (string.Equals(unit, "V", StringComparison.OrdinalIgnoreCase) && Math.Abs(value.Value) >= 1000.0)
- return $"{value.Value / 1000.0:0.00} kV";
+ var scaled = string.Equals(Unit, "V", StringComparison.OrdinalIgnoreCase) &&
+ Math.Abs(number) >= 1000.0
+ ? number / 1000.0
+ : number;
+ return scaled.ToString("0.00", CultureInfo.InvariantCulture);
+ }
- return $"{value.Value:0.00} {unit}";
+ private string FormatEngineeringValue(double? value)
+ {
+ if (value is not { } number || !double.IsFinite(number))
+ return "N/A";
+ var unit = DisplayUnit(number);
+ return unit == "—" ? FormatNumber(value) : FormatNumber(value) + " " + unit;
}
}
diff --git a/src/ProcessBus.Core/Models/DiagnosticEventItem.cs b/src/ProcessBus.Core/Models/DiagnosticEventItem.cs
index c98225f..9043d13 100644
--- a/src/ProcessBus.Core/Models/DiagnosticEventItem.cs
+++ b/src/ProcessBus.Core/Models/DiagnosticEventItem.cs
@@ -5,4 +5,36 @@ public sealed class DiagnosticEventItem
public DateTime TimestampUtc { get; set; }
public string Severity { get; set; } = "Info";
public string Message { get; set; } = string.Empty;
+
+ // Separate a concise finding from its unchanged forensic message.
+ // This is presentation only: the full evidence remains in Message.
+ public string FindingText
+ {
+ get
+ {
+ if (string.IsNullOrWhiteSpace(Message))
+ return "No description";
+ var separator = Message.IndexOf(':');
+ if (separator is >= 8 and <= 96)
+ return Message[..separator].Trim();
+ return Message.Length <= 96 ? Message : Message[..93] + "…";
+ }
+ }
+
+ public string SourceText
+ {
+ get
+ {
+ // Only surface an identity when it occurs explicitly in the captured evidence.
+ var at = Message.IndexOf("svID=", StringComparison.OrdinalIgnoreCase);
+ if (at < 0)
+ return "Capture";
+ at += 5;
+ var end = at;
+ while (end < Message.Length && end - at < 40 &&
+ Message[end] is not (';' or ',' or ' ' or '\r' or '\n'))
+ end++;
+ return end > at ? Message[at..end] : "Capture";
+ }
+ }
}
diff --git a/tests/ProcessBus.Tests/OperatorReadoutContractTests.cs b/tests/ProcessBus.Tests/OperatorReadoutContractTests.cs
new file mode 100644
index 0000000..2d4146e
--- /dev/null
+++ b/tests/ProcessBus.Tests/OperatorReadoutContractTests.cs
@@ -0,0 +1,56 @@
+using ProcessBus.Core.Models;
+using Xunit;
+
+namespace ProcessBus.Tests;
+
+public sealed class OperatorReadoutContractTests
+{
+ [Fact]
+ public void RawCountsRemainUncalibratedAndInSeparateUnitColumn()
+ {
+ var voltage = new ChannelValueModel("Ua")
+ {
+ Unit = "counts", RmsValue = 5773.162, AngleDegrees = -119.995
+ };
+ Assert.Equal("5773.16", voltage.RmsNumberText);
+ Assert.Equal("counts", voltage.RmsUnitText);
+ Assert.Equal("-120.00°", voltage.AngleNumberText);
+ Assert.Equal("5773.16 counts", voltage.RmsDisplayText);
+ }
+
+ [Fact]
+ public void ScalingUsesActualEngineeringUnitAndKeepsMagnitudeConsistent()
+ {
+ var voltage = new ChannelValueModel("Ua") { Unit = "V", RmsValue = 11000 };
+ var current = new ChannelValueModel("Ia") { Unit = "A", RmsValue = 999.35 };
+ Assert.Equal("11.00", voltage.RmsNumberText);
+ Assert.Equal("kV", voltage.RmsUnitText);
+ Assert.Equal("11.00 kV", voltage.RmsDisplayText);
+ Assert.Equal("999.35", current.RmsNumberText);
+ Assert.Equal("A", current.RmsUnitText);
+ }
+
+ [Fact]
+ public void UnknownOrNonFiniteValuesNeverClaimPhysicalReadings()
+ {
+ var unknown = new ChannelValueModel("Ua") { RmsValue = 200, AngleDegrees = double.NaN };
+ var invalid = new ChannelValueModel("Ia") { Unit = "A", RmsValue = double.PositiveInfinity };
+ Assert.Equal("—", unknown.RmsUnitText);
+ Assert.Equal("200.00", unknown.RmsNumberText);
+ Assert.Equal("N/A", unknown.AngleNumberText);
+ Assert.Equal("N/A", invalid.RmsNumberText);
+ Assert.Equal("—", invalid.RmsUnitText);
+ Assert.Equal("N/A", invalid.RmsDisplayText);
+ }
+
+ [Fact]
+ public void DiagnosticRowSummarizesButNeverLosesOriginalEvidence()
+ {
+ var detail = "SV arrival timing excursions >= 300 us: 4 event(s); latest: svID=AR_DIAG_SV1; APPID=0x4F01";
+ var row = new DiagnosticEventItem { Message = detail, Severity = "Warning" };
+ Assert.Equal("SV arrival timing excursions >= 300 us", row.FindingText);
+ Assert.Equal("AR_DIAG_SV1", row.SourceText);
+ Assert.Equal(detail, row.Message);
+ Assert.Equal("Capture", new DiagnosticEventItem { Message = "PTP observed" }.SourceText);
+ }
+}