From 6b2625c40a73b0805698d69c8b7b7a756aa38389 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:27:30 +0700 Subject: [PATCH 01/10] P1D.2C: add trigger-focused time-signals navigation math --- Services/ComtradeTimeSignalsNavigationMath.cs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 Services/ComtradeTimeSignalsNavigationMath.cs diff --git a/Services/ComtradeTimeSignalsNavigationMath.cs b/Services/ComtradeTimeSignalsNavigationMath.cs new file mode 100644 index 000000000..9c96c99d6 --- /dev/null +++ b/Services/ComtradeTimeSignalsNavigationMath.cs @@ -0,0 +1,86 @@ +namespace ArIED61850Tester.Services; + +internal readonly record struct ComtradeTimeWindow(double StartMilliseconds, double EndMilliseconds) +{ + internal double SpanMilliseconds => Math.Max(0.0, EndMilliseconds - StartMilliseconds); +} + +internal readonly record struct ComtradeInitialCursors(double? Cursor1Milliseconds, double? Cursor2Milliseconds); + +internal static class ComtradeTimeSignalsNavigationMath +{ + internal const double DefaultVisibleCycles = 8.0; + internal const double DefaultTriggerFraction = 0.45; + + internal static ComtradeTimeWindow CreateTriggerFocusedWindow( + double fullStartMilliseconds, + double fullEndMilliseconds, + double? triggerMilliseconds, + double nominalFrequencyHz, + double visibleCycles = DefaultVisibleCycles, + double triggerFraction = DefaultTriggerFraction) + { + if (!double.IsFinite(fullStartMilliseconds) || !double.IsFinite(fullEndMilliseconds) || fullEndMilliseconds <= fullStartMilliseconds) + return new ComtradeTimeWindow(0.0, 1.0); + + var fullSpan = fullEndMilliseconds - fullStartMilliseconds; + if (triggerMilliseconds is not { } trigger || !double.IsFinite(trigger) || + nominalFrequencyHz <= 0 || !double.IsFinite(nominalFrequencyHz) || + visibleCycles <= 0 || !double.IsFinite(visibleCycles)) + { + return new ComtradeTimeWindow(fullStartMilliseconds, fullEndMilliseconds); + } + + triggerFraction = Math.Clamp(triggerFraction, 0.05, 0.95); + var cycleMilliseconds = 1000.0 / nominalFrequencyHz; + var targetSpan = Math.Min(fullSpan, Math.Max(cycleMilliseconds, cycleMilliseconds * visibleCycles)); + var start = trigger - targetSpan * triggerFraction; + var end = start + targetSpan; + + if (start < fullStartMilliseconds) + { + start = fullStartMilliseconds; + end = start + targetSpan; + } + if (end > fullEndMilliseconds) + { + end = fullEndMilliseconds; + start = end - targetSpan; + } + + start = Math.Max(fullStartMilliseconds, start); + end = Math.Min(fullEndMilliseconds, end); + return end > start + ? new ComtradeTimeWindow(start, end) + : new ComtradeTimeWindow(fullStartMilliseconds, fullEndMilliseconds); + } + + internal static ComtradeInitialCursors CreateInitialCursors( + double fullStartMilliseconds, + double fullEndMilliseconds, + double? triggerMilliseconds, + double nominalFrequencyHz) + { + if (!double.IsFinite(fullStartMilliseconds) || !double.IsFinite(fullEndMilliseconds) || fullEndMilliseconds <= fullStartMilliseconds || + triggerMilliseconds is not { } trigger || !double.IsFinite(trigger) || + nominalFrequencyHz <= 0 || !double.IsFinite(nominalFrequencyHz)) + { + return new ComtradeInitialCursors(null, null); + } + + var cycleMilliseconds = 1000.0 / nominalFrequencyHz; + var c1 = Math.Clamp(trigger - cycleMilliseconds, fullStartMilliseconds, fullEndMilliseconds); + var c2 = Math.Clamp(trigger + cycleMilliseconds, fullStartMilliseconds, fullEndMilliseconds); + return new ComtradeInitialCursors(c1, c2); + } + + internal static double SnapToleranceMilliseconds(double visibleSpanMilliseconds, double plotWidthPixels, double tolerancePixels = 12.0) + { + if (!double.IsFinite(visibleSpanMilliseconds) || visibleSpanMilliseconds <= 0 || + !double.IsFinite(plotWidthPixels) || plotWidthPixels <= 0 || + !double.IsFinite(tolerancePixels) || tolerancePixels <= 0) + return 0.0; + + return visibleSpanMilliseconds * tolerancePixels / plotWidthPixels; + } +} From bf724cbdcbea6b02af86c2fc605c089d265903f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:29:04 +0700 Subject: [PATCH 02/10] P1D.2C: cover trigger-focused time navigation math --- .../ComtradeTimeSignalsNavigationMathTests.cs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/ARSAS.Tests/ComtradeTimeSignalsNavigationMathTests.cs diff --git a/tests/ARSAS.Tests/ComtradeTimeSignalsNavigationMathTests.cs b/tests/ARSAS.Tests/ComtradeTimeSignalsNavigationMathTests.cs new file mode 100644 index 000000000..fe679c2c9 --- /dev/null +++ b/tests/ARSAS.Tests/ComtradeTimeSignalsNavigationMathTests.cs @@ -0,0 +1,74 @@ +using ArIED61850Tester.Services; + +namespace ARSAS.Tests; + +public sealed class ComtradeTimeSignalsNavigationMathTests +{ + [Fact] + public void TriggerFocusedWindow_ShowsEightCyclesWithTriggerAtFortyFivePercent() + { + var window = ComtradeTimeSignalsNavigationMath.CreateTriggerFocusedWindow( + 0.0, 5000.0, 1000.0, 50.0); + + Assert.Equal(928.0, window.StartMilliseconds, 9); + Assert.Equal(1088.0, window.EndMilliseconds, 9); + Assert.Equal(160.0, window.SpanMilliseconds, 9); + Assert.Equal(0.45, (1000.0 - window.StartMilliseconds) / window.SpanMilliseconds, 9); + } + + [Fact] + public void TriggerFocusedWindow_ClampsAtRecordStartAndEnd() + { + var nearStart = ComtradeTimeSignalsNavigationMath.CreateTriggerFocusedWindow( + 0.0, 500.0, 20.0, 50.0); + var nearEnd = ComtradeTimeSignalsNavigationMath.CreateTriggerFocusedWindow( + 0.0, 500.0, 490.0, 50.0); + + Assert.Equal(0.0, nearStart.StartMilliseconds, 9); + Assert.Equal(160.0, nearStart.EndMilliseconds, 9); + Assert.Equal(340.0, nearEnd.StartMilliseconds, 9); + Assert.Equal(500.0, nearEnd.EndMilliseconds, 9); + } + + [Theory] + [InlineData(null, 50.0)] + [InlineData(1000.0, 0.0)] + [InlineData(1000.0, double.NaN)] + public void TriggerFocusedWindow_FallsBackToFullRangeWithoutUsableReference(double? trigger, double frequency) + { + var window = ComtradeTimeSignalsNavigationMath.CreateTriggerFocusedWindow( + 100.0, 900.0, trigger, frequency); + + Assert.Equal(100.0, window.StartMilliseconds, 9); + Assert.Equal(900.0, window.EndMilliseconds, 9); + } + + [Fact] + public void InitialCursors_BracketTriggerByOneNominalCycle() + { + var cursors = ComtradeTimeSignalsNavigationMath.CreateInitialCursors( + 0.0, 5000.0, 1000.0, 50.0); + + Assert.Equal(980.0, cursors.Cursor1Milliseconds!.Value, 9); + Assert.Equal(1020.0, cursors.Cursor2Milliseconds!.Value, 9); + } + + [Fact] + public void InitialCursors_ClampToAvailableRecord() + { + var cursors = ComtradeTimeSignalsNavigationMath.CreateInitialCursors( + 990.0, 1010.0, 1000.0, 50.0); + + Assert.Equal(990.0, cursors.Cursor1Milliseconds!.Value, 9); + Assert.Equal(1010.0, cursors.Cursor2Milliseconds!.Value, 9); + } + + [Fact] + public void SnapTolerance_ConvertsPixelRadiusToCurrentTimeSpan() + { + var tolerance = ComtradeTimeSignalsNavigationMath.SnapToleranceMilliseconds( + 160.0, 800.0, 12.0); + + Assert.Equal(2.4, tolerance, 9); + } +} From ea3f6347a25ce3720989df74945715017d0cc97e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:31:41 +0700 Subject: [PATCH 03/10] P1D.2C: consume optional ArdIrec investigation bridge capabilities --- Services/ArdIrecNativeBridge.cs | 198 ++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/Services/ArdIrecNativeBridge.cs b/Services/ArdIrecNativeBridge.cs index bf024bae5..74e450f0b 100644 --- a/Services/ArdIrecNativeBridge.cs +++ b/Services/ArdIrecNativeBridge.cs @@ -39,6 +39,41 @@ internal sealed record ComtradeStatusChannelInfo( string Circuit, int NormalState); +internal sealed record ComtradeAnalogSemantics( + int Role, + int PhaseRole, + int RecordedRepresentation, + bool HasValidTransformerRatio, + double ScaleToSecondary, + double ScaleToPrimary); + +internal sealed record ComtradeCursorMeasurement( + bool Valid, + ulong ReferenceFrame, + uint RawTimestamp, + double TimeSeconds, + double Instantaneous, + double Rms, + ulong WindowStartFrame, + ulong WindowEndExclusive, + uint WindowSampleCount); + +internal sealed record ComtradeStatusState( + int RawState, + int NormalState, + bool IsActive); + +internal sealed record ComtradeStatusEdge( + bool Valid, + uint ChannelIndex, + ulong FrameIndex, + uint RawTimestamp, + int BeforeState, + int AfterState, + int NormalState, + bool BecameActive, + double DistanceSeconds); + internal sealed record ComtradePhasor( bool Valid, double MagnitudeRms, @@ -71,6 +106,14 @@ internal sealed record ComtradeHarmonicSpectrum( internal static class ArdIrecNativeBridge { internal const uint ExpectedAbiVersion = 1; + internal const ulong CapCursorMeasurement = 1UL << 0; + internal const ulong CapChannelSemantics = 1UL << 1; + internal const ulong CapValueRepresentation = 1UL << 2; + internal const ulong CapStatusState = 1UL << 3; + internal const ulong CapDigitalEdgeSnap = 1UL << 4; + internal const ulong CapPhasor = 1UL << 5; + internal const ulong CapHarmonics = 1UL << 6; + private const string BridgeFileName = "ardirec_bridge.dll"; private static readonly object Sync = new(); private static NativeApi? _api; @@ -242,10 +285,19 @@ internal NativeApi(string libraryPath) CopyRawTimestamps = Export("ardirec_record_copy_raw_timestamps"); GetPhasor = Export("ardirec_record_get_phasor"); GetHarmonicSpectrum = Export("ardirec_record_get_harmonic_spectrum"); + + var capabilities = TryExport("ardirec_bridge_capabilities"); + Capabilities = capabilities?.Invoke() ?? 0UL; + GetAnalogSemantics = TryExport("ardirec_record_get_analog_semantics"); + GetRepresentationScale = TryExport("ardirec_record_get_representation_scale"); + GetCursorMeasurement = TryExport("ardirec_record_get_cursor_measurement"); + GetStatusState = TryExport("ardirec_record_get_status_state"); + FindNearestStatusEdge = TryExport("ardirec_record_find_nearest_status_edge"); } internal string LibraryPath { get; } internal uint AbiVersion { get; } + internal ulong Capabilities { get; } internal RecordOpenDelegate Open { get; } internal RecordCloseDelegate Close { get; } internal RecordGetInfoDelegate GetInfo { get; } @@ -256,6 +308,11 @@ internal NativeApi(string libraryPath) internal RecordCopyRawTimestampsDelegate CopyRawTimestamps { get; } internal RecordGetPhasorDelegate GetPhasor { get; } internal RecordGetHarmonicSpectrumDelegate GetHarmonicSpectrum { get; } + internal RecordGetAnalogSemanticsDelegate? GetAnalogSemantics { get; } + internal RecordGetRepresentationScaleDelegate? GetRepresentationScale { get; } + internal RecordGetCursorMeasurementDelegate? GetCursorMeasurement { get; } + internal RecordGetStatusStateDelegate? GetStatusState { get; } + internal RecordFindNearestStatusEdgeDelegate? FindNearestStatusEdge { get; } private T Export(string name) where T : Delegate { @@ -263,6 +320,11 @@ private T Export(string name) where T : Delegate return Marshal.GetDelegateForFunctionPointer(address); } + private T? TryExport(string name) where T : Delegate + => NativeLibrary.TryGetExport(_library, name, out var address) + ? Marshal.GetDelegateForFunctionPointer(address) + : null; + public void Dispose() { if (_library == IntPtr.Zero) return; @@ -272,11 +334,17 @@ public void Dispose() } [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate uint BridgeAbiVersionDelegate(); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate ulong BridgeCapabilitiesDelegate(); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordOpenDelegate(IntPtr cfgPath, out IntPtr handle, IntPtr error, nuint errorCapacity); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate void RecordCloseDelegate(IntPtr handle); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetInfoDelegate(IntPtr handle, ref NativeRecordInfo info); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetAnalogChannelDelegate(IntPtr handle, uint index, ref NativeAnalogChannelInfo info); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetStatusChannelDelegate(IntPtr handle, uint index, ref NativeStatusChannelInfo info); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetAnalogSemanticsDelegate(IntPtr handle, uint channel, ref NativeAnalogSemanticsInfo info); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetRepresentationScaleDelegate(IntPtr handle, uint channel, int representation, out double scale); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetCursorMeasurementDelegate(IntPtr handle, uint channel, ulong referenceFrame, int representation, ref NativeCursorMeasurementInfo info); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordGetStatusStateDelegate(IntPtr handle, uint channel, ulong referenceFrame, ref NativeStatusStateInfo info); + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordFindNearestStatusEdgeDelegate(IntPtr handle, ulong referenceFrame, double maxDistanceSeconds, ref NativeStatusEdgeInfo info); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordCopyAnalogDelegate(IntPtr handle, uint channel, ulong start, ulong count, [Out] double[] destination); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordCopyStatusDelegate(IntPtr handle, uint channel, ulong start, ulong count, [Out] byte[] destination); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate int RecordCopyRawTimestampsDelegate(IntPtr handle, ulong start, ulong count, [Out] uint[] destination); @@ -354,6 +422,53 @@ internal struct NativeStatusChannelInfo }; } + [StructLayout(LayoutKind.Sequential)] + internal struct NativeAnalogSemanticsInfo + { + internal int Role; + internal int PhaseRole; + internal int RecordedRepresentation; + internal int HasValidTransformerRatio; + internal double ScaleToSecondary; + internal double ScaleToPrimary; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeCursorMeasurementInfo + { + internal int Valid; + internal ulong ReferenceFrame; + internal uint RawTimestamp; + internal double TimeSeconds; + internal double Instantaneous; + internal double Rms; + internal ulong WindowStartFrame; + internal ulong WindowEndExclusive; + internal uint WindowSampleCount; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeStatusStateInfo + { + internal int RawState; + internal int NormalState; + internal int IsActive; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeStatusEdgeInfo + { + internal int Valid; + internal uint ChannelIndex; + internal ulong FrameIndex; + internal uint RawTimestamp; + internal int BeforeState; + internal int AfterState; + internal int NormalState; + internal int BecameActive; + internal double DistanceSeconds; + } + [StructLayout(LayoutKind.Sequential)] internal struct NativePhasorInfo { @@ -431,6 +546,9 @@ internal ArdIrecNativeRecord(ArdIrecNativeBridge.NativeApi api, IntPtr handle, s internal ComtradeRecordInfo Info { get; } internal IReadOnlyList AnalogChannels { get; } internal IReadOnlyList StatusChannels { get; } + internal ulong BridgeCapabilities => _api.Capabilities; + + internal bool Supports(ulong capability) => (_api.Capabilities & capability) == capability; internal double[] ReadAnalog(uint channelIndex, ulong startFrame, int frameCount) { @@ -459,6 +577,86 @@ internal uint[] ReadRawTimestamps(ulong startFrame, int frameCount) return values; } + internal bool TryReadAnalogSemantics(uint channelIndex, out ComtradeAnalogSemantics? semantics) + { + EnsureOpen(); + semantics = null; + if (!Supports(ArdIrecNativeBridge.CapChannelSemantics) || _api.GetAnalogSemantics is null) + return false; + + var native = new ArdIrecNativeBridge.NativeAnalogSemanticsInfo(); + if (_api.GetAnalogSemantics(_handle, channelIndex, ref native) != 0) + return false; + semantics = new ComtradeAnalogSemantics( + native.Role, + native.PhaseRole, + native.RecordedRepresentation, + native.HasValidTransformerRatio != 0, + native.ScaleToSecondary, + native.ScaleToPrimary); + return true; + } + + internal bool TryReadCursorMeasurement(uint channelIndex, ulong referenceFrame, int representation, out ComtradeCursorMeasurement? measurement) + { + EnsureOpen(); + measurement = null; + if (!Supports(ArdIrecNativeBridge.CapCursorMeasurement) || _api.GetCursorMeasurement is null) + return false; + + var native = new ArdIrecNativeBridge.NativeCursorMeasurementInfo(); + if (_api.GetCursorMeasurement(_handle, channelIndex, referenceFrame, representation, ref native) != 0) + return false; + measurement = new ComtradeCursorMeasurement( + native.Valid != 0, + native.ReferenceFrame, + native.RawTimestamp, + native.TimeSeconds, + native.Instantaneous, + native.Rms, + native.WindowStartFrame, + native.WindowEndExclusive, + native.WindowSampleCount); + return true; + } + + internal bool TryReadStatusState(uint channelIndex, ulong referenceFrame, out ComtradeStatusState? state) + { + EnsureOpen(); + state = null; + if (!Supports(ArdIrecNativeBridge.CapStatusState) || _api.GetStatusState is null) + return false; + + var native = new ArdIrecNativeBridge.NativeStatusStateInfo(); + if (_api.GetStatusState(_handle, channelIndex, referenceFrame, ref native) != 0) + return false; + state = new ComtradeStatusState(native.RawState, native.NormalState, native.IsActive != 0); + return true; + } + + internal bool TryFindNearestStatusEdge(ulong referenceFrame, double maxDistanceSeconds, out ComtradeStatusEdge? edge) + { + EnsureOpen(); + edge = null; + if (!Supports(ArdIrecNativeBridge.CapDigitalEdgeSnap) || _api.FindNearestStatusEdge is null) + return false; + + var native = new ArdIrecNativeBridge.NativeStatusEdgeInfo(); + if (_api.FindNearestStatusEdge(_handle, referenceFrame, maxDistanceSeconds, ref native) != 0 || native.Valid == 0) + return false; + edge = new ComtradeStatusEdge( + true, + native.ChannelIndex, + native.FrameIndex, + native.RawTimestamp, + native.BeforeState, + native.AfterState, + native.NormalState, + native.BecameActive != 0, + native.DistanceSeconds); + return true; + } + internal ComtradePhasor ReadPhasor(uint channelIndex, ulong referenceFrame) { EnsureOpen(); From 938118c45eb2dea132f11cd8543df98b82d68bda Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:33:24 +0700 Subject: [PATCH 04/10] P1D.2C: make Time Signals cursors and navigation first-class --- Controls/ComtradeDisturbanceView.cs | 429 ++++++++++++++++++++++------ 1 file changed, 342 insertions(+), 87 deletions(-) diff --git a/Controls/ComtradeDisturbanceView.cs b/Controls/ComtradeDisturbanceView.cs index bb7c40878..90c697199 100644 --- a/Controls/ComtradeDisturbanceView.cs +++ b/Controls/ComtradeDisturbanceView.cs @@ -6,6 +6,19 @@ namespace ArIED61850Tester.Controls; +internal enum ComtradeDisturbanceCursor +{ + Cursor1, + Cursor2 +} + +internal sealed record ComtradeDisturbanceDigitalEdge( + uint Timestamp, + ulong SourceFrame, + byte BeforeState, + byte AfterState, + int NormalState); + internal sealed record ComtradeDisturbanceTrack( string Title, string Subtitle, @@ -15,7 +28,11 @@ internal sealed record ComtradeDisturbanceTrack( byte[]? Digital, uint[] Timestamps, Color StrokeColor, - bool PreserveAllPoints = false); + bool PreserveAllPoints = false, + ulong[]? SourceFrames = null, + int DigitalNormalState = 0, + IReadOnlyList? DigitalEdges = null, + bool DigitalIsLossy = false); internal sealed class ComtradeDisturbanceNavigationChangedEventArgs : EventArgs { @@ -23,6 +40,35 @@ internal sealed class ComtradeDisturbanceNavigationChangedEventArgs : EventArgs internal string Summary { get; } } +internal sealed class ComtradeDisturbanceCursorChangedEventArgs : EventArgs +{ + internal ComtradeDisturbanceCursorChangedEventArgs( + ComtradeDisturbanceCursor cursor, + double absoluteMilliseconds, + double snapToleranceMilliseconds, + bool isFinal, + bool locallySnapped) + { + Cursor = cursor; + AbsoluteMilliseconds = absoluteMilliseconds; + SnapToleranceMilliseconds = snapToleranceMilliseconds; + IsFinal = isFinal; + LocallySnapped = locallySnapped; + } + + internal ComtradeDisturbanceCursor Cursor { get; } + internal double AbsoluteMilliseconds { get; } + internal double SnapToleranceMilliseconds { get; } + internal bool IsFinal { get; } + internal bool LocallySnapped { get; } +} + +internal sealed class ComtradeDisturbancePanRequestedEventArgs : EventArgs +{ + internal ComtradeDisturbancePanRequestedEventArgs(double deltaFraction) => DeltaFraction = deltaFraction; + internal double DeltaFraction { get; } +} + public sealed class ComtradeDisturbanceView : FrameworkElement { private const double LabelWidth = 150.0; @@ -32,34 +78,53 @@ public sealed class ComtradeDisturbanceView : FrameworkElement private const double AnalogTrackHeight = 92.0; private const double DigitalTrackHeight = 40.0; private const double TrackGap = 6.0; + private const double CursorHitRadius = 9.0; + private const double PanActivationPixels = 4.0; + + private enum PointerMode + { + None, + PendingPan, + Panning, + Cursor1, + Cursor2 + } private IReadOnlyList _tracks = Array.Empty(); private double _timeMultiplier = 1.0; private double? _triggerMilliseconds; + private double _nominalFrequencyHz; private double _fullStartMilliseconds; private double _fullEndMilliseconds; private double _viewStartMilliseconds; private double _viewEndMilliseconds; - private double? _cursorAMilliseconds; - private double? _cursorBMilliseconds; + private double? _cursor1Milliseconds; + private double? _cursor2Milliseconds; private Rect _lastPlot; - private bool _isPanning; - private Point _panStartPoint; + private PointerMode _pointerMode; + private Point _pointerStartPoint; private double _panStartMilliseconds; private double _panEndMilliseconds; internal event EventHandler? NavigationChanged; - - internal double? CursorAMilliseconds => _cursorAMilliseconds; - internal double? CursorBMilliseconds => _cursorBMilliseconds; + internal event EventHandler? CursorChanged; + internal event EventHandler? PanRequested; + + internal double? Cursor1Milliseconds => _cursor1Milliseconds; + internal double? Cursor2Milliseconds => _cursor2Milliseconds; + // Compatibility aliases while P1D analysis consumers are being migrated from A/B naming. + internal double? CursorAMilliseconds => _cursor1Milliseconds; + internal double? CursorBMilliseconds => _cursor2Milliseconds; internal double ViewStartMilliseconds => _viewStartMilliseconds; internal double ViewEndMilliseconds => _viewEndMilliseconds; + internal double FullStartMilliseconds => _fullStartMilliseconds; + internal double FullEndMilliseconds => _fullEndMilliseconds; public ComtradeDisturbanceView() { Focusable = true; Cursor = Cursors.Cross; - ToolTip = "Wheel: zoom • Shift+wheel: pan • Alt+drag/middle-drag: pan • Click: Cursor A • Ctrl+click/right-click: Cursor B"; + ToolTip = "Wheel: scroll signals • Ctrl+wheel: zoom • Drag plot: pan • Drag C1/C2: move • Right-click: C2 • cursors snap to digital edges"; } internal void ShowTracks( @@ -79,12 +144,8 @@ internal void ShowTracks( _viewEndMilliseconds = bounds.End; if (!preserveCursor) { - _cursorAMilliseconds = null; - _cursorBMilliseconds = null; - } - else - { - ClampCursorsToFullRange(); + _cursor1Milliseconds = null; + _cursor2Milliseconds = null; } Height = Math.Max(330, TopMargin + BottomAxisHeight + _tracks.Sum(track => TrackHeight(track) + TrackGap)); @@ -99,18 +160,58 @@ internal void ShowMessage(string message) _fullEndMilliseconds = 0; _viewStartMilliseconds = 0; _viewEndMilliseconds = 0; - _cursorAMilliseconds = null; - _cursorBMilliseconds = null; + _cursor1Milliseconds = null; + _cursor2Milliseconds = null; Height = 330; ToolTip = message; InvalidateVisual(); } + internal void ApplyTriggerFocusedDefault(double nominalFrequencyHz) + { + _nominalFrequencyHz = nominalFrequencyHz; + if (_fullEndMilliseconds <= _fullStartMilliseconds) + return; + + var window = ComtradeTimeSignalsNavigationMath.CreateTriggerFocusedWindow( + _fullStartMilliseconds, + _fullEndMilliseconds, + _triggerMilliseconds, + nominalFrequencyHz); + SetView(window.StartMilliseconds, window.EndMilliseconds); + + var cursors = ComtradeTimeSignalsNavigationMath.CreateInitialCursors( + _fullStartMilliseconds, + _fullEndMilliseconds, + _triggerMilliseconds, + nominalFrequencyHz); + _cursor1Milliseconds = cursors.Cursor1Milliseconds; + _cursor2Milliseconds = cursors.Cursor2Milliseconds; + InvalidateVisual(); + RaiseNavigationChanged(); + } + + internal void ResetToTriggerView() + { + if (_nominalFrequencyHz > 0) + { + ApplyTriggerFocusedDefault(_nominalFrequencyHz); + return; + } + ResetNavigation(); + } + internal void SetCursorAFromAbsoluteMilliseconds(double milliseconds) + => SetCursorFromHost(ComtradeDisturbanceCursor.Cursor1, milliseconds); + + internal void SetCursorFromHost(ComtradeDisturbanceCursor cursor, double milliseconds) { - if (!double.IsFinite(milliseconds) || _fullEndMilliseconds <= _fullStartMilliseconds) + if (!double.IsFinite(milliseconds)) return; - _cursorAMilliseconds = Math.Clamp(milliseconds, _fullStartMilliseconds, _fullEndMilliseconds); + if (cursor == ComtradeDisturbanceCursor.Cursor1) + _cursor1Milliseconds = milliseconds; + else + _cursor2Milliseconds = milliseconds; InvalidateVisual(); RaiseNavigationChanged(); } @@ -125,6 +226,13 @@ internal void ResetNavigation() RaiseNavigationChanged(); } + internal void SetViewWindow(double startMilliseconds, double endMilliseconds) + { + SetView(startMilliseconds, endMilliseconds); + InvalidateVisual(); + RaiseNavigationChanged(); + } + internal double PlotFractionAt(double x) { if (_lastPlot.Width <= 0) return 0; @@ -170,33 +278,26 @@ protected override void OnRender(DrawingContext dc) var tracksBottom = Math.Min(bounds.Height - BottomAxisHeight, y - TrackGap); var timelinePlot = new Rect(LabelWidth, TopMargin, plotWidth, Math.Max(1, tracksBottom - TopMargin)); DrawTrigger(dc, timelinePlot, dpi, semibold); - DrawCursor(dc, timelinePlot, _cursorAMilliseconds, "A", Color.FromRgb(221, 142, 32), dpi, semibold); - DrawCursor(dc, timelinePlot, _cursorBMilliseconds, "B", Color.FromRgb(36, 172, 211), dpi, semibold); + DrawCursor(dc, timelinePlot, _cursor1Milliseconds, "C1", Color.FromRgb(221, 142, 32), dpi, semibold); + DrawCursor(dc, timelinePlot, _cursor2Milliseconds, "C2", Color.FromRgb(36, 172, 211), dpi, semibold); DrawTimeAxis(dc, new Rect(LabelWidth, tracksBottom, plotWidth, BottomAxisHeight), dpi, body, semibold); } protected override void OnMouseWheel(MouseWheelEventArgs e) { base.OnMouseWheel(e); + if ((Keyboard.Modifiers & ModifierKeys.Control) == 0) + return; // Deliberately bubble normal wheel to the outer vertical signal ScrollViewer. if (!_lastPlot.Contains(e.GetPosition(this)) || _viewEndMilliseconds <= _viewStartMilliseconds) return; var span = _viewEndMilliseconds - _viewStartMilliseconds; - if ((Keyboard.Modifiers & ModifierKeys.Shift) != 0) - { - var delta = span * 0.10 * (e.Delta > 0 ? -1 : 1); - SetView(_viewStartMilliseconds + delta, _viewEndMilliseconds + delta); - } - else - { - var fraction = PlotFractionAt(e.GetPosition(this).X); - var anchor = _viewStartMilliseconds + span * fraction; - var factor = e.Delta > 0 ? 0.78 : 1.28; - var nextSpan = Math.Clamp(span * factor, MinimumViewSpan(), Math.Max(MinimumViewSpan(), _fullEndMilliseconds - _fullStartMilliseconds)); - var start = anchor - nextSpan * fraction; - SetView(start, start + nextSpan); - } - + var fraction = PlotFractionAt(e.GetPosition(this).X); + var anchor = _viewStartMilliseconds + span * fraction; + var factor = e.Delta > 0 ? 0.72 : 1.38; + var nextSpan = Math.Clamp(span * factor, MinimumViewSpan(), Math.Max(MinimumViewSpan(), _fullEndMilliseconds - _fullStartMilliseconds)); + var start = anchor - nextSpan * fraction; + SetView(start, start + nextSpan); InvalidateVisual(); RaiseNavigationChanged(); e.Handled = true; @@ -205,40 +306,51 @@ protected override void OnMouseWheel(MouseWheelEventArgs e) protected override void OnMouseDown(MouseButtonEventArgs e) { base.OnMouseDown(e); - if (!_lastPlot.Contains(e.GetPosition(this)) || _viewEndMilliseconds <= _viewStartMilliseconds) + var point = e.GetPosition(this); + if (!_lastPlot.Contains(point) || _viewEndMilliseconds <= _viewStartMilliseconds) return; Focus(); if (e.ClickCount >= 2 && e.ChangedButton == MouseButton.Left) { - ResetNavigation(); + ResetToTriggerView(); + e.Handled = true; + return; + } + + if (e.ChangedButton == MouseButton.Right) + { + PlaceCursor(ComtradeDisturbanceCursor.Cursor2, TimeAtFraction(PlotFractionAt(point.X)), true); e.Handled = true; return; } - var panGesture = e.ChangedButton == MouseButton.Middle || - (e.ChangedButton == MouseButton.Left && (Keyboard.Modifiers & ModifierKeys.Alt) != 0); - if (panGesture) + if (e.ChangedButton == MouseButton.Left) { - _isPanning = true; - _panStartPoint = e.GetPosition(this); + _pointerStartPoint = point; + if (IsNearCursor(point.X, _cursor1Milliseconds)) + _pointerMode = PointerMode.Cursor1; + else if (IsNearCursor(point.X, _cursor2Milliseconds)) + _pointerMode = PointerMode.Cursor2; + else + _pointerMode = PointerMode.PendingPan; + _panStartMilliseconds = _viewStartMilliseconds; _panEndMilliseconds = _viewEndMilliseconds; CaptureMouse(); - Cursor = Cursors.SizeWE; + Cursor = _pointerMode is PointerMode.Cursor1 or PointerMode.Cursor2 ? Cursors.SizeWE : Cursors.Hand; e.Handled = true; return; } - if (e.ChangedButton is MouseButton.Left or MouseButton.Right) + if (e.ChangedButton == MouseButton.Middle) { - var time = TimeAtFraction(PlotFractionAt(e.GetPosition(this).X)); - if (e.ChangedButton == MouseButton.Right || (Keyboard.Modifiers & ModifierKeys.Control) != 0) - _cursorBMilliseconds = time; - else - _cursorAMilliseconds = time; - InvalidateVisual(); - RaiseNavigationChanged(); + _pointerStartPoint = point; + _pointerMode = PointerMode.Panning; + _panStartMilliseconds = _viewStartMilliseconds; + _panEndMilliseconds = _viewEndMilliseconds; + CaptureMouse(); + Cursor = Cursors.Hand; e.Handled = true; } } @@ -246,10 +358,31 @@ protected override void OnMouseDown(MouseButtonEventArgs e) protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); - if (!_isPanning || !IsMouseCaptured || _lastPlot.Width <= 0) + if (_pointerMode == PointerMode.None || !IsMouseCaptured || _lastPlot.Width <= 0) + { + Cursor = HoverCursor(e.GetPosition(this)); return; + } + + var point = e.GetPosition(this); + if (_pointerMode is PointerMode.Cursor1 or PointerMode.Cursor2) + { + var cursor = _pointerMode == PointerMode.Cursor1 + ? ComtradeDisturbanceCursor.Cursor1 + : ComtradeDisturbanceCursor.Cursor2; + PlaceCursor(cursor, TimeAtFraction(PlotFractionAt(point.X)), false); + e.Handled = true; + return; + } + + var deltaPixels = point.X - _pointerStartPoint.X; + if (_pointerMode == PointerMode.PendingPan && Math.Abs(deltaPixels) >= PanActivationPixels) + _pointerMode = PointerMode.Panning; + if (_pointerMode != PointerMode.Panning) + return; + var span = _panEndMilliseconds - _panStartMilliseconds; - var delta = -(e.GetPosition(this).X - _panStartPoint.X) / _lastPlot.Width * span; + var delta = -deltaPixels / _lastPlot.Width * span; SetView(_panStartMilliseconds + delta, _panEndMilliseconds + delta); InvalidateVisual(); RaiseNavigationChanged(); @@ -259,20 +392,119 @@ protected override void OnMouseMove(MouseEventArgs e) protected override void OnMouseUp(MouseButtonEventArgs e) { base.OnMouseUp(e); - if (!_isPanning) return; - _isPanning = false; + if (_pointerMode == PointerMode.None) + return; + + var mode = _pointerMode; + var point = e.GetPosition(this); + _pointerMode = PointerMode.None; if (IsMouseCaptured) ReleaseMouseCapture(); Cursor = Cursors.Cross; + + if (mode is PointerMode.Cursor1 or PointerMode.Cursor2) + { + var cursor = mode == PointerMode.Cursor1 + ? ComtradeDisturbanceCursor.Cursor1 + : ComtradeDisturbanceCursor.Cursor2; + PlaceCursor(cursor, TimeAtFraction(PlotFractionAt(point.X)), true); + } + else if (mode == PointerMode.PendingPan && e.ChangedButton == MouseButton.Left) + { + PlaceCursor(ComtradeDisturbanceCursor.Cursor1, TimeAtFraction(PlotFractionAt(point.X)), true); + } + else if (mode == PointerMode.Panning && _lastPlot.Width > 0) + { + var deltaFraction = -(point.X - _pointerStartPoint.X) / _lastPlot.Width; + if (Math.Abs(deltaFraction) > 1e-6) + PanRequested?.Invoke(this, new ComtradeDisturbancePanRequestedEventArgs(deltaFraction)); + } + e.Handled = true; } protected override void OnLostMouseCapture(MouseEventArgs e) { base.OnLostMouseCapture(e); - _isPanning = false; + _pointerMode = PointerMode.None; Cursor = Cursors.Cross; } + private Cursor HoverCursor(Point point) + { + if (!_lastPlot.Contains(point)) return Cursors.Arrow; + if (IsNearCursor(point.X, _cursor1Milliseconds) || IsNearCursor(point.X, _cursor2Milliseconds)) + return Cursors.SizeWE; + return Cursors.Cross; + } + + private bool IsNearCursor(double x, double? time) + { + if (time is not { } milliseconds || _viewEndMilliseconds <= _viewStartMilliseconds || + milliseconds < _viewStartMilliseconds || milliseconds > _viewEndMilliseconds) + return false; + return Math.Abs(x - XForTime(milliseconds, _lastPlot)) <= CursorHitRadius; + } + + private void PlaceCursor(ComtradeDisturbanceCursor cursor, double requestedMilliseconds, bool isFinal) + { + if (!double.IsFinite(requestedMilliseconds)) return; + requestedMilliseconds = Math.Clamp(requestedMilliseconds, _fullStartMilliseconds, _fullEndMilliseconds); + var tolerance = ComtradeTimeSignalsNavigationMath.SnapToleranceMilliseconds( + _viewEndMilliseconds - _viewStartMilliseconds, + Math.Max(1.0, _lastPlot.Width)); + var snapped = TrySnapToVisibleDigitalEdge(requestedMilliseconds, tolerance, out var snappedMilliseconds); + var value = snapped ? snappedMilliseconds : requestedMilliseconds; + + if (cursor == ComtradeDisturbanceCursor.Cursor1) + _cursor1Milliseconds = value; + else + _cursor2Milliseconds = value; + + InvalidateVisual(); + RaiseNavigationChanged(); + CursorChanged?.Invoke(this, new ComtradeDisturbanceCursorChangedEventArgs(cursor, value, tolerance, isFinal, snapped)); + } + + private bool TrySnapToVisibleDigitalEdge(double requestedMilliseconds, double toleranceMilliseconds, out double snappedMilliseconds) + { + snappedMilliseconds = requestedMilliseconds; + if (toleranceMilliseconds <= 0) return false; + + var bestDistance = double.PositiveInfinity; + foreach (var track in _tracks.Where(item => item.IsDigital)) + { + if (track.DigitalEdges is { Count: > 0 }) + { + foreach (var edge in track.DigitalEdges) + { + var time = ToMilliseconds(edge.Timestamp); + var distance = Math.Abs(time - requestedMilliseconds); + if (distance < bestDistance) + { + bestDistance = distance; + snappedMilliseconds = time; + } + } + continue; + } + + 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.Digital[i - 1] != 0) == (track.Digital[i] != 0)) continue; + var time = ToMilliseconds(track.Timestamps[i]); + var distance = Math.Abs(time - requestedMilliseconds); + if (distance < bestDistance) + { + bestDistance = distance; + snappedMilliseconds = time; + } + } + } + return bestDistance <= toleranceMilliseconds; + } + private void DrawTrackBackground(DrawingContext dc, Rect row, Rect plot) { var border = FrozenPen(Color.FromRgb(226, 232, 240), 1); @@ -291,6 +523,7 @@ private void DrawTrackLabel(DrawingContext dc, ComtradeDisturbanceTrack track, R dc.DrawRoundedRectangle(accent, null, new Rect(10, row.Top + 10, 4, Math.Max(14, row.Height - 20)), 2, 2); DrawText(dc, track.Title, 10.7, semibold, Color.FromRgb(43, 61, 82), new Point(22, row.Top + 8), dpi, maxWidth: LabelWidth - 30); var subtitle = string.IsNullOrWhiteSpace(track.Units) ? track.Subtitle : $"{track.Subtitle} • {track.Units}"; + if (track.DigitalIsLossy) subtitle = string.IsNullOrWhiteSpace(subtitle) ? "sampled events" : $"{subtitle} • sampled events"; DrawText(dc, subtitle, 8.8, body, Color.FromRgb(119, 132, 149), new Point(22, row.Top + 27), dpi, maxWidth: LabelWidth - 30); } @@ -315,7 +548,8 @@ private void DrawAnalogTrack(DrawingContext dc, ComtradeDisturbanceTrack track, if (Math.Abs(max - min) < 1e-12) { var pad = Math.Max(1.0, Math.Abs(max) * 0.1); - min -= pad; max += pad; + min -= pad; + max += pad; } if (min < 0 && max > 0) @@ -360,30 +594,54 @@ private void DrawDigitalTrack(DrawingContext dc, ComtradeDisturbanceTrack track, var activeBrush = new SolidColorBrush(Color.FromArgb(46, track.StrokeColor.R, track.StrokeColor.G, track.StrokeColor.B)); activeBrush.Freeze(); var pen = FrozenPen(track.StrokeColor, 1.35); - var previousState = track.Digital[0] != 0; - var previousMs = ToMilliseconds(track.Timestamps[0]); - for (var i = 1; i <= count; i++) + if (!track.DigitalIsLossy) { - var endMs = i < count ? ToMilliseconds(track.Timestamps[i]) : ToMilliseconds(track.Timestamps[count - 1]); - var clampedStart = Math.Max(previousMs, _viewStartMilliseconds); - var clampedEnd = Math.Min(endMs, _viewEndMilliseconds); - if (previousState && clampedEnd > clampedStart) + var previousRaw = track.Digital[0] != 0 ? 1 : 0; + var previousMs = ToMilliseconds(track.Timestamps[0]); + for (var i = 1; i <= count; i++) { - var x1 = XForTime(clampedStart, plot); - var x2 = XForTime(clampedEnd, plot); - dc.DrawRectangle(activeBrush, null, new Rect(x1, plot.Top + 5, Math.Max(1, x2 - x1), plot.Height - 10)); + var endMs = i < count ? ToMilliseconds(track.Timestamps[i]) : ToMilliseconds(track.Timestamps[count - 1]); + var clampedStart = Math.Max(previousMs, _viewStartMilliseconds); + var clampedEnd = Math.Min(endMs, _viewEndMilliseconds); + var active = previousRaw != track.DigitalNormalState; + if (active && clampedEnd > clampedStart) + { + var x1 = XForTime(clampedStart, plot); + var x2 = XForTime(clampedEnd, plot); + dc.DrawRectangle(activeBrush, null, new Rect(x1, plot.Top + 5, Math.Max(1, x2 - x1), plot.Height - 10)); + } + + if (i >= count) break; + previousRaw = track.Digital[i] != 0 ? 1 : 0; + previousMs = endMs; } + } - if (i >= count) break; - var currentState = track.Digital[i] != 0; - if (currentState != previousState && endMs >= _viewStartMilliseconds && endMs <= _viewEndMilliseconds) + var edges = track.DigitalEdges; + if (edges is { Count: > 0 }) + { + foreach (var edge in edges) { - var x = XForTime(endMs, plot); + var ms = ToMilliseconds(edge.Timestamp); + if (ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) continue; + var x = XForTime(ms, plot); dc.DrawLine(pen, new Point(x, plot.Top + 4), new Point(x, plot.Bottom - 4)); - DrawText(dc, currentState ? "↑" : "↓", 10, body, track.StrokeColor, new Point(x + 2, plot.Top + 1), dpi); + DrawText(dc, edge.AfterState != 0 ? "↑" : "↓", 10, body, track.StrokeColor, new Point(x + 2, plot.Top + 1), dpi); + } + } + else + { + for (var i = 1; i < count; i++) + { + var before = track.Digital[i - 1] != 0; + var after = track.Digital[i] != 0; + if (before == after) continue; + var ms = ToMilliseconds(track.Timestamps[i]); + if (ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) continue; + var x = XForTime(ms, plot); + dc.DrawLine(pen, new Point(x, plot.Top + 4), new Point(x, plot.Bottom - 4)); + DrawText(dc, after ? "↑" : "↓", 10, body, track.StrokeColor, new Point(x + 2, plot.Top + 1), dpi); } - previousState = currentState; - previousMs = endMs; } } @@ -393,15 +651,18 @@ private void DrawTrigger(DrawingContext dc, Rect plot, double dpi, Typeface semi return; var x = XForTime(trigger, plot); dc.DrawLine(FrozenDashedPen(Color.FromRgb(217, 121, 41), 1.1), new Point(x, plot.Top), new Point(x, plot.Bottom)); - DrawText(dc, "TRG 0", 8.5, semibold, Color.FromRgb(186, 99, 31), new Point(x + 3, plot.Top + 1), dpi); + DrawText(dc, "TRG 0", 8.5, semibold, Color.FromRgb(186, 99, 31), new Point(x + 3, plot.Top + 17), dpi); } private void DrawCursor(DrawingContext dc, Rect plot, double? time, string label, Color color, double dpi, Typeface semibold) { if (time is not { } ms || ms < _viewStartMilliseconds || ms > _viewEndMilliseconds) return; var x = XForTime(ms, plot); - dc.DrawLine(FrozenPen(color, 1.2), new Point(x, plot.Top), new Point(x, plot.Bottom)); - DrawText(dc, label, 9, semibold, color, new Point(x + 3, plot.Top + 15), dpi); + var brush = new SolidColorBrush(color); brush.Freeze(); + dc.DrawLine(FrozenPen(color, 1.2), new Point(x, plot.Top + 15), new Point(x, plot.Bottom)); + var handle = new Rect(x - 13, plot.Top, 26, 15); + dc.DrawRoundedRectangle(brush, null, handle, 3, 3); + DrawText(dc, label, 8.3, semibold, Colors.White, new Point(x - 8, plot.Top + 1), dpi); } private void DrawTimeAxis(DrawingContext dc, Rect axis, double dpi, Typeface body, Typeface semibold) @@ -452,12 +713,6 @@ private void SetView(double start, double end) return (start, end); } - private void ClampCursorsToFullRange() - { - if (_cursorAMilliseconds is { } a && (a < _fullStartMilliseconds || a > _fullEndMilliseconds)) _cursorAMilliseconds = null; - if (_cursorBMilliseconds is { } b && (b < _fullStartMilliseconds || b > _fullEndMilliseconds)) _cursorBMilliseconds = null; - } - private void RaiseNavigationChanged() { var trigger = _triggerMilliseconds ?? 0.0; @@ -465,9 +720,9 @@ private void RaiseNavigationChanged() { $"View {FormatRelative(_viewStartMilliseconds - trigger)} … {FormatRelative(_viewEndMilliseconds - trigger)}" }; - if (_cursorAMilliseconds is { } a) parts.Add($"A {FormatRelative(a - trigger)}"); else parts.Add("A —"); - if (_cursorBMilliseconds is { } b) parts.Add($"B {FormatRelative(b - trigger)}"); else parts.Add("B —"); - if (_cursorAMilliseconds is { } ca && _cursorBMilliseconds is { } cb) parts.Add($"Δt {Math.Abs(cb - ca):G6} ms"); + if (_cursor1Milliseconds is { } c1) parts.Add($"C1 {FormatRelative(c1 - trigger)}"); else parts.Add("C1 —"); + if (_cursor2Milliseconds is { } c2) parts.Add($"C2 {FormatRelative(c2 - trigger)}"); else parts.Add("C2 —"); + if (_cursor1Milliseconds is { } first && _cursor2Milliseconds is { } second) parts.Add($"Δt {Math.Abs(second - first):G6} ms"); NavigationChanged?.Invoke(this, new ComtradeDisturbanceNavigationChangedEventArgs(string.Join(" | ", parts))); } From dd1c7d3891c9ac89630e9803fde52112954b0739 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:34:58 +0700 Subject: [PATCH 05/10] P1D.2C: preserve source identity and protection-event semantics --- ComtradeWorkspaceWindow.Disturbance.cs | 410 ++++++++++++++++++------- 1 file changed, 294 insertions(+), 116 deletions(-) diff --git a/ComtradeWorkspaceWindow.Disturbance.cs b/ComtradeWorkspaceWindow.Disturbance.cs index 1f7c40baa..6ed3c6cc5 100644 --- a/ComtradeWorkspaceWindow.Disturbance.cs +++ b/ComtradeWorkspaceWindow.Disturbance.cs @@ -13,16 +13,15 @@ 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() { @@ -33,9 +32,9 @@ private void InitializeDisturbanceWorkspace() 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 +51,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 +66,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 +91,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 +116,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 +128,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 +178,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 +196,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 +213,26 @@ 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; + } + 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 +243,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 +270,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 +290,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 static ulong[] BuildSequentialFrames(ulong startFrame, int count) + { + 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 IReadOnlyList BuildDigitalEventRows(IReadOnlyList tracks, double? triggerMilliseconds) + private static IReadOnlyList BuildReducedDigitalEdges( + ComtradeDigitalTransitionSet transitionSet, + int normalState) { - var events = new List<(double Time, string Signal, string Event, string State)>(); + 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 +467,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,82 +481,115 @@ 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) - { - _disturbanceRequestedViewport = full; + if (_disturbanceRequestedViewport != full || _disturbanceLoadedViewport != full) + await ReloadDisturbanceAsync(full, 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); - return; - } 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 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); - } + var target = ComtradeAbsoluteViewportMath.Zoom( + current, + _record.Info.FrameCount, + fraction, + 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; + 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_PreviewMouseUp(object sender, MouseButtonEventArgs e) + private async void DisturbanceView_CursorChanged(object? sender, ComtradeDisturbanceCursorChangedEventArgs 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; - _disturbanceRequestedViewport = target; - await ReloadDisturbanceAsync(target, initialLoad: false).ConfigureAwait(true); + 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() @@ -438,29 +607,36 @@ 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 false; + return DisturbanceView.Cursor1Milliseconds is { } cursor && + TryResolveDisturbanceFrameAtMilliseconds(cursor, out frame); + } - if (_disturbanceReferenceExact && _disturbanceReferenceTimestamps is { Length: > 0 } timestamps) - { - 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; - } + 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 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); + 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 index = ComtradeDisturbanceTimelineMath.NearestTimestampIndex( + count == timestamps.Length ? timestamps : timestamps.Take(count).ToArray(), + targetRaw); + if (index < 0 || index >= count) return false; + frame = Math.Min(_record.Info.FrameCount - 1, sourceFrames[index]); return true; } + 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) => string.Join(" • ", new[] { string.IsNullOrWhiteSpace(phase) ? null : $"phase {phase}", circuit } .Where(value => !string.IsNullOrWhiteSpace(value))); @@ -488,14 +664,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, From 9d3d63ebb8b77b4e93349535d508b69548a41205 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:36:47 +0700 Subject: [PATCH 06/10] P1D.2C: group Time Signals by native electrical semantics --- ComtradeWorkspaceWindow.xaml.cs | 120 ++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 12 deletions(-) diff --git a/ComtradeWorkspaceWindow.xaml.cs b/ComtradeWorkspaceWindow.xaml.cs index 6b9c09463..d1659ceb6 100644 --- a/ComtradeWorkspaceWindow.xaml.cs +++ b/ComtradeWorkspaceWindow.xaml.cs @@ -1,6 +1,7 @@ using System.IO; using System.Windows; using System.Windows.Controls; +using System.Windows.Data; using System.Windows.Input; using System.Windows.Media; using ArIED61850Tester.Controls; @@ -13,7 +14,7 @@ public partial class ComtradeWorkspaceWindow : Window private const int ExactSignalFrameLimit = 500_000; private const int FullRecordAnalogBuckets = 4_096; private const int FullRecordDigitalTransitionCap = 100_000; - private const string NavigationHint = "Wheel zoom • Shift+wheel pan • Alt/middle-drag pan • Click Cursor A • Ctrl/right-click Cursor B"; + private const string NavigationHint = "Wheel scrolls signals • Ctrl+wheel zooms time • drag plot pans • drag C1/C2 moves cursors • right-click places C2"; private readonly ArdIrecNativeRecord _record; private readonly SemaphoreSlim _nativeGate = new(1, 1); private CancellationTokenSource? _signalLoadCts; @@ -36,6 +37,7 @@ internal ComtradeWorkspaceWindow(ArdIrecNativeRecord record) WaveformView.PreviewMouseUp += WaveformView_PreviewMouseUp; ConfigureTriggerReference(); ResetViewButton.IsEnabled = false; + FullRecordButton.IsEnabled = false; PopulateHeader(); PopulateSignals(); Closed += ComtradeWorkspaceWindow_Closed; @@ -93,27 +95,56 @@ private void PopulateHeader() private void PopulateSignals() { - var analogAccent = new SolidColorBrush(Color.FromRgb(42, 120, 223)); - var digitalAccent = new SolidColorBrush(Color.FromRgb(31, 145, 94)); - analogAccent.Freeze(); - digitalAccent.Freeze(); - var signals = new List(_record.AnalogChannels.Count + _record.StatusChannels.Count); for (var i = 0; i < _record.AnalogChannels.Count; i++) { var channel = _record.AnalogChannels[i]; - var context = string.Join(" • ", new[] { channel.Phase, channel.Circuit, channel.Units }.Where(value => !string.IsNullOrWhiteSpace(value))); - signals.Add(new ComtradeSignalItem(true, checked((uint)i), channel.Id, context, analogAccent)); + _record.TryReadAnalogSemantics(checked((uint)i), out var semantics); + var section = ResolveAnalogSection(channel.Units, semantics); + var sectionOrder = section switch + { + "Voltage" => 0, + "Current" => 1, + _ => 2 + }; + var phaseOrder = ResolvePhaseOrder(channel.Phase, channel.Id, semantics); + var context = string.Join(" • ", new[] { channel.Phase, channel.Circuit, channel.Units } + .Where(value => !string.IsNullOrWhiteSpace(value))); + signals.Add(new ComtradeSignalItem( + true, + checked((uint)i), + channel.Id, + context, + FrozenSignalBrush(ResolveSignalColor(channel.Phase, channel.Id, false)), + section, + sectionOrder, + phaseOrder)); } for (var i = 0; i < _record.StatusChannels.Count; i++) { var channel = _record.StatusChannels[i]; - var context = string.Join(" • ", new[] { channel.Phase, channel.Circuit, $"normal {channel.NormalState}" }.Where(value => !string.IsNullOrWhiteSpace(value))); - signals.Add(new ComtradeSignalItem(false, checked((uint)i), channel.Id, context, digitalAccent)); + var context = string.Join(" • ", new[] { channel.Phase, channel.Circuit, $"normal {channel.NormalState}" } + .Where(value => !string.IsNullOrWhiteSpace(value))); + signals.Add(new ComtradeSignalItem( + false, + checked((uint)i), + channel.Id, + context, + FrozenSignalBrush(ResolveSignalColor(channel.Phase, channel.Id, true)), + "Digital Events", + 3, + ResolvePhaseOrder(channel.Phase, channel.Id, null))); } - SignalList.ItemsSource = signals; + var ordered = signals + .OrderBy(item => item.SectionOrder) + .ThenBy(item => item.PhaseOrder) + .ThenBy(item => item.Index) + .ToList(); + var view = new ListCollectionView(ordered); + view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(ComtradeSignalItem.Section))); + SignalList.ItemsSource = view; SignalCountText.Text = $"{signals.Count} total"; if (signals.Count > 0) SignalList.SelectedIndex = 0; @@ -121,10 +152,60 @@ private void PopulateSignals() { NavigationTextBlock.Text = NavigationHint; ResetViewButton.IsEnabled = false; + FullRecordButton.IsEnabled = false; WaveformView.ShowMessage("No channels", "The COMTRADE record contains no analog or digital channels."); } } + private static string ResolveAnalogSection(string units, ComtradeAnalogSemantics? semantics) + { + if (semantics is not null) + { + return semantics.Role switch + { + 1 => "Voltage", + 2 => "Current", + _ => "Other Analog" + }; + } + + var normalized = (units ?? string.Empty).Trim().ToUpperInvariant().Replace(" ", string.Empty, StringComparison.Ordinal); + if (normalized is "V" or "KV" or "MV" or "UV") return "Voltage"; + if (normalized is "A" or "KA" or "MA" or "UA") return "Current"; + return "Other Analog"; + } + + private static int ResolvePhaseOrder(string phase, string title, ComtradeAnalogSemantics? semantics) + { + if (semantics is not null) + { + return semantics.PhaseRole switch + { + 1 => 0, + 2 => 1, + 3 => 2, + 4 => 3, + _ => 4 + }; + } + + return NormalizePhase(phase, title) switch + { + "L1" => 0, + "L2" => 1, + "L3" => 2, + "N" or "E" => 3, + _ => 4 + }; + } + + private static Brush FrozenSignalBrush(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } + private async void SignalList_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (SignalList.SelectedItem is not ComtradeSignalItem signal) @@ -132,6 +213,12 @@ private async void SignalList_SelectionChanged(object sender, SelectionChangedEv _activeSignal = signal; _sourceNavigationEnabled = _record.Info.FrameCount > ExactSignalFrameLimit; + + // P1D uses the multi-track Time Signals renderer. Do not scan the permanently collapsed + // legacy one-channel view on every list selection; analysis consumers use _activeSignal. + if (WaveformView.Visibility != Visibility.Visible) + return; + var full = ComtradeAbsoluteViewportMath.Full(_record.Info.FrameCount); _requestedSourceViewport = full; await LoadAndDisplaySignalAsync(signal, full, initialSelection: true).ConfigureAwait(true); @@ -563,7 +650,16 @@ private static string BuildSignalSubtitle(string phase, string circuit, string d _ => "UNKNOWN" }; - private sealed record ComtradeSignalItem(bool IsAnalog, uint Index, string Title, string Subtitle, Brush Accent); + private sealed record ComtradeSignalItem( + bool IsAnalog, + uint Index, + string Title, + string Subtitle, + Brush Accent, + string Section, + int SectionOrder, + int PhaseOrder); + private sealed record SignalPreview( double[]? Analog, byte[]? Status, From 5c5877fbd8490fee3ea5e31925d0e3191f756bec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 10 Sep 2026 08:37:35 +0700 Subject: [PATCH 07/10] P1D.2C: present grouped Time Signals workstation controls --- ComtradeWorkspaceWindow.xaml | 47 ++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/ComtradeWorkspaceWindow.xaml b/ComtradeWorkspaceWindow.xaml index 552017738..97a0ce940 100644 --- a/ComtradeWorkspaceWindow.xaml +++ b/ComtradeWorkspaceWindow.xaml @@ -92,13 +92,28 @@ -