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,1226 +0,0 @@
1
- """Spectral visualization functions.
2
-
3
- This module provides spectrum and spectrogram plots for
4
- frequency-domain analysis visualization.
5
-
6
-
7
- Example:
8
- >>> from oscura.visualization.spectral import plot_spectrum, plot_spectrogram
9
- >>> plot_spectrum(trace)
10
- >>> plot_spectrogram(trace)
11
-
12
- References:
13
- matplotlib best practices for scientific visualization
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- from pathlib import Path
19
- from typing import TYPE_CHECKING, Any, cast
20
-
21
- import numpy as np
22
-
23
- try:
24
- import matplotlib.pyplot as plt
25
- from matplotlib.colors import Normalize # noqa: F401
26
-
27
- HAS_MATPLOTLIB = True
28
- except ImportError:
29
- HAS_MATPLOTLIB = False
30
-
31
-
32
- if TYPE_CHECKING:
33
- from matplotlib.axes import Axes
34
- from matplotlib.figure import Figure
35
- from numpy.typing import NDArray
36
-
37
- from oscura.core.types import WaveformTrace
38
-
39
-
40
- def _get_fft_data(
41
- trace: WaveformTrace,
42
- fft_result: tuple[Any, Any] | None,
43
- window: str,
44
- ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
45
- """Get FFT data, either from cache or by computing.
46
-
47
- Args:
48
- trace: Waveform trace.
49
- fft_result: Pre-computed FFT result.
50
- window: Window function name.
51
-
52
- Returns:
53
- Tuple of (frequencies, magnitudes_db).
54
- """
55
- if fft_result is not None:
56
- return fft_result
57
-
58
- from oscura.analyzers.waveform.spectral import fft
59
-
60
- return fft(trace, window=window) # type: ignore[return-value]
61
-
62
-
63
- def _scale_frequencies(
64
- freq: NDArray[np.float64], freq_unit: str
65
- ) -> tuple[NDArray[np.float64], float, str]:
66
- """Scale frequencies to appropriate unit.
67
-
68
- Args:
69
- freq: Frequency array in Hz.
70
- freq_unit: Requested unit or "auto".
71
-
72
- Returns:
73
- Tuple of (scaled_frequencies, divisor, unit_name).
74
- """
75
- if freq_unit == "auto":
76
- max_freq = freq[-1]
77
- freq_unit = _auto_select_freq_unit(max_freq)
78
-
79
- freq_divisors = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
80
- divisor = freq_divisors.get(freq_unit, 1.0)
81
- return freq / divisor, divisor, freq_unit
82
-
83
-
84
- def _set_auto_ylimits(ax: Axes, mag_db: NDArray[np.float64]) -> None:
85
- """Set reasonable y-axis limits based on data.
86
-
87
- Args:
88
- ax: Matplotlib axes.
89
- mag_db: Magnitude data in dB.
90
- """
91
- valid_db = mag_db[np.isfinite(mag_db)]
92
- if len(valid_db) == 0:
93
- return
94
-
95
- y_max = np.max(valid_db)
96
- y_min = max(np.min(valid_db), y_max - 120) # Limit dynamic range
97
- ax.set_ylim(y_min, y_max + 5)
98
-
99
-
100
- def _apply_axis_limits(
101
- ax: Axes,
102
- divisor: float,
103
- freq_range: tuple[float, float] | None,
104
- xlim: tuple[float, float] | None,
105
- ylim: tuple[float, float] | None,
106
- ) -> None:
107
- """Apply custom axis limits if specified.
108
-
109
- Args:
110
- ax: Matplotlib axes.
111
- divisor: Frequency divisor for unit conversion.
112
- freq_range: Frequency range in Hz (will be converted to display units).
113
- xlim: X-axis limits in display units.
114
- ylim: Y-axis limits.
115
- """
116
- if freq_range is not None and len(freq_range) == 2:
117
- # freq_range is in Hz, convert to display units
118
- freq_min = freq_range[0] / divisor
119
- freq_max = freq_range[1] / divisor
120
-
121
- # For log scale, ensure minimum is positive (avoid 0 on log axis)
122
- if ax.get_xscale() == "log" and freq_min <= 0:
123
- freq_min = freq_max / 1000 # Use a small positive value
124
-
125
- ax.set_xlim(freq_min, freq_max)
126
- elif xlim is not None:
127
- ax.set_xlim(xlim)
128
-
129
- if ylim is not None:
130
- ax.set_ylim(ylim)
131
-
132
-
133
- def _prepare_spectrum_data(
134
- trace: WaveformTrace,
135
- fft_result: tuple[Any, Any] | None,
136
- window: str,
137
- db_ref: float | None,
138
- ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
139
- """Prepare spectrum data with FFT and dB scaling.
140
-
141
- Args:
142
- trace: Waveform trace to analyze.
143
- fft_result: Pre-computed FFT result or None.
144
- window: Window function name.
145
- db_ref: Reference for dB scaling or None.
146
-
147
- Returns:
148
- Tuple of (frequencies, magnitudes_db).
149
- """
150
- freq, mag_db = _get_fft_data(trace, fft_result, window)
151
-
152
- # Adjust dB reference if specified
153
- if db_ref is not None:
154
- mag_db = mag_db - db_ref
155
-
156
- return freq, mag_db
157
-
158
-
159
- def _render_spectrum_plot(
160
- ax: Axes,
161
- freq_scaled: NDArray[np.float64],
162
- mag_db: NDArray[np.float64],
163
- freq_unit: str,
164
- color: str,
165
- title: str | None,
166
- log_scale: bool,
167
- show_grid: bool,
168
- ) -> None:
169
- """Render spectrum plot on axes.
170
-
171
- Args:
172
- ax: Matplotlib axes to plot on.
173
- freq_scaled: Scaled frequency array.
174
- mag_db: Magnitude array in dB.
175
- freq_unit: Frequency unit string.
176
- color: Line color.
177
- title: Plot title.
178
- log_scale: Use logarithmic frequency scale.
179
- show_grid: Show grid lines.
180
- """
181
- ax.plot(freq_scaled, mag_db, color=color, linewidth=0.8)
182
- ax.set_xlabel(f"Frequency ({freq_unit})")
183
- ax.set_ylabel("Magnitude (dB)")
184
- ax.set_xscale("log" if log_scale else "linear")
185
- ax.set_title(title if title else "Magnitude Spectrum")
186
-
187
- if show_grid:
188
- ax.grid(True, alpha=0.3, which="both")
189
-
190
-
191
- def plot_spectrum(
192
- trace: WaveformTrace,
193
- *,
194
- ax: Axes | None = None,
195
- freq_unit: str = "auto",
196
- db_ref: float | None = None,
197
- freq_range: tuple[float, float] | None = None,
198
- show_grid: bool = True,
199
- color: str = "C0",
200
- title: str | None = None,
201
- window: str = "hann",
202
- show: bool = True,
203
- save_path: str | None = None,
204
- figsize: tuple[float, float] = (10, 6),
205
- xlim: tuple[float, float] | None = None,
206
- ylim: tuple[float, float] | None = None,
207
- fft_result: tuple[Any, Any] | None = None,
208
- log_scale: bool = True,
209
- ) -> Figure:
210
- """Plot magnitude spectrum.
211
-
212
- Args:
213
- trace: Waveform trace to analyze.
214
- ax: Matplotlib axes. If None, creates new figure.
215
- freq_unit: Frequency unit ("Hz", "kHz", "MHz", "auto").
216
- db_ref: Reference for dB scaling. If None, uses max value.
217
- freq_range: Frequency range (min, max) in Hz to display.
218
- show_grid: Show grid lines.
219
- color: Line color.
220
- title: Plot title.
221
- window: Window function for FFT.
222
- show: If True, call plt.show() to display the plot.
223
- save_path: Path to save the figure. If None, figure is not saved.
224
- figsize: Figure size (width, height) in inches. Only used if ax is None.
225
- xlim: X-axis limits (min, max) in selected frequency units.
226
- ylim: Y-axis limits (min, max) in dB.
227
- fft_result: Pre-computed FFT result (frequencies, magnitudes). If None, computes FFT.
228
- log_scale: Use logarithmic scale for frequency axis (default True).
229
-
230
- Returns:
231
- Matplotlib Figure object.
232
-
233
- Raises:
234
- ImportError: If matplotlib is not installed.
235
- ValueError: If axes must have an associated figure.
236
-
237
- Example:
238
- >>> import oscura as osc
239
- >>> trace = osc.load("signal.wfm")
240
- >>> fig = osc.plot_spectrum(trace, freq_unit="MHz", log_scale=True)
241
-
242
- >>> # With pre-computed FFT
243
- >>> freq, mag = osc.fft(trace)
244
- >>> fig = osc.plot_spectrum(trace, fft_result=(freq, mag), show=False)
245
- >>> fig.savefig("spectrum.png")
246
- """
247
- if not HAS_MATPLOTLIB:
248
- raise ImportError("matplotlib is required for visualization")
249
-
250
- # Figure/axes creation
251
- if ax is None:
252
- fig, ax = plt.subplots(figsize=figsize)
253
- else:
254
- fig_temp = ax.get_figure()
255
- if fig_temp is None:
256
- raise ValueError("Axes must have an associated figure")
257
- fig = cast("Figure", fig_temp)
258
-
259
- # Data preparation
260
- freq, mag_db = _prepare_spectrum_data(trace, fft_result, window, db_ref)
261
-
262
- # Unit/scale selection
263
- freq_scaled, divisor, freq_unit = _scale_frequencies(freq, freq_unit)
264
-
265
- # Plotting/rendering
266
- _render_spectrum_plot(ax, freq_scaled, mag_db, freq_unit, color, title, log_scale, show_grid)
267
-
268
- # Set limits
269
- _set_auto_ylimits(ax, mag_db)
270
- _apply_axis_limits(ax, divisor, freq_range, xlim, ylim)
271
-
272
- # Layout/formatting
273
- fig.tight_layout()
274
-
275
- if save_path is not None:
276
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
277
-
278
- if show:
279
- plt.show()
280
-
281
- return fig
282
-
283
-
284
- def _auto_select_time_unit(max_time: float) -> str:
285
- """Select appropriate time unit based on maximum time value.
286
-
287
- Args:
288
- max_time: Maximum time value in seconds.
289
-
290
- Returns:
291
- Time unit string ("s", "ms", "us", or "ns").
292
- """
293
- if max_time < 1e-6:
294
- return "ns"
295
- elif max_time < 1e-3:
296
- return "us"
297
- elif max_time < 1:
298
- return "ms"
299
- else:
300
- return "s"
301
-
302
-
303
- def _auto_select_freq_unit(max_freq: float) -> str:
304
- """Select appropriate frequency unit based on maximum frequency.
305
-
306
- Args:
307
- max_freq: Maximum frequency in Hz.
308
-
309
- Returns:
310
- Frequency unit string ("Hz", "kHz", "MHz", or "GHz").
311
- """
312
- if max_freq >= 1e9:
313
- return "GHz"
314
- elif max_freq >= 1e6:
315
- return "MHz"
316
- elif max_freq >= 1e3:
317
- return "kHz"
318
- else:
319
- return "Hz"
320
-
321
-
322
- def _get_unit_multipliers() -> tuple[dict[str, float], dict[str, float]]:
323
- """Get time and frequency unit multipliers.
324
-
325
- Returns:
326
- Tuple of (time_multipliers, freq_divisors).
327
- """
328
- time_mult = {"s": 1.0, "ms": 1e3, "us": 1e6, "ns": 1e9}
329
- freq_div = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
330
- return time_mult, freq_div
331
-
332
-
333
- def _auto_color_limits(
334
- data: NDArray[np.float64], vmin: float | None, vmax: float | None
335
- ) -> tuple[float | None, float | None]:
336
- """Automatically determine color limits for spectrogram.
337
-
338
- Args:
339
- data: Spectrogram data in dB.
340
- vmin: Minimum dB value (if None, auto-computed).
341
- vmax: Maximum dB value (if None, auto-computed).
342
-
343
- Returns:
344
- Tuple of (vmin, vmax).
345
- """
346
- if vmin is not None and vmax is not None:
347
- return vmin, vmax
348
-
349
- valid_db = data[np.isfinite(data)]
350
- if len(valid_db) == 0:
351
- return vmin, vmax
352
-
353
- if vmax is None:
354
- vmax = np.max(valid_db)
355
- if vmin is None:
356
- vmin = max(np.min(valid_db), vmax - 80)
357
-
358
- return vmin, vmax
359
-
360
-
361
- def _compute_spectrogram_data(
362
- trace: WaveformTrace,
363
- window: str,
364
- nperseg: int | None,
365
- nfft: int | None,
366
- overlap: float | None,
367
- ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
368
- """Compute spectrogram data using STFT.
369
-
370
- Args:
371
- trace: Waveform trace to analyze.
372
- window: Window function name.
373
- nperseg: Segment length for STFT.
374
- nfft: FFT length (overrides nperseg if specified).
375
- overlap: Overlap fraction (0.0 to 1.0).
376
-
377
- Returns:
378
- Tuple of (times, frequencies, Sxx_db).
379
- """
380
- from oscura.analyzers.waveform.spectral import spectrogram
381
-
382
- if nfft is not None:
383
- nperseg = nfft
384
- noverlap = int(nperseg * overlap) if overlap is not None and nperseg is not None else None
385
-
386
- return spectrogram(trace, window=window, nperseg=nperseg, noverlap=noverlap)
387
-
388
-
389
- def _scale_spectrogram_axes(
390
- times: NDArray[np.float64],
391
- freq: NDArray[np.float64],
392
- time_unit: str,
393
- freq_unit: str,
394
- ) -> tuple[NDArray[np.float64], NDArray[np.float64], str, str]:
395
- """Scale time and frequency axes to appropriate units.
396
-
397
- Args:
398
- times: Time array in seconds.
399
- freq: Frequency array in Hz.
400
- time_unit: Time unit ("auto" or specific).
401
- freq_unit: Frequency unit ("auto" or specific).
402
-
403
- Returns:
404
- Tuple of (times_scaled, freq_scaled, time_unit, freq_unit).
405
- """
406
- if time_unit == "auto":
407
- max_time = times[-1] if len(times) > 0 else 0
408
- time_unit = _auto_select_time_unit(max_time)
409
-
410
- if freq_unit == "auto":
411
- max_freq = freq[-1] if len(freq) > 0 else 0
412
- freq_unit = _auto_select_freq_unit(max_freq)
413
-
414
- time_multipliers, freq_divisors = _get_unit_multipliers()
415
- times_scaled = times * time_multipliers.get(time_unit, 1.0)
416
- freq_scaled = freq / freq_divisors.get(freq_unit, 1.0)
417
-
418
- return times_scaled, freq_scaled, time_unit, freq_unit
419
-
420
-
421
- def _render_spectrogram_plot(
422
- ax: Axes,
423
- times_scaled: NDArray[np.float64],
424
- freq_scaled: NDArray[np.float64],
425
- Sxx_db: NDArray[np.float64],
426
- time_unit: str,
427
- freq_unit: str,
428
- cmap: str,
429
- vmin: float | None,
430
- vmax: float | None,
431
- title: str | None,
432
- ) -> None:
433
- """Render spectrogram plot on axes.
434
-
435
- Args:
436
- ax: Matplotlib axes to plot on.
437
- times_scaled: Scaled time array.
438
- freq_scaled: Scaled frequency array.
439
- Sxx_db: Spectrogram data in dB.
440
- time_unit: Time unit string.
441
- freq_unit: Frequency unit string.
442
- cmap: Colormap name.
443
- vmin: Minimum dB value for color scaling.
444
- vmax: Maximum dB value for color scaling.
445
- title: Plot title.
446
- """
447
- # Auto color limits
448
- vmin, vmax = _auto_color_limits(Sxx_db, vmin, vmax)
449
-
450
- # Plot
451
- pcm = ax.pcolormesh(
452
- times_scaled,
453
- freq_scaled,
454
- Sxx_db,
455
- shading="auto",
456
- cmap=cmap,
457
- vmin=vmin,
458
- vmax=vmax,
459
- )
460
-
461
- ax.set_xlabel(f"Time ({time_unit})")
462
- ax.set_ylabel(f"Frequency ({freq_unit})")
463
- ax.set_title(title if title else "Spectrogram")
464
-
465
- # Colorbar
466
- fig = ax.get_figure()
467
- if fig is not None:
468
- cbar = fig.colorbar(pcm, ax=ax)
469
- cbar.set_label("Magnitude (dB)")
470
-
471
-
472
- def plot_spectrogram(
473
- trace: WaveformTrace,
474
- *,
475
- ax: Axes | None = None,
476
- time_unit: str = "auto",
477
- freq_unit: str = "auto",
478
- cmap: str = "viridis",
479
- vmin: float | None = None,
480
- vmax: float | None = None,
481
- title: str | None = None,
482
- window: str = "hann",
483
- nperseg: int | None = None,
484
- nfft: int | None = None,
485
- overlap: float | None = None,
486
- ) -> Figure:
487
- """Plot spectrogram (time-frequency representation).
488
-
489
- Args:
490
- trace: Waveform trace to analyze.
491
- ax: Matplotlib axes. If None, creates new figure.
492
- time_unit: Time unit ("s", "ms", "us", "auto").
493
- freq_unit: Frequency unit ("Hz", "kHz", "MHz", "auto").
494
- cmap: Colormap name.
495
- vmin: Minimum dB value for color scaling.
496
- vmax: Maximum dB value for color scaling.
497
- title: Plot title.
498
- window: Window function.
499
- nperseg: Segment length for STFT.
500
- nfft: FFT length. If specified, overrides nperseg.
501
- overlap: Overlap fraction (0.0 to 1.0). Default is 0.5 (50%).
502
-
503
- Returns:
504
- Matplotlib Figure object.
505
-
506
- Raises:
507
- ImportError: If matplotlib is not installed.
508
- ValueError: If axes must have an associated figure.
509
-
510
- Example:
511
- >>> fig = plot_spectrogram(trace)
512
- >>> plt.show()
513
- """
514
- if not HAS_MATPLOTLIB:
515
- raise ImportError("matplotlib is required for visualization")
516
-
517
- # Figure/axes creation
518
- if ax is None:
519
- fig, ax = plt.subplots(figsize=(10, 4))
520
- else:
521
- fig_temp = ax.get_figure()
522
- if fig_temp is None:
523
- raise ValueError("Axes must have an associated figure")
524
- fig = cast("Figure", fig_temp)
525
-
526
- # Data preparation/computation
527
- times, freq, Sxx_db = _compute_spectrogram_data(trace, window, nperseg, nfft, overlap)
528
-
529
- # Unit/scale selection
530
- times_scaled, freq_scaled, time_unit, freq_unit = _scale_spectrogram_axes(
531
- times, freq, time_unit, freq_unit
532
- )
533
-
534
- # Plotting/rendering
535
- _render_spectrogram_plot(
536
- ax, times_scaled, freq_scaled, Sxx_db, time_unit, freq_unit, cmap, vmin, vmax, title
537
- )
538
-
539
- # Layout/formatting
540
- fig.tight_layout()
541
- return fig
542
-
543
-
544
- def plot_psd(
545
- trace: WaveformTrace,
546
- *,
547
- ax: Axes | None = None,
548
- freq_unit: str = "auto",
549
- show_grid: bool = True,
550
- color: str = "C0",
551
- title: str | None = None,
552
- window: str = "hann",
553
- log_scale: bool = True,
554
- ) -> Figure:
555
- """Plot Power Spectral Density.
556
-
557
- Args:
558
- trace: Waveform trace to analyze.
559
- ax: Matplotlib axes.
560
- freq_unit: Frequency unit.
561
- show_grid: Show grid lines.
562
- color: Line color.
563
- title: Plot title.
564
- window: Window function.
565
- log_scale: Use logarithmic scale for frequency axis (default True).
566
-
567
- Returns:
568
- Matplotlib Figure object.
569
-
570
- Raises:
571
- ImportError: If matplotlib is not installed.
572
- ValueError: If axes must have an associated figure.
573
-
574
- Example:
575
- >>> fig = plot_psd(trace)
576
- >>> plt.show()
577
- """
578
- if not HAS_MATPLOTLIB:
579
- raise ImportError("matplotlib is required for visualization")
580
-
581
- from oscura.analyzers.waveform.spectral import psd
582
-
583
- if ax is None:
584
- fig, ax = plt.subplots(figsize=(10, 4))
585
- else:
586
- fig_temp = ax.get_figure()
587
- if fig_temp is None:
588
- raise ValueError("Axes must have an associated figure")
589
- fig = cast("Figure", fig_temp)
590
-
591
- # Compute PSD
592
- freq, psd_db = psd(trace, window=window)
593
-
594
- # Auto-select frequency unit
595
- if freq_unit == "auto":
596
- max_freq = freq[-1]
597
- if max_freq >= 1e9:
598
- freq_unit = "GHz"
599
- elif max_freq >= 1e6:
600
- freq_unit = "MHz"
601
- elif max_freq >= 1e3:
602
- freq_unit = "kHz"
603
- else:
604
- freq_unit = "Hz"
605
-
606
- freq_divisors = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}
607
- divisor = freq_divisors.get(freq_unit, 1.0)
608
- freq_scaled = freq / divisor
609
-
610
- # Plot
611
- ax.plot(freq_scaled, psd_db, color=color, linewidth=0.8)
612
-
613
- ax.set_xlabel(f"Frequency ({freq_unit})")
614
- ax.set_ylabel("PSD (dB/Hz)")
615
- ax.set_xscale("log" if log_scale else "linear")
616
-
617
- if title:
618
- ax.set_title(title)
619
- else:
620
- ax.set_title("Power Spectral Density")
621
-
622
- if show_grid:
623
- ax.grid(True, alpha=0.3, which="both")
624
-
625
- fig.tight_layout()
626
- return fig
627
-
628
-
629
- def plot_fft(
630
- trace: WaveformTrace,
631
- *,
632
- ax: Axes | None = None,
633
- show: bool = True,
634
- save_path: str | None = None,
635
- title: str | None = None,
636
- xlabel: str = "Frequency",
637
- ylabel: str = "Magnitude (dB)",
638
- figsize: tuple[float, float] = (10, 6),
639
- freq_unit: str = "auto",
640
- log_scale: bool = True,
641
- show_grid: bool = True,
642
- color: str = "C0",
643
- window: str = "hann",
644
- xlim: tuple[float, float] | None = None,
645
- ylim: tuple[float, float] | None = None,
646
- ) -> Figure:
647
- """Plot FFT magnitude spectrum.
648
-
649
- Computes and plots the FFT magnitude spectrum of a waveform trace.
650
- This is a convenience function that combines FFT computation and visualization.
651
-
652
- Args:
653
- trace: Waveform trace to analyze and plot.
654
- ax: Matplotlib axes. If None, creates new figure.
655
- show: If True, call plt.show() to display the plot.
656
- save_path: Path to save the figure. If None, figure is not saved.
657
- title: Plot title. If None, uses default "FFT Magnitude Spectrum".
658
- xlabel: X-axis label (appended with frequency unit).
659
- ylabel: Y-axis label.
660
- figsize: Figure size (width, height) in inches. Only used if ax is None.
661
- freq_unit: Frequency unit ("Hz", "kHz", "MHz", "GHz", "auto").
662
- log_scale: Use logarithmic scale for frequency axis.
663
- show_grid: Show grid lines.
664
- color: Line color.
665
- window: Window function for FFT computation.
666
- xlim: X-axis limits (min, max) in selected frequency units.
667
- ylim: Y-axis limits (min, max) in dB.
668
-
669
- Returns:
670
- Matplotlib Figure object.
671
-
672
- Raises:
673
- ImportError: If matplotlib is not installed.
674
- ValueError: If axes must have an associated figure.
675
-
676
- Example:
677
- >>> import oscura as osc
678
- >>> trace = osc.load("signal.wfm")
679
- >>> fig = osc.plot_fft(trace, freq_unit="MHz", show=False)
680
- >>> fig.savefig("spectrum.png")
681
-
682
- >>> # With custom styling
683
- >>> fig = osc.plot_fft(trace,
684
- ... title="Signal FFT",
685
- ... log_scale=True,
686
- ... xlim=(1e3, 1e6),
687
- ... ylim=(-100, 0))
688
-
689
- References:
690
- IEEE 1241-2010: Standard for Terminology and Test Methods for
691
- Analog-to-Digital Converters
692
- """
693
- if not HAS_MATPLOTLIB:
694
- raise ImportError("matplotlib is required for visualization")
695
-
696
- # Setup figure and axes
697
- fig, ax = _setup_plot_figure(ax, figsize)
698
-
699
- # Plot spectrum using main plotting function
700
- plot_spectrum(
701
- trace,
702
- ax=ax,
703
- freq_unit=freq_unit,
704
- show_grid=show_grid,
705
- color=color,
706
- title=title if title else "FFT Magnitude Spectrum",
707
- window=window,
708
- log_scale=log_scale,
709
- )
710
-
711
- # Apply custom labels and limits
712
- _apply_custom_labels(ax, xlabel, ylabel)
713
- _apply_axis_limits_simple(ax, xlim, ylim)
714
-
715
- # Output handling
716
- _handle_plot_output(fig, save_path, show)
717
-
718
- return fig
719
-
720
-
721
- def _setup_plot_figure(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
722
- """Setup figure and axes for plotting.
723
-
724
- Args:
725
- ax: Existing axes or None.
726
- figsize: Figure size if creating new.
727
-
728
- Returns:
729
- Tuple of (Figure, Axes).
730
- """
731
- if ax is None:
732
- return plt.subplots(figsize=figsize)
733
-
734
- fig_temp = ax.get_figure()
735
- if fig_temp is None:
736
- raise ValueError("Axes must have an associated figure")
737
- return cast("Figure", fig_temp), ax
738
-
739
-
740
- def _apply_custom_labels(ax: Axes, xlabel: str, ylabel: str) -> None:
741
- """Apply custom labels to plot axes.
742
-
743
- Args:
744
- ax: Matplotlib axes.
745
- xlabel: X-axis label.
746
- ylabel: Y-axis label.
747
- """
748
- if xlabel != "Frequency":
749
- current_label = ax.get_xlabel()
750
- if "(" in current_label and ")" in current_label:
751
- unit = current_label[current_label.find("(") : current_label.find(")") + 1]
752
- ax.set_xlabel(f"{xlabel} {unit}")
753
- else:
754
- ax.set_xlabel(xlabel)
755
-
756
- if ylabel != "Magnitude (dB)":
757
- ax.set_ylabel(ylabel)
758
-
759
-
760
- def _apply_axis_limits_simple(
761
- ax: Axes, xlim: tuple[float, float] | None, ylim: tuple[float, float] | None
762
- ) -> None:
763
- """Apply axis limits if specified.
764
-
765
- Args:
766
- ax: Matplotlib axes.
767
- xlim: X-axis limits.
768
- ylim: Y-axis limits.
769
- """
770
- if xlim is not None:
771
- ax.set_xlim(xlim)
772
- if ylim is not None:
773
- ax.set_ylim(ylim)
774
-
775
-
776
- def _handle_plot_output(fig: Figure, save_path: str | None, show: bool) -> None:
777
- """Handle plot output (save and/or show).
778
-
779
- Args:
780
- fig: Matplotlib figure.
781
- save_path: Path to save figure.
782
- show: Whether to display plot.
783
- """
784
- if save_path is not None:
785
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
786
- if show:
787
- plt.show()
788
-
789
-
790
- def _create_harmonic_labels(
791
- n_harmonics: int,
792
- fundamental_freq: float | None,
793
- ) -> list[str]:
794
- """Create x-axis labels for harmonics.
795
-
796
- Args:
797
- n_harmonics: Number of harmonics.
798
- fundamental_freq: Fundamental frequency in Hz or None.
799
-
800
- Returns:
801
- List of label strings.
802
- """
803
- if fundamental_freq is not None:
804
- labels = [
805
- f"H{i + 1}\n({(i + 1) * fundamental_freq / 1e3:.1f} kHz)"
806
- if fundamental_freq >= 1000
807
- else f"H{i + 1}\n({(i + 1) * fundamental_freq:.0f} Hz)"
808
- for i in range(n_harmonics)
809
- ]
810
- labels[0] = (
811
- f"Fund\n({fundamental_freq / 1e3:.1f} kHz)"
812
- if fundamental_freq >= 1000
813
- else f"Fund\n({fundamental_freq:.0f} Hz)"
814
- )
815
- else:
816
- labels = [f"H{i + 1}" for i in range(n_harmonics)]
817
- labels[0] = "Fund"
818
-
819
- return labels
820
-
821
-
822
- def _assign_harmonic_colors(
823
- harmonic_magnitudes: NDArray[np.floating[Any]],
824
- ) -> list[str]:
825
- """Assign colors to harmonics based on magnitude.
826
-
827
- Args:
828
- harmonic_magnitudes: Array of harmonic magnitudes in dB.
829
-
830
- Returns:
831
- List of color strings.
832
- """
833
- colors = []
834
- for i, mag in enumerate(harmonic_magnitudes):
835
- if i == 0:
836
- colors.append("#3498DB") # Blue for fundamental
837
- elif mag > -30:
838
- colors.append("#E74C3C") # Red for significant harmonics
839
- elif mag > -50:
840
- colors.append("#F39C12") # Orange for moderate
841
- else:
842
- colors.append("#95A5A6") # Gray for low
843
-
844
- return colors
845
-
846
-
847
- def _add_thd_annotation(
848
- ax: Axes,
849
- thd_value: float | None,
850
- show_thd: bool,
851
- ) -> None:
852
- """Add THD annotation to plot.
853
-
854
- Args:
855
- ax: Matplotlib axes to annotate.
856
- thd_value: THD value in dB or %.
857
- show_thd: Show annotation flag.
858
- """
859
- if show_thd and thd_value is not None:
860
- if thd_value > 0:
861
- thd_text = f"THD: {thd_value:.2f}%"
862
- else:
863
- thd_text = f"THD: {thd_value:.1f} dB"
864
-
865
- ax.text(
866
- 0.98,
867
- 0.98,
868
- thd_text,
869
- transform=ax.transAxes,
870
- fontsize=12,
871
- fontweight="bold",
872
- ha="right",
873
- va="top",
874
- bbox={"boxstyle": "round,pad=0.5", "facecolor": "wheat", "alpha": 0.9},
875
- )
876
-
877
-
878
- def plot_thd_bars(
879
- harmonic_magnitudes: NDArray[np.floating[Any]],
880
- *,
881
- fundamental_freq: float | None = None,
882
- ax: Axes | None = None,
883
- figsize: tuple[float, float] = (10, 6),
884
- title: str | None = None,
885
- thd_value: float | None = None,
886
- show_thd: bool = True,
887
- reference_db: float = 0.0,
888
- show: bool = True,
889
- save_path: str | Path | None = None,
890
- ) -> Figure:
891
- """Plot THD harmonic bar chart.
892
-
893
- Creates a bar chart showing harmonic content relative to the fundamental,
894
- useful for visualizing Total Harmonic Distortion analysis results.
895
-
896
- Args:
897
- harmonic_magnitudes: Array of harmonic magnitudes in dB (relative to fundamental).
898
- Index 0 = fundamental (0 dB), Index 1 = 2nd harmonic, etc.
899
- fundamental_freq: Fundamental frequency in Hz (for x-axis labels).
900
- ax: Matplotlib axes. If None, creates new figure.
901
- figsize: Figure size in inches.
902
- title: Plot title.
903
- thd_value: Pre-calculated THD value in dB or % to display.
904
- show_thd: Show THD annotation on plot.
905
- reference_db: Reference level for the fundamental (default 0 dB).
906
- show: Display plot interactively.
907
- save_path: Save plot to file.
908
-
909
- Returns:
910
- Matplotlib Figure object.
911
-
912
- Example:
913
- >>> # Harmonic magnitudes relative to fundamental (in dB)
914
- >>> harmonics = np.array([0, -40, -60, -55, -70, -65]) # Fund, H2, H3, H4, H5, H6
915
- >>> fig = plot_thd_bars(harmonics, fundamental_freq=1000, thd_value=-38.5)
916
-
917
- References:
918
- IEEE 1241-2010: ADC Testing Standards
919
- IEC 61000-4-7: Harmonics measurement
920
- """
921
- if not HAS_MATPLOTLIB:
922
- raise ImportError("matplotlib is required for visualization")
923
-
924
- # Figure/axes creation
925
- if ax is None:
926
- fig, ax = plt.subplots(figsize=figsize)
927
- else:
928
- fig_temp = ax.get_figure()
929
- if fig_temp is None:
930
- raise ValueError("Axes must have an associated figure")
931
- fig = cast("Figure", fig_temp)
932
-
933
- # Data preparation
934
- n_harmonics = len(harmonic_magnitudes)
935
- x_pos = np.arange(n_harmonics)
936
- labels = _create_harmonic_labels(n_harmonics, fundamental_freq)
937
- colors = _assign_harmonic_colors(harmonic_magnitudes)
938
-
939
- # Plotting/rendering
940
- ax.bar(
941
- x_pos, harmonic_magnitudes - reference_db, color=colors, edgecolor="black", linewidth=0.5
942
- )
943
- ax.axhline(0, color="gray", linestyle="--", linewidth=1, alpha=0.7)
944
-
945
- # Annotation/labeling
946
- _add_thd_annotation(ax, thd_value, show_thd)
947
- ax.set_xticks(x_pos)
948
- ax.set_xticklabels(labels, fontsize=9)
949
- ax.set_xlabel("Harmonic", fontsize=11)
950
- ax.set_ylabel("Magnitude (dB rel. to fundamental)", fontsize=11)
951
- ax.grid(True, axis="y", alpha=0.3)
952
-
953
- # Layout/formatting
954
- min_mag = min(harmonic_magnitudes) - reference_db
955
- ax.set_ylim(min(min_mag - 10, -80), 10)
956
-
957
- if title:
958
- ax.set_title(title, fontsize=12, fontweight="bold")
959
- else:
960
- ax.set_title("Harmonic Distortion Analysis", fontsize=12, fontweight="bold")
961
-
962
- fig.tight_layout()
963
-
964
- if save_path is not None:
965
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
966
-
967
- if show:
968
- plt.show()
969
-
970
- return fig
971
-
972
-
973
- def plot_quality_summary(
974
- metrics: dict[str, float],
975
- *,
976
- ax: Axes | None = None,
977
- figsize: tuple[float, float] = (10, 6),
978
- title: str | None = None,
979
- show_specs: dict[str, float] | None = None,
980
- show: bool = True,
981
- save_path: str | Path | None = None,
982
- ) -> Figure:
983
- """Plot ADC/signal quality summary with metrics.
984
-
985
- Creates a summary panel showing SNR, SINAD, THD, ENOB, and SFDR
986
- with optional pass/fail indication against specifications.
987
-
988
- Args:
989
- metrics: Dictionary with keys like "snr", "sinad", "thd", "enob", "sfdr".
990
- ax: Matplotlib axes.
991
- figsize: Figure size.
992
- title: Plot title.
993
- show_specs: Dictionary of specification values for pass/fail.
994
- show: Display plot.
995
- save_path: Save path.
996
-
997
- Returns:
998
- Matplotlib Figure object.
999
-
1000
- Example:
1001
- >>> metrics = {"snr": 72.5, "sinad": 70.2, "thd": -65.3, "enob": 11.2, "sfdr": 75.8}
1002
- >>> specs = {"snr": 70.0, "enob": 10.0}
1003
- >>> fig = plot_quality_summary(metrics, show_specs=specs)
1004
-
1005
- References:
1006
- IEEE 1241-2010: ADC Testing Standards
1007
- """
1008
- if not HAS_MATPLOTLIB:
1009
- raise ImportError("matplotlib is required for visualization")
1010
-
1011
- # Setup figure and axes
1012
- fig, ax = _setup_quality_plot_axes(ax, figsize)
1013
-
1014
- # Define metric metadata
1015
- metric_info = _get_metric_info()
1016
-
1017
- # Filter to available metrics
1018
- available_metrics = [(k, v) for k, v in metrics.items() if k in metric_info]
1019
-
1020
- if len(available_metrics) == 0:
1021
- ax.text(0.5, 0.5, "No metrics available", ha="center", va="center", fontsize=14)
1022
- ax.axis("off")
1023
- return fig
1024
-
1025
- # Plot metrics with pass/fail coloring
1026
- _plot_quality_bars(ax, available_metrics, metric_info, show_specs)
1027
-
1028
- # Add value labels and spec markers
1029
- _add_quality_labels(ax, available_metrics, metric_info)
1030
- _add_spec_markers(ax, available_metrics, show_specs)
1031
-
1032
- # Configure axes
1033
- _configure_quality_axes(ax, available_metrics, metric_info, title)
1034
-
1035
- fig.tight_layout()
1036
-
1037
- if save_path is not None:
1038
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
1039
-
1040
- if show:
1041
- plt.show()
1042
-
1043
- return fig
1044
-
1045
-
1046
- def _setup_quality_plot_axes(
1047
- ax: Axes | None,
1048
- figsize: tuple[float, float],
1049
- ) -> tuple[Figure, Axes]:
1050
- """Setup figure and axes for quality summary plot.
1051
-
1052
- Args:
1053
- ax: Existing axes or None.
1054
- figsize: Figure size if creating new figure.
1055
-
1056
- Returns:
1057
- Tuple of (Figure, Axes).
1058
-
1059
- Raises:
1060
- ValueError: If provided axes has no associated figure.
1061
- """
1062
- if ax is None:
1063
- fig, ax_obj = plt.subplots(figsize=figsize)
1064
- return fig, ax_obj
1065
- else:
1066
- fig_temp = ax.get_figure()
1067
- if fig_temp is None:
1068
- raise ValueError("Axes must have an associated figure")
1069
- return cast("Figure", fig_temp), ax
1070
-
1071
-
1072
- def _get_metric_info() -> dict[str, dict[str, Any]]:
1073
- """Get metric display information.
1074
-
1075
- Returns:
1076
- Dictionary mapping metric keys to display info (name, unit, higher_better).
1077
- """
1078
- return {
1079
- "snr": {"name": "SNR", "unit": "dB", "higher_better": True},
1080
- "sinad": {"name": "SINAD", "unit": "dB", "higher_better": True},
1081
- "thd": {"name": "THD", "unit": "dB", "higher_better": False},
1082
- "enob": {"name": "ENOB", "unit": "bits", "higher_better": True},
1083
- "sfdr": {"name": "SFDR", "unit": "dBc", "higher_better": True},
1084
- }
1085
-
1086
-
1087
- def _plot_quality_bars(
1088
- ax: Axes,
1089
- available_metrics: list[tuple[str, float]],
1090
- metric_info: dict[str, dict[str, Any]],
1091
- show_specs: dict[str, float] | None,
1092
- ) -> None:
1093
- """Plot horizontal bars for quality metrics.
1094
-
1095
- Args:
1096
- ax: Matplotlib axes.
1097
- available_metrics: List of (key, value) tuples for available metrics.
1098
- metric_info: Metric display information.
1099
- show_specs: Specification values for pass/fail coloring.
1100
- """
1101
- n_metrics = len(available_metrics)
1102
- y_pos = np.arange(n_metrics)
1103
- values = [v for _, v in available_metrics]
1104
-
1105
- # Determine colors based on pass/fail
1106
- colors = _determine_bar_colors(available_metrics, metric_info, show_specs)
1107
-
1108
- # Plot horizontal bars
1109
- ax.barh(y_pos, values, color=colors, edgecolor="black", linewidth=0.5)
1110
-
1111
-
1112
- def _determine_bar_colors(
1113
- available_metrics: list[tuple[str, float]],
1114
- metric_info: dict[str, dict[str, Any]],
1115
- show_specs: dict[str, float] | None,
1116
- ) -> list[str]:
1117
- """Determine bar colors based on pass/fail status.
1118
-
1119
- Args:
1120
- available_metrics: List of (key, value) tuples.
1121
- metric_info: Metric display information.
1122
- show_specs: Specification values.
1123
-
1124
- Returns:
1125
- List of color strings (hex codes).
1126
- """
1127
- colors = []
1128
- for key, value in available_metrics:
1129
- if show_specs and key in show_specs:
1130
- spec = show_specs[key]
1131
- info = metric_info[key]
1132
- if info["higher_better"]:
1133
- passed = value >= spec
1134
- else:
1135
- # For THD, more negative is better
1136
- passed = value <= spec
1137
- colors.append("#27AE60" if passed else "#E74C3C")
1138
- else:
1139
- colors.append("#3498DB")
1140
- return colors
1141
-
1142
-
1143
- def _add_quality_labels(
1144
- ax: Axes,
1145
- available_metrics: list[tuple[str, float]],
1146
- metric_info: dict[str, dict[str, Any]],
1147
- ) -> None:
1148
- """Add value labels to quality metric bars.
1149
-
1150
- Args:
1151
- ax: Matplotlib axes.
1152
- available_metrics: List of (key, value) tuples.
1153
- metric_info: Metric display information.
1154
- """
1155
- for i, (key, value) in enumerate(available_metrics):
1156
- unit = metric_info[key]["unit"]
1157
- label_text = f"{value:.1f} {unit}"
1158
- ax.text(
1159
- value + 2 if value >= 0 else value - 2,
1160
- i,
1161
- label_text,
1162
- va="center",
1163
- ha="left" if value >= 0 else "right",
1164
- fontsize=10,
1165
- fontweight="bold",
1166
- )
1167
-
1168
-
1169
- def _add_spec_markers(
1170
- ax: Axes,
1171
- available_metrics: list[tuple[str, float]],
1172
- show_specs: dict[str, float] | None,
1173
- ) -> None:
1174
- """Add specification markers to quality plot.
1175
-
1176
- Args:
1177
- ax: Matplotlib axes.
1178
- available_metrics: List of (key, value) tuples.
1179
- show_specs: Specification values.
1180
- """
1181
- if not show_specs:
1182
- return
1183
-
1184
- for i, (key, _) in enumerate(available_metrics):
1185
- if key in show_specs:
1186
- spec = show_specs[key]
1187
- ax.plot(spec, i, "k|", markersize=20, markeredgewidth=2)
1188
- ax.text(spec, i + 0.3, f"Spec: {spec}", fontsize=8, ha="center")
1189
-
1190
-
1191
- def _configure_quality_axes(
1192
- ax: Axes,
1193
- available_metrics: list[tuple[str, float]],
1194
- metric_info: dict[str, dict[str, Any]],
1195
- title: str | None,
1196
- ) -> None:
1197
- """Configure axes for quality summary plot.
1198
-
1199
- Args:
1200
- ax: Matplotlib axes.
1201
- available_metrics: List of (key, value) tuples.
1202
- metric_info: Metric display information.
1203
- title: Plot title.
1204
- """
1205
- n_metrics = len(available_metrics)
1206
- y_pos = np.arange(n_metrics)
1207
- names = [metric_info[k]["name"] for k, _ in available_metrics]
1208
-
1209
- ax.set_yticks(y_pos)
1210
- ax.set_yticklabels([str(name) for name in names], fontsize=11)
1211
- ax.set_xlabel("Value", fontsize=11)
1212
- ax.grid(True, axis="x", alpha=0.3)
1213
- ax.invert_yaxis()
1214
-
1215
- if title:
1216
- ax.set_title(title, fontsize=12, fontweight="bold")
1217
- else:
1218
- ax.set_title("Signal Quality Summary (IEEE 1241-2010)", fontsize=12, fontweight="bold")
1219
-
1220
-
1221
- __all__ = [
1222
- "plot_fft",
1223
- "plot_psd",
1224
- "plot_spectrogram",
1225
- "plot_spectrum",
1226
- ]