From 0fe852ebf0501b68d3d3eb9c747cc5bc5a3a4873 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:36:07 +0700 Subject: [PATCH 01/10] feat(scope): align real coherent SV windows to rising Ua zero-cross without synthetic points --- .../Analysis/RawProcessBusAnalyzer.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs index 9bca7db7..08fae1c6 100644 --- a/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs +++ b/src/ProcessBus.Iec61850.Raw/Analysis/RawProcessBusAnalyzer.cs @@ -1606,6 +1606,24 @@ private IReadOnlyList BuildCoherentScopeWindow(int samplesPerC if (frames.Length < window) return Array.Empty(); + // Prefer a real positive-going Ua zero crossing for the visible + // window. Take a contiguous chronological slice from existing SV + // frames; never rotate, interpolate, or synthesize wire samples. + // Leave up to one measured cycle of slack for an aligned 1/2/4/8 + // cycle window, otherwise retain the established slot-locked fallback. + var alignedStart = FindUaRisingStart(frames, window, pointsPerCycle); + if (alignedStart >= 0) + { + var aligned = new ScopeSampleFrame[window]; + for (var index = 0; index < window; index++) + aligned[index] = frames[alignedStart + index] with { Index = index }; + + _cachedWindow = aligned; + _cachedWindowIndex = _scopeFrameIndex; + _cachedWindowCycles = _scopeCycles; + return _cachedWindow; + } + var slots = new ScopeSampleFrame?[window]; foreach (var frame in frames) { @@ -1633,6 +1651,50 @@ private IReadOnlyList BuildCoherentScopeWindow(int samplesPerC return _cachedWindow; } + // Called only when publishing a snapshot, never per received packet. + // Index continuity is mandatory: otherwise a sample loss could become + // a visually convincing periodic signal by compressing the time axis. + private static int FindUaRisingStart( + IReadOnlyList frames, int window, int samplesPerCycle) + { + var latestStart = frames.Count - window; + if (latestStart < 1 || samplesPerCycle < 2) + return -1; + + var firstCandidate = Math.Max(1, latestStart - samplesPerCycle); + for (var start = latestStart; start >= firstCandidate; start--) + { + var previous = frames[start - 1].GetValue("Ua"); + var current = frames[start].GetValue("Ua"); + if (!previous.HasValue || !current.HasValue || + !double.IsFinite(previous.Value) || !double.IsFinite(current.Value) || + previous.Value > 0 || current.Value < 0 || + (previous.Value == 0 && current.Value == 0)) + continue; + + var contiguous = frames[start].Index == frames[start - 1].Index + 1; + var peak = 0.0; + for (var i = 0; contiguous && i < window; i++) + { + var frame = frames[start + i]; + var value = frame.GetValue("Ua"); + if (!value.HasValue || !double.IsFinite(value.Value) || + (i > 0 && frame.Index != frames[start + i - 1].Index + 1)) + { + contiguous = false; + break; + } + peak = Math.Max(peak, Math.Abs(value.Value)); + } + + // Flat/noisy Ua is not a meaningful phase reference. + if (contiguous && peak > 1e-6) + return start; + } + + return -1; + } + private static int ResolveArsvinPointsPerCycle(int samplesPerCycle) { var candidate = samplesPerCycle > 0 ? samplesPerCycle : DefaultSamplesPerCycle; From 6e1d0d3220324815e71fa87dbd1d6c9f0483c26a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:36:37 +0700 Subject: [PATCH 02/10] test(scope): rising zero-cross on real Ua samples, same-slot phase coherence and flat-signal fallback --- .../ScopeZeroCrossAlignmentTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/ProcessBus.Tests/ScopeZeroCrossAlignmentTests.cs diff --git a/tests/ProcessBus.Tests/ScopeZeroCrossAlignmentTests.cs b/tests/ProcessBus.Tests/ScopeZeroCrossAlignmentTests.cs new file mode 100644 index 00000000..97ee42e3 --- /dev/null +++ b/tests/ProcessBus.Tests/ScopeZeroCrossAlignmentTests.cs @@ -0,0 +1,74 @@ +using ProcessBus.Iec61850.Raw.Analysis; +using Xunit; + +namespace ProcessBus.Tests; + +public sealed class ScopeZeroCrossAlignmentTests +{ + private const int SamplesPerCycle = 80; + + private static int Sample(int index, double phaseDegrees, double magnitude = 8000) => + (int)Math.Round(magnitude * Math.Sin(2 * Math.PI * (index % SamplesPerCycle) / SamplesPerCycle + + phaseDegrees * Math.PI / 180)); + + private static int[] Channels(int index) => + [ + Sample(index, -30, 1400), Sample(index, -150, 1400), + Sample(index, 90, 1400), 0, + Sample(index, 0), Sample(index, -120), + Sample(index, 120), 0 + ]; + + [Fact] + public void RealTwoCycleWindowBeginsNearRisingUaZeroRatherThanArbitraryCaptureOffset() + { + var analyzer = new RawProcessBusAnalyzer(); + for (var n = 17; n < 17 + 720; n++) + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + (ushort)(n % 4000), Channels(n))); + + var snapshot = analyzer.GetAnalyzerSnapshot(); + var ua = snapshot.Waveform.VoltageSeries.Single(x => x.Name == "Ua").Samples; + var ub = snapshot.Waveform.VoltageSeries.Single(x => x.Name == "Ub").Samples; + var uc = snapshot.Waveform.VoltageSeries.Single(x => x.Name == "Uc").Samples; + var ia = snapshot.Waveform.CurrentSeries.Single(x => x.Name == "Ia").Samples; + Assert.Equal(160, ua.Count); + Assert.InRange(ua[0], 0, 650); // actual captured sample, <= one 4.5° step + Assert.True(ua[1] > ua[0], "Ua must rise through zero"); + Assert.InRange(ub[0], -7600, -6500); + Assert.InRange(uc[0], 6500, 7600); + Assert.InRange(ia[0], -810, -600); + Assert.Equal(ua[0], ua[80]); + Assert.Equal(ub[0], ub[80]); + } + + [Fact] + public void MissingUaCannotCreateAnArtificialZeroCrossing() + { + var analyzer = new RawProcessBusAnalyzer(); + for (var n = 0; n < 700; n++) + { + var samples = Channels(n); + samples[4] = 0; + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + (ushort)n, samples)); + } + var snapshot = analyzer.GetAnalyzerSnapshot(); + var ua = snapshot.Waveform.VoltageSeries.Single(x => x.Name == "Ua").Samples; + Assert.All(ua, point => Assert.Equal(0, point)); + } + + [Fact] + public void PhaseAlignedPresentationDoesNotInventFixedElectricalAngles() + { + var analyzer = new RawProcessBusAnalyzer(); + for (var n = 17; n < 737; n++) + analyzer.ObserveFrame(GoldenFrames.SvFrameWithChannelSamples( + (ushort)n, Channels(n))); + + var snapshot = analyzer.GetAnalyzerSnapshot(); + Assert.NotNull(snapshot.AnalogValues.Ic.AngleDegrees); + Assert.InRange(snapshot.AnalogValues.Ic.AngleDegrees!.Value, 89, 91); + Assert.InRange(snapshot.AnalogValues.Uc.AngleDegrees!.Value, 119, 121); + } +} From dc63454eaef744eb21433a4e3f2c0ca7f2e6b79a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:38:11 +0700 Subject: [PATCH 03/10] feat(goose-ui): add useful zero-event guidance and reduce dense catalog wording --- src/ProcessBus.App.Wpf/MainWindow.xaml | 41 ++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index f51ea27b..1d3684a5 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3158,6 +3158,43 @@ + + + + + + + + + + + + + + + + + @@ -3935,7 +3972,7 @@ - + From e6095781ba893215e150c27f7792a8894c47d844 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:38:45 +0700 Subject: [PATCH 04/10] feat(scl): present short grounded identity verdict and clarify matching score is not probability --- .../ViewModels/MainWindowViewModel.cs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index dcd8a8ae..159747fa 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -1343,6 +1343,10 @@ public SclStreamCatalogRow? SelectedSclStreamCatalog public string SclSelectedBindingText => SelectedSclBindingMatrixRow?.StatusSummaryText ?? SelectedSclStreamCatalog?.LiveStatusText ?? "Binding pending"; + public string SclSelectedVerdictText => SelectedSclBindingMatrixRow?.OperatorVerdictText + ?? "Select a stream to inspect its live identity."; + public string SclSelectedVerdictDetailText => SelectedSclBindingMatrixRow?.OperatorEvidenceText + ?? "Compare the selected SCL stream with captured publisher identity."; public string SclSelectedExpectedText => SelectedSclBindingMatrixRow?.ExpectedDetailText ?? SelectedSclStreamCatalog?.TransportText ?? "No expected stream selected"; @@ -3967,6 +3971,8 @@ private void RaiseSclSelectionProperties() OnPropertyChanged(nameof(SclSelectedTransportText)); OnPropertyChanged(nameof(SclSelectedDatasetText)); OnPropertyChanged(nameof(SclSelectedBindingText)); + OnPropertyChanged(nameof(SclSelectedVerdictText)); + OnPropertyChanged(nameof(SclSelectedVerdictDetailText)); OnPropertyChanged(nameof(SclSelectedExpectedText)); OnPropertyChanged(nameof(SclSelectedObservedText)); OnPropertyChanged(nameof(SelectedSclBindingMatrixRow)); @@ -4991,7 +4997,26 @@ public sealed class SclBindingMatrixRow : INotifyPropertyChanged _ => "#8FA8BF" }; - public string StatusSummaryText => $"{StatusText} - confidence {Score}% - {EvidenceText}"; + // Similarity score ranks observed candidates; it is NOT the probability + // that the publisher matches the engineering configuration. + public string StatusSummaryText => $"{StatusText} · match score {Score}% · {EvidenceText}"; + public string OperatorVerdictText => StatusText switch + { + "MISMATCH" when Protocol == "SV" && + !string.Equals(ExpectedName, ObservedName, StringComparison.OrdinalIgnoreCase) + => "MISMATCH · svID differs", + "MISMATCH" => "MISMATCH · publisher attributes differ", + "MATCHED" => "MATCHED · observed identity agrees", + "MATCHED_WITH_WARNING" => "MATCHED · design warning", + "MISSING" => "NOT OBSERVED · expected stream", + "UNEXPECTED" => "UNEXPECTED · not defined in SCL", + "AMBIGUOUS" => "AMBIGUOUS · multiple identity candidates", + "CONFLICT" => "CONFLICT · identity collision", + _ => $"{StatusText} · verify identity" + }; + public string OperatorEvidenceText => string.IsNullOrWhiteSpace(EvidencePrimaryText) + ? "Inspect the original expected/observed attributes." + : EvidencePrimaryText + " · Match score " + Score.ToString(System.Globalization.CultureInfo.InvariantCulture) + "% (not probability)"; public string StatusBackgroundBrush => StatusText switch { "MATCHED" => "#17382C", @@ -5005,7 +5030,7 @@ public sealed class SclBindingMatrixRow : INotifyPropertyChanged _ => "#142235" }; - public string ScoreText => Score > 0 ? $"{Score}% confidence" : "No score"; + public string ScoreText => Score > 0 ? $"Match score {Score}%" : "No score"; public string ExpectedMetaText => string.Join(" - ", new[] { IedName, AppIdText, VlanText }.Where(x => !string.IsNullOrWhiteSpace(x))); public string ObservedMetaText => string.IsNullOrWhiteSpace(LiveKey) ? "Not seen on selected adapter" : string.Join(" - ", new[] { AppIdText, VlanText }.Where(x => !string.IsNullOrWhiteSpace(x))); public string EvidencePrimaryText => SplitEvidence(EvidenceText).Primary; From 5bdcf1664638234b6d997a3353fdc3027b104787 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:39:15 +0700 Subject: [PATCH 05/10] feat(scl-ui): replace text wall with concise binding verdict and expandable full expected/observed evidence --- src/ProcessBus.App.Wpf/MainWindow.xaml | 37 +++++++++++++++++--------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 1d3684a5..8a3d3482 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3974,7 +3974,7 @@ @@ -4211,7 +4211,25 @@ - + + + + + + + + + + + @@ -4230,18 +4248,11 @@ - - - - - + - + From c60c9cb9d4507c2191486df94e56def89c278edc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:40:02 +0700 Subject: [PATCH 06/10] feat(diag): offer concise evidence-based operator next check without guessing publisher fault --- .../ViewModels/MainWindowViewModel.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs index 159747fa..4b15489f 100644 --- a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs +++ b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs @@ -315,6 +315,24 @@ public string DiagnosticAffectedSummaryText // 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. + // One bounded operator hint derived from actual SV evidence; not an + // automated root-cause diagnosis or an accusation against the publisher. + public string DiagnosticOperatorGuidanceText + { + get + { + if (!string.Equals(SelectedDiagnosticTarget?.Protocol, "SV", StringComparison.OrdinalIgnoreCase)) + return "Inspect selected protocol evidence below; passive capture cannot verify subscriber operation."; + if (Diagnostics.SequenceErrors > 0 || Diagnostics.MissingSamples > 0) + return "smpCnt discontinuities observed. Compare publisher counters and capture-path drops before assigning the cause."; + if (Diagnostics.RecentJitterOver300MicrosecondsCount > 0) + return "Arrival excursions in the last 5 s. Inspect adapter/USB timestamping or SPAN/TAP before attributing publisher jitter."; + if (Diagnostics.SmpSynchText == "0") + return "Publisher reports unsynchronized samples (smpSynch=0); observed PTP packets do not establish clock lock."; + return "No current sample-sequence alert; inspect the capture timeline for historical excursions."; + } + } + public string DiagnosticFindingsHeaderText => "Recent capture findings · all sources"; @@ -2495,6 +2513,7 @@ private void RaiseDiagnosticScopeProperties() OnPropertyChanged(nameof(DiagnosticTargetHealthText)); OnPropertyChanged(nameof(DiagnosticAffectedSummaryText)); OnPropertyChanged(nameof(DiagnosticFindingsHeaderText)); + OnPropertyChanged(nameof(DiagnosticOperatorGuidanceText)); } private static string ShortenMac(string value) @@ -2656,6 +2675,7 @@ private void RaiseDiagnosticsProperties() // 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(DiagnosticOperatorGuidanceText)); OnPropertyChanged(nameof(HealthIcon)); OnPropertyChanged(nameof(HealthBackgroundBrush)); OnPropertyChanged(nameof(IsHealthy)); From 3bb970e4c259734adc32790b835ac72842bf4526 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:40:21 +0700 Subject: [PATCH 07/10] feat(diag-ui): surface relevant operator guidance above metrics while preserving full forensic findings --- src/ProcessBus.App.Wpf/MainWindow.xaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 8a3d3482..6f2a5167 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -2485,6 +2485,11 @@ FontSize="12.5" FontWeight="SemiBold" Margin="0,1,0,0"/> + @@ -2916,7 +2921,7 @@ Date: Sun, 18 Oct 2026 05:41:26 +0700 Subject: [PATCH 08/10] ci(candidate): include feature UX branches in signed-off Windows package gate --- .github/workflows/candidate-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/candidate-package.yml b/.github/workflows/candidate-package.yml index 60e02c61..18d6ce2c 100644 --- a/.github/workflows/candidate-package.yml +++ b/.github/workflows/candidate-package.yml @@ -36,7 +36,7 @@ env: jobs: candidate: name: Build verified Windows x64 candidate - if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') || startsWith(github.head_ref, 'legal/') || startsWith(github.head_ref, 'fix/') + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'stabilization/') || startsWith(github.head_ref, 'architecture/') || startsWith(github.head_ref, 'legal/') || startsWith(github.head_ref, 'fix/') || startsWith(github.head_ref, 'feat/') runs-on: windows-latest timeout-minutes: 35 From 69c1d3e496a7fdd25d79d6f834caeafe3428ed8e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:42:37 +0700 Subject: [PATCH 09/10] refactor(ptp-ui): assign unused vertical space to live PTP event history --- src/ProcessBus.App.Wpf/MainWindow.xaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index 6f2a5167..e5ff7c6a 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -3429,10 +3429,10 @@ - - - - + + + + @@ -3483,7 +3483,7 @@ - + From 03f11d53af97d34150ea997c139ba50f26de8357 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 18 Oct 2026 05:43:41 +0700 Subject: [PATCH 10/10] docs: operator zero-cross and progressive-disclosure UX acceptance with bench limitations --- docs/OPERATOR_UX_ACCEPTANCE_2026-10-18.md | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/OPERATOR_UX_ACCEPTANCE_2026-10-18.md diff --git a/docs/OPERATOR_UX_ACCEPTANCE_2026-10-18.md b/docs/OPERATOR_UX_ACCEPTANCE_2026-10-18.md new file mode 100644 index 00000000..f1b58c5f --- /dev/null +++ b/docs/OPERATOR_UX_ACCEPTANCE_2026-10-18.md @@ -0,0 +1,25 @@ +# Operator UX upgrade — engineering acceptance (2026-10-18) + +Source baseline: `main@4cf439eaa2de1a53cca752b5af4428d5a212d6fd`. + +## Operator tasks covered + +1. **Analyzer** — select one publisher, see its actual sampled signal, scaled 4I+4V assumptions and phasor/RMS. The default scope now prefers the positive-going **Ua zero crossing** from an unbroken chronological window of raw decoded SV sample points. No synthetic sinusoid, per-channel phase shift, or value interpolation is permitted. On startup or when Ua is unavailable, it falls back to the prior slot-locked view. The rising crossing is a display alignment, **not a timestamp or clock-lock measurement**. Displayed values may lag up to one nominal cycle versus arrival when waiting for the most recent complete aligned window. +2. **Diagnostics** — retain current jitter, sample-counter jumps, missing samples, separate quality/sync, and detailed event evidence. Add one contextual, evidence-only operator next check instead of speculative root-cause detection. No new packet-thread text processing. +3. **GOOSE** — empty timeline describes exactly *no events in the selected filtered view*. Do not imply the publisher is offline. Make capture-adapter, VLAN mirror, publisher state, filtering and retransmission checks discoverable. +4. **Timing/PTP** — size factual metrics to content; reclaim remaining height for existing PTP event history. PTP presence remains distinct from verified clock lock. +5. **SCL** — show a concise selected identity verdict with the original expected/observed attributes and evidence under an expandable detail. The candidate similarity score is **not a probability**. + +## Visual QA still required on actual Windows WPF + +- 1366×768 at 100%, 1600×900 at 100%, 1920×1080 at 125%, 150% DPI and minimum supported window. +- No publisher / one / many / stale and fast stream switching; 4I+4V auto nominal versus operator raw override. +- Zero-cross start of Ua with offset sample counters, distortion, zero-amplitude Ua, counter rollover and missing samples. At the first complete window the legacy slot-locked fallback is expected until enough additional coherent samples arrive. +- GOOSE zero events and filter-empty state, event arrival transition, selection, retransmission on/off. +- SCL matched/mismatch/missing/unexpected and missing fields; open the full evidence expander. +- Long-duration WPF redraw, UI responsiveness, memory and CPU: no new high-rate callback or WPF animation was added. Real CPU/memory/GC numbers cannot be claimed without a Windows bench runner. +- The existing CI, runtime stability, CodeQL, dependency audit and candidate ZIP gates are necessary but do not replace physical acceptance issue #30. + +## Safety boundary + +Receive-only, raw-passive IEC 61850 SV / GOOSE / PTP. No publisher controls, synchronizing, protection operate, or subscriber acknowledgement. Software/Npcap arrival timestamps are screening evidence only, not certified timing.