oscura 0.7.0__py3-none-any.whl → 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oscura/__init__.py +19 -19
- oscura/analyzers/__init__.py +2 -0
- oscura/analyzers/digital/extraction.py +2 -3
- oscura/analyzers/digital/quality.py +1 -1
- oscura/analyzers/digital/timing.py +1 -1
- oscura/analyzers/eye/__init__.py +5 -1
- oscura/analyzers/eye/generation.py +501 -0
- oscura/analyzers/jitter/__init__.py +6 -6
- oscura/analyzers/jitter/timing.py +419 -0
- oscura/analyzers/patterns/__init__.py +94 -0
- oscura/analyzers/patterns/reverse_engineering.py +991 -0
- oscura/analyzers/power/__init__.py +35 -12
- oscura/analyzers/power/basic.py +3 -3
- oscura/analyzers/power/soa.py +1 -1
- oscura/analyzers/power/switching.py +3 -3
- oscura/analyzers/signal_classification.py +529 -0
- oscura/analyzers/signal_integrity/sparams.py +3 -3
- oscura/analyzers/statistics/__init__.py +4 -0
- oscura/analyzers/statistics/basic.py +152 -0
- oscura/analyzers/statistics/correlation.py +47 -6
- oscura/analyzers/validation.py +1 -1
- oscura/analyzers/waveform/__init__.py +2 -0
- oscura/analyzers/waveform/measurements.py +329 -163
- oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
- oscura/analyzers/waveform/spectral.py +498 -54
- oscura/api/dsl/commands.py +15 -6
- oscura/api/server/templates/base.html +137 -146
- oscura/api/server/templates/export.html +84 -110
- oscura/api/server/templates/home.html +248 -267
- oscura/api/server/templates/protocols.html +44 -48
- oscura/api/server/templates/reports.html +27 -35
- oscura/api/server/templates/session_detail.html +68 -78
- oscura/api/server/templates/sessions.html +62 -72
- oscura/api/server/templates/waveforms.html +54 -64
- oscura/automotive/__init__.py +1 -1
- oscura/automotive/can/session.py +1 -1
- oscura/automotive/dbc/generator.py +638 -23
- oscura/automotive/dtc/data.json +102 -17
- oscura/automotive/uds/decoder.py +99 -6
- oscura/cli/analyze.py +8 -2
- oscura/cli/batch.py +36 -5
- oscura/cli/characterize.py +18 -4
- oscura/cli/export.py +47 -5
- oscura/cli/main.py +2 -0
- oscura/cli/onboarding/wizard.py +10 -6
- oscura/cli/pipeline.py +585 -0
- oscura/cli/visualize.py +6 -4
- oscura/convenience.py +400 -32
- oscura/core/config/loader.py +0 -1
- oscura/core/measurement_result.py +286 -0
- oscura/core/progress.py +1 -1
- oscura/core/schemas/device_mapping.json +8 -2
- oscura/core/schemas/packet_format.json +24 -4
- oscura/core/schemas/protocol_definition.json +12 -2
- oscura/core/types.py +300 -199
- oscura/correlation/multi_protocol.py +1 -1
- oscura/export/legacy/__init__.py +11 -0
- oscura/export/legacy/wav.py +75 -0
- oscura/exporters/__init__.py +19 -0
- oscura/exporters/wireshark.py +809 -0
- oscura/hardware/acquisition/file.py +5 -19
- oscura/hardware/acquisition/saleae.py +10 -10
- oscura/hardware/acquisition/socketcan.py +4 -6
- oscura/hardware/acquisition/synthetic.py +1 -5
- oscura/hardware/acquisition/visa.py +6 -6
- oscura/hardware/security/side_channel_detector.py +5 -508
- oscura/inference/message_format.py +686 -1
- oscura/jupyter/display.py +2 -2
- oscura/jupyter/magic.py +3 -3
- oscura/loaders/__init__.py +17 -12
- oscura/loaders/binary.py +1 -1
- oscura/loaders/chipwhisperer.py +1 -2
- oscura/loaders/configurable.py +1 -1
- oscura/loaders/csv_loader.py +2 -2
- oscura/loaders/hdf5_loader.py +1 -1
- oscura/loaders/lazy.py +6 -1
- oscura/loaders/mmap_loader.py +0 -1
- oscura/loaders/numpy_loader.py +8 -7
- oscura/loaders/preprocessing.py +3 -5
- oscura/loaders/rigol.py +21 -7
- oscura/loaders/sigrok.py +2 -5
- oscura/loaders/tdms.py +3 -2
- oscura/loaders/tektronix.py +38 -32
- oscura/loaders/tss.py +20 -27
- oscura/loaders/vcd.py +13 -8
- oscura/loaders/wav.py +1 -6
- oscura/pipeline/__init__.py +76 -0
- oscura/pipeline/handlers/__init__.py +165 -0
- oscura/pipeline/handlers/analyzers.py +1045 -0
- oscura/pipeline/handlers/decoders.py +899 -0
- oscura/pipeline/handlers/exporters.py +1103 -0
- oscura/pipeline/handlers/filters.py +891 -0
- oscura/pipeline/handlers/loaders.py +640 -0
- oscura/pipeline/handlers/transforms.py +768 -0
- oscura/reporting/__init__.py +88 -1
- oscura/reporting/automation.py +348 -0
- oscura/reporting/citations.py +374 -0
- oscura/reporting/core.py +54 -0
- oscura/reporting/formatting/__init__.py +11 -0
- oscura/reporting/formatting/measurements.py +320 -0
- oscura/reporting/html.py +57 -0
- oscura/reporting/interpretation.py +431 -0
- oscura/reporting/summary.py +329 -0
- oscura/reporting/templates/enhanced/protocol_re.html +504 -503
- oscura/reporting/visualization.py +542 -0
- oscura/side_channel/__init__.py +38 -57
- oscura/utils/builders/signal_builder.py +5 -5
- oscura/utils/comparison/compare.py +7 -9
- oscura/utils/comparison/golden.py +1 -1
- oscura/utils/filtering/convenience.py +2 -2
- oscura/utils/math/arithmetic.py +38 -62
- oscura/utils/math/interpolation.py +20 -20
- oscura/utils/pipeline/__init__.py +4 -17
- oscura/utils/progressive.py +1 -4
- oscura/utils/triggering/edge.py +1 -1
- oscura/utils/triggering/pattern.py +2 -2
- oscura/utils/triggering/pulse.py +2 -2
- oscura/utils/triggering/window.py +3 -3
- oscura/validation/hil_testing.py +11 -11
- oscura/visualization/__init__.py +47 -284
- oscura/visualization/batch.py +160 -0
- oscura/visualization/plot.py +542 -53
- oscura/visualization/styles.py +184 -318
- oscura/workflows/__init__.py +2 -0
- oscura/workflows/batch/advanced.py +1 -1
- oscura/workflows/batch/aggregate.py +7 -8
- oscura/workflows/complete_re.py +251 -23
- oscura/workflows/digital.py +27 -4
- oscura/workflows/multi_trace.py +136 -17
- oscura/workflows/waveform.py +788 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
- oscura/side_channel/dpa.py +0 -1025
- oscura/utils/optimization/__init__.py +0 -19
- oscura/utils/optimization/parallel.py +0 -443
- oscura/utils/optimization/search.py +0 -532
- oscura/utils/pipeline/base.py +0 -338
- oscura/utils/pipeline/composition.py +0 -248
- oscura/utils/pipeline/parallel.py +0 -449
- oscura/utils/pipeline/pipeline.py +0 -375
- oscura/utils/search/__init__.py +0 -16
- oscura/utils/search/anomaly.py +0 -424
- oscura/utils/search/context.py +0 -294
- oscura/utils/search/pattern.py +0 -288
- oscura/utils/storage/__init__.py +0 -61
- oscura/utils/storage/database.py +0 -1166
- oscura/visualization/accessibility.py +0 -526
- oscura/visualization/annotations.py +0 -371
- oscura/visualization/axis_scaling.py +0 -305
- oscura/visualization/colors.py +0 -451
- oscura/visualization/digital.py +0 -436
- oscura/visualization/eye.py +0 -571
- oscura/visualization/histogram.py +0 -281
- oscura/visualization/interactive.py +0 -1035
- oscura/visualization/jitter.py +0 -1042
- oscura/visualization/keyboard.py +0 -394
- oscura/visualization/layout.py +0 -400
- oscura/visualization/optimization.py +0 -1079
- oscura/visualization/palettes.py +0 -446
- oscura/visualization/power.py +0 -508
- oscura/visualization/power_extended.py +0 -955
- oscura/visualization/presets.py +0 -469
- oscura/visualization/protocols.py +0 -1246
- oscura/visualization/render.py +0 -223
- oscura/visualization/rendering.py +0 -444
- oscura/visualization/reverse_engineering.py +0 -838
- oscura/visualization/signal_integrity.py +0 -989
- oscura/visualization/specialized.py +0 -643
- oscura/visualization/spectral.py +0 -1226
- oscura/visualization/thumbnails.py +0 -340
- oscura/visualization/time_axis.py +0 -351
- oscura/visualization/waveform.py +0 -454
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,1035 +0,0 @@
|
|
|
1
|
-
"""Interactive visualization features.
|
|
2
|
-
|
|
3
|
-
This module provides interactive plotting capabilities including zoom,
|
|
4
|
-
pan, cursors, and specialized plot types.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Example:
|
|
8
|
-
>>> from oscura.visualization.interactive import (
|
|
9
|
-
... plot_with_cursors, plot_phase, plot_bode,
|
|
10
|
-
... plot_waterfall, plot_histogram
|
|
11
|
-
... )
|
|
12
|
-
>>> fig, ax = plot_with_cursors(trace)
|
|
13
|
-
>>> plot_bode(frequencies, magnitude, phase)
|
|
14
|
-
|
|
15
|
-
References:
|
|
16
|
-
matplotlib interactive features
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
from dataclasses import dataclass, field
|
|
22
|
-
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
23
|
-
|
|
24
|
-
import numpy as np
|
|
25
|
-
from scipy import signal as scipy_signal
|
|
26
|
-
|
|
27
|
-
if TYPE_CHECKING:
|
|
28
|
-
from matplotlib.axes import Axes
|
|
29
|
-
from matplotlib.backend_bases import MouseEvent
|
|
30
|
-
from matplotlib.figure import Figure
|
|
31
|
-
from numpy.typing import NDArray
|
|
32
|
-
|
|
33
|
-
from oscura.core.types import WaveformTrace
|
|
34
|
-
|
|
35
|
-
# Optional matplotlib import
|
|
36
|
-
try:
|
|
37
|
-
import matplotlib.pyplot as plt
|
|
38
|
-
from matplotlib.widgets import Cursor, MultiCursor, SpanSelector # noqa: F401
|
|
39
|
-
|
|
40
|
-
MATPLOTLIB_AVAILABLE = True
|
|
41
|
-
except ImportError:
|
|
42
|
-
MATPLOTLIB_AVAILABLE = False
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
@dataclass
|
|
46
|
-
class CursorMeasurement:
|
|
47
|
-
"""Measurement result from cursors.
|
|
48
|
-
|
|
49
|
-
Attributes:
|
|
50
|
-
x1: First cursor X position.
|
|
51
|
-
x2: Second cursor X position.
|
|
52
|
-
y1: First cursor Y position.
|
|
53
|
-
y2: Second cursor Y position.
|
|
54
|
-
delta_x: X difference (x2 - x1).
|
|
55
|
-
delta_y: Y difference (y2 - y1).
|
|
56
|
-
frequency: 1/delta_x if delta_x > 0.
|
|
57
|
-
slope: delta_y/delta_x if delta_x != 0.
|
|
58
|
-
|
|
59
|
-
References:
|
|
60
|
-
VIS-008
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
x1: float
|
|
64
|
-
x2: float
|
|
65
|
-
y1: float
|
|
66
|
-
y2: float
|
|
67
|
-
delta_x: float
|
|
68
|
-
delta_y: float
|
|
69
|
-
frequency: float | None = None
|
|
70
|
-
slope: float | None = None
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@dataclass
|
|
74
|
-
class ZoomState:
|
|
75
|
-
"""Current zoom/pan state.
|
|
76
|
-
|
|
77
|
-
Attributes:
|
|
78
|
-
xlim: Current X-axis limits.
|
|
79
|
-
ylim: Current Y-axis limits.
|
|
80
|
-
history: Stack of previous zoom states.
|
|
81
|
-
home_xlim: Original X-axis limits.
|
|
82
|
-
home_ylim: Original Y-axis limits.
|
|
83
|
-
|
|
84
|
-
References:
|
|
85
|
-
VIS-007
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
xlim: tuple[float, float]
|
|
89
|
-
ylim: tuple[float, float]
|
|
90
|
-
history: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
|
|
91
|
-
home_xlim: tuple[float, float] | None = None
|
|
92
|
-
home_ylim: tuple[float, float] | None = None
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _create_scroll_handler(ax: Axes, state: ZoomState, zoom_factor: float) -> Any:
|
|
96
|
-
"""Create scroll event handler for zooming."""
|
|
97
|
-
|
|
98
|
-
def on_scroll(event): # type: ignore[no-untyped-def]
|
|
99
|
-
if event.inaxes != ax:
|
|
100
|
-
return
|
|
101
|
-
|
|
102
|
-
x_data = event.xdata
|
|
103
|
-
y_data = event.ydata
|
|
104
|
-
|
|
105
|
-
if x_data is None or y_data is None:
|
|
106
|
-
return
|
|
107
|
-
|
|
108
|
-
if event.button == "up":
|
|
109
|
-
factor = 1 / zoom_factor
|
|
110
|
-
elif event.button == "down":
|
|
111
|
-
factor = zoom_factor
|
|
112
|
-
else:
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
state.history.append((state.xlim, state.ylim))
|
|
116
|
-
|
|
117
|
-
cur_xlim = ax.get_xlim()
|
|
118
|
-
cur_ylim = ax.get_ylim()
|
|
119
|
-
|
|
120
|
-
new_width = (cur_xlim[1] - cur_xlim[0]) * factor
|
|
121
|
-
new_height = (cur_ylim[1] - cur_ylim[0]) * factor
|
|
122
|
-
|
|
123
|
-
rel_x = (x_data - cur_xlim[0]) / (cur_xlim[1] - cur_xlim[0])
|
|
124
|
-
rel_y = (y_data - cur_ylim[0]) / (cur_ylim[1] - cur_ylim[0])
|
|
125
|
-
|
|
126
|
-
new_xlim = (
|
|
127
|
-
x_data - new_width * rel_x,
|
|
128
|
-
x_data + new_width * (1 - rel_x),
|
|
129
|
-
)
|
|
130
|
-
new_ylim = (
|
|
131
|
-
y_data - new_height * rel_y,
|
|
132
|
-
y_data + new_height * (1 - rel_y),
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
ax.set_xlim(new_xlim)
|
|
136
|
-
ax.set_ylim(new_ylim)
|
|
137
|
-
state.xlim = new_xlim
|
|
138
|
-
state.ylim = new_ylim
|
|
139
|
-
|
|
140
|
-
ax.figure.canvas.draw_idle()
|
|
141
|
-
|
|
142
|
-
return on_scroll
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _create_pan_handlers(ax: Axes, state: ZoomState) -> tuple[Any, Any, Any]:
|
|
146
|
-
"""Create pan event handlers for click-drag panning."""
|
|
147
|
-
pan_active = [False]
|
|
148
|
-
pan_start: list[float | None] = [None, None]
|
|
149
|
-
|
|
150
|
-
def on_press(event): # type: ignore[no-untyped-def]
|
|
151
|
-
if event.inaxes != ax:
|
|
152
|
-
return
|
|
153
|
-
if event.button == 1:
|
|
154
|
-
pan_active[0] = True
|
|
155
|
-
pan_start[0] = event.xdata
|
|
156
|
-
pan_start[1] = event.ydata
|
|
157
|
-
|
|
158
|
-
def on_release(event: MouseEvent) -> None:
|
|
159
|
-
pan_active[0] = False
|
|
160
|
-
|
|
161
|
-
def on_motion(event: MouseEvent) -> None:
|
|
162
|
-
if not pan_active[0]:
|
|
163
|
-
return
|
|
164
|
-
if event.inaxes != ax:
|
|
165
|
-
return
|
|
166
|
-
if event.xdata is None or event.ydata is None:
|
|
167
|
-
return
|
|
168
|
-
if pan_start[0] is None or pan_start[1] is None:
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
dx = pan_start[0] - event.xdata
|
|
172
|
-
dy = pan_start[1] - event.ydata
|
|
173
|
-
|
|
174
|
-
cur_xlim = ax.get_xlim()
|
|
175
|
-
cur_ylim = ax.get_ylim()
|
|
176
|
-
|
|
177
|
-
new_xlim = (cur_xlim[0] + dx, cur_xlim[1] + dx)
|
|
178
|
-
new_ylim = (cur_ylim[0] + dy, cur_ylim[1] + dy)
|
|
179
|
-
|
|
180
|
-
ax.set_xlim(new_xlim)
|
|
181
|
-
ax.set_ylim(new_ylim)
|
|
182
|
-
state.xlim = new_xlim
|
|
183
|
-
state.ylim = new_ylim
|
|
184
|
-
|
|
185
|
-
ax.figure.canvas.draw_idle()
|
|
186
|
-
|
|
187
|
-
return on_press, on_release, on_motion
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def enable_zoom_pan(
|
|
191
|
-
ax: Axes,
|
|
192
|
-
*,
|
|
193
|
-
enable_zoom: bool = True,
|
|
194
|
-
enable_pan: bool = True,
|
|
195
|
-
zoom_factor: float = 1.5,
|
|
196
|
-
) -> ZoomState:
|
|
197
|
-
"""Enable interactive zoom and pan on an axes.
|
|
198
|
-
|
|
199
|
-
Adds scroll wheel zoom and click-drag pan functionality.
|
|
200
|
-
|
|
201
|
-
Args:
|
|
202
|
-
ax: Matplotlib axes to enable zoom/pan on.
|
|
203
|
-
enable_zoom: Enable scroll wheel zoom.
|
|
204
|
-
enable_pan: Enable click-drag pan.
|
|
205
|
-
zoom_factor: Zoom factor per scroll step.
|
|
206
|
-
|
|
207
|
-
Returns:
|
|
208
|
-
ZoomState object tracking zoom history.
|
|
209
|
-
|
|
210
|
-
Raises:
|
|
211
|
-
ImportError: If matplotlib is not available.
|
|
212
|
-
|
|
213
|
-
Example:
|
|
214
|
-
>>> fig, ax = plt.subplots()
|
|
215
|
-
>>> ax.plot(trace.time_vector, trace.data)
|
|
216
|
-
>>> state = enable_zoom_pan(ax)
|
|
217
|
-
|
|
218
|
-
References:
|
|
219
|
-
VIS-007
|
|
220
|
-
"""
|
|
221
|
-
if not MATPLOTLIB_AVAILABLE:
|
|
222
|
-
raise ImportError("matplotlib is required for interactive visualization")
|
|
223
|
-
|
|
224
|
-
xlim = ax.get_xlim()
|
|
225
|
-
ylim = ax.get_ylim()
|
|
226
|
-
state = ZoomState(xlim=xlim, ylim=ylim, home_xlim=xlim, home_ylim=ylim)
|
|
227
|
-
|
|
228
|
-
if enable_zoom:
|
|
229
|
-
on_scroll = _create_scroll_handler(ax, state, zoom_factor)
|
|
230
|
-
ax.figure.canvas.mpl_connect("scroll_event", on_scroll)
|
|
231
|
-
|
|
232
|
-
if enable_pan:
|
|
233
|
-
on_press, on_release, on_motion = _create_pan_handlers(ax, state)
|
|
234
|
-
ax.figure.canvas.mpl_connect("button_press_event", on_press)
|
|
235
|
-
ax.figure.canvas.mpl_connect("button_release_event", on_release)
|
|
236
|
-
ax.figure.canvas.mpl_connect("motion_notify_event", on_motion)
|
|
237
|
-
|
|
238
|
-
return state
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def plot_with_cursors(
|
|
242
|
-
trace: WaveformTrace | NDArray[np.floating[Any]],
|
|
243
|
-
*,
|
|
244
|
-
sample_rate: float | None = None,
|
|
245
|
-
cursor_type: Literal["vertical", "horizontal", "cross"] = "cross",
|
|
246
|
-
ax: Axes | None = None,
|
|
247
|
-
**plot_kwargs: Any,
|
|
248
|
-
) -> tuple[Figure, Axes, Cursor]:
|
|
249
|
-
"""Plot waveform with interactive measurement cursors.
|
|
250
|
-
|
|
251
|
-
Args:
|
|
252
|
-
trace: Input trace or numpy array.
|
|
253
|
-
sample_rate: Sample rate (required for arrays).
|
|
254
|
-
cursor_type: Type of cursor lines.
|
|
255
|
-
ax: Existing axes to plot on.
|
|
256
|
-
**plot_kwargs: Additional arguments to plot().
|
|
257
|
-
|
|
258
|
-
Returns:
|
|
259
|
-
Tuple of (figure, axes, cursor widget).
|
|
260
|
-
|
|
261
|
-
Raises:
|
|
262
|
-
ImportError: If matplotlib is not available.
|
|
263
|
-
ValueError: If axes has no associated figure.
|
|
264
|
-
|
|
265
|
-
Example:
|
|
266
|
-
>>> fig, ax, cursor = plot_with_cursors(trace)
|
|
267
|
-
>>> plt.show()
|
|
268
|
-
|
|
269
|
-
References:
|
|
270
|
-
VIS-008
|
|
271
|
-
"""
|
|
272
|
-
if not MATPLOTLIB_AVAILABLE:
|
|
273
|
-
raise ImportError("matplotlib is required for interactive visualization")
|
|
274
|
-
|
|
275
|
-
# Get data and time vector
|
|
276
|
-
if isinstance(trace, WaveformTrace):
|
|
277
|
-
data = trace.data
|
|
278
|
-
time = trace.time_vector
|
|
279
|
-
else:
|
|
280
|
-
data = np.asarray(trace)
|
|
281
|
-
if sample_rate is None:
|
|
282
|
-
sample_rate = 1.0
|
|
283
|
-
time = np.arange(len(data)) / sample_rate
|
|
284
|
-
|
|
285
|
-
# Create figure if needed
|
|
286
|
-
if ax is None:
|
|
287
|
-
fig, ax = plt.subplots(figsize=(10, 6))
|
|
288
|
-
else:
|
|
289
|
-
fig_temp = ax.figure
|
|
290
|
-
if fig_temp is None:
|
|
291
|
-
raise ValueError("Axes must have an associated figure")
|
|
292
|
-
fig = cast("Figure", fig_temp)
|
|
293
|
-
|
|
294
|
-
# Plot data
|
|
295
|
-
ax.plot(time, data, **plot_kwargs)
|
|
296
|
-
ax.set_xlabel("Time (s)")
|
|
297
|
-
ax.set_ylabel("Amplitude")
|
|
298
|
-
ax.grid(True, alpha=0.3)
|
|
299
|
-
|
|
300
|
-
# Create cursor
|
|
301
|
-
if cursor_type == "vertical":
|
|
302
|
-
cursor = Cursor(ax, useblit=True, color="red", linewidth=1, vertOn=True, horizOn=False)
|
|
303
|
-
elif cursor_type == "horizontal":
|
|
304
|
-
cursor = Cursor(ax, useblit=True, color="red", linewidth=1, vertOn=False, horizOn=True)
|
|
305
|
-
else: # cross
|
|
306
|
-
cursor = Cursor(ax, useblit=True, color="red", linewidth=1)
|
|
307
|
-
|
|
308
|
-
return fig, ax, cursor
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def add_measurement_cursors(
|
|
312
|
-
ax: Axes,
|
|
313
|
-
*,
|
|
314
|
-
color: str = "red",
|
|
315
|
-
linestyle: str = "--",
|
|
316
|
-
) -> dict: # type: ignore[type-arg]
|
|
317
|
-
"""Add dual measurement cursors to an axes.
|
|
318
|
-
|
|
319
|
-
Click and drag to define measurement region. Returns measurement
|
|
320
|
-
data in the callback.
|
|
321
|
-
|
|
322
|
-
Args:
|
|
323
|
-
ax: Axes to add cursors to.
|
|
324
|
-
color: Cursor line color.
|
|
325
|
-
linestyle: Cursor line style.
|
|
326
|
-
|
|
327
|
-
Returns:
|
|
328
|
-
Dictionary with cursor state and get_measurement() function.
|
|
329
|
-
|
|
330
|
-
Raises:
|
|
331
|
-
ImportError: If matplotlib is not available.
|
|
332
|
-
|
|
333
|
-
Example:
|
|
334
|
-
>>> cursors = add_measurement_cursors(ax)
|
|
335
|
-
>>> measurement = cursors['get_measurement']()
|
|
336
|
-
>>> print(f"Delta X: {measurement.delta_x}")
|
|
337
|
-
|
|
338
|
-
References:
|
|
339
|
-
VIS-008
|
|
340
|
-
"""
|
|
341
|
-
if not MATPLOTLIB_AVAILABLE:
|
|
342
|
-
raise ImportError("matplotlib is required for interactive visualization")
|
|
343
|
-
|
|
344
|
-
# Setup: initialize state
|
|
345
|
-
state = _create_cursor_state()
|
|
346
|
-
|
|
347
|
-
# Processing: create selector with callback
|
|
348
|
-
onselect_callback = _create_cursor_select_handler(ax, state)
|
|
349
|
-
span = SpanSelector(
|
|
350
|
-
ax,
|
|
351
|
-
onselect_callback,
|
|
352
|
-
"horizontal",
|
|
353
|
-
useblit=True,
|
|
354
|
-
props={"alpha": 0.3, "facecolor": color},
|
|
355
|
-
interactive=True,
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
# Formatting: create measurement accessor
|
|
359
|
-
get_measurement = _create_measurement_getter(state)
|
|
360
|
-
|
|
361
|
-
return {"span": span, "state": state, "get_measurement": get_measurement}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
def _create_cursor_state() -> dict[str, float | None | Any]:
|
|
365
|
-
"""Create cursor state dictionary.
|
|
366
|
-
|
|
367
|
-
Returns:
|
|
368
|
-
State dictionary with x/y positions and line references.
|
|
369
|
-
"""
|
|
370
|
-
return {"x1": None, "x2": None, "y1": None, "y2": None, "line1": None, "line2": None}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def _create_cursor_select_handler(ax: Axes, state: dict[str, float | None | Any]) -> Any:
|
|
374
|
-
"""Create cursor selection callback.
|
|
375
|
-
|
|
376
|
-
Args:
|
|
377
|
-
ax: Axes to interpolate data from.
|
|
378
|
-
state: Cursor state dictionary.
|
|
379
|
-
|
|
380
|
-
Returns:
|
|
381
|
-
Selection callback function.
|
|
382
|
-
"""
|
|
383
|
-
|
|
384
|
-
def onselect(xmin: float, xmax: float) -> None:
|
|
385
|
-
state["x1"] = xmin
|
|
386
|
-
state["x2"] = xmax
|
|
387
|
-
|
|
388
|
-
for line in ax.get_lines():
|
|
389
|
-
xdata_arr = np.asarray(line.get_xdata())
|
|
390
|
-
ydata_arr = np.asarray(line.get_ydata())
|
|
391
|
-
if len(xdata_arr) > 0:
|
|
392
|
-
state["y1"] = float(np.interp(xmin, xdata_arr, ydata_arr))
|
|
393
|
-
state["y2"] = float(np.interp(xmax, xdata_arr, ydata_arr))
|
|
394
|
-
break
|
|
395
|
-
|
|
396
|
-
return onselect
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def _create_measurement_getter(state: dict[str, float | None | Any]) -> Any:
|
|
400
|
-
"""Create measurement getter function.
|
|
401
|
-
|
|
402
|
-
Args:
|
|
403
|
-
state: Cursor state dictionary.
|
|
404
|
-
|
|
405
|
-
Returns:
|
|
406
|
-
Function that returns CursorMeasurement or None.
|
|
407
|
-
"""
|
|
408
|
-
|
|
409
|
-
def get_measurement() -> CursorMeasurement | None:
|
|
410
|
-
x1 = state["x1"]
|
|
411
|
-
x2 = state["x2"]
|
|
412
|
-
y1 = state["y1"]
|
|
413
|
-
y2 = state["y2"]
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
x1 is None
|
|
417
|
-
or x2 is None
|
|
418
|
-
or not isinstance(x1, int | float)
|
|
419
|
-
or not isinstance(x2, int | float)
|
|
420
|
-
):
|
|
421
|
-
return None
|
|
422
|
-
|
|
423
|
-
delta_x = x2 - x1
|
|
424
|
-
y1_val = float(y1) if y1 is not None else 0.0
|
|
425
|
-
y2_val = float(y2) if y2 is not None else 0.0
|
|
426
|
-
delta_y = y2_val - y1_val
|
|
427
|
-
|
|
428
|
-
return CursorMeasurement(
|
|
429
|
-
x1=x1,
|
|
430
|
-
x2=x2,
|
|
431
|
-
y1=y1_val,
|
|
432
|
-
y2=y2_val,
|
|
433
|
-
delta_x=delta_x,
|
|
434
|
-
delta_y=delta_y,
|
|
435
|
-
frequency=1 / delta_x if delta_x > 0 else None,
|
|
436
|
-
slope=delta_y / delta_x if delta_x != 0 else None,
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
return get_measurement
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def plot_phase(
|
|
443
|
-
trace1: WaveformTrace | NDArray[np.floating[Any]],
|
|
444
|
-
trace2: WaveformTrace | NDArray[np.floating[Any]] | None = None,
|
|
445
|
-
*,
|
|
446
|
-
delay: int = 1,
|
|
447
|
-
delay_samples: int | None = None,
|
|
448
|
-
ax: Axes | None = None,
|
|
449
|
-
**plot_kwargs: Any,
|
|
450
|
-
) -> tuple[Figure, Axes]:
|
|
451
|
-
"""Create phase plot (X-Y plot) of two signals.
|
|
452
|
-
|
|
453
|
-
Plots trace1 on X-axis vs trace2 on Y-axis, useful for
|
|
454
|
-
visualizing phase relationships and Lissajous figures.
|
|
455
|
-
If trace2 is not provided, creates a self-phase plot using
|
|
456
|
-
time-delayed version of trace1.
|
|
457
|
-
|
|
458
|
-
Args:
|
|
459
|
-
trace1: Signal for X-axis.
|
|
460
|
-
trace2: Signal for Y-axis. If None, uses delayed trace1.
|
|
461
|
-
delay: Sample delay for self-phase plot (when trace2=None).
|
|
462
|
-
delay_samples: Alias for delay parameter.
|
|
463
|
-
ax: Existing axes to plot on.
|
|
464
|
-
**plot_kwargs: Additional arguments to plot().
|
|
465
|
-
|
|
466
|
-
Returns:
|
|
467
|
-
Tuple of (figure, axes).
|
|
468
|
-
|
|
469
|
-
Raises:
|
|
470
|
-
ImportError: If matplotlib is not available.
|
|
471
|
-
ValueError: If axes has no associated figure.
|
|
472
|
-
|
|
473
|
-
Example:
|
|
474
|
-
>>> fig, ax = plot_phase(signal_x, signal_y)
|
|
475
|
-
>>> plt.show()
|
|
476
|
-
>>> # Self-phase plot
|
|
477
|
-
>>> fig, ax = plot_phase(signal, delay_samples=10)
|
|
478
|
-
|
|
479
|
-
References:
|
|
480
|
-
VIS-009
|
|
481
|
-
"""
|
|
482
|
-
if not MATPLOTLIB_AVAILABLE:
|
|
483
|
-
raise ImportError("matplotlib is required for interactive visualization")
|
|
484
|
-
|
|
485
|
-
# Handle delay_samples alias
|
|
486
|
-
if delay_samples is not None:
|
|
487
|
-
delay = delay_samples
|
|
488
|
-
|
|
489
|
-
# Get data
|
|
490
|
-
data1 = trace1.data if isinstance(trace1, WaveformTrace) else np.asarray(trace1)
|
|
491
|
-
|
|
492
|
-
# If trace2 not provided, create self-phase plot with delay
|
|
493
|
-
if trace2 is None:
|
|
494
|
-
data2 = np.roll(data1, -delay)
|
|
495
|
-
else:
|
|
496
|
-
data2 = trace2.data if isinstance(trace2, WaveformTrace) else np.asarray(trace2)
|
|
497
|
-
|
|
498
|
-
# Ensure same length
|
|
499
|
-
n = min(len(data1), len(data2))
|
|
500
|
-
data1 = data1[:n]
|
|
501
|
-
data2 = data2[:n]
|
|
502
|
-
|
|
503
|
-
# Create figure if needed
|
|
504
|
-
if ax is None:
|
|
505
|
-
fig, ax = plt.subplots(figsize=(8, 8))
|
|
506
|
-
else:
|
|
507
|
-
fig_temp = ax.figure
|
|
508
|
-
if fig_temp is None:
|
|
509
|
-
raise ValueError("Axes must have an associated figure")
|
|
510
|
-
fig = cast("Figure", fig_temp)
|
|
511
|
-
|
|
512
|
-
# Plot
|
|
513
|
-
defaults: dict[str, Any] = {"alpha": 0.5, "marker": ".", "linestyle": "-", "markersize": 2}
|
|
514
|
-
defaults.update(plot_kwargs)
|
|
515
|
-
ax.plot(data1, data2, **defaults)
|
|
516
|
-
|
|
517
|
-
# Equal aspect ratio for proper phase visualization
|
|
518
|
-
ax.set_aspect("equal", adjustable="datalim")
|
|
519
|
-
ax.set_xlabel("Signal 1")
|
|
520
|
-
ax.set_ylabel("Signal 2")
|
|
521
|
-
ax.set_title("Phase Plot (X-Y)")
|
|
522
|
-
ax.grid(True, alpha=0.3)
|
|
523
|
-
|
|
524
|
-
return fig, ax
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def plot_bode(
|
|
528
|
-
frequencies: NDArray[np.floating[Any]],
|
|
529
|
-
magnitude: NDArray[np.floating[Any]] | NDArray[np.complexfloating[Any, Any]],
|
|
530
|
-
phase: NDArray[np.floating[Any]] | None = None,
|
|
531
|
-
*,
|
|
532
|
-
magnitude_db: bool = True,
|
|
533
|
-
phase_degrees: bool = True,
|
|
534
|
-
show_margins: bool = False,
|
|
535
|
-
fig: Figure | None = None,
|
|
536
|
-
**plot_kwargs: Any,
|
|
537
|
-
) -> Figure:
|
|
538
|
-
"""Create Bode plot with magnitude and phase.
|
|
539
|
-
|
|
540
|
-
Standard frequency response visualization with logarithmic
|
|
541
|
-
frequency axis.
|
|
542
|
-
|
|
543
|
-
Args:
|
|
544
|
-
frequencies: Frequency array in Hz.
|
|
545
|
-
magnitude: Magnitude array (linear or dB), or complex transfer function H(s).
|
|
546
|
-
If complex, magnitude and phase are extracted automatically.
|
|
547
|
-
phase: Phase array in radians (optional). Ignored if magnitude is complex.
|
|
548
|
-
magnitude_db: If True, magnitude is already in dB. Ignored if complex input.
|
|
549
|
-
phase_degrees: If True, convert phase to degrees.
|
|
550
|
-
show_margins: If True, annotate stability margins (currently unused, reserved for future).
|
|
551
|
-
fig: Existing figure to plot on.
|
|
552
|
-
**plot_kwargs: Additional arguments to plot().
|
|
553
|
-
|
|
554
|
-
Returns:
|
|
555
|
-
Matplotlib Figure object with magnitude and optionally phase axes.
|
|
556
|
-
|
|
557
|
-
Raises:
|
|
558
|
-
ImportError: If matplotlib is not available.
|
|
559
|
-
|
|
560
|
-
Example:
|
|
561
|
-
>>> # With complex transfer function
|
|
562
|
-
>>> H = 1 / (1 + 1j * freqs / 1000)
|
|
563
|
-
>>> fig = plot_bode(freqs, H)
|
|
564
|
-
>>> ax_mag, ax_phase = fig.axes[:2] # Access axes from figure
|
|
565
|
-
>>> plt.show()
|
|
566
|
-
|
|
567
|
-
References:
|
|
568
|
-
VIS-010
|
|
569
|
-
"""
|
|
570
|
-
if not MATPLOTLIB_AVAILABLE:
|
|
571
|
-
raise ImportError("matplotlib is required for interactive visualization")
|
|
572
|
-
|
|
573
|
-
frequencies = np.asarray(frequencies)
|
|
574
|
-
magnitude = np.asarray(magnitude)
|
|
575
|
-
|
|
576
|
-
# Handle complex transfer function input
|
|
577
|
-
if np.iscomplexobj(magnitude):
|
|
578
|
-
# Extract phase from complex input
|
|
579
|
-
phase = np.angle(magnitude)
|
|
580
|
-
# Convert to magnitude in dB
|
|
581
|
-
with np.errstate(divide="ignore"):
|
|
582
|
-
magnitude = 20 * np.log10(np.abs(magnitude))
|
|
583
|
-
magnitude = np.nan_to_num(magnitude, neginf=-200)
|
|
584
|
-
elif not magnitude_db:
|
|
585
|
-
# Convert magnitude to dB if needed
|
|
586
|
-
with np.errstate(divide="ignore"):
|
|
587
|
-
magnitude = 20 * np.log10(np.abs(magnitude))
|
|
588
|
-
magnitude = np.nan_to_num(magnitude, neginf=-200)
|
|
589
|
-
|
|
590
|
-
# Create figure
|
|
591
|
-
if phase is not None:
|
|
592
|
-
if fig is None:
|
|
593
|
-
fig, (ax_mag, ax_phase) = plt.subplots(2, 1, figsize=(10, 8), sharex=True)
|
|
594
|
-
else:
|
|
595
|
-
axes = fig.subplots(2, 1, sharex=True)
|
|
596
|
-
ax_mag, ax_phase = axes
|
|
597
|
-
else:
|
|
598
|
-
if fig is None:
|
|
599
|
-
fig, ax_mag = plt.subplots(figsize=(10, 5))
|
|
600
|
-
else:
|
|
601
|
-
ax_mag = fig.subplots()
|
|
602
|
-
ax_phase = None
|
|
603
|
-
|
|
604
|
-
# Plot magnitude
|
|
605
|
-
ax_mag.semilogx(frequencies, magnitude, **plot_kwargs)
|
|
606
|
-
ax_mag.set_ylabel("Magnitude (dB)")
|
|
607
|
-
ax_mag.grid(True, which="both", alpha=0.3)
|
|
608
|
-
ax_mag.set_title("Bode Plot")
|
|
609
|
-
|
|
610
|
-
# Plot phase if provided
|
|
611
|
-
if phase is not None and ax_phase is not None:
|
|
612
|
-
phase = np.asarray(phase)
|
|
613
|
-
if phase_degrees:
|
|
614
|
-
phase = np.degrees(phase)
|
|
615
|
-
ylabel = "Phase (degrees)"
|
|
616
|
-
else:
|
|
617
|
-
ylabel = "Phase (radians)"
|
|
618
|
-
|
|
619
|
-
ax_phase.semilogx(frequencies, phase, **plot_kwargs)
|
|
620
|
-
ax_phase.set_ylabel(ylabel)
|
|
621
|
-
ax_phase.set_xlabel("Frequency (Hz)")
|
|
622
|
-
ax_phase.grid(True, which="both", alpha=0.3)
|
|
623
|
-
else:
|
|
624
|
-
ax_mag.set_xlabel("Frequency (Hz)")
|
|
625
|
-
|
|
626
|
-
fig.tight_layout()
|
|
627
|
-
|
|
628
|
-
return fig
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
def plot_waterfall(
|
|
632
|
-
data: NDArray[np.floating[Any]],
|
|
633
|
-
*,
|
|
634
|
-
time_axis: NDArray[np.floating[Any]] | None = None,
|
|
635
|
-
freq_axis: NDArray[np.floating[Any]] | None = None,
|
|
636
|
-
sample_rate: float = 1.0,
|
|
637
|
-
nperseg: int = 256,
|
|
638
|
-
noverlap: int | None = None,
|
|
639
|
-
cmap: str = "viridis",
|
|
640
|
-
ax: Axes | None = None,
|
|
641
|
-
**kwargs: Any,
|
|
642
|
-
) -> tuple[Figure, Axes]:
|
|
643
|
-
"""Create 3D waterfall plot (spectrogram with depth).
|
|
644
|
-
|
|
645
|
-
Shows spectrum evolution over time as stacked frequency slices.
|
|
646
|
-
|
|
647
|
-
Args:
|
|
648
|
-
data: Input signal array (1D) or pre-computed spectrogram (2D).
|
|
649
|
-
If 2D, treated as (n_traces, n_points) spectrogram data.
|
|
650
|
-
time_axis: Time axis for signal.
|
|
651
|
-
freq_axis: Frequency axis (if pre-computed).
|
|
652
|
-
sample_rate: Sample rate in Hz.
|
|
653
|
-
nperseg: Segment length for FFT.
|
|
654
|
-
noverlap: Overlap between segments.
|
|
655
|
-
cmap: Colormap for amplitude coloring.
|
|
656
|
-
ax: Existing 3D axes to plot on.
|
|
657
|
-
**kwargs: Additional arguments.
|
|
658
|
-
|
|
659
|
-
Returns:
|
|
660
|
-
Tuple of (figure, axes).
|
|
661
|
-
|
|
662
|
-
Raises:
|
|
663
|
-
ImportError: If matplotlib is not available.
|
|
664
|
-
TypeError: If axes is not a 3D axes.
|
|
665
|
-
ValueError: If axes has no associated figure.
|
|
666
|
-
|
|
667
|
-
Example:
|
|
668
|
-
>>> fig, ax = plot_waterfall(signal, sample_rate=1e6)
|
|
669
|
-
>>> plt.show()
|
|
670
|
-
>>> # With 2D precomputed data
|
|
671
|
-
>>> fig, ax = plot_waterfall(spectrogram_data)
|
|
672
|
-
|
|
673
|
-
References:
|
|
674
|
-
VIS-011
|
|
675
|
-
"""
|
|
676
|
-
if not MATPLOTLIB_AVAILABLE:
|
|
677
|
-
raise ImportError("matplotlib is required for interactive visualization")
|
|
678
|
-
|
|
679
|
-
data = np.asarray(data)
|
|
680
|
-
Sxx_db, frequencies, times = _prepare_waterfall_data(
|
|
681
|
-
data, time_axis, freq_axis, sample_rate, nperseg, noverlap
|
|
682
|
-
)
|
|
683
|
-
fig, ax = _create_waterfall_figure(ax)
|
|
684
|
-
T, F = np.meshgrid(times, frequencies)
|
|
685
|
-
Sxx_db = _align_waterfall_dimensions(Sxx_db, T)
|
|
686
|
-
surf = _plot_waterfall_surface(ax, T, F, Sxx_db, cmap)
|
|
687
|
-
_format_waterfall_axes(ax, fig, surf)
|
|
688
|
-
|
|
689
|
-
return fig, ax
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
def _prepare_waterfall_data(
|
|
693
|
-
data: NDArray[np.floating[Any]],
|
|
694
|
-
time_axis: NDArray[np.floating[Any]] | None,
|
|
695
|
-
freq_axis: NDArray[np.floating[Any]] | None,
|
|
696
|
-
sample_rate: float,
|
|
697
|
-
nperseg: int,
|
|
698
|
-
noverlap: int | None,
|
|
699
|
-
) -> tuple[NDArray[np.floating[Any]], NDArray[np.floating[Any]], NDArray[np.floating[Any]]]:
|
|
700
|
-
"""Prepare spectrogram data for waterfall plot.
|
|
701
|
-
|
|
702
|
-
Args:
|
|
703
|
-
data: Input data array.
|
|
704
|
-
time_axis: Time axis.
|
|
705
|
-
freq_axis: Frequency axis.
|
|
706
|
-
sample_rate: Sample rate.
|
|
707
|
-
nperseg: Segment length.
|
|
708
|
-
noverlap: Overlap length.
|
|
709
|
-
|
|
710
|
-
Returns:
|
|
711
|
-
Tuple of (Sxx_db, frequencies, times).
|
|
712
|
-
"""
|
|
713
|
-
if data.ndim == 2:
|
|
714
|
-
Sxx_db = data
|
|
715
|
-
n_traces, n_points = data.shape
|
|
716
|
-
frequencies: NDArray[np.floating[Any]] = (
|
|
717
|
-
freq_axis if freq_axis is not None else np.arange(n_points, dtype=np.float64)
|
|
718
|
-
)
|
|
719
|
-
times: NDArray[np.floating[Any]] = (
|
|
720
|
-
time_axis if time_axis is not None else np.arange(n_traces, dtype=np.float64)
|
|
721
|
-
)
|
|
722
|
-
elif freq_axis is not None:
|
|
723
|
-
Sxx_db = data
|
|
724
|
-
frequencies = freq_axis
|
|
725
|
-
times = (
|
|
726
|
-
time_axis
|
|
727
|
-
if time_axis is not None
|
|
728
|
-
else np.arange(Sxx_db.shape[1] if Sxx_db.ndim > 1 else 1, dtype=np.float64)
|
|
729
|
-
)
|
|
730
|
-
else:
|
|
731
|
-
if noverlap is None:
|
|
732
|
-
noverlap = nperseg // 2
|
|
733
|
-
frequencies_raw, times_raw, Sxx = scipy_signal.spectrogram(
|
|
734
|
-
data, fs=sample_rate, nperseg=nperseg, noverlap=noverlap
|
|
735
|
-
)
|
|
736
|
-
frequencies = np.asarray(frequencies_raw, dtype=np.float64)
|
|
737
|
-
Sxx_db = 10 * np.log10(Sxx + 1e-10)
|
|
738
|
-
times = time_axis if time_axis is not None else np.arange(Sxx_db.shape[1], dtype=np.float64)
|
|
739
|
-
|
|
740
|
-
return Sxx_db, frequencies, times
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
def _create_waterfall_figure(ax: Axes | None) -> tuple[Figure, Axes]:
|
|
744
|
-
"""Create 3D figure for waterfall plot.
|
|
745
|
-
|
|
746
|
-
Args:
|
|
747
|
-
ax: Existing axes or None.
|
|
748
|
-
|
|
749
|
-
Returns:
|
|
750
|
-
Tuple of (figure, axes).
|
|
751
|
-
|
|
752
|
-
Raises:
|
|
753
|
-
ValueError: If axes has no figure.
|
|
754
|
-
"""
|
|
755
|
-
if ax is None:
|
|
756
|
-
fig = plt.figure(figsize=(12, 8))
|
|
757
|
-
ax = fig.add_subplot(111, projection="3d")
|
|
758
|
-
else:
|
|
759
|
-
fig_temp = ax.figure
|
|
760
|
-
if fig_temp is None:
|
|
761
|
-
raise ValueError("Axes must have an associated figure")
|
|
762
|
-
fig = cast("Figure", fig_temp)
|
|
763
|
-
return fig, ax
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
def _align_waterfall_dimensions(
|
|
767
|
-
Sxx_db: NDArray[np.floating[Any]], T: NDArray[np.floating[Any]]
|
|
768
|
-
) -> NDArray[np.floating[Any]]:
|
|
769
|
-
"""Align spectrogram dimensions to match meshgrid.
|
|
770
|
-
|
|
771
|
-
Args:
|
|
772
|
-
Sxx_db: Spectrogram data.
|
|
773
|
-
T: Meshgrid time array.
|
|
774
|
-
|
|
775
|
-
Returns:
|
|
776
|
-
Aligned spectrogram.
|
|
777
|
-
"""
|
|
778
|
-
if Sxx_db.shape != T.shape:
|
|
779
|
-
if Sxx_db.T.shape == T.shape:
|
|
780
|
-
Sxx_db = Sxx_db.T
|
|
781
|
-
return Sxx_db
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
def _plot_waterfall_surface(
|
|
785
|
-
ax: Axes,
|
|
786
|
-
T: NDArray[np.floating[Any]],
|
|
787
|
-
F: NDArray[np.floating[Any]],
|
|
788
|
-
Sxx_db: NDArray[np.floating[Any]],
|
|
789
|
-
cmap: str,
|
|
790
|
-
) -> Any:
|
|
791
|
-
"""Plot waterfall surface.
|
|
792
|
-
|
|
793
|
-
Args:
|
|
794
|
-
ax: 3D axes.
|
|
795
|
-
T: Time meshgrid.
|
|
796
|
-
F: Frequency meshgrid.
|
|
797
|
-
Sxx_db: Spectrogram data.
|
|
798
|
-
cmap: Colormap.
|
|
799
|
-
|
|
800
|
-
Returns:
|
|
801
|
-
Surface object.
|
|
802
|
-
|
|
803
|
-
Raises:
|
|
804
|
-
TypeError: If axes is not 3D.
|
|
805
|
-
"""
|
|
806
|
-
if not hasattr(ax, "plot_surface"):
|
|
807
|
-
raise TypeError("Axes must be a 3D axes for waterfall plot")
|
|
808
|
-
return cast("Any", ax).plot_surface(
|
|
809
|
-
T, F, Sxx_db, cmap=cmap, linewidth=0, antialiased=True, alpha=0.8
|
|
810
|
-
)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
def _format_waterfall_axes(ax: Axes, fig: Figure, surf: Any) -> None:
|
|
814
|
-
"""Format waterfall plot axes.
|
|
815
|
-
|
|
816
|
-
Args:
|
|
817
|
-
ax: 3D axes.
|
|
818
|
-
fig: Figure object.
|
|
819
|
-
surf: Surface object.
|
|
820
|
-
"""
|
|
821
|
-
ax.set_xlabel("Time (s)")
|
|
822
|
-
ax.set_ylabel("Frequency (Hz)")
|
|
823
|
-
if hasattr(ax, "set_zlabel"):
|
|
824
|
-
ax.set_zlabel("Power (dB)")
|
|
825
|
-
ax.set_title("Waterfall Plot (Spectrogram)")
|
|
826
|
-
fig.colorbar(surf, ax=ax, label="Power (dB)", shrink=0.5)
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
def plot_histogram(
|
|
830
|
-
trace: WaveformTrace | NDArray[np.floating[Any]],
|
|
831
|
-
*,
|
|
832
|
-
bins: int | str | NDArray[np.floating[Any]] = "auto",
|
|
833
|
-
density: bool = True,
|
|
834
|
-
show_stats: bool = True,
|
|
835
|
-
show_kde: bool = False,
|
|
836
|
-
ax: Axes | None = None,
|
|
837
|
-
save_path: str | None = None,
|
|
838
|
-
show: bool = True,
|
|
839
|
-
**hist_kwargs: Any,
|
|
840
|
-
) -> tuple[Figure, Axes, dict[str, Any]]:
|
|
841
|
-
"""Create histogram plot of signal amplitude distribution.
|
|
842
|
-
|
|
843
|
-
Optionally overlays kernel density estimate and statistics.
|
|
844
|
-
|
|
845
|
-
Args:
|
|
846
|
-
trace: Input trace or numpy array.
|
|
847
|
-
bins: Number of bins or binning strategy.
|
|
848
|
-
density: If True, normalize to probability density.
|
|
849
|
-
show_stats: Show mean and standard deviation lines.
|
|
850
|
-
show_kde: Overlay kernel density estimate.
|
|
851
|
-
ax: Existing axes to plot on.
|
|
852
|
-
save_path: Path to save figure. If None, figure is not saved.
|
|
853
|
-
show: If True, display the figure. If False, close it.
|
|
854
|
-
**hist_kwargs: Additional arguments to hist().
|
|
855
|
-
|
|
856
|
-
Returns:
|
|
857
|
-
Tuple of (Figure, Axes, statistics dict).
|
|
858
|
-
|
|
859
|
-
Raises:
|
|
860
|
-
ImportError: If matplotlib is not available.
|
|
861
|
-
ValueError: If axes has no associated figure.
|
|
862
|
-
|
|
863
|
-
Example:
|
|
864
|
-
>>> fig = plot_histogram(trace, bins=50, show_kde=True)
|
|
865
|
-
>>> # With save
|
|
866
|
-
>>> fig = plot_histogram(trace, save_path="hist.png", show=False)
|
|
867
|
-
|
|
868
|
-
References:
|
|
869
|
-
VIS-012
|
|
870
|
-
"""
|
|
871
|
-
if not MATPLOTLIB_AVAILABLE:
|
|
872
|
-
raise ImportError("matplotlib is required for interactive visualization")
|
|
873
|
-
|
|
874
|
-
data = trace.data if isinstance(trace, WaveformTrace) else np.asarray(trace)
|
|
875
|
-
fig, ax = _setup_histogram_figure(ax)
|
|
876
|
-
stats = _calculate_histogram_statistics(data)
|
|
877
|
-
bin_edges = _plot_histogram_data(ax, data, bins, density, hist_kwargs)
|
|
878
|
-
stats["bins"] = len(bin_edges) - 1
|
|
879
|
-
_add_histogram_overlays(ax, data, stats, bin_edges, density, show_stats, show_kde)
|
|
880
|
-
_format_histogram_axes(ax, density, show_stats, show_kde)
|
|
881
|
-
_handle_histogram_output(fig, save_path, show)
|
|
882
|
-
|
|
883
|
-
return fig, ax, stats
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
def _setup_histogram_figure(ax: Axes | None) -> tuple[Figure, Axes]:
|
|
887
|
-
"""Setup figure and axes for histogram.
|
|
888
|
-
|
|
889
|
-
Args:
|
|
890
|
-
ax: Existing axes or None.
|
|
891
|
-
|
|
892
|
-
Returns:
|
|
893
|
-
Tuple of (figure, axes).
|
|
894
|
-
|
|
895
|
-
Raises:
|
|
896
|
-
ValueError: If axes has no figure.
|
|
897
|
-
"""
|
|
898
|
-
if ax is None:
|
|
899
|
-
fig, ax = plt.subplots(figsize=(10, 6))
|
|
900
|
-
else:
|
|
901
|
-
fig_temp = ax.figure
|
|
902
|
-
if fig_temp is None:
|
|
903
|
-
raise ValueError("Axes must have an associated figure")
|
|
904
|
-
fig = cast("Figure", fig_temp)
|
|
905
|
-
return fig, ax
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
def _calculate_histogram_statistics(data: NDArray[np.floating[Any]]) -> dict[str, Any]:
|
|
909
|
-
"""Calculate histogram statistics.
|
|
910
|
-
|
|
911
|
-
Args:
|
|
912
|
-
data: Data array.
|
|
913
|
-
|
|
914
|
-
Returns:
|
|
915
|
-
Statistics dictionary.
|
|
916
|
-
"""
|
|
917
|
-
return {
|
|
918
|
-
"mean": float(np.mean(data)),
|
|
919
|
-
"std": float(np.std(data)),
|
|
920
|
-
"median": float(np.median(data)),
|
|
921
|
-
"min": float(np.min(data)),
|
|
922
|
-
"max": float(np.max(data)),
|
|
923
|
-
"count": len(data),
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
def _plot_histogram_data(
|
|
928
|
-
ax: Axes,
|
|
929
|
-
data: NDArray[np.floating[Any]],
|
|
930
|
-
bins: int | str | NDArray[np.floating[Any]],
|
|
931
|
-
density: bool,
|
|
932
|
-
hist_kwargs: dict[str, Any],
|
|
933
|
-
) -> NDArray[Any]:
|
|
934
|
-
"""Plot histogram data.
|
|
935
|
-
|
|
936
|
-
Args:
|
|
937
|
-
ax: Axes to plot on.
|
|
938
|
-
data: Data array.
|
|
939
|
-
bins: Bin specification.
|
|
940
|
-
density: Normalize to density.
|
|
941
|
-
hist_kwargs: Additional histogram arguments.
|
|
942
|
-
|
|
943
|
-
Returns:
|
|
944
|
-
Bin edges array.
|
|
945
|
-
"""
|
|
946
|
-
defaults: dict[str, Any] = {"alpha": 0.7, "edgecolor": "black", "linewidth": 0.5}
|
|
947
|
-
defaults.update(hist_kwargs)
|
|
948
|
-
_counts, bin_edges, _patches = ax.hist(data, bins=bins, density=density, **defaults) # type: ignore[arg-type]
|
|
949
|
-
return bin_edges
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
def _add_histogram_overlays(
|
|
953
|
-
ax: Axes,
|
|
954
|
-
data: NDArray[np.floating[Any]],
|
|
955
|
-
stats: dict[str, Any],
|
|
956
|
-
bin_edges: NDArray[Any],
|
|
957
|
-
density: bool,
|
|
958
|
-
show_stats: bool,
|
|
959
|
-
show_kde: bool,
|
|
960
|
-
) -> None:
|
|
961
|
-
"""Add overlays to histogram.
|
|
962
|
-
|
|
963
|
-
Args:
|
|
964
|
-
ax: Axes object.
|
|
965
|
-
data: Data array.
|
|
966
|
-
stats: Statistics dict.
|
|
967
|
-
bin_edges: Bin edges.
|
|
968
|
-
density: Whether density normalized.
|
|
969
|
-
show_stats: Show statistics lines.
|
|
970
|
-
show_kde: Show KDE overlay.
|
|
971
|
-
"""
|
|
972
|
-
if show_stats:
|
|
973
|
-
mean, std = stats["mean"], stats["std"]
|
|
974
|
-
ax.axvline(mean, color="red", linestyle="--", linewidth=2, label=f"Mean: {mean:.3g}")
|
|
975
|
-
ax.axvline(mean - std, color="orange", linestyle=":", linewidth=1.5, label="Mean - Std")
|
|
976
|
-
ax.axvline(mean + std, color="orange", linestyle=":", linewidth=1.5, label="Mean + Std")
|
|
977
|
-
|
|
978
|
-
if show_kde:
|
|
979
|
-
from scipy.stats import gaussian_kde
|
|
980
|
-
|
|
981
|
-
kde = gaussian_kde(data)
|
|
982
|
-
x_kde = np.linspace(stats["min"], stats["max"], 200)
|
|
983
|
-
y_kde = kde(x_kde)
|
|
984
|
-
|
|
985
|
-
if density:
|
|
986
|
-
ax.plot(x_kde, y_kde, "r-", linewidth=2, label="KDE")
|
|
987
|
-
else:
|
|
988
|
-
bin_width = bin_edges[1] - bin_edges[0]
|
|
989
|
-
ax.plot(x_kde, y_kde * len(data) * bin_width, "r-", linewidth=2, label="KDE")
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
def _format_histogram_axes(ax: Axes, density: bool, show_stats: bool, show_kde: bool) -> None:
|
|
993
|
-
"""Format histogram axes.
|
|
994
|
-
|
|
995
|
-
Args:
|
|
996
|
-
ax: Axes object.
|
|
997
|
-
density: Whether density normalized.
|
|
998
|
-
show_stats: Whether stats shown.
|
|
999
|
-
show_kde: Whether KDE shown.
|
|
1000
|
-
"""
|
|
1001
|
-
ax.set_xlabel("Amplitude")
|
|
1002
|
-
ax.set_ylabel("Density" if density else "Count")
|
|
1003
|
-
ax.set_title("Amplitude Distribution")
|
|
1004
|
-
if show_stats or show_kde:
|
|
1005
|
-
ax.legend(loc="upper right")
|
|
1006
|
-
ax.grid(True, alpha=0.3)
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
def _handle_histogram_output(fig: Figure, save_path: str | None, show: bool) -> None:
|
|
1010
|
-
"""Handle histogram output.
|
|
1011
|
-
|
|
1012
|
-
Args:
|
|
1013
|
-
fig: Figure object.
|
|
1014
|
-
save_path: Save path.
|
|
1015
|
-
show: Whether to show.
|
|
1016
|
-
"""
|
|
1017
|
-
if save_path is not None:
|
|
1018
|
-
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
1019
|
-
if show:
|
|
1020
|
-
plt.show()
|
|
1021
|
-
else:
|
|
1022
|
-
plt.close(fig)
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
__all__ = [
|
|
1026
|
-
"CursorMeasurement",
|
|
1027
|
-
"ZoomState",
|
|
1028
|
-
"add_measurement_cursors",
|
|
1029
|
-
"enable_zoom_pan",
|
|
1030
|
-
"plot_bode",
|
|
1031
|
-
"plot_histogram",
|
|
1032
|
-
"plot_phase",
|
|
1033
|
-
"plot_waterfall",
|
|
1034
|
-
"plot_with_cursors",
|
|
1035
|
-
]
|