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