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
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.
diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml
index f51ea27b..e5ff7c6a 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3387,10 +3429,10 @@
-
-
-
-
+
+
+
+
@@ -3441,7 +3483,7 @@
-
+
@@ -3935,9 +3977,9 @@
-
+
@@ -4047,7 +4089,7 @@
@@ -4174,7 +4216,25 @@
-
+
+
+
+
+
+
+
+
+
+
+
@@ -4193,18 +4253,11 @@
-
-
-
-
-
+
-
+
diff --git a/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs b/src/ProcessBus.App.Wpf/ViewModels/MainWindowViewModel.cs
index dcd8a8ae..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";
@@ -1343,6 +1361,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";
@@ -2491,6 +2513,7 @@ private void RaiseDiagnosticScopeProperties()
OnPropertyChanged(nameof(DiagnosticTargetHealthText));
OnPropertyChanged(nameof(DiagnosticAffectedSummaryText));
OnPropertyChanged(nameof(DiagnosticFindingsHeaderText));
+ OnPropertyChanged(nameof(DiagnosticOperatorGuidanceText));
}
private static string ShortenMac(string value)
@@ -2652,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));
@@ -3967,6 +3991,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 +5017,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 +5050,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;
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;
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);
+ }
+}