From 52b28497c0f980ad208042db07c6877cf3378d20 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 18:29:59 +0700 Subject: [PATCH 1/7] feat(ui): separate phase RMS values and engineering units with fail-closed formatting --- .../Models/ChannelValueModel.cs | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) 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; } } From 0c3ff9caf03484a6a20628c3ed7582a67191abb0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 18:30:21 +0700 Subject: [PATCH 2/7] test: verify metering units, invalid readings and findings evidence --- .../OperatorReadoutContractTests.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tests/ProcessBus.Tests/OperatorReadoutContractTests.cs 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); + } +} From 009cb7ab9effbe5cce13f5fe06e8fb32cfe713f7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 18:30:24 +0700 Subject: [PATCH 3/7] feat(ui): expose concise findings without discarding raw event evidence --- .../Models/DiagnosticEventItem.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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"; + } + } } From a2258487377eff6ea3ebd27f028c5facb9925665 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 18:30:46 +0700 Subject: [PATCH 4/7] fix(ptp): notify replaced diagnostics snapshot and correct capture-wide evidence scope --- .../ViewModels/MainWindowViewModel.cs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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)); From 55b3c670d75a00f1fb172aab51b83b94cbc391de Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 18:31:24 +0700 Subject: [PATCH 5/7] refactor(ui): centralize compact WPF metering ledger styles --- src/ProcessBus.App.Wpf/Themes/Controls.xaml | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) 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 @@ + + + + + + From fe7ba8ee6daa79b62c1b0a5a6e5e976722b1b620 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 18:32:01 +0700 Subject: [PATCH 6/7] feat(ui): rebuild six-phase ledger and virtualized actionable diagnostic findings --- src/ProcessBus.App.Wpf/MainWindow.xaml | 390 ++++++++++++++----------- 1 file changed, 213 insertions(+), 177 deletions(-) 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"/> - +