diff --git a/ProcessBusSuite.sln b/ProcessBusSuite.sln index 714775f1..ee41b6d5 100644 --- a/ProcessBusSuite.sln +++ b/ProcessBusSuite.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessBus.Iec61850.Raw", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessBus.Tests", "tests\ProcessBus.Tests\ProcessBus.Tests.csproj", "{2F4B9C6E-7A31-4E8D-9B5A-6C1D82E4A0F7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProcessBus.InteropCli", "tools\ProcessBus.InteropCli\ProcessBus.InteropCli.csproj", "{6F21A42B-A4EE-4901-8336-05A85E40D15F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,6 +52,14 @@ Global {2F4B9C6E-7A31-4E8D-9B5A-6C1D82E4A0F7}.Release|Any CPU.Build.0 = Release|Any CPU {2F4B9C6E-7A31-4E8D-9B5A-6C1D82E4A0F7}.Release|x64.ActiveCfg = Release|x64 {2F4B9C6E-7A31-4E8D-9B5A-6C1D82E4A0F7}.Release|x64.Build.0 = Release|x64 + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Debug|x64.ActiveCfg = Debug|x64 + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Debug|x64.Build.0 = Debug|x64 + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Release|Any CPU.Build.0 = Release|Any CPU + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Release|x64.ActiveCfg = Release|x64 + {6F21A42B-A4EE-4901-8336-05A85E40D15F}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md b/docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md new file mode 100644 index 00000000..c2f07e69 --- /dev/null +++ b/docs/M1_M2_RUNTIME_HARDENING_2026-10-17.md @@ -0,0 +1,78 @@ +# M1/M2 receiver hardening — 17 October 2026 + +Scope: Process Bus Insight / DigSubAnalyzer receive-only application. This record +describes the bounded software tranche proposed in PR #29. Physical interoperability +and timestamp accuracy require separate evidence. + +## Non-negotiable IEC 61850 boundaries + +- The ARStack injector's canonical SCL-derived multicast/VLAN/APPID/svID identity and + broadcast `AR_DIAG_SV1` mirror are distinct streams. A mirror observation cannot + establish canonical multicast/VLAN acceptance. +- One frame may contain multiple SV ASDUs; each ASDU contributes to sample sequence, + but the frame has only one host receive timestamp. Do not invent intra-frame jitter. +- `SmpRate/SmpMod` are sampling metadata, not universal sample-counter modulo rules. + Counter wrap conclusions remain bounded by observed and profile-supported evidence. +- The eight (INT32 instMag, UINT32 q) pairs are a 64-byte 4I+4V *profile-specific* + decoding authority. Never apply their quality interpretation to arbitrary SCL + layouts solely because the payload is 64 bytes. +- Quality with unusable validity/detail flags immediately invalidates displayed + engineering measurements; wait for a new complete coherent window before resuming. + A TEST or operator-blocked quality remains visible with its truthful context. +- PTP observations are scoped to domain/sourcePortIdentity/transport; observation + does not imply clock lock, grandmaster traceability, power-profile compliance, + or reliable software receive timing. + +## Data plane and UI rules + +- One dedicated capture pump preserves input ordering. Do not introduce independent, + unordered parallel SV workers or silent lossy queues merely to increase thread count. +- Npcap owns raw frame memory only until its next API read. The source transfers + a newly allocated owned frame to the analyzer. The latest payload is retained for + debug-on-demand; indiscriminate ArrayPool return would corrupt this evidence. +- Per-ASDU projections use direct bounded INT32 reads and stack-local eight-channel + values. SCL eligibility and explanatory labels refresh when mapping/sequence state + changes, not for each SV frame. +- Rolling jitter maximum uses a bounded monotonic ring rather than scanning the + entire window for each sample. Retire the unused counter dictionary/linear scan. +- The active per-stream coherent waveform buffer is the single snapshot authority. + RMS/phasor/waveform projections of the same generation reuse that window; an + invalid quality state must invalidate prior measurements, never show stale values + as live. +- User-visible SCL comparisons may be coalesced to 500 ms, with explicit user SCL + reload forcing immediate recomputation. +- Stream history has a 256-identity guard. Reclaim only stale unselected state; + when at active capacity, report rejection rather than silently evicting a stream + currently being inspected. +- PTP source history is bounded and stale sources are labeled STALE instead of + green OBSERVED; the interface should emphasize the fault and next diagnostic + action rather than flooding raw details. + +## Automated acceptance + +`dotnet build ProcessBusSuite.sln -c Release` and `dotnet test +ProcessBusSuite.sln -c Release` plus repeated RuntimeStability acceptance must pass. + +New regressions include: + +- O(1) rolling jitter reference equivalence under a 30,000-sample run; +- invalid-quality suppression, complete coherent recovery, and TEST labeling; +- multi-ASDU continuity with a single frame timestamp and independent frame count; +- per-domain PTP source isolation and stale Announce; +- strict PTPv2 length/transportSpecific parsing; +- bounded stream identity churn and preserved selected stream; +- rejection of `SmpRate` as an unverified counter modulus. + +## Evidence still required before field/product claims + +1. Trusted independent canonical multicast/VLAN capture from ARStack ESP32-P4. +2. Same-run canonical SV (4000/4800 samples/s) plus Announce, Sync, Follow_Up, + Pdelay and `smpSynch` transition stimuli. +3. Npcap/adapter drop counters, host CPU/GC/memory, sustained capture throughput, + and 60+ minute bench soak with no cross-stream contamination. +4. Hardware timestamp validation for any measurement-grade jitter/offset statement. +5. Multi-vendor profile/SCL fixtures, arbitrary `nofASDU` and vendor DataSet layouts, + and receiver/IED behavior with real devices. + +The software tranche avoids claiming those field results. A green CI result means +the bounded software regressions passed, not that an IED accepted the traffic. diff --git a/docs/M3_OPERATOR_WORKSPACE_AND_M1_M2_CLOSEOUT_2026-10-17.md b/docs/M3_OPERATOR_WORKSPACE_AND_M1_M2_CLOSEOUT_2026-10-17.md new file mode 100644 index 00000000..081924d9 --- /dev/null +++ b/docs/M3_OPERATOR_WORKSPACE_AND_M1_M2_CLOSEOUT_2026-10-17.md @@ -0,0 +1,94 @@ +# M3 operator workspace and M1/M2 closeout — 17 October 2026 + +This engineering handoff describes the implemented changes on PR #29, and +distinguishes a successful software acceptance run from hardware interoperability. + +## Operating contract + +Process Bus Insight remains a **receive-only** IEC 61850 SV/GOOSE/PTP analyzer. +The operator workflow is: + +1. Select the physical Ethernet/Npcap adapter. +2. Press **Start** to observe traffic. +3. Choose an SV publisher in the explorer to inspect waveform, RMS, phasors, + stream identity, quality and counter continuity. +4. Read the one-line context-aware status for the current situation. +5. Open **Diagnostics** for capture-driver drops vs SV sequence evidence, and + **Timing/PTP** for passive source-specific PTP evidence. +6. Open **SCL/Validation** only to compare engineering-file expectations with + actual wire-observed identities; **Advanced** holds low-level raw evidence. + +No value inferred from the diagnostic broadcast mirror is presented as proof +of canonical multicast, APPID, VLAN, PCP or SCL profile acceptance. + +## M3 implementation + +- Main capture-status strip displays one concise contextual message instead of + five overlapping status chips. The persistent footer is short and conditional: + it surfaces diagnostic-mirror, invalid-quality and stale-stream concerns. +- Real SV measurements are never tweened/interpolated between capture snapshots. + Dead 30 FPS composition-loop/interpolation code was removed from the waveform + control: WPF paints the latest decoded, coherent snapshot atomically. +- `LiveRenderCoalescer` compares **every numeric waveform sample** plus + series/shape/status metadata and channel RMS/angle/instant/units. It suppresses + equivalent redraw notifications while preserving tiny real transients, shape + changes, quality invalidations, stream selections and cleared values. +- Phasor lists and per-stream UI workspace objects are reused while unchanged. + Periodic SelectedStream/IsRunning re-notifications were eliminated to prevent + selection churn. +- Diagnostic navigator refresh is capped to the passive UI cadence; + managed-memory sampling runs at 1 Hz; SCL binding joins are coalesced. +- Engineering tables use recycling/row virtualization and shorter minimum row + heights. Static flow instructions were removed from the main content. +- PTP two-step sequence correlation is passive and displayed only in the Timing + workspace; there is **no** claim of measured PTP lock or UTC traceability. + +## M1/M2 additional correctness/efficiency + +- PTP packet-rate tracking uses fixed 5-second buckets, not an unbounded list + of packet arrival timestamps per source. +- Announce/Sync/Follow_Up evidence is isolated by source/domain/transport; + Sync+Follow_Up pairing additionally requires matching sequence and bounded + freshness. Source history and pending correlators have hard capacity limits. +- Diagnostic/event collection indexes persist across UI refreshes, and remain + bounded at 200 visible rows; reset removes both rows and indexes. +- Stable SV explorer rows are retained rather than replaced on every update. +- Npcap native drop statistics are polled at 1 Hz off the capture hot path. + Driver drop counters are displayed as **capture-path evidence**, and are never + folded into IEC 61850 sample-counter packet-loss inferences. +- Full IEEE 61850 profile compliance, PTP servo discipline, calibrated host + jitter, and physical IED subscription are not asserted here. + +## Automated regression gates + +Windows .NET 8 full build and complete xUnit suite must pass together with +repeated RuntimeStability, CodeQL, and Dependency Security Review workflows on +the **same final commit**. + +New coverage includes render-coalescing correctness, multi-ASDU timing, +quality invalidation/recovery, rate-window boundedness, cross-source two-step +correlation, stream catalog pressure and reset-safe UI event deduplication. +Do not merge if final-commit CI is red or incomplete. + +## Physical and measured acceptance still necessary + +The following must be collected on real hardware before an +**interoperable/field-ready** release claim: + +- independent preserved-VLAN TAP/SPAN capture of ARStack canonical multicast + alongside the separately labeled diagnostic mirror; +- 4000/4800 SV samples/s and concurrent L2 PTP source / receiving profile; +- verified observed APPID, VLAN, MAC, confRev, svID, quality, smpCnt rollover, + two-step matching, source expiry and smpSynch transition evidence; +- actual Npcap drop counters, process CPU, allocation rate, Gen0/Gen1/Gen2 GC, + heap/working set, UI frame responsiveness during sustained mixed traffic; +- 60+ minute hardware soak and multi-vendor IED capture/acceptance matrix. + +**Receiver-worker decision:** maintain one ordered dedicated capture worker until +independent profiling demonstrates its insufficiency. Unbounded independent +workers or lossy frame queues would violate sample continuity. A multi-worker +dispatcher requires bounded queues, source-preserving ordering and explicit +drop accounting and remains a future measured-design gate, not a hidden +"optimization" in this PR. + +The software milestone can be reviewed independently of that bench evidence. diff --git a/docs/M4_ARSTACK_INTEROP_ACCEPTANCE.md b/docs/M4_ARSTACK_INTEROP_ACCEPTANCE.md new file mode 100644 index 00000000..68a6e781 --- /dev/null +++ b/docs/M4_ARSTACK_INTEROP_ACCEPTANCE.md @@ -0,0 +1,146 @@ +# M4 — ARStack Injector ↔ DigSubAnalyzer interoperability acceptance + +Date: 17 October 2026 +Repositories: `masarray/DigSubAnalyzer` (consumer, **receive-only**) and +`masarray/arstack61850` (ESP32-P4 SMV Injector). + +## Three different evidence levels + +1. **Software replay gate (automated):** anonymous synthetic PCAPs and SCL-intent + fixtures exercise the **same production parser** used by live reception. + This demonstrates decoder behavior but not a physical process bus. +2. **Independent observed-wire gate (operator bench):** capture on an independent + TAP or correctly configured SPAN port that preserves multicast and 802.1Q + tags. Compare observed frames with the actually deployed SCL profile. +3. **Device/IED gate (external equipment):** verify subscriber/IED acquisition, + quality, synchronization and sustained operation with equipment-approved + tools. DigSubAnalyzer alone cannot certify relay subscription, functional + safety, protection action or hardware PTP synchronization. + +The M4 software utility intentionally reports **WIRE_FIELDS_MATCH** instead +of “compliant”, “certified”, “locked” or “accepted by IED”. + +## Run the offline acceptance inspector + +Windows with .NET 8 SDK installed: + +```powershell +dotnet build .\ProcessBusSuite.sln -c Release +dotnet test .\ProcessBusSuite.sln -c Release --no-build --filter Category=M4Interop +dotnet run --project .\tools\ProcessBus.InteropCli -c Release -- --pcap C:\lab\arstack-canonical.pcap --scl C:\lab\current-profile.scd --max-frames 20000000 --json C:\lab\interop-report.json +``` + +The tool supports classic Ethernet PCAP 2.4 (microsecond/nanosecond). If the +capture application creates PCAPNG, export/copy the packet recording as +**classic PCAP** with full Ethernet headers before inspecting it. + +`--scl` is optional for wire fingerprint only; mandatory for automated +expected-vs-observed verdicts. The CLI prints a short operator summary, +keeps raw details in optional local JSON, and returns: + +- **0**: screening executed and no specified SCL comparison failed; **not** + certification and **not** proof of capture-path visibility. For a capture + without SCL, there are no configured fields to validate. +- **3**: observed/configured mismatch, unresolved SCL identity or expected + stream not observed; this includes **MIRROR_ONLY**. +- **2**: malformed arguments, unreadable/unsupported PCAP, or no useful + successfully decoded traffic. + +Bench files, identities, MAC addresses, PCAP, JSON logs and original SCL stay +on the authorized **local machine**; do not push them to GitHub. + +### Verdicts + +| Verdict | Interpretation | Operator action | +| --- | --- | --- | +| `WIRE_FIELDS_MATCH` | Visible fields match configured SCL stream | Verify TAP/SPAN provenance and independent IED behavior | +| `MIRROR_ONLY` | Only ARStack diagnostic broadcast identified | Fix capture visibility; do not infer canonical multicast/VLAN | +| `NOT_OBSERVED` | Configured svID absent | Check VLAN membership, multicast filtering, SPAN/TAP and NIC | +| `CONFLICT` | APPID/MAC/VID/PCP/confRev/nofASDU differs | Inspect deployed SCL and exact wire identity; do not normalize away | +| `INCOMPLETE_INTENT` | Some observed fields match but SCL lacks authority | Load the deployed full SCL/profile; do not auto-fill defaults | +| `UNRESOLVED` | No trustworthy SCL svID | Correct/resolve engineering intent before claiming match | + +Capture **VLAN absence on a Windows USB-NIC is not proof of untagged wire TX**. +A mirrored `AR_DIAG_SV1`, APPID `0x4F01`, broadcast/untagged stream is diagnostic +evidence only, always separate from the canonical SCL stream. + +## Physical bench procedure + +1. Record ARStack Injector firmware build/manifest, selected immutable profile, + source identity, configured DataSet order, 4I+4V payload authority, APPID, + multicast MAC, VLAN ID/PCP, confRev, nofASDU, sample cadence, smpCnt modulus + policy and intended `smpSynch`. The SCL is **configured intent**, not + automatically the deployed firmware identity. +2. Connect a trusted independent TAP or SPAN that demonstrably retains VLAN + tags, **without trusting a diagnostic mirror as a canonical substitute**. + Capture timestamp source, precision, NIC/driver version and driver drops. +3. With an ARStack `LAB_SOURCE` release-candidate profile, observe L2 PTPv2, + domain 0, Announce ~1/s, two-step Sync ~4/s, matching Follow_Up and peer + delay response **when requested**. clockClass 248 and clockAccuracy Unknown + do **not** constitute external UTC traceability; AUTO `smpSynch=0` remains + truthful for undisciplined source mode. +4. With the same capture path, observe independent canonical SV and diagnostic + mirror identities and intentionally verify **different** APPID, destination + MAC, VLAN visibility and counters. Check `nofASDU`, channel layout, + instMag/q quality and `smpCnt` continuity for the selected profile. +5. Test nominal **4000 and 4800 samples/s**, including appropriate + profile-validated counter rollover, TEST/bad-quality stimuli, stream restart + and multi-source isolation. A PCAP's host-arrival jitter is + screening only, not on-wire timestamp quality. +6. Run mixed SV/PTP for at least **60 minutes** per acceptance scenario. + A 4000 sample/s stream produces ~14.4m SV frames in 60 minutes if + `nofASDU=1`; canonical plus mirror doubles the SV frames. Ensure + `--max-frames` covers all traffic, or split recordings into explicitly + indexed windows and inspect each. +7. Record capture-driver counters alongside publisher firmware telemetry + (TX failure, missed slots and timestamp quality). A zero sequence-gap result + on the reader does **not** imply an independent NIC dropped zero frames. +8. Independently verify receiving IED/subscriber behavior. A matched stream + in DigSubAnalyzer is not proof the IED subscribed or acted on samples. + +## Performance and UX acceptance + +While the live analyzer displays mixed traffic, record every 5 seconds: + +```powershell +.\\scripts\\measure-m4-bench.ps1 -DurationMinutes 60 -IntervalSeconds 5 -OutputPath C:\\lab\\m4-local-metrics.csv +``` + +This is a local process CPU/working-set sampler, **not** a substitute for +`dotnet-counters`, native Npcap driver statistics or hardware wire timestamps. + + +- CPU utilization and process memory/working set; +- .NET allocation rate and GC collections (`dotnet-counters` or equivalent); +- NIC driver/interface drops where supported; +- SV sample/counter anomalies, rejected frames and identity counts; +- selection-to-coherent-snapshot latency and scope redraw responsiveness. + +Inspect the main workspace for these user-facing invariants: + +- changing selected SV publisher never carries waveform, RMS, phasor or + engineering scaling from the previous publisher; +- TEST and invalid quality are labeled; invalid data never appears as + valid calibrated measurements; +- canonical and mirror traffic remain distinct; +- PTP observation, pairing, domain and freshness are not misrepresented as + servo LOCKED; +- the one-line status remains calm, and detailed raw evidence lives under + Timing/Diagnostics/Advanced, not in an overwhelming initial screen. + +There is no synthetic CPU/heap reduction percentage quoted here. Measure +**before/after** on the same hardware/NIC/driver/traffic scenario and record +absolute values and drop counters. Without such measurements, resource +optimization is implementation-level, not performance-certified. + +## Release gate and signed-off evidence + +The engineering reviewer should retain, locally or in an authorized evidence +store: sanitizer-approved capture checksum, SCL version, firmware manifest, +analyzer commit/build, report JSON, port/TAP diagram and VLAN settings, +NIC/drop counters, 60-minute performance measurements and IED independent +results. Publish only sanitized summaries. + +M4 cannot be called **physically complete** without this independently +collected evidence. The automated replay suite is suitable for a software +checkpoint and for detecting compatibility regressions. diff --git a/scripts/measure-m4-bench.ps1 b/scripts/measure-m4-bench.ps1 new file mode 100644 index 00000000..c028d8e2 --- /dev/null +++ b/scripts/measure-m4-bench.ps1 @@ -0,0 +1,68 @@ +param( + [ValidateRange(1, 240)][int]$DurationMinutes = 60, + [ValidateRange(1, 60)][int]$IntervalSeconds = 5, + [string]$ProcessName = 'ProcessBus.App.Wpf', + [string]$OutputPath = '.\\m4-bench-local.csv' +) + +# Operator-invoked local read-only monitoring; no capture, no remote transfer, +# no registry or NIC modifications. Values are process-level resource screening. +$ErrorActionPreference = 'Stop' +$deadline = (Get-Date).AddMinutes($DurationMinutes) +$previous = $null +$writer = [System.IO.StreamWriter]::new( + [System.IO.Path]::GetFullPath($OutputPath), $false, + [System.Text.UTF8Encoding]::new($false)) + +try { + $writer.WriteLine('UtcTimestamp,Pid,ElapsedSeconds,CpuPercentOfMachine,WorkingSetBytes,PrivateBytes,ThreadCount,HandleCount') + while ((Get-Date) -lt $deadline) { + $now = [DateTime]::UtcNow + $candidate = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue | + Sort-Object -Property StartTime -Descending | + Select-Object -First 1 + + if ($null -ne $candidate) { + $elapsed = '' + $cpuPercent = '' + if ($null -ne $previous -and $previous.Pid -eq $candidate.Id) { + $elapsedSeconds = ($now - $previous.At).TotalSeconds + if ($elapsedSeconds -gt 0) { + $cpuTime = $candidate.TotalProcessorTime.TotalSeconds - $previous.CpuSeconds + $cpuPercent = [Math]::Round( + 100 * $cpuTime / $elapsedSeconds / [Environment]::ProcessorCount, 3) + $elapsed = [Math]::Round($elapsedSeconds, 3) + } + } + + $writer.WriteLine(('{0},{1},{2},{3},{4},{5},{6},{7}' -f + $now.ToString('o'), + $candidate.Id, + $elapsed, + $cpuPercent, + $candidate.WorkingSet64, + $candidate.PrivateMemorySize64, + $candidate.Threads.Count, + $candidate.HandleCount)) + + $previous = [PSCustomObject]@{ + Pid = $candidate.Id + At = $now + CpuSeconds = $candidate.TotalProcessorTime.TotalSeconds + } + } else { + # Preserve a missing-process sample; do not report zero CPU/memory as healthy. + $writer.WriteLine(('{0},,,,,,,' -f $now.ToString('o'))) + $previous = $null + } + + $writer.Flush() + Start-Sleep -Seconds $IntervalSeconds + } +} finally { + $writer.Dispose() +} + +Write-Host "Local metrics recorded: $OutputPath" +Write-Host 'These counters do not prove NIC wire-loss, PTP clock lock or GC allocation rate.' +Write-Host 'Collect dotnet-counters and independent TAP/SPAN evidence separately.' diff --git a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs index 629d561b..35897b3b 100644 --- a/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs +++ b/src/ProcessBus.App.Wpf/Controls/WaveformPanelControl.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Diagnostics; using System.Windows; using System.Windows.Input; using System.Windows.Media; @@ -42,13 +41,6 @@ public sealed class WaveformPanelControl : FrameworkElement private double _currentAutoPeak; private DateTime _voltagePeakUpdatedUtc = DateTime.MinValue; private DateTime _currentPeakUpdatedUtc = DateTime.MinValue; - private readonly Stopwatch _smoothClock = new(); - private WaveformSnapshot? _fromSnapshot; - private WaveformSnapshot? _targetSnapshot; - private WaveformSnapshot? _visualSnapshot; - private TimeSpan _lastVisualRender = TimeSpan.Zero; - private bool _renderLoopAttached; - private const double AutoScaleHeadroom = 1.28; private const double AutoScaleSmoothing = 0.18; // Steady-state deadband: the observed window peak jitters a little every cycle, and an @@ -57,15 +49,12 @@ public sealed class WaveformPanelControl : FrameworkElement private const double AutoScaleDeadband = 0.02; private const double PeakHoldSeconds = 6.0; private const double SamplesPerPixel = 0.8; - private const double VisualSmoothingMilliseconds = 120.0; - private const double VisualRenderFps = 30.0; public WaveformPanelControl() { Focusable = true; Cursor = Cursors.Cross; - // Raw SV scope renders atomically on Snapshot changes; no CompositionTarget animation loop. - Unloaded += (_, _) => DetachRenderLoop(); + // Render only the latest immutable waveform snapshot; never tween real SV samples. } public static readonly DependencyProperty SnapshotProperty = @@ -73,10 +62,7 @@ public WaveformPanelControl() nameof(Snapshot), typeof(WaveformSnapshot), typeof(WaveformPanelControl), - new FrameworkPropertyMetadata( - null, - FrameworkPropertyMetadataOptions.AffectsRender, - OnSnapshotChanged)); + new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty ShowModeProperty = DependencyProperty.Register( @@ -126,32 +112,6 @@ public WaveformSnapshot? Snapshot set => SetValue(SnapshotProperty, value); } - private static void OnSnapshotChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) - { - if (dependencyObject is not WaveformPanelControl control) - return; - - var next = args.NewValue as WaveformSnapshot; - if (next is null) - { - control._fromSnapshot = null; - control._targetSnapshot = null; - control._visualSnapshot = null; - control.DetachRenderLoop(); - control.InvalidateVisual(); - return; - } - - // Raw SV scope must not tween/interpolate between snapshots. Interpolation makes a - // stable phase-locked waveform crawl and creates fake transient shapes between two - // real SV sample windows. Render the latest snapshot atomically, ARSVIN-style. - control.DetachRenderLoop(); - control._fromSnapshot = null; - control._targetSnapshot = next; - control._visualSnapshot = next; - control.InvalidateVisual(); - } - public string ShowMode { get => (string)GetValue(ShowModeProperty); @@ -202,7 +162,7 @@ protected override void OnRender(DrawingContext dc) var bounds = new Rect(new Point(0, 0), RenderSize); dc.DrawRectangle(PanelBrush, null, bounds); - var snapshot = _visualSnapshot ?? Snapshot; + var snapshot = Snapshot; if (snapshot is null || (snapshot.VoltageSeries.Count == 0 && snapshot.CurrentSeries.Count == 0)) { DrawCenteredMessage(dc, bounds, "Waveform data pending"); @@ -238,141 +198,6 @@ protected override void OnRender(DrawingContext dc) } } - private void AttachRenderLoopIfNeeded() - { - if (_renderLoopAttached || !IsLoaded) - return; - - CompositionTarget.Rendering += OnCompositionRendering; - _renderLoopAttached = true; - } - - private void DetachRenderLoop() - { - if (!_renderLoopAttached) - return; - - CompositionTarget.Rendering -= OnCompositionRendering; - _renderLoopAttached = false; - } - - private void OnCompositionRendering(object? sender, EventArgs e) - { - if (_fromSnapshot is null || _targetSnapshot is null) - { - DetachRenderLoop(); - return; - } - - var elapsed = _smoothClock.Elapsed; - if (elapsed - _lastVisualRender < TimeSpan.FromMilliseconds(1000.0 / VisualRenderFps)) - return; - - _lastVisualRender = elapsed; - var progress = Math.Clamp(elapsed.TotalMilliseconds / VisualSmoothingMilliseconds, 0.0, 1.0); - var eased = EaseOutCubic(progress); - _visualSnapshot = progress >= 1.0 - ? _targetSnapshot - : InterpolateSnapshot(_fromSnapshot, _targetSnapshot, eased); - - InvalidateVisual(); - - if (progress >= 1.0) - DetachRenderLoop(); - } - - private static bool HasAnySeries(WaveformSnapshot snapshot) - { - return snapshot.VoltageSeries.Count > 0 || snapshot.CurrentSeries.Count > 0; - } - - private static WaveformSnapshot InterpolateSnapshot(WaveformSnapshot from, WaveformSnapshot to, double amount) - { - return new WaveformSnapshot - { - VoltageSeries = InterpolateSeries(from.VoltageSeries, to.VoltageSeries, amount), - CurrentSeries = InterpolateSeries(from.CurrentSeries, to.CurrentSeries, amount), - SampleRateHz = Lerp(from.SampleRateHz, to.SampleRateHz, amount), - MeasuredFrequencyHz = Lerp(from.MeasuredFrequencyHz, to.MeasuredFrequencyHz, amount), - WindowDurationMilliseconds = Lerp(from.WindowDurationMilliseconds, to.WindowDurationMilliseconds, amount), - StatusText = to.StatusText, - ShapeSeverity = to.ShapeSeverity, - ShapeStatusText = to.ShapeStatusText, - HasShapeWarning = to.HasShapeWarning, - SamplesPerCycle = to.SamplesPerCycle, - IsReconstructed = to.IsReconstructed - }; - } - - private static IReadOnlyList InterpolateSeries( - IReadOnlyList fromSeries, - IReadOnlyList toSeries, - double amount) - { - if (toSeries.Count == 0) - return Array.Empty(); - - var fromByName = fromSeries.ToDictionary(item => item.Name, StringComparer.OrdinalIgnoreCase); - var result = new WaveformSeriesModel[toSeries.Count]; - for (var i = 0; i < toSeries.Count; i++) - { - var target = toSeries[i]; - if (!fromByName.TryGetValue(target.Name, out var source) || source.Samples.Count == 0 || target.Samples.Count == 0) - { - result[i] = target; - continue; - } - - var samples = new double[target.Samples.Count]; - for (var sampleIndex = 0; sampleIndex < samples.Length; sampleIndex++) - { - var fraction = samples.Length <= 1 ? 0.0 : sampleIndex / (samples.Length - 1.0); - var sourceValue = SampleAt(source.Samples, fraction); - samples[sampleIndex] = Lerp(sourceValue, target.Samples[sampleIndex], amount); - } - - result[i] = new WaveformSeriesModel - { - Name = target.Name, - Unit = target.Unit, - Samples = samples, - ShapeSeverity = target.ShapeSeverity, - ShapeStatusText = target.ShapeStatusText, - ShapeResidualPercent = target.ShapeResidualPercent, - CrestFactor = target.CrestFactor, - HasShapeDistortion = target.HasShapeDistortion - }; - } - - return result; - } - - private static double SampleAt(IReadOnlyList samples, double fraction) - { - if (samples.Count == 0) - return 0; - - if (samples.Count == 1) - return samples[0]; - - var position = Math.Clamp(fraction, 0.0, 1.0) * (samples.Count - 1); - var left = (int)Math.Floor(position); - var right = Math.Min(samples.Count - 1, left + 1); - var amount = position - left; - return Lerp(samples[left], samples[right], amount); - } - - private static double Lerp(double from, double to, double amount) - { - return from + ((to - from) * amount); - } - - private static double EaseOutCubic(double amount) - { - var inverse = 1.0 - Math.Clamp(amount, 0.0, 1.0); - return 1.0 - (inverse * inverse * inverse); - } - private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, WaveformSnapshot snapshot) { dc.DrawRoundedRectangle(PlotBackgroundBrush, BorderPen, rect, 10, 10); diff --git a/src/ProcessBus.App.Wpf/MainWindow.xaml b/src/ProcessBus.App.Wpf/MainWindow.xaml index c19ad425..9752e2ea 100644 --- a/src/ProcessBus.App.Wpf/MainWindow.xaml +++ b/src/ProcessBus.App.Wpf/MainWindow.xaml @@ -837,66 +837,18 @@ ROW 2 — QUIET STATUS STRIP ========================================================= --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + MinHeight="32" + Padding="14,7" + Background="#07111D" + CornerRadius="0,0,8,8"> + + + @@ -2811,6 +2763,7 @@ + + @@ -3563,6 +3517,7 @@ EnableColumnVirtualization="True" VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" + ScrollViewer.CanContentScroll="True" ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"> @@ -3613,7 +3568,7 @@ CornerRadius="10"> - + @@ -3623,9 +3578,7 @@ Text="{Binding ValidationSummaryCompactText}" Style="{StaticResource PanelSubtitleTextStyle}" TextWrapping="Wrap"/> - + @@ -3665,8 +3618,26 @@ - - + +