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.
Files changed (175) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/analyzers/__init__.py +2 -0
  3. oscura/analyzers/digital/extraction.py +2 -3
  4. oscura/analyzers/digital/quality.py +1 -1
  5. oscura/analyzers/digital/timing.py +1 -1
  6. oscura/analyzers/eye/__init__.py +5 -1
  7. oscura/analyzers/eye/generation.py +501 -0
  8. oscura/analyzers/jitter/__init__.py +6 -6
  9. oscura/analyzers/jitter/timing.py +419 -0
  10. oscura/analyzers/patterns/__init__.py +94 -0
  11. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  12. oscura/analyzers/power/__init__.py +35 -12
  13. oscura/analyzers/power/basic.py +3 -3
  14. oscura/analyzers/power/soa.py +1 -1
  15. oscura/analyzers/power/switching.py +3 -3
  16. oscura/analyzers/signal_classification.py +529 -0
  17. oscura/analyzers/signal_integrity/sparams.py +3 -3
  18. oscura/analyzers/statistics/__init__.py +4 -0
  19. oscura/analyzers/statistics/basic.py +152 -0
  20. oscura/analyzers/statistics/correlation.py +47 -6
  21. oscura/analyzers/validation.py +1 -1
  22. oscura/analyzers/waveform/__init__.py +2 -0
  23. oscura/analyzers/waveform/measurements.py +329 -163
  24. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  25. oscura/analyzers/waveform/spectral.py +498 -54
  26. oscura/api/dsl/commands.py +15 -6
  27. oscura/api/server/templates/base.html +137 -146
  28. oscura/api/server/templates/export.html +84 -110
  29. oscura/api/server/templates/home.html +248 -267
  30. oscura/api/server/templates/protocols.html +44 -48
  31. oscura/api/server/templates/reports.html +27 -35
  32. oscura/api/server/templates/session_detail.html +68 -78
  33. oscura/api/server/templates/sessions.html +62 -72
  34. oscura/api/server/templates/waveforms.html +54 -64
  35. oscura/automotive/__init__.py +1 -1
  36. oscura/automotive/can/session.py +1 -1
  37. oscura/automotive/dbc/generator.py +638 -23
  38. oscura/automotive/dtc/data.json +102 -17
  39. oscura/automotive/uds/decoder.py +99 -6
  40. oscura/cli/analyze.py +8 -2
  41. oscura/cli/batch.py +36 -5
  42. oscura/cli/characterize.py +18 -4
  43. oscura/cli/export.py +47 -5
  44. oscura/cli/main.py +2 -0
  45. oscura/cli/onboarding/wizard.py +10 -6
  46. oscura/cli/pipeline.py +585 -0
  47. oscura/cli/visualize.py +6 -4
  48. oscura/convenience.py +400 -32
  49. oscura/core/config/loader.py +0 -1
  50. oscura/core/measurement_result.py +286 -0
  51. oscura/core/progress.py +1 -1
  52. oscura/core/schemas/device_mapping.json +8 -2
  53. oscura/core/schemas/packet_format.json +24 -4
  54. oscura/core/schemas/protocol_definition.json +12 -2
  55. oscura/core/types.py +300 -199
  56. oscura/correlation/multi_protocol.py +1 -1
  57. oscura/export/legacy/__init__.py +11 -0
  58. oscura/export/legacy/wav.py +75 -0
  59. oscura/exporters/__init__.py +19 -0
  60. oscura/exporters/wireshark.py +809 -0
  61. oscura/hardware/acquisition/file.py +5 -19
  62. oscura/hardware/acquisition/saleae.py +10 -10
  63. oscura/hardware/acquisition/socketcan.py +4 -6
  64. oscura/hardware/acquisition/synthetic.py +1 -5
  65. oscura/hardware/acquisition/visa.py +6 -6
  66. oscura/hardware/security/side_channel_detector.py +5 -508
  67. oscura/inference/message_format.py +686 -1
  68. oscura/jupyter/display.py +2 -2
  69. oscura/jupyter/magic.py +3 -3
  70. oscura/loaders/__init__.py +17 -12
  71. oscura/loaders/binary.py +1 -1
  72. oscura/loaders/chipwhisperer.py +1 -2
  73. oscura/loaders/configurable.py +1 -1
  74. oscura/loaders/csv_loader.py +2 -2
  75. oscura/loaders/hdf5_loader.py +1 -1
  76. oscura/loaders/lazy.py +6 -1
  77. oscura/loaders/mmap_loader.py +0 -1
  78. oscura/loaders/numpy_loader.py +8 -7
  79. oscura/loaders/preprocessing.py +3 -5
  80. oscura/loaders/rigol.py +21 -7
  81. oscura/loaders/sigrok.py +2 -5
  82. oscura/loaders/tdms.py +3 -2
  83. oscura/loaders/tektronix.py +38 -32
  84. oscura/loaders/tss.py +20 -27
  85. oscura/loaders/vcd.py +13 -8
  86. oscura/loaders/wav.py +1 -6
  87. oscura/pipeline/__init__.py +76 -0
  88. oscura/pipeline/handlers/__init__.py +165 -0
  89. oscura/pipeline/handlers/analyzers.py +1045 -0
  90. oscura/pipeline/handlers/decoders.py +899 -0
  91. oscura/pipeline/handlers/exporters.py +1103 -0
  92. oscura/pipeline/handlers/filters.py +891 -0
  93. oscura/pipeline/handlers/loaders.py +640 -0
  94. oscura/pipeline/handlers/transforms.py +768 -0
  95. oscura/reporting/__init__.py +88 -1
  96. oscura/reporting/automation.py +348 -0
  97. oscura/reporting/citations.py +374 -0
  98. oscura/reporting/core.py +54 -0
  99. oscura/reporting/formatting/__init__.py +11 -0
  100. oscura/reporting/formatting/measurements.py +320 -0
  101. oscura/reporting/html.py +57 -0
  102. oscura/reporting/interpretation.py +431 -0
  103. oscura/reporting/summary.py +329 -0
  104. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  105. oscura/reporting/visualization.py +542 -0
  106. oscura/side_channel/__init__.py +38 -57
  107. oscura/utils/builders/signal_builder.py +5 -5
  108. oscura/utils/comparison/compare.py +7 -9
  109. oscura/utils/comparison/golden.py +1 -1
  110. oscura/utils/filtering/convenience.py +2 -2
  111. oscura/utils/math/arithmetic.py +38 -62
  112. oscura/utils/math/interpolation.py +20 -20
  113. oscura/utils/pipeline/__init__.py +4 -17
  114. oscura/utils/progressive.py +1 -4
  115. oscura/utils/triggering/edge.py +1 -1
  116. oscura/utils/triggering/pattern.py +2 -2
  117. oscura/utils/triggering/pulse.py +2 -2
  118. oscura/utils/triggering/window.py +3 -3
  119. oscura/validation/hil_testing.py +11 -11
  120. oscura/visualization/__init__.py +47 -284
  121. oscura/visualization/batch.py +160 -0
  122. oscura/visualization/plot.py +542 -53
  123. oscura/visualization/styles.py +184 -318
  124. oscura/workflows/__init__.py +2 -0
  125. oscura/workflows/batch/advanced.py +1 -1
  126. oscura/workflows/batch/aggregate.py +7 -8
  127. oscura/workflows/complete_re.py +251 -23
  128. oscura/workflows/digital.py +27 -4
  129. oscura/workflows/multi_trace.py +136 -17
  130. oscura/workflows/waveform.py +788 -0
  131. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  132. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
  133. oscura/side_channel/dpa.py +0 -1025
  134. oscura/utils/optimization/__init__.py +0 -19
  135. oscura/utils/optimization/parallel.py +0 -443
  136. oscura/utils/optimization/search.py +0 -532
  137. oscura/utils/pipeline/base.py +0 -338
  138. oscura/utils/pipeline/composition.py +0 -248
  139. oscura/utils/pipeline/parallel.py +0 -449
  140. oscura/utils/pipeline/pipeline.py +0 -375
  141. oscura/utils/search/__init__.py +0 -16
  142. oscura/utils/search/anomaly.py +0 -424
  143. oscura/utils/search/context.py +0 -294
  144. oscura/utils/search/pattern.py +0 -288
  145. oscura/utils/storage/__init__.py +0 -61
  146. oscura/utils/storage/database.py +0 -1166
  147. oscura/visualization/accessibility.py +0 -526
  148. oscura/visualization/annotations.py +0 -371
  149. oscura/visualization/axis_scaling.py +0 -305
  150. oscura/visualization/colors.py +0 -451
  151. oscura/visualization/digital.py +0 -436
  152. oscura/visualization/eye.py +0 -571
  153. oscura/visualization/histogram.py +0 -281
  154. oscura/visualization/interactive.py +0 -1035
  155. oscura/visualization/jitter.py +0 -1042
  156. oscura/visualization/keyboard.py +0 -394
  157. oscura/visualization/layout.py +0 -400
  158. oscura/visualization/optimization.py +0 -1079
  159. oscura/visualization/palettes.py +0 -446
  160. oscura/visualization/power.py +0 -508
  161. oscura/visualization/power_extended.py +0 -955
  162. oscura/visualization/presets.py +0 -469
  163. oscura/visualization/protocols.py +0 -1246
  164. oscura/visualization/render.py +0 -223
  165. oscura/visualization/rendering.py +0 -444
  166. oscura/visualization/reverse_engineering.py +0 -838
  167. oscura/visualization/signal_integrity.py +0 -989
  168. oscura/visualization/specialized.py +0 -643
  169. oscura/visualization/spectral.py +0 -1226
  170. oscura/visualization/thumbnails.py +0 -340
  171. oscura/visualization/time_axis.py +0 -351
  172. oscura/visualization/waveform.py +0 -454
  173. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  174. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  175. {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
- ]