oscura 0.8.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 (151) 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/patterns/__init__.py +66 -0
  7. oscura/analyzers/power/basic.py +3 -3
  8. oscura/analyzers/power/soa.py +1 -1
  9. oscura/analyzers/power/switching.py +3 -3
  10. oscura/analyzers/signal_classification.py +529 -0
  11. oscura/analyzers/signal_integrity/sparams.py +3 -3
  12. oscura/analyzers/statistics/basic.py +10 -7
  13. oscura/analyzers/validation.py +1 -1
  14. oscura/analyzers/waveform/measurements.py +200 -156
  15. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  16. oscura/analyzers/waveform/spectral.py +164 -73
  17. oscura/api/dsl/commands.py +15 -6
  18. oscura/api/server/templates/base.html +137 -146
  19. oscura/api/server/templates/export.html +84 -110
  20. oscura/api/server/templates/home.html +248 -267
  21. oscura/api/server/templates/protocols.html +44 -48
  22. oscura/api/server/templates/reports.html +27 -35
  23. oscura/api/server/templates/session_detail.html +68 -78
  24. oscura/api/server/templates/sessions.html +62 -72
  25. oscura/api/server/templates/waveforms.html +54 -64
  26. oscura/automotive/__init__.py +1 -1
  27. oscura/automotive/can/session.py +1 -1
  28. oscura/automotive/dbc/generator.py +638 -23
  29. oscura/automotive/uds/decoder.py +99 -6
  30. oscura/cli/analyze.py +8 -2
  31. oscura/cli/batch.py +36 -5
  32. oscura/cli/characterize.py +18 -4
  33. oscura/cli/export.py +47 -5
  34. oscura/cli/main.py +2 -0
  35. oscura/cli/onboarding/wizard.py +10 -6
  36. oscura/cli/pipeline.py +585 -0
  37. oscura/cli/visualize.py +6 -4
  38. oscura/convenience.py +400 -32
  39. oscura/core/measurement_result.py +286 -0
  40. oscura/core/progress.py +1 -1
  41. oscura/core/types.py +232 -239
  42. oscura/correlation/multi_protocol.py +1 -1
  43. oscura/export/legacy/__init__.py +11 -0
  44. oscura/export/legacy/wav.py +75 -0
  45. oscura/exporters/__init__.py +19 -0
  46. oscura/exporters/wireshark.py +809 -0
  47. oscura/hardware/acquisition/file.py +5 -19
  48. oscura/hardware/acquisition/saleae.py +10 -10
  49. oscura/hardware/acquisition/socketcan.py +4 -6
  50. oscura/hardware/acquisition/synthetic.py +1 -5
  51. oscura/hardware/acquisition/visa.py +6 -6
  52. oscura/hardware/security/side_channel_detector.py +5 -508
  53. oscura/inference/message_format.py +686 -1
  54. oscura/jupyter/display.py +2 -2
  55. oscura/jupyter/magic.py +3 -3
  56. oscura/loaders/__init__.py +17 -12
  57. oscura/loaders/binary.py +1 -1
  58. oscura/loaders/chipwhisperer.py +1 -2
  59. oscura/loaders/configurable.py +1 -1
  60. oscura/loaders/csv_loader.py +2 -2
  61. oscura/loaders/hdf5_loader.py +1 -1
  62. oscura/loaders/lazy.py +6 -1
  63. oscura/loaders/mmap_loader.py +0 -1
  64. oscura/loaders/numpy_loader.py +8 -7
  65. oscura/loaders/preprocessing.py +3 -5
  66. oscura/loaders/rigol.py +21 -7
  67. oscura/loaders/sigrok.py +2 -5
  68. oscura/loaders/tdms.py +3 -2
  69. oscura/loaders/tektronix.py +38 -32
  70. oscura/loaders/tss.py +20 -27
  71. oscura/loaders/vcd.py +13 -8
  72. oscura/loaders/wav.py +1 -6
  73. oscura/pipeline/__init__.py +76 -0
  74. oscura/pipeline/handlers/__init__.py +165 -0
  75. oscura/pipeline/handlers/analyzers.py +1045 -0
  76. oscura/pipeline/handlers/decoders.py +899 -0
  77. oscura/pipeline/handlers/exporters.py +1103 -0
  78. oscura/pipeline/handlers/filters.py +891 -0
  79. oscura/pipeline/handlers/loaders.py +640 -0
  80. oscura/pipeline/handlers/transforms.py +768 -0
  81. oscura/reporting/formatting/measurements.py +55 -14
  82. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  83. oscura/side_channel/__init__.py +38 -57
  84. oscura/utils/builders/signal_builder.py +5 -5
  85. oscura/utils/comparison/compare.py +7 -9
  86. oscura/utils/comparison/golden.py +1 -1
  87. oscura/utils/filtering/convenience.py +2 -2
  88. oscura/utils/math/arithmetic.py +38 -62
  89. oscura/utils/math/interpolation.py +20 -20
  90. oscura/utils/pipeline/__init__.py +4 -17
  91. oscura/utils/progressive.py +1 -4
  92. oscura/utils/triggering/edge.py +1 -1
  93. oscura/utils/triggering/pattern.py +2 -2
  94. oscura/utils/triggering/pulse.py +2 -2
  95. oscura/utils/triggering/window.py +3 -3
  96. oscura/validation/hil_testing.py +11 -11
  97. oscura/visualization/__init__.py +46 -284
  98. oscura/visualization/batch.py +72 -433
  99. oscura/visualization/plot.py +542 -53
  100. oscura/visualization/styles.py +184 -318
  101. oscura/workflows/batch/advanced.py +1 -1
  102. oscura/workflows/batch/aggregate.py +7 -8
  103. oscura/workflows/complete_re.py +251 -23
  104. oscura/workflows/digital.py +27 -4
  105. oscura/workflows/multi_trace.py +136 -17
  106. oscura/workflows/waveform.py +11 -6
  107. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  108. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/RECORD +111 -136
  109. oscura/side_channel/dpa.py +0 -1025
  110. oscura/utils/optimization/__init__.py +0 -19
  111. oscura/utils/optimization/parallel.py +0 -443
  112. oscura/utils/optimization/search.py +0 -532
  113. oscura/utils/pipeline/base.py +0 -338
  114. oscura/utils/pipeline/composition.py +0 -248
  115. oscura/utils/pipeline/parallel.py +0 -449
  116. oscura/utils/pipeline/pipeline.py +0 -375
  117. oscura/utils/search/__init__.py +0 -16
  118. oscura/utils/search/anomaly.py +0 -424
  119. oscura/utils/search/context.py +0 -294
  120. oscura/utils/search/pattern.py +0 -288
  121. oscura/utils/storage/__init__.py +0 -61
  122. oscura/utils/storage/database.py +0 -1166
  123. oscura/visualization/accessibility.py +0 -526
  124. oscura/visualization/annotations.py +0 -371
  125. oscura/visualization/axis_scaling.py +0 -305
  126. oscura/visualization/colors.py +0 -451
  127. oscura/visualization/digital.py +0 -436
  128. oscura/visualization/eye.py +0 -571
  129. oscura/visualization/histogram.py +0 -281
  130. oscura/visualization/interactive.py +0 -1035
  131. oscura/visualization/jitter.py +0 -1042
  132. oscura/visualization/keyboard.py +0 -394
  133. oscura/visualization/layout.py +0 -400
  134. oscura/visualization/optimization.py +0 -1079
  135. oscura/visualization/palettes.py +0 -446
  136. oscura/visualization/power.py +0 -508
  137. oscura/visualization/power_extended.py +0 -955
  138. oscura/visualization/presets.py +0 -469
  139. oscura/visualization/protocols.py +0 -1246
  140. oscura/visualization/render.py +0 -223
  141. oscura/visualization/rendering.py +0 -444
  142. oscura/visualization/reverse_engineering.py +0 -838
  143. oscura/visualization/signal_integrity.py +0 -989
  144. oscura/visualization/specialized.py +0 -643
  145. oscura/visualization/spectral.py +0 -1226
  146. oscura/visualization/thumbnails.py +0 -340
  147. oscura/visualization/time_axis.py +0 -351
  148. oscura/visualization/waveform.py +0 -454
  149. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  150. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  151. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,989 +0,0 @@
1
- """Signal Integrity Visualization Functions.
2
-
3
- This module provides visualization functions for signal integrity analysis
4
- including TDR impedance plots, S-parameter displays, setup/hold timing
5
- diagrams, and eye diagram enhancements.
6
-
7
- Example:
8
- >>> from oscura.visualization.signal_integrity import plot_tdr, plot_sparams
9
- >>> fig = plot_tdr(impedance_profile, distance_axis)
10
- >>> fig = plot_sparams(frequencies, s11, s21)
11
-
12
- References:
13
- - IEEE 370-2020: Electrical Characterization of Printed Circuit Board
14
- - TDR impedance measurement best practices
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- from pathlib import Path
20
- from typing import TYPE_CHECKING, Any, cast
21
-
22
- import numpy as np
23
-
24
- try:
25
- import matplotlib.pyplot as plt
26
-
27
- HAS_MATPLOTLIB = True
28
- except ImportError:
29
- HAS_MATPLOTLIB = False
30
-
31
- if TYPE_CHECKING:
32
- from matplotlib.axes import Axes
33
- from matplotlib.figure import Figure
34
- from numpy.typing import NDArray
35
-
36
- __all__ = [
37
- "plot_setup_hold_timing",
38
- "plot_sparams_magnitude",
39
- "plot_sparams_phase",
40
- "plot_tdr",
41
- "plot_timing_margin",
42
- ]
43
-
44
-
45
- def plot_tdr(
46
- impedance: NDArray[np.floating[Any]],
47
- distance: NDArray[np.floating[Any]],
48
- *,
49
- z0: float = 50.0,
50
- ax: Axes | None = None,
51
- figsize: tuple[float, float] = (12, 6),
52
- title: str | None = None,
53
- distance_unit: str = "auto",
54
- show_reference: bool = True,
55
- show_discontinuities: bool = True,
56
- discontinuity_threshold: float = 5.0,
57
- show: bool = True,
58
- save_path: str | Path | None = None,
59
- ) -> Figure:
60
- """Plot TDR impedance profile vs distance.
61
-
62
- Creates a Time Domain Reflectometry impedance plot showing impedance
63
- as a function of distance along a transmission line, with annotations
64
- for discontinuities and reference impedance.
65
-
66
- Args:
67
- impedance: Impedance values in Ohms.
68
- distance: Distance values (in meters).
69
- z0: Reference impedance (Ohms) for the reference line.
70
- ax: Matplotlib axes. If None, creates new figure.
71
- figsize: Figure size in inches (only if ax is None).
72
- title: Plot title.
73
- distance_unit: Distance unit ("m", "cm", "mm", "auto").
74
- show_reference: Show reference impedance line at z0.
75
- show_discontinuities: Annotate significant discontinuities.
76
- discontinuity_threshold: Impedance change threshold (Ohms) for marking.
77
- show: Display plot interactively.
78
- save_path: Save plot to file.
79
-
80
- Returns:
81
- Matplotlib Figure object.
82
-
83
- Raises:
84
- ImportError: If matplotlib is not available.
85
- ValueError: If input arrays have different lengths.
86
-
87
- Example:
88
- >>> z_profile = np.array([50, 50, 75, 75, 50, 50])
89
- >>> dist = np.linspace(0, 0.5, 6) # 0 to 50 cm
90
- >>> fig = plot_tdr(z_profile, dist, z0=50, show=False)
91
- >>> fig.savefig("tdr_impedance.png")
92
- """
93
- _validate_tdr_inputs(impedance, distance)
94
- fig, ax = _setup_tdr_figure(ax, figsize)
95
-
96
- distance_unit_final, dist_scaled = _scale_tdr_distance(distance, distance_unit)
97
- impedance_display = np.clip(impedance, 0, 500)
98
-
99
- _plot_tdr_impedance_profile(ax, dist_scaled, impedance_display)
100
- _fill_tdr_impedance_regions(ax, dist_scaled, impedance_display, z0, discontinuity_threshold)
101
-
102
- if show_reference:
103
- _add_tdr_reference_line(ax, z0)
104
-
105
- if show_discontinuities:
106
- _annotate_tdr_discontinuities(ax, dist_scaled, impedance_display, discontinuity_threshold)
107
-
108
- _format_tdr_axes(ax, dist_scaled, impedance_display, distance_unit_final, title)
109
- _finalize_tdr_plot(fig, save_path, show)
110
-
111
- return fig
112
-
113
-
114
- def _validate_tdr_inputs(
115
- impedance: NDArray[np.floating[Any]], distance: NDArray[np.floating[Any]]
116
- ) -> None:
117
- """Validate TDR input arrays."""
118
- if not HAS_MATPLOTLIB:
119
- raise ImportError("matplotlib is required for visualization")
120
-
121
- if len(impedance) != len(distance):
122
- raise ValueError(
123
- f"impedance and distance must have same length "
124
- f"(got {len(impedance)} and {len(distance)})"
125
- )
126
-
127
-
128
- def _setup_tdr_figure(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
129
- """Setup TDR figure and axes."""
130
- if ax is None:
131
- fig, ax_new = plt.subplots(figsize=figsize)
132
- return fig, ax_new
133
-
134
- fig_temp = ax.get_figure()
135
- if fig_temp is None:
136
- raise ValueError("Axes must have an associated figure")
137
- return cast("Figure", fig_temp), ax
138
-
139
-
140
- def _scale_tdr_distance(
141
- distance: NDArray[np.floating[Any]], distance_unit: str
142
- ) -> tuple[str, NDArray[np.floating[Any]]]:
143
- """Scale distance to appropriate unit."""
144
- if distance_unit == "auto":
145
- max_dist = np.max(distance)
146
- if max_dist < 0.01:
147
- distance_unit = "mm"
148
- distance_mult = 1000.0
149
- elif max_dist < 1.0:
150
- distance_unit = "cm"
151
- distance_mult = 100.0
152
- else:
153
- distance_unit = "m"
154
- distance_mult = 1.0
155
- else:
156
- distance_mult = {"m": 1.0, "cm": 100.0, "mm": 1000.0}.get(distance_unit, 1.0)
157
-
158
- dist_scaled = distance * distance_mult
159
- return distance_unit, dist_scaled
160
-
161
-
162
- def _plot_tdr_impedance_profile(
163
- ax: Axes,
164
- dist_scaled: NDArray[np.floating[Any]],
165
- impedance_display: NDArray[np.floating[Any]],
166
- ) -> None:
167
- """Plot main impedance profile line."""
168
- ax.plot(dist_scaled, impedance_display, "b-", linewidth=2, label="Impedance")
169
-
170
-
171
- def _fill_tdr_impedance_regions(
172
- ax: Axes,
173
- dist_scaled: NDArray[np.floating[Any]],
174
- impedance_display: NDArray[np.floating[Any]],
175
- z0: float,
176
- discontinuity_threshold: float,
177
- ) -> None:
178
- """Fill colored regions based on impedance deviation."""
179
- for i in range(len(dist_scaled) - 1):
180
- z = impedance_display[i]
181
- color, alpha = _get_tdr_region_color(z, z0, discontinuity_threshold)
182
-
183
- ax.fill_between(
184
- [dist_scaled[i], dist_scaled[i + 1]],
185
- [z0, z0],
186
- [z, impedance_display[i + 1]],
187
- color=color,
188
- alpha=alpha,
189
- )
190
-
191
-
192
- def _get_tdr_region_color(z: float, z0: float, threshold: float) -> tuple[str, float]:
193
- """Get color and alpha for impedance region."""
194
- if z > z0 + threshold:
195
- return "#FFA500", 0.3 # Orange for high-Z
196
- elif z < z0 - threshold:
197
- return "#1E90FF", 0.3 # Blue for low-Z
198
- else:
199
- return "#90EE90", 0.2 # Light green for matched
200
-
201
-
202
- def _add_tdr_reference_line(ax: Axes, z0: float) -> None:
203
- """Add reference impedance line."""
204
- ax.axhline(z0, color="gray", linestyle="--", linewidth=1.5, label=f"Z0 = {z0} Ω")
205
-
206
-
207
- def _annotate_tdr_discontinuities(
208
- ax: Axes,
209
- dist_scaled: NDArray[np.floating[Any]],
210
- impedance_display: NDArray[np.floating[Any]],
211
- discontinuity_threshold: float,
212
- ) -> None:
213
- """Find and annotate impedance discontinuities."""
214
- z_diff = np.abs(np.diff(impedance_display))
215
- discontinuities = np.where(z_diff > discontinuity_threshold)[0]
216
-
217
- for idx in discontinuities:
218
- z_before = impedance_display[idx]
219
- z_after = impedance_display[idx + 1]
220
- d = dist_scaled[idx]
221
-
222
- disc_type, color = _classify_tdr_discontinuity(z_before, z_after, discontinuity_threshold)
223
- if disc_type is None:
224
- continue
225
-
226
- _add_tdr_discontinuity_marker(ax, d, z_after, disc_type, color)
227
-
228
-
229
- def _classify_tdr_discontinuity(
230
- z_before: float, z_after: float, threshold: float
231
- ) -> tuple[str | None, str]:
232
- """Classify discontinuity type."""
233
- if z_after > z_before + threshold:
234
- return "High-Z", "orange"
235
- elif z_after < z_before - threshold:
236
- return "Low-Z", "blue"
237
- return None, ""
238
-
239
-
240
- def _add_tdr_discontinuity_marker(
241
- ax: Axes, d: float, z_after: float, disc_type: str, color: str
242
- ) -> None:
243
- """Add marker and annotation for discontinuity."""
244
- ax.plot(d, z_after, "o", color=color, markersize=8)
245
-
246
- z_str = f"{z_after:.0f}" if z_after < 500 else "Open"
247
- ax.annotate(
248
- f"{disc_type}\n{z_str} Ω",
249
- xy=(d, z_after),
250
- xytext=(10, 10),
251
- textcoords="offset points",
252
- fontsize=8,
253
- ha="left",
254
- bbox={"boxstyle": "round,pad=0.3", "facecolor": "white", "alpha": 0.8},
255
- )
256
-
257
-
258
- def _format_tdr_axes(
259
- ax: Axes,
260
- dist_scaled: NDArray[np.floating[Any]],
261
- impedance_display: NDArray[np.floating[Any]],
262
- distance_unit: str,
263
- title: str | None,
264
- ) -> None:
265
- """Format axes labels, limits, and title."""
266
- ax.set_xlabel(f"Distance ({distance_unit})", fontsize=11)
267
- ax.set_ylabel("Impedance (Ω)", fontsize=11)
268
- ax.set_xlim(0, dist_scaled[-1])
269
-
270
- y_min = max(0, np.min(impedance_display) - 10)
271
- y_max = min(200, np.max(impedance_display) + 10)
272
- ax.set_ylim(y_min, y_max)
273
-
274
- ax.grid(True, alpha=0.3)
275
- ax.legend(loc="upper right")
276
-
277
- title_text = title if title else "TDR Impedance Profile"
278
- ax.set_title(title_text, fontsize=12, fontweight="bold")
279
-
280
-
281
- def _finalize_tdr_plot(fig: Figure, save_path: str | Path | None, show: bool) -> None:
282
- """Finalize plot layout, save, and show."""
283
- fig.tight_layout()
284
-
285
- if save_path is not None:
286
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
287
-
288
- if show:
289
- plt.show()
290
-
291
-
292
- def _select_sparams_freq_unit(
293
- frequencies: NDArray[np.floating[Any]], freq_unit: str
294
- ) -> tuple[str, float]:
295
- """Select frequency unit and divisor for S-parameter plots."""
296
- if freq_unit == "auto":
297
- max_freq = np.max(frequencies)
298
- if max_freq >= 1e9:
299
- return "GHz", 1e9
300
- elif max_freq >= 1e6:
301
- return "MHz", 1e6
302
- elif max_freq >= 1e3:
303
- return "kHz", 1e3
304
- else:
305
- return "Hz", 1.0
306
- else:
307
- freq_div = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}.get(freq_unit, 1.0)
308
- return freq_unit, freq_div
309
-
310
-
311
- def _convert_sparam_to_db(s: NDArray[Any]) -> NDArray[np.floating[Any]]:
312
- """Convert S-parameter to dB."""
313
- if np.iscomplexobj(s):
314
- result: NDArray[np.floating[Any]] = 20 * np.log10(np.abs(s) + 1e-12)
315
- return result
316
- return np.asarray(s, dtype=np.float64)
317
-
318
-
319
- def _add_3db_marker(
320
- ax: Axes,
321
- freq_scaled: NDArray[np.floating[Any]],
322
- s_db: NDArray[np.floating[Any]],
323
- freq_unit: str,
324
- ) -> None:
325
- """Add -3dB bandwidth marker to S21 plot."""
326
- max_db = np.max(s_db)
327
- db_3_level = max_db - 3
328
-
329
- crossings = np.where(np.diff(np.sign(s_db - db_3_level)))[0]
330
- if len(crossings) > 0:
331
- f_3db = float(freq_scaled[crossings[0]])
332
- db_3_level_float = float(db_3_level)
333
- ax.axhline(db_3_level_float, color="gray", linestyle=":", alpha=0.7, linewidth=1)
334
- ax.axvline(f_3db, color="gray", linestyle=":", alpha=0.7, linewidth=1)
335
- ax.plot(f_3db, db_3_level_float, "ko", markersize=6)
336
- ax.annotate(
337
- f"-3dB: {f_3db:.2f} {freq_unit}",
338
- xy=(f_3db, db_3_level_float),
339
- xytext=(10, -15),
340
- textcoords="offset points",
341
- fontsize=9,
342
- ha="left",
343
- )
344
-
345
-
346
- def plot_sparams_magnitude(
347
- frequencies: NDArray[np.floating[Any]],
348
- s11: NDArray[np.complexfloating[Any, Any]] | NDArray[np.floating[Any]] | None = None,
349
- s21: NDArray[np.complexfloating[Any, Any]] | NDArray[np.floating[Any]] | None = None,
350
- s12: NDArray[np.complexfloating[Any, Any]] | NDArray[np.floating[Any]] | None = None,
351
- s22: NDArray[np.complexfloating[Any, Any]] | NDArray[np.floating[Any]] | None = None,
352
- *,
353
- ax: Axes | None = None,
354
- figsize: tuple[float, float] = (12, 6),
355
- title: str | None = None,
356
- freq_unit: str = "auto",
357
- show_markers: bool = True,
358
- db_3_marker: bool = True,
359
- show: bool = True,
360
- save_path: str | Path | None = None,
361
- ) -> Figure:
362
- """Plot S-parameter magnitude vs frequency.
363
-
364
- Creates a frequency response plot showing S-parameter magnitudes
365
- in dB with optional -3dB marker for bandwidth measurement.
366
-
367
- Args:
368
- frequencies: Frequency array in Hz.
369
- s11: S11 (input reflection) - complex or dB values.
370
- s21: S21 (forward transmission) - complex or dB values.
371
- s12: S12 (reverse transmission) - complex or dB values.
372
- s22: S22 (output reflection) - complex or dB values.
373
- ax: Matplotlib axes. If None, creates new figure.
374
- figsize: Figure size in inches.
375
- title: Plot title.
376
- freq_unit: Frequency unit ("Hz", "kHz", "MHz", "GHz", "auto").
377
- show_markers: Show markers at key frequencies.
378
- db_3_marker: Show -3dB bandwidth marker for S21.
379
- show: Display plot interactively.
380
- save_path: Save plot to file.
381
-
382
- Returns:
383
- Matplotlib Figure object.
384
-
385
- Example:
386
- >>> freq = np.linspace(1e6, 1e9, 1000)
387
- >>> s21 = 1 / (1 + 1j * freq / 100e6) # Low-pass response
388
- >>> fig = plot_sparams_magnitude(freq, s21=s21)
389
- """
390
- if not HAS_MATPLOTLIB:
391
- raise ImportError("matplotlib is required for visualization")
392
-
393
- fig, ax = _setup_tdr_figure(ax, figsize)
394
- freq_unit, freq_div = _select_sparams_freq_unit(frequencies, freq_unit)
395
- freq_scaled = frequencies / freq_div
396
-
397
- colors = {"S11": "#E74C3C", "S21": "#3498DB", "S12": "#2ECC71", "S22": "#9B59B6"}
398
- linestyles = {"S11": "-", "S21": "-", "S12": "--", "S22": "--"}
399
- params = [("S11", s11), ("S21", s21), ("S12", s12), ("S22", s22)]
400
-
401
- for name, s_param in params:
402
- if s_param is not None:
403
- s_db = _convert_sparam_to_db(s_param)
404
- ax.semilogx(
405
- freq_scaled,
406
- s_db,
407
- color=colors[name],
408
- linestyle=linestyles[name],
409
- linewidth=2,
410
- label=name,
411
- )
412
-
413
- if name == "S21" and db_3_marker:
414
- _add_3db_marker(ax, freq_scaled, s_db, freq_unit)
415
-
416
- ax.set_xlabel(f"Frequency ({freq_unit})", fontsize=11)
417
- ax.set_ylabel("Magnitude (dB)", fontsize=11)
418
- ax.grid(True, which="both", alpha=0.3)
419
- ax.legend(loc="best")
420
- ax.set_title(
421
- title if title else "S-Parameter Magnitude Response", fontsize=12, fontweight="bold"
422
- )
423
-
424
- fig.tight_layout()
425
-
426
- if save_path is not None:
427
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
428
- if show:
429
- plt.show()
430
-
431
- return fig
432
-
433
-
434
- def plot_sparams_phase(
435
- frequencies: NDArray[np.floating[Any]],
436
- s11: NDArray[np.complexfloating[Any, Any]] | None = None,
437
- s21: NDArray[np.complexfloating[Any, Any]] | None = None,
438
- *,
439
- ax: Axes | None = None,
440
- figsize: tuple[float, float] = (12, 6),
441
- title: str | None = None,
442
- freq_unit: str = "auto",
443
- unwrap: bool = True,
444
- show: bool = True,
445
- save_path: str | Path | None = None,
446
- ) -> Figure:
447
- """Plot S-parameter phase vs frequency.
448
-
449
- Args:
450
- frequencies: Frequency array in Hz.
451
- s11: S11 complex values.
452
- s21: S21 complex values.
453
- ax: Matplotlib axes.
454
- figsize: Figure size.
455
- title: Plot title.
456
- freq_unit: Frequency unit.
457
- unwrap: Unwrap phase discontinuities.
458
- show: Display plot.
459
- save_path: Save path.
460
-
461
- Returns:
462
- Matplotlib Figure object.
463
- """
464
- if not HAS_MATPLOTLIB:
465
- raise ImportError("matplotlib is required for visualization")
466
-
467
- if ax is None:
468
- fig, ax = plt.subplots(figsize=figsize)
469
- else:
470
- fig_temp = ax.get_figure()
471
- if fig_temp is None:
472
- raise ValueError("Axes must have an associated figure")
473
- fig = cast("Figure", fig_temp)
474
-
475
- # Select frequency unit
476
- if freq_unit == "auto":
477
- max_freq = np.max(frequencies)
478
- if max_freq >= 1e9:
479
- freq_unit = "GHz"
480
- freq_div = 1e9
481
- elif max_freq >= 1e6:
482
- freq_unit = "MHz"
483
- freq_div = 1e6
484
- else:
485
- freq_unit = "kHz"
486
- freq_div = 1e3
487
- else:
488
- freq_div = {"Hz": 1.0, "kHz": 1e3, "MHz": 1e6, "GHz": 1e9}.get(freq_unit, 1.0)
489
-
490
- freq_scaled = frequencies / freq_div
491
-
492
- colors = {"S11": "#E74C3C", "S21": "#3498DB"}
493
-
494
- for name, s_param in [("S11", s11), ("S21", s21)]:
495
- if s_param is not None:
496
- phase = np.angle(s_param, deg=True)
497
- if unwrap:
498
- phase = np.rad2deg(np.unwrap(np.deg2rad(phase)))
499
-
500
- ax.semilogx(freq_scaled, phase, color=colors[name], linewidth=2, label=name)
501
-
502
- ax.set_xlabel(f"Frequency ({freq_unit})", fontsize=11)
503
- ax.set_ylabel("Phase (degrees)", fontsize=11)
504
- ax.grid(True, which="both", alpha=0.3)
505
- ax.legend(loc="best")
506
-
507
- if title:
508
- ax.set_title(title, fontsize=12, fontweight="bold")
509
- else:
510
- ax.set_title("S-Parameter Phase Response", fontsize=12, fontweight="bold")
511
-
512
- fig.tight_layout()
513
-
514
- if save_path is not None:
515
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
516
-
517
- if show:
518
- plt.show()
519
-
520
- return fig
521
-
522
-
523
- def plot_setup_hold_timing(
524
- clock_edges: NDArray[np.floating[Any]],
525
- data_edges: NDArray[np.floating[Any]],
526
- setup_time: float,
527
- hold_time: float,
528
- *,
529
- clock_data: NDArray[np.floating[Any]] | None = None,
530
- data_data: NDArray[np.floating[Any]] | None = None,
531
- time_axis: NDArray[np.floating[Any]] | None = None,
532
- ax: Axes | None = None,
533
- figsize: tuple[float, float] = (14, 8),
534
- title: str | None = None,
535
- time_unit: str = "auto",
536
- show_margins: bool = True,
537
- setup_spec: float | None = None,
538
- hold_spec: float | None = None,
539
- show: bool = True,
540
- save_path: str | Path | None = None,
541
- ) -> Figure:
542
- """Plot setup/hold timing diagram with annotations.
543
-
544
- Creates a timing diagram showing clock and data relationships
545
- with setup and hold time annotations and optional pass/fail
546
- indication against specifications.
547
-
548
- Args:
549
- clock_edges: Array of clock edge times (rising edges).
550
- data_edges: Array of data transition times.
551
- setup_time: Measured setup time (seconds).
552
- hold_time: Measured hold time (seconds).
553
- clock_data: Optional clock waveform for display.
554
- data_data: Optional data waveform for display.
555
- time_axis: Time axis for waveforms.
556
- ax: Matplotlib axes.
557
- figsize: Figure size.
558
- title: Plot title.
559
- time_unit: Time unit ("s", "ms", "us", "ns", "ps", "auto").
560
- show_margins: Show setup/hold timing arrows.
561
- setup_spec: Setup time specification for pass/fail.
562
- hold_spec: Hold time specification for pass/fail.
563
- show: Display plot.
564
- save_path: Save path.
565
-
566
- Returns:
567
- Matplotlib Figure object.
568
-
569
- Example:
570
- >>> clk_edges = np.array([0, 10e-9, 20e-9])
571
- >>> data_edges = np.array([8e-9, 18e-9])
572
- >>> fig = plot_setup_hold_timing(
573
- ... clk_edges, data_edges,
574
- ... setup_time=2e-9, hold_time=1e-9,
575
- ... setup_spec=1e-9, hold_spec=0.5e-9
576
- ... )
577
- """
578
- if not HAS_MATPLOTLIB:
579
- raise ImportError("matplotlib is required for visualization")
580
-
581
- # Create figure and axes
582
- fig, axes = _create_timing_figure(ax, clock_data, figsize)
583
-
584
- # Determine time scaling
585
- time_unit_final, time_mult = _select_time_unit(time_unit, clock_edges, data_edges)
586
- setup_scaled = setup_time * time_mult
587
- hold_scaled = hold_time * time_mult
588
-
589
- # Plot waveforms if provided
590
- ax_timing = _plot_timing_waveforms(axes, clock_data, data_data, time_axis, time_mult)
591
-
592
- # Setup timing annotation panel
593
- _setup_timing_panel(ax_timing, clock_edges, data_edges, time_mult)
594
-
595
- # Draw timing arrows
596
- if show_margins:
597
- _draw_timing_arrows(
598
- ax_timing,
599
- clock_edges,
600
- data_edges,
601
- setup_scaled,
602
- hold_scaled,
603
- time_mult,
604
- time_unit_final,
605
- )
606
-
607
- # Add pass/fail status
608
- _add_passfail_status(
609
- ax_timing, setup_time, hold_time, setup_spec, hold_spec, time_mult, time_unit_final
610
- )
611
-
612
- # Finalize plot
613
- axes[-1].set_xlabel(f"Time ({time_unit_final})", fontsize=11)
614
- fig.suptitle(title if title else "Setup/Hold Timing Analysis", fontsize=14, fontweight="bold")
615
- fig.tight_layout()
616
-
617
- if save_path is not None:
618
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
619
- if show:
620
- plt.show()
621
-
622
- return fig
623
-
624
-
625
- def _create_timing_figure(
626
- ax: Axes | None,
627
- clock_data: NDArray[np.floating[Any]] | None,
628
- figsize: tuple[float, float],
629
- ) -> tuple[Figure, list[Any]]:
630
- """Create figure and axes for timing diagram.
631
-
632
- Args:
633
- ax: Existing axes or None.
634
- clock_data: Clock waveform data (determines row count).
635
- figsize: Figure size.
636
-
637
- Returns:
638
- Tuple of (figure, axes_list).
639
- """
640
- if ax is not None:
641
- fig_temp = ax.get_figure()
642
- if fig_temp is None:
643
- raise ValueError("Axes must have an associated figure")
644
- return cast("Figure", fig_temp), [ax]
645
-
646
- n_rows = 3 if clock_data is not None else 1
647
- fig, axes = plt.subplots(
648
- n_rows, 1, figsize=figsize, sharex=True, gridspec_kw={"height_ratios": [1] * n_rows}
649
- )
650
- return fig, [axes] if n_rows == 1 else axes
651
-
652
-
653
- def _select_time_unit(
654
- time_unit: str,
655
- clock_edges: NDArray[np.floating[Any]],
656
- data_edges: NDArray[np.floating[Any]],
657
- ) -> tuple[str, float]:
658
- """Select appropriate time unit and multiplier.
659
-
660
- Args:
661
- time_unit: Requested unit or "auto".
662
- clock_edges: Clock edge times.
663
- data_edges: Data edge times.
664
-
665
- Returns:
666
- Tuple of (unit_string, multiplier).
667
- """
668
- if time_unit == "auto":
669
- max_time = max(np.max(clock_edges), np.max(data_edges))
670
- if max_time < 1e-9:
671
- return "ps", 1e12
672
- elif max_time < 1e-6:
673
- return "ns", 1e9
674
- elif max_time < 1e-3:
675
- return "us", 1e6
676
- else:
677
- return "ms", 1e3
678
- else:
679
- mult = {"s": 1, "ms": 1e3, "us": 1e6, "ns": 1e9, "ps": 1e12}.get(time_unit, 1e9)
680
- return time_unit, mult
681
-
682
-
683
- def _plot_timing_waveforms(
684
- axes: list[Any],
685
- clock_data: NDArray[np.floating[Any]] | None,
686
- data_data: NDArray[np.floating[Any]] | None,
687
- time_axis: NDArray[np.floating[Any]] | None,
688
- time_mult: float,
689
- ) -> Any:
690
- """Plot clock and data waveforms.
691
-
692
- Args:
693
- axes: List of axes.
694
- clock_data: Clock waveform.
695
- data_data: Data waveform.
696
- time_axis: Time axis.
697
- time_mult: Time multiplier.
698
-
699
- Returns:
700
- Axes for timing annotations.
701
- """
702
- if clock_data is not None and data_data is not None and time_axis is not None:
703
- time_scaled = time_axis * time_mult
704
-
705
- # Clock waveform
706
- ax_clk = axes[0]
707
- ax_clk.step(time_scaled, clock_data, where="post", color="#3498DB", linewidth=2)
708
- ax_clk.set_ylabel(
709
- "CLK", rotation=0, ha="right", va="center", fontsize=11, fontweight="bold"
710
- )
711
- ax_clk.set_ylim(-0.2, 1.3)
712
- ax_clk.set_yticks([0, 1])
713
- ax_clk.grid(True, axis="x", alpha=0.3)
714
-
715
- # Data waveform
716
- ax_data = axes[1]
717
- ax_data.step(time_scaled, data_data, where="post", color="#E74C3C", linewidth=2)
718
- ax_data.set_ylabel(
719
- "DATA", rotation=0, ha="right", va="center", fontsize=11, fontweight="bold"
720
- )
721
- ax_data.set_ylim(-0.2, 1.3)
722
- ax_data.set_yticks([0, 1])
723
- ax_data.grid(True, axis="x", alpha=0.3)
724
-
725
- return axes[2] if len(axes) > 2 else axes[-1]
726
- else:
727
- return axes[0]
728
-
729
-
730
- def _setup_timing_panel(
731
- ax: Any,
732
- clock_edges: NDArray[np.floating[Any]],
733
- data_edges: NDArray[np.floating[Any]],
734
- time_mult: float,
735
- ) -> None:
736
- """Setup timing annotation panel.
737
-
738
- Args:
739
- ax: Timing axes.
740
- clock_edges: Clock edge times.
741
- data_edges: Data edge times.
742
- time_mult: Time multiplier.
743
- """
744
- ax.set_ylim(0, 1)
745
- ax.set_xlim(0, max(clock_edges[-1], data_edges[-1]) * time_mult * 1.1)
746
- ax.axis("off")
747
-
748
-
749
- def _draw_timing_arrows(
750
- ax: Any,
751
- clock_edges: NDArray[np.floating[Any]],
752
- data_edges: NDArray[np.floating[Any]],
753
- setup_scaled: float,
754
- hold_scaled: float,
755
- time_mult: float,
756
- time_unit: str,
757
- ) -> None:
758
- """Draw setup and hold timing arrows.
759
-
760
- Args:
761
- ax: Timing axes.
762
- clock_edges: Clock edge times.
763
- data_edges: Data edge times.
764
- setup_scaled: Scaled setup time.
765
- hold_scaled: Scaled hold time.
766
- time_mult: Time multiplier.
767
- time_unit: Time unit string.
768
- """
769
- if len(clock_edges) == 0 or len(data_edges) == 0:
770
- return
771
-
772
- clk_edge = clock_edges[0] * time_mult
773
-
774
- # Setup time arrow
775
- data_before = data_edges[data_edges < clock_edges[0]]
776
- if len(data_before) > 0:
777
- data_edge = data_before[-1] * time_mult
778
- y_setup = 0.7
779
- ax.annotate(
780
- "",
781
- xy=(clk_edge, y_setup),
782
- xytext=(data_edge, y_setup),
783
- arrowprops={"arrowstyle": "<->", "color": "#27AE60", "lw": 2},
784
- )
785
- ax.text(
786
- (data_edge + clk_edge) / 2,
787
- y_setup + 0.1,
788
- f"Setup: {setup_scaled:.2f} {time_unit}",
789
- ha="center",
790
- va="bottom",
791
- fontsize=10,
792
- fontweight="bold",
793
- color="#27AE60",
794
- )
795
-
796
- # Hold time arrow
797
- data_after = data_edges[data_edges > clock_edges[0]]
798
- if len(data_after) > 0:
799
- data_edge_after = data_after[0] * time_mult
800
- y_hold = 0.3
801
- ax.annotate(
802
- "",
803
- xy=(data_edge_after, y_hold),
804
- xytext=(clk_edge, y_hold),
805
- arrowprops={"arrowstyle": "<->", "color": "#E67E22", "lw": 2},
806
- )
807
- ax.text(
808
- (clk_edge + data_edge_after) / 2,
809
- y_hold + 0.1,
810
- f"Hold: {hold_scaled:.2f} {time_unit}",
811
- ha="center",
812
- va="bottom",
813
- fontsize=10,
814
- fontweight="bold",
815
- color="#E67E22",
816
- )
817
-
818
-
819
- def _add_passfail_status(
820
- ax: Any,
821
- setup_time: float,
822
- hold_time: float,
823
- setup_spec: float | None,
824
- hold_spec: float | None,
825
- time_mult: float,
826
- time_unit: str,
827
- ) -> None:
828
- """Add pass/fail status text.
829
-
830
- Args:
831
- ax: Timing axes.
832
- setup_time: Measured setup time.
833
- hold_time: Measured hold time.
834
- setup_spec: Setup specification.
835
- hold_spec: Hold specification.
836
- time_mult: Time multiplier.
837
- time_unit: Time unit string.
838
- """
839
- status_y = 0.9
840
-
841
- if setup_spec is not None:
842
- setup_pass = setup_time >= setup_spec
843
- status = "PASS" if setup_pass else "FAIL"
844
- color = "#27AE60" if setup_pass else "#E74C3C"
845
- ax.text(
846
- 0.02,
847
- status_y,
848
- f"Setup: {status} (spec: {setup_spec * time_mult:.2f} {time_unit})",
849
- transform=ax.transAxes,
850
- fontsize=10,
851
- color=color,
852
- fontweight="bold",
853
- )
854
- status_y -= 0.15
855
-
856
- if hold_spec is not None:
857
- hold_pass = hold_time >= hold_spec
858
- status = "PASS" if hold_pass else "FAIL"
859
- color = "#27AE60" if hold_pass else "#E74C3C"
860
- ax.text(
861
- 0.02,
862
- status_y,
863
- f"Hold: {status} (spec: {hold_spec * time_mult:.2f} {time_unit})",
864
- transform=ax.transAxes,
865
- fontsize=10,
866
- color=color,
867
- fontweight="bold",
868
- )
869
-
870
-
871
- def plot_timing_margin(
872
- setup_times: NDArray[np.floating[Any]],
873
- hold_times: NDArray[np.floating[Any]],
874
- *,
875
- setup_spec: float | None = None,
876
- hold_spec: float | None = None,
877
- ax: Axes | None = None,
878
- figsize: tuple[float, float] = (10, 8),
879
- title: str | None = None,
880
- time_unit: str = "ns",
881
- show: bool = True,
882
- save_path: str | Path | None = None,
883
- ) -> Figure:
884
- """Plot setup vs hold timing margin scatter plot.
885
-
886
- Creates a scatter plot showing the distribution of setup and hold
887
- times with specification regions marked.
888
-
889
- Args:
890
- setup_times: Array of setup time measurements.
891
- hold_times: Array of hold time measurements.
892
- setup_spec: Setup time specification.
893
- hold_spec: Hold time specification.
894
- ax: Matplotlib axes.
895
- figsize: Figure size.
896
- title: Plot title.
897
- time_unit: Time unit for display.
898
- show: Display plot.
899
- save_path: Save path.
900
-
901
- Returns:
902
- Matplotlib Figure object.
903
- """
904
- if not HAS_MATPLOTLIB:
905
- raise ImportError("matplotlib is required for visualization")
906
-
907
- fig, ax = _get_or_create_axes(ax, figsize)
908
- time_mult = {"s": 1, "ms": 1e3, "us": 1e6, "ns": 1e9, "ps": 1e12}.get(time_unit, 1e9)
909
-
910
- setup_scaled, hold_scaled = setup_times * time_mult, hold_times * time_mult
911
-
912
- # Scatter plot
913
- ax.scatter(setup_scaled, hold_scaled, c="#3498DB", alpha=0.6, s=50)
914
-
915
- # Add specification lines and regions
916
- _add_spec_lines(ax, setup_spec, hold_spec, time_mult, time_unit)
917
- _add_pass_region(ax, setup_spec, hold_spec, time_mult)
918
-
919
- # Configure axes
920
- ax.set_xlabel(f"Setup Time ({time_unit})", fontsize=11)
921
- ax.set_ylabel(f"Hold Time ({time_unit})", fontsize=11)
922
- ax.grid(True, alpha=0.3)
923
- ax.legend(loc="best")
924
- ax.set_title(title or "Setup/Hold Timing Margin", fontsize=12, fontweight="bold")
925
-
926
- fig.tight_layout()
927
- _save_and_show_figure(fig, save_path, show)
928
- return fig
929
-
930
-
931
- def _get_or_create_axes(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
932
- """Get existing axes or create new figure with axes."""
933
- if ax is None:
934
- fig, ax = plt.subplots(figsize=figsize)
935
- else:
936
- fig_temp = ax.get_figure()
937
- if fig_temp is None:
938
- raise ValueError("Axes must have an associated figure")
939
- fig = cast("Figure", fig_temp)
940
- return fig, ax
941
-
942
-
943
- def _add_spec_lines(
944
- ax: Axes, setup_spec: float | None, hold_spec: float | None, time_mult: float, time_unit: str
945
- ) -> None:
946
- """Add specification lines to timing margin plot."""
947
- if setup_spec is not None:
948
- spec_scaled = setup_spec * time_mult
949
- ax.axvline(
950
- spec_scaled,
951
- color="#E74C3C",
952
- linestyle="--",
953
- linewidth=2,
954
- label=f"Setup Spec ({spec_scaled:.2f} {time_unit})",
955
- )
956
-
957
- if hold_spec is not None:
958
- spec_scaled = hold_spec * time_mult
959
- ax.axhline(
960
- spec_scaled,
961
- color="#E67E22",
962
- linestyle="--",
963
- linewidth=2,
964
- label=f"Hold Spec ({spec_scaled:.2f} {time_unit})",
965
- )
966
-
967
-
968
- def _add_pass_region(
969
- ax: Axes, setup_spec: float | None, hold_spec: float | None, time_mult: float
970
- ) -> None:
971
- """Add pass/fail region shading to timing margin plot."""
972
- if setup_spec is not None and hold_spec is not None:
973
- x_lim, y_lim = ax.get_xlim(), ax.get_ylim()
974
- ax.fill_between(
975
- [setup_spec * time_mult, x_lim[1]],
976
- [hold_spec * time_mult, hold_spec * time_mult],
977
- [y_lim[1], y_lim[1]],
978
- color="#27AE60",
979
- alpha=0.1,
980
- label="Pass Region",
981
- )
982
-
983
-
984
- def _save_and_show_figure(fig: Figure, save_path: str | Path | None, show: bool) -> None:
985
- """Save and optionally show figure."""
986
- if save_path is not None:
987
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
988
- if show:
989
- plt.show()