diff --git a/ComtradeWorkspaceWindow.Analysis.cs b/ComtradeWorkspaceWindow.Analysis.cs index db5cce41d..c63129054 100644 --- a/ComtradeWorkspaceWindow.Analysis.cs +++ b/ComtradeWorkspaceWindow.Analysis.cs @@ -66,11 +66,12 @@ private void SetAnalysisMode(AnalysisMode mode) PhasorView.Visibility = mode == AnalysisMode.Phasor ? Visibility.Visible : Visibility.Collapsed; HarmonicsView.Visibility = mode == AnalysisMode.Harmonics ? Visibility.Visible : Visibility.Collapsed; ResetViewButton.Visibility = mode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; + FullRecordButton.Visibility = mode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; NavigationTextBlock.Text = mode switch { - AnalysisMode.Phasor => "Cursor A = phasor reference • native one-cycle DFT • select an analog signal family on the left", - AnalysisMode.Harmonics => "Cursor A = harmonic reference • full-cycle native DFT • click a bar for details", - _ => "Shared trigger-relative timeline • click Cursor A • Ctrl/right-click Cursor B • digital ↑/↓ = transition" + AnalysisMode.Phasor => "C1 = phasor reference • native one-cycle DFT • select an analog signal family on the left", + AnalysisMode.Harmonics => "C1 = harmonic reference • full-cycle native DFT • click a bar for details", + _ => "Wheel scrolls tracks • Ctrl+wheel zooms time • drag plot pans • drag C1/C2 measures • right-click places C2" }; ApplyAnalysisModeVisuals(); @@ -85,13 +86,13 @@ private void UpdateAnalysisAvailability() HarmonicsModeButton.IsEnabled = analog; if (!analog) { - PhasorModeButton.ToolTip = "Select an analog signal first. Cursor A on the waveform becomes the phasor reference."; - HarmonicsModeButton.ToolTip = "Select an analog signal first. Cursor A on the waveform becomes the harmonic reference."; + PhasorModeButton.ToolTip = "Select an analog signal first. C1 on Time Signals becomes the phasor reference."; + HarmonicsModeButton.ToolTip = "Select an analog signal first. C1 on Time Signals becomes the harmonic reference."; } else { - PhasorModeButton.ToolTip = "Show same-unit fundamental RMS phasors at Cursor A (or viewport center when Cursor A is unset)."; - HarmonicsModeButton.ToolTip = "Show native ArdIrec harmonic spectrum at Cursor A (or viewport center when Cursor A is unset)."; + PhasorModeButton.ToolTip = "Show same-unit fundamental RMS phasors at C1 (or the visible Time Signals center when C1 is unset)."; + HarmonicsModeButton.ToolTip = "Show native ArdIrec harmonic spectrum at C1 (or the visible Time Signals center when C1 is unset)."; } } @@ -126,9 +127,9 @@ private async Task RefreshNativeAnalysisAsync() var triggerMs = ResolveTriggerMilliseconds(); var relative = timeMs is { } absolute && triggerMs is { } trigger ? absolute - trigger : (double?)null; AnalysisReferenceTextBlock.Text = timeMs is { } ms - ? $"Analysis reference: {(cursorReference ? "Cursor A" : "viewport center")} • frame {referenceFrame:N0} • " + + ? $"Analysis reference: {(cursorReference ? "C1" : "visible center")} • frame {referenceFrame:N0} • " + (relative is { } rel ? ComtradeDisturbanceTimelineMath.FormatRelativeTime(rel) : $"{ms:G7} ms") - : $"Analysis reference: {(cursorReference ? "Cursor A" : "viewport center")} • frame {referenceFrame:N0}"; + : $"Analysis reference: {(cursorReference ? "C1" : "visible center")} • frame {referenceFrame:N0}"; try { @@ -147,7 +148,7 @@ private async Task RefreshNativeAnalysisAsync() var subtitle = BuildAnalysisSubtitle(selectedMetadata, referenceFrame, result.Count, "fundamental RMS • same-unit channels"); PhasorView.ShowPhasors("Phasor diagram", subtitle, result); - StatusTextBlock.Text = $"Native ArdIrec phasor analysis • {(cursorReference ? "Cursor A" : "viewport center")} • frame {referenceFrame:N0} • {result.Count} vector(s)"; + StatusTextBlock.Text = $"Native ArdIrec phasor analysis • {(cursorReference ? "C1" : "visible center")} • frame {referenceFrame:N0} • {result.Count} vector(s)"; return; } @@ -176,7 +177,7 @@ private async Task RefreshNativeAnalysisAsync() var harmonicSubtitle = BuildAnalysisSubtitle(metadata, referenceFrame, spectrum.Bins.Count, $"orders H1…H{spectrum.Bins[^1].Order}"); HarmonicsView.ShowSpectrum("Harmonic spectrum", harmonicSubtitle, display); - StatusTextBlock.Text = $"Native ArdIrec harmonics • {(cursorReference ? "Cursor A" : "viewport center")} • THD {spectrum.ThdPercent:G5}% • " + + StatusTextBlock.Text = $"Native ArdIrec harmonics • {(cursorReference ? "C1" : "visible center")} • THD {spectrum.ThdPercent:G5}% • " + (spectrum.DominantOrder > 1 ? $"dominant H{spectrum.DominantOrder} {spectrum.DominantPercent:G4}%" : "no meaningful distortion harmonic"); @@ -201,18 +202,12 @@ private ulong ResolveAnalysisReferenceFrame() { if (TryResolveDisturbanceCursorFrame(out var cursorFrame)) return cursorFrame; + if (TryResolveDisturbanceViewportCenterFrame(out var centerFrame)) + return centerFrame; var total = _record.Info.FrameCount; if (total == 0) return 0; - var viewport = _requestedSourceViewport.FrameCount > 0 - ? _requestedSourceViewport - : _loadedSourceViewport.FrameCount > 0 - ? _loadedSourceViewport - : ComtradeAbsoluteViewportMath.Full(total); - viewport = ComtradeAbsoluteViewportMath.Normalize(viewport, total); - if (viewport.FrameCount == 0) return Math.Min(total - 1, viewport.StartFrame); - var offset = (viewport.FrameCount - 1) / 2; - return Math.Min(total - 1, viewport.StartFrame + offset); + return (total - 1) / 2; } private async Task TryReadReferenceTimeMillisecondsAsync(ulong referenceFrame, CancellationToken token) diff --git a/ComtradeWorkspaceWindow.Disturbance.cs b/ComtradeWorkspaceWindow.Disturbance.cs index 1f7c40baa..18d4c21a0 100644 --- a/ComtradeWorkspaceWindow.Disturbance.cs +++ b/ComtradeWorkspaceWindow.Disturbance.cs @@ -13,29 +13,27 @@ public partial class ComtradeWorkspaceWindow private const int MaxVisibleDisturbanceTracks = 16; private readonly HashSet _disturbanceVisibleSignals = new(); private CancellationTokenSource? _disturbanceLoadCts; + private CancellationTokenSource? _disturbanceCursorSnapCts; private bool _disturbanceInitialized; private bool _disturbanceVisibilityReady; private bool _disturbanceCheckboxSync; + private bool _disturbanceInitialFocusApplied; private ComtradeSourceViewport _disturbanceLoadedViewport; private ComtradeSourceViewport _disturbanceRequestedViewport; private uint[]? _disturbanceReferenceTimestamps; - private bool _disturbanceReferenceExact; - private bool _disturbanceSourcePanGesture; - private Point _disturbanceSourcePanStartPoint; - private ComtradeSourceViewport _disturbanceSourcePanStartViewport; + private ulong[]? _disturbanceReferenceSourceFrames; private void InitializeDisturbanceWorkspace() { if (_disturbanceInitialized) return; _disturbanceInitialized = true; - WaveformView.NavigationChanged -= WaveformView_NavigationChanged; WaveformView.Visibility = Visibility.Collapsed; DisturbanceView.Visibility = _analysisMode == AnalysisMode.Waveform ? Visibility.Visible : Visibility.Collapsed; DisturbanceView.NavigationChanged += DisturbanceView_NavigationChanged; + DisturbanceView.CursorChanged += DisturbanceView_CursorChanged; + DisturbanceView.PanRequested += DisturbanceView_PanRequested; DisturbanceView.PreviewMouseWheel += DisturbanceView_PreviewMouseWheel; - DisturbanceView.PreviewMouseDown += DisturbanceView_PreviewMouseDown; - DisturbanceView.PreviewMouseUp += DisturbanceView_PreviewMouseUp; Closed += DisturbanceWindow_Closed; BuildDefaultVisibleSignals(); @@ -52,10 +50,13 @@ private void DisturbanceWindow_Closed(object? sender, EventArgs e) _disturbanceLoadCts?.Cancel(); _disturbanceLoadCts?.Dispose(); _disturbanceLoadCts = null; + _disturbanceCursorSnapCts?.Cancel(); + _disturbanceCursorSnapCts?.Dispose(); + _disturbanceCursorSnapCts = null; DisturbanceView.NavigationChanged -= DisturbanceView_NavigationChanged; + DisturbanceView.CursorChanged -= DisturbanceView_CursorChanged; + DisturbanceView.PanRequested -= DisturbanceView_PanRequested; DisturbanceView.PreviewMouseWheel -= DisturbanceView_PreviewMouseWheel; - DisturbanceView.PreviewMouseDown -= DisturbanceView_PreviewMouseDown; - DisturbanceView.PreviewMouseUp -= DisturbanceView_PreviewMouseUp; } private void BuildDefaultVisibleSignals() @@ -64,7 +65,21 @@ private void BuildDefaultVisibleSignals() if (SignalList.ItemsSource is not IEnumerable signals) return; var all = signals.ToArray(); - foreach (var signal in all.Where(item => item.IsAnalog).Take(6)) + var preferredAnalog = all + .Where(item => item.IsAnalog && item.Section == "Voltage") + .OrderBy(item => item.PhaseOrder) + .Take(3) + .Concat(all.Where(item => item.IsAnalog && item.Section == "Current") + .OrderBy(item => item.PhaseOrder) + .Take(3)) + .Distinct() + .ToList(); + foreach (var signal in all.Where(item => item.IsAnalog).OrderBy(item => item.SectionOrder).ThenBy(item => item.PhaseOrder)) + { + if (preferredAnalog.Count >= 6) break; + if (!preferredAnalog.Contains(signal)) preferredAnalog.Add(signal); + } + foreach (var signal in preferredAnalog.Take(6)) _disturbanceVisibleSignals.Add(signal); var semanticDigital = all @@ -75,7 +90,10 @@ private void BuildDefaultVisibleSignals() ? semanticDigital : all.Where(item => !item.IsAnalog).Take(4).ToArray(); foreach (var signal in digitalDefaults) + { + if (_disturbanceVisibleSignals.Count >= MaxVisibleDisturbanceTracks) break; _disturbanceVisibleSignals.Add(signal); + } } private void SignalVisibility_Loaded(object sender, RoutedEventArgs e) @@ -97,11 +115,11 @@ private async void SignalVisibility_Checked(object sender, RoutedEventArgs e) _disturbanceCheckboxSync = true; checkBox.IsChecked = false; _disturbanceCheckboxSync = false; - StatusTextBlock.Text = $"Waveform workspace supports up to {MaxVisibleDisturbanceTracks} visible tracks at once. Hide another signal first."; + StatusTextBlock.Text = $"Time Signals supports up to {MaxVisibleDisturbanceTracks} visible tracks at once. Hide another signal first."; return; } _disturbanceVisibleSignals.Add(signal); - await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false, preserveLocalView: true).ConfigureAwait(true); } private async void SignalVisibility_Unchecked(object sender, RoutedEventArgs e) @@ -109,14 +127,14 @@ private async void SignalVisibility_Unchecked(object sender, RoutedEventArgs e) if (_disturbanceCheckboxSync || sender is not CheckBox checkBox || checkBox.DataContext is not ComtradeSignalItem signal) return; if (!_disturbanceVisibleSignals.Remove(signal)) return; - await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false, preserveLocalView: true).ConfigureAwait(true); } private async void AutoSignals_Click(object sender, RoutedEventArgs e) { BuildDefaultVisibleSignals(); SyncSignalVisibilityCheckboxes(); - await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false).ConfigureAwait(true); + await ReloadDisturbanceAsync(CurrentDisturbanceViewport(), initialLoad: false, preserveLocalView: true).ConfigureAwait(true); } private async void ClearSignals_Click(object sender, RoutedEventArgs e) @@ -159,8 +177,13 @@ private void SyncSignalVisibilityCheckboxes() return null; } - private async Task ReloadDisturbanceAsync(ComtradeSourceViewport requestedViewport, bool initialLoad) + private async Task ReloadDisturbanceAsync( + ComtradeSourceViewport requestedViewport, + bool initialLoad, + bool preserveLocalView = false) { + var previousView = new ComtradeTimeWindow(DisturbanceView.ViewStartMilliseconds, DisturbanceView.ViewEndMilliseconds); + var previousSourceViewport = _disturbanceLoadedViewport; _disturbanceRequestedViewport = requestedViewport; _disturbanceLoadCts?.Cancel(); _disturbanceLoadCts?.Dispose(); @@ -172,13 +195,13 @@ private async Task ReloadDisturbanceAsync(ComtradeSourceViewport requestedViewpo { DisturbanceView.ShowMessage("Select signals to display."); DigitalEventGrid.ItemsSource = Array.Empty(); - StatusTextBlock.Text = "No waveform tracks selected • use the checkboxes in Signals or choose Auto."; - NavigationTextBlock.Text = "Shared timeline • trigger = 0 ms • click a digital event to move Cursor A"; + StatusTextBlock.Text = "No Time Signals tracks selected • use the checkboxes in Signals or choose Auto."; + NavigationTextBlock.Text = "Wheel scrolls signals • Ctrl+wheel zooms time • drag plot pans • drag C1/C2 measures"; return; } StatusTextBlock.Text = initialLoad - ? $"Building synchronized disturbance view for {selected.Length} tracks…" + ? $"Building Time Signals workstation for {selected.Length} tracks…" : $"Refreshing {selected.Length} synchronized tracks…"; try @@ -189,14 +212,43 @@ private async Task ReloadDisturbanceAsync(ComtradeSourceViewport requestedViewpo _disturbanceLoadedViewport = result.SourceViewport; _disturbanceRequestedViewport = result.SourceViewport; _disturbanceReferenceTimestamps = result.ReferenceTimestamps; - _disturbanceReferenceExact = result.ReferenceIsExact; + _disturbanceReferenceSourceFrames = result.ReferenceSourceFrames; var triggerMs = ResolveTriggerMilliseconds(); DisturbanceView.ShowTracks(result.Tracks.Select(item => item.Track).ToArray(), _record.Info.TimeMultiplier, triggerMs); + + if (initialLoad && !_disturbanceInitialFocusApplied) + { + DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); + _disturbanceInitialFocusApplied = true; + + // A full-record 4096-bucket envelope can be too sparse for an eight-cycle opening + // view. Use timestamp->source-frame identity from that overview to reload a buffered + // trigger neighborhood, then keep the same trigger-focused time window. + if (result.SourceViewport.FrameCount > ExactSignalFrameLimit && + TryBuildSourceViewportForTimeWindow( + DisturbanceView.ViewStartMilliseconds, + DisturbanceView.ViewEndMilliseconds, + out var triggerViewport) && + triggerViewport.FrameCount > 0 && + triggerViewport.FrameCount < result.SourceViewport.FrameCount) + { + StatusTextBlock.Text = "Refining trigger neighborhood from native source frames…"; + await ReloadDisturbanceAsync(triggerViewport, initialLoad: false).ConfigureAwait(true); + DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); + return; + } + } + else if (preserveLocalView && previousSourceViewport == result.SourceViewport && previousView.SpanMilliseconds > 0) + { + DisturbanceView.SetViewWindow(previousView.StartMilliseconds, previousView.EndMilliseconds); + } + DigitalEventGrid.ItemsSource = BuildDigitalEventRows(result.Tracks, triggerMs); DigitalEventExpander.Visibility = result.Tracks.Any(item => item.Track.IsDigital) ? Visibility.Visible : Visibility.Collapsed; ResetViewButton.IsEnabled = result.Tracks.Any(item => item.Track.Timestamps.Length > 1); - StatusTextBlock.Text = $"Synchronized disturbance view • {result.Tracks.Count} tracks • {result.SourceViewport.FrameCount:N0} source frames" + - (result.ReferenceIsExact ? " • exact native samples" : " • bounded native overview"); + FullRecordButton.IsEnabled = result.Tracks.Any(item => item.Track.Timestamps.Length > 1); + StatusTextBlock.Text = $"Time Signals • {result.Tracks.Count} tracks • {result.SourceViewport.FrameCount:N0} source frames" + + (result.SourceViewport.FrameCount <= ExactSignalFrameLimit ? " • exact native samples" : " • bounded native overview"); } catch (OperationCanceledException) { @@ -207,7 +259,7 @@ private async Task ReloadDisturbanceAsync(ComtradeSourceViewport requestedViewpo catch (Exception ex) { DisturbanceView.ShowMessage(ex.Message); - StatusTextBlock.Text = $"Synchronized waveform load failed: {ex.Message}"; + StatusTextBlock.Text = $"Time Signals load failed: {ex.Message}"; } } @@ -234,13 +286,13 @@ private DisturbanceLoadResult BuildDisturbanceTracks( { var viewport = ComtradeAbsoluteViewportMath.Normalize(requestedViewport, _record.Info.FrameCount); var loaded = new List(signals.Count); - uint[]? referenceTimestamps = null; var exact = viewport.FrameCount <= ExactSignalFrameLimit; if (exact) { var count = checked((int)viewport.FrameCount); - referenceTimestamps = _record.ReadRawTimestamps(viewport.StartFrame, count); + var timestamps = _record.ReadRawTimestamps(viewport.StartFrame, count); + var sourceFrames = BuildSequentialFrames(viewport.StartFrame, count); foreach (var signal in signals) { token.ThrowIfCancellationRequested(); @@ -254,76 +306,175 @@ private DisturbanceLoadResult BuildDisturbanceTracks( false, _record.ReadAnalog(signal.Index, viewport.StartFrame, count), null, - referenceTimestamps, - ResolveSignalColor(metadata.Phase, signal.Title, false)))); + timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, false), + SourceFrames: sourceFrames))); } else { var metadata = _record.StatusChannels[checked((int)signal.Index)]; + var states = _record.ReadStatus(signal.Index, viewport.StartFrame, count); + var edges = BuildExactDigitalEdges(states, timestamps, sourceFrames, metadata.NormalState); loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( signal.Title, $"{BuildTrackSubtitle(metadata.Phase, metadata.Circuit)} • normal {metadata.NormalState}", "", true, null, - _record.ReadStatus(signal.Index, viewport.StartFrame, count), - referenceTimestamps, - ResolveSignalColor(metadata.Phase, signal.Title, true)))); + states, + timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, true), + SourceFrames: sourceFrames, + DigitalNormalState: metadata.NormalState, + DigitalEdges: edges))); } } } else { + var source = new ArdIrecRangeSource(_record); foreach (var signal in signals) { token.ThrowIfCancellationRequested(); - var preview = BuildSignalPreview(signal, viewport, token); - referenceTimestamps ??= preview.Timestamps; - if (signal.IsAnalog && preview.Analog is not null) + if (signal.IsAnalog) { + var envelope = ComtradeRangeDecimator.BuildAnalogEnvelope( + source, + signal.Index, + viewport.StartFrame, + viewport.FrameCount, + FullRecordAnalogBuckets, + cancellationToken: token); + var series = ComtradeDecimatedSeriesBuilder.BuildAnalog(envelope); var metadata = _record.AnalogChannels[checked((int)signal.Index)]; loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( - signal.Title, BuildTrackSubtitle(metadata.Phase, metadata.Circuit), metadata.Units, - false, preview.Analog, null, preview.Timestamps, - ResolveSignalColor(metadata.Phase, signal.Title, false), PreserveAllPoints: true))); + signal.Title, + BuildTrackSubtitle(metadata.Phase, metadata.Circuit), + metadata.Units, + false, + series.Values, + null, + series.Timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, false), + PreserveAllPoints: true, + SourceFrames: series.SourceFrames))); } - else if (!signal.IsAnalog && preview.Status is not null) + else { + var transitionSet = ComtradeRangeDecimator.BuildDigitalTransitions( + source, + signal.Index, + viewport.StartFrame, + viewport.FrameCount, + FullRecordDigitalTransitionCap, + cancellationToken: token); + var series = ComtradeDecimatedSeriesBuilder.BuildDigital(transitionSet); var metadata = _record.StatusChannels[checked((int)signal.Index)]; + var edges = BuildReducedDigitalEdges(transitionSet, metadata.NormalState); loaded.Add(new LoadedDisturbanceTrack(signal, new ComtradeDisturbanceTrack( - signal.Title, $"{BuildTrackSubtitle(metadata.Phase, metadata.Circuit)} • normal {metadata.NormalState}", "", - true, null, preview.Status, preview.Timestamps, - ResolveSignalColor(metadata.Phase, signal.Title, true)))); + signal.Title, + $"{BuildTrackSubtitle(metadata.Phase, metadata.Circuit)} • normal {metadata.NormalState}", + "", + true, + null, + series.States, + series.Timestamps, + ResolveSignalColor(metadata.Phase, signal.Title, true), + SourceFrames: series.SourceFrames, + DigitalNormalState: metadata.NormalState, + DigitalEdges: edges, + DigitalIsLossy: series.IsTruncated))); } } } - return new DisturbanceLoadResult(loaded, viewport, referenceTimestamps ?? Array.Empty(), exact); + var reference = loaded + .Select(item => item.Track) + .Where(track => track.Timestamps.Length > 0 && track.SourceFrames is { Length: > 0 }) + .OrderByDescending(track => track.Timestamps.Length) + .FirstOrDefault(); + return new DisturbanceLoadResult( + loaded, + viewport, + reference?.Timestamps ?? Array.Empty(), + reference?.SourceFrames ?? Array.Empty()); } - private IReadOnlyList BuildDigitalEventRows(IReadOnlyList tracks, double? triggerMilliseconds) + private static ulong[] BuildSequentialFrames(ulong startFrame, int count) { - var events = new List<(double Time, string Signal, string Event, string State)>(); + var frames = new ulong[count]; + for (var i = 0; i < count; i++) + frames[i] = startFrame + checked((ulong)i); + return frames; + } + + private static IReadOnlyList BuildExactDigitalEdges( + IReadOnlyList states, + IReadOnlyList timestamps, + IReadOnlyList sourceFrames, + int normalState) + { + var count = Math.Min(states.Count, Math.Min(timestamps.Count, sourceFrames.Count)); + var edges = new List(); + for (var i = 1; i < count; i++) + { + var before = states[i - 1] == 0 ? (byte)0 : (byte)1; + var after = states[i] == 0 ? (byte)0 : (byte)1; + if (before == after) continue; + edges.Add(new ComtradeDisturbanceDigitalEdge(timestamps[i], sourceFrames[i], before, after, normalState)); + } + return edges; + } + + private static IReadOnlyList BuildReducedDigitalEdges( + ComtradeDigitalTransitionSet transitionSet, + int normalState) + { + if (transitionSet.Transitions.Count <= 1) + return Array.Empty(); + + // Every retained item after the initial state is an actual binary transition. Adaptive + // sampling may omit intermediate transitions, so infer the immediate before-state as the + // opposite raw value instead of comparing adjacent retained samples. + return transitionSet.Transitions + .Skip(1) + .Select(transition => + { + var after = transition.State == 0 ? (byte)0 : (byte)1; + var before = after == 0 ? (byte)1 : (byte)0; + return new ComtradeDisturbanceDigitalEdge( + transition.Timestamp, + transition.Frame, + before, + after, + normalState); + }) + .ToArray(); + } + + private IReadOnlyList BuildDigitalEventRows( + IReadOnlyList tracks, + double? triggerMilliseconds) + { + var events = new List<(double Time, ulong SourceFrame, string Signal, string Event, string State)>(); foreach (var loaded in tracks.Where(item => item.Track.IsDigital)) { var track = loaded.Track; - if (track.Digital is null) continue; - var count = Math.Min(track.Digital.Length, track.Timestamps.Length); - for (var i = 1; i < count; i++) + if (track.DigitalEdges is not { Count: > 0 }) continue; + foreach (var edge in track.DigitalEdges) { - var before = track.Digital[i - 1] != 0; - var after = track.Digital[i] != 0; - if (before == after) continue; - var time = ComtradeTimeMath.ToMilliseconds(track.Timestamps[i], _record.Info.TimeMultiplier); + var time = ComtradeTimeMath.ToMilliseconds(edge.Timestamp, _record.Info.TimeMultiplier); + var active = (edge.AfterState != 0 ? 1 : 0) != edge.NormalState; events.Add(( time, + edge.SourceFrame, track.Title, - ComtradeDisturbanceTimelineMath.DescribeDigitalEvent(track.Title, after), - $"{(before ? 1 : 0)}→{(after ? 1 : 0)}")); + ComtradeDisturbanceTimelineMath.DescribeDigitalEvent(track.Title, active), + $"{edge.BeforeState}→{edge.AfterState} • {(active ? "active" : "normal")}")); } } - var ordered = events.OrderBy(item => item.Time).ToArray(); + var ordered = events.OrderBy(item => item.Time).ThenBy(item => item.Signal, StringComparer.OrdinalIgnoreCase).ToArray(); var rows = new List(ordered.Length); double? previous = null; foreach (var item in ordered) @@ -332,6 +483,7 @@ private IReadOnlyList BuildDigitalEventRows(IReadOnlyLi var delta = previous is { } previousTime ? item.Time - previousTime : (double?)null; rows.Add(new ComtradeDigitalEventRow( item.Time, + item.SourceFrame, ComtradeDisturbanceTimelineMath.FormatRelativeTime(relative), delta is { } d ? $"{d:G6} ms" : "—", item.Signal, @@ -345,84 +497,145 @@ private IReadOnlyList BuildDigitalEventRows(IReadOnlyLi private void DigitalEventGrid_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (DigitalEventGrid.SelectedItem is not ComtradeDigitalEventRow row) return; - DisturbanceView.SetCursorAFromAbsoluteMilliseconds(row.AbsoluteMilliseconds); - StatusTextBlock.Text = $"Cursor A moved to {row.Signal} • {row.Event} • {row.TimeText}."; + DisturbanceView.SetCursorFromHost(ComtradeDisturbanceCursor.Cursor1, row.AbsoluteMilliseconds); + StatusTextBlock.Text = $"C1 moved to {row.Signal} • {row.Event} • {row.TimeText}."; } private async void DisturbanceReset_Click(object sender, RoutedEventArgs e) { var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); - if (_record.Info.FrameCount > ExactSignalFrameLimit && _disturbanceRequestedViewport != full) + if (_disturbanceRequestedViewport != full || _disturbanceLoadedViewport != full) { - _disturbanceRequestedViewport = full; await ReloadDisturbanceAsync(full, initialLoad: false).ConfigureAwait(true); - return; + if (_record.Info.FrameCount > ExactSignalFrameLimit && + TryBuildSourceViewportForTimeWindow( + DisturbanceView.ViewStartMilliseconds, + DisturbanceView.ViewEndMilliseconds, + out var triggerViewport) && + triggerViewport.FrameCount > 0 && triggerViewport != full) + { + await ReloadDisturbanceAsync(triggerViewport, initialLoad: false).ConfigureAwait(true); + } } + DisturbanceView.ApplyTriggerFocusedDefault(_record.Info.NominalFrequency); + _disturbanceInitialFocusApplied = true; + } + + private async void DisturbanceFullRecord_Click(object sender, RoutedEventArgs e) + { + var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); + if (_disturbanceRequestedViewport != full || _disturbanceLoadedViewport != full) + await ReloadDisturbanceAsync(full, initialLoad: false).ConfigureAwait(true); DisturbanceView.ResetNavigation(); } private void DisturbanceView_NavigationChanged(object? sender, ComtradeDisturbanceNavigationChangedEventArgs e) { if (_analysisMode != AnalysisMode.Waveform) return; - NavigationTextBlock.Text = e.Summary + (_record.Info.FrameCount > ExactSignalFrameLimit - ? " | wheel reloads native source range" - : " | shared trigger-relative timeline"); + NavigationTextBlock.Text = e.Summary + " | wheel scrolls tracks • Ctrl+wheel zooms • drag pans"; UpdateAnalysisAvailability(); } private async void DisturbanceView_PreviewMouseWheel(object sender, MouseWheelEventArgs e) { + if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) + return; // Normal wheel belongs to the vertical track ScrollViewer. if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) - return; + return; // Small records use the control's in-memory Ctrl+wheel zoom. + var current = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; - var fraction = DisturbanceView.PlotFractionAt(e.GetPosition(DisturbanceView).X); - ComtradeSourceViewport target; - if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0) + var plotFraction = DisturbanceView.PlotFractionAt(e.GetPosition(DisturbanceView).X); + var visibleSpan = DisturbanceView.ViewEndMilliseconds - DisturbanceView.ViewStartMilliseconds; + var anchorMilliseconds = DisturbanceView.ViewStartMilliseconds + visibleSpan * plotFraction; + var sourceFraction = plotFraction; + if (TryResolveDisturbanceFrameAtMilliseconds(anchorMilliseconds, out var anchorFrame) && current.FrameCount > 1 && + anchorFrame >= current.StartFrame && anchorFrame < current.EndExclusive) { - var step = Math.Max(1UL, current.FrameCount / 10); - target = ComtradeAbsoluteViewportMath.Pan(current, _record.Info.FrameCount, ToSignedStep(step, e.Delta > 0 ? -1 : 1)); - } - else - { - target = ComtradeAbsoluteViewportMath.Zoom(current, _record.Info.FrameCount, fraction, e.Delta > 0 ? 0.60 : 1.60, minimumFrames: 32); + sourceFraction = (anchorFrame - current.StartFrame) / (double)(current.FrameCount - 1); } + + var target = ComtradeAbsoluteViewportMath.Zoom( + current, + _record.Info.FrameCount, + Math.Clamp(sourceFraction, 0.0, 1.0), + e.Delta > 0 ? 0.60 : 1.60, + minimumFrames: 32); e.Handled = true; if (target == current) return; _disturbanceRequestedViewport = target; await ReloadDisturbanceAsync(target, initialLoad: false).ConfigureAwait(true); } - private void DisturbanceView_PreviewMouseDown(object sender, MouseButtonEventArgs e) + private async void DisturbanceView_PanRequested(object? sender, ComtradeDisturbancePanRequestedEventArgs e) { - if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) return; - var pan = e.ChangedButton == MouseButton.Middle || - (e.ChangedButton == MouseButton.Left && (Keyboard.Modifiers & ModifierKeys.Alt) != 0); - if (!pan) return; - _disturbanceSourcePanGesture = true; - _disturbanceSourcePanStartPoint = e.GetPosition(DisturbanceView); - _disturbanceSourcePanStartViewport = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; - DisturbanceView.CaptureMouse(); - DisturbanceView.Cursor = Cursors.SizeWE; - e.Handled = true; - } + if (_record.Info.FrameCount <= ExactSignalFrameLimit || _disturbanceLoadedViewport.FrameCount == 0) + return; - private async void DisturbanceView_PreviewMouseUp(object sender, MouseButtonEventArgs e) - { - if (!_disturbanceSourcePanGesture) return; - _disturbanceSourcePanGesture = false; - var end = e.GetPosition(DisturbanceView); - if (DisturbanceView.IsMouseCaptured) DisturbanceView.ReleaseMouseCapture(); - DisturbanceView.Cursor = Cursors.Cross; - e.Handled = true; - var width = Math.Max(1.0, DisturbanceView.ActualWidth - 168.0); - var fraction = -(end.X - _disturbanceSourcePanStartPoint.X) / width; - var delta = ToSignedDelta(_disturbanceSourcePanStartViewport.FrameCount, fraction); - var target = ComtradeAbsoluteViewportMath.Pan(_disturbanceSourcePanStartViewport, _record.Info.FrameCount, delta); - if (target == _disturbanceRequestedViewport) return; + // If the in-memory view still has margin inside the loaded native range, local panning is + // sufficient. Reload source frames only when the gesture reaches a loaded-range edge. + const double epsilon = 1e-6; + if (DisturbanceView.ViewStartMilliseconds > DisturbanceView.FullStartMilliseconds + epsilon && + DisturbanceView.ViewEndMilliseconds < DisturbanceView.FullEndMilliseconds - epsilon) + return; + + var current = _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport : _disturbanceLoadedViewport; + var delta = ToSignedDelta(current.FrameCount, e.DeltaFraction); + var target = ComtradeAbsoluteViewportMath.Pan(current, _record.Info.FrameCount, delta); + if (target == current) return; _disturbanceRequestedViewport = target; await ReloadDisturbanceAsync(target, initialLoad: false).ConfigureAwait(true); } + private async void DisturbanceView_CursorChanged(object? sender, ComtradeDisturbanceCursorChangedEventArgs e) + { + if (!e.IsFinal || e.SnapToleranceMilliseconds <= 0 || + !_record.Supports(ArdIrecNativeBridge.CapDigitalEdgeSnap) || + !TryResolveDisturbanceFrameAtMilliseconds(e.AbsoluteMilliseconds, out var sourceFrame)) + return; + + _disturbanceCursorSnapCts?.Cancel(); + _disturbanceCursorSnapCts?.Dispose(); + _disturbanceCursorSnapCts = new CancellationTokenSource(); + var token = _disturbanceCursorSnapCts.Token; + try + { + await _nativeGate.WaitAsync(token).ConfigureAwait(false); + ComtradeStatusEdge? edge; + try + { + token.ThrowIfCancellationRequested(); + var toleranceSeconds = e.SnapToleranceMilliseconds / 1000.0; + edge = await Task.Run(() => + { + _record.TryFindNearestStatusEdge(sourceFrame, toleranceSeconds, out var nativeEdge); + return nativeEdge; + }, token).ConfigureAwait(false); + } + finally + { + _nativeGate.Release(); + } + + if (token.IsCancellationRequested || edge is not { Valid: true }) return; + var snappedMilliseconds = ComtradeTimeMath.ToMilliseconds(edge.RawTimestamp, _record.Info.TimeMultiplier); + await Dispatcher.InvokeAsync(() => + { + DisturbanceView.SetCursorFromHost(e.Cursor, snappedMilliseconds); + var signal = edge.ChannelIndex < _record.StatusChannels.Count + ? _record.StatusChannels[checked((int)edge.ChannelIndex)].Id + : $"digital {edge.ChannelIndex + 1}"; + StatusTextBlock.Text = $"{(e.Cursor == ComtradeDisturbanceCursor.Cursor1 ? "C1" : "C2")} snapped to {signal} • " + + $"{edge.BeforeState}→{edge.AfterState} • {(edge.BecameActive ? "active" : "normal")}."; + }); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + private ComtradeSourceViewport CurrentDisturbanceViewport() => _disturbanceRequestedViewport.FrameCount > 0 ? _disturbanceRequestedViewport @@ -438,27 +651,74 @@ private ComtradeSourceViewport CurrentDisturbanceViewport() private bool TryResolveDisturbanceCursorFrame(out ulong frame) { frame = 0; - if (DisturbanceView.CursorAMilliseconds is not { } cursor || _record.Info.FrameCount == 0 || _disturbanceLoadedViewport.FrameCount == 0) + return DisturbanceView.Cursor1Milliseconds is { } cursor && + TryResolveDisturbanceFrameAtMilliseconds(cursor, out frame); + } + + private bool TryResolveDisturbanceFrameAtMilliseconds(double milliseconds, out ulong frame) + { + frame = 0; + if (!double.IsFinite(milliseconds) || _record.Info.FrameCount == 0 || + _disturbanceReferenceTimestamps is not { Length: > 0 } timestamps || + _disturbanceReferenceSourceFrames is not { Length: > 0 } sourceFrames) + return false; + + var count = Math.Min(timestamps.Length, sourceFrames.Length); + if (count <= 0) return false; + var targetRaw = milliseconds * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); + var searchTimestamps = count == timestamps.Length ? timestamps : timestamps.Take(count).ToArray(); + var index = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(searchTimestamps, targetRaw); + if (index < 0 || index >= count) return false; + frame = Math.Min(_record.Info.FrameCount - 1, sourceFrames[index]); + return true; + } + + private bool TryBuildSourceViewportForTimeWindow( + double startMilliseconds, + double endMilliseconds, + out ComtradeSourceViewport viewport) + { + viewport = default; + if (!double.IsFinite(startMilliseconds) || !double.IsFinite(endMilliseconds) || endMilliseconds <= startMilliseconds || + _disturbanceReferenceTimestamps is not { Length: > 1 } timestamps || + _disturbanceReferenceSourceFrames is not { Length: > 1 } sourceFrames) return false; - if (_disturbanceReferenceExact && _disturbanceReferenceTimestamps is { Length: > 0 } timestamps) + var count = Math.Min(timestamps.Length, sourceFrames.Length); + var targetStartRaw = startMilliseconds * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); + var targetEndRaw = endMilliseconds * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); + var searchTimestamps = count == timestamps.Length ? timestamps : timestamps.Take(count).ToArray(); + var startIndex = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(searchTimestamps, targetStartRaw); + var endIndex = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(searchTimestamps, targetEndRaw); + if (startIndex < 0 || endIndex < 0) return false; + + var lowIndex = Math.Max(0, Math.Min(startIndex, endIndex) - 2); + var highIndex = Math.Min(count - 1, Math.Max(startIndex, endIndex) + 2); + var startFrame = Math.Min(sourceFrames[lowIndex], sourceFrames[highIndex]); + var endFrame = Math.Max(sourceFrames[lowIndex], sourceFrames[highIndex]); + var frameCount = endFrame >= startFrame ? endFrame - startFrame + 1 : 0; + if (frameCount == 0) return false; + + const ulong minimumFrames = 32; + if (frameCount < minimumFrames) { - var targetRaw = cursor * 1000.0 / Math.Max(1e-12, _record.Info.TimeMultiplier); - var index = ComtradeDisturbanceTimelineMath.NearestTimestampIndex(timestamps, targetRaw); - if (index < 0) return false; - frame = Math.Min(_record.Info.FrameCount - 1, _disturbanceLoadedViewport.StartFrame + checked((ulong)index)); - return true; + var center = startFrame + frameCount / 2; + var half = minimumFrames / 2; + startFrame = center > half ? center - half : 0; + frameCount = minimumFrames; } - var start = DisturbanceView.ViewStartMilliseconds; - var end = DisturbanceView.ViewEndMilliseconds; - if (end <= start) return false; - var fraction = Math.Clamp((cursor - start) / (end - start), 0.0, 1.0); - var offset = _disturbanceLoadedViewport.FrameCount <= 1 - ? 0UL - : checked((ulong)Math.Round((_disturbanceLoadedViewport.FrameCount - 1) * fraction, MidpointRounding.AwayFromZero)); - frame = Math.Min(_record.Info.FrameCount - 1, _disturbanceLoadedViewport.StartFrame + offset); - return true; + viewport = ComtradeAbsoluteViewportMath.Normalize( + new ComtradeSourceViewport(startFrame, frameCount), + _record.Info.FrameCount); + return viewport.FrameCount > 0; + } + + private bool TryResolveDisturbanceViewportCenterFrame(out ulong frame) + { + var center = DisturbanceView.ViewStartMilliseconds + + (DisturbanceView.ViewEndMilliseconds - DisturbanceView.ViewStartMilliseconds) * 0.5; + return TryResolveDisturbanceFrameAtMilliseconds(center, out frame); } private static string BuildTrackSubtitle(string phase, string circuit) @@ -488,14 +748,16 @@ private static Color ResolveSignalColor(string phase, string title, bool digital } private sealed record LoadedDisturbanceTrack(ComtradeSignalItem Signal, ComtradeDisturbanceTrack Track); + private sealed record DisturbanceLoadResult( IReadOnlyList Tracks, ComtradeSourceViewport SourceViewport, uint[] ReferenceTimestamps, - bool ReferenceIsExact); + ulong[] ReferenceSourceFrames); private sealed record ComtradeDigitalEventRow( double AbsoluteMilliseconds, + ulong SourceFrame, string TimeText, string DeltaText, string Signal, diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index 552017738..97a0ce940 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -92,13 +92,28 @@ -