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,1042 +0,0 @@
1
- """Jitter Analysis Visualization Functions.
2
-
3
- This module provides visualization functions for jitter analysis including
4
- TIE histograms, bathtub curves, DDJ/DCD plots, and jitter trend analysis.
5
-
6
- Example:
7
- >>> from oscura.visualization.jitter import plot_tie_histogram, plot_bathtub_full
8
- >>> fig = plot_tie_histogram(tie_data)
9
- >>> fig = plot_bathtub_full(bathtub_result)
10
-
11
- References:
12
- - IEEE 802.3: Jitter measurement specifications
13
- - JEDEC JESD65B: High-Speed Interface Measurements
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 scipy.stats import norm
26
-
27
- HAS_MATPLOTLIB = True
28
- HAS_SCIPY = True
29
- except ImportError:
30
- HAS_MATPLOTLIB = False
31
- HAS_SCIPY = False
32
-
33
- if TYPE_CHECKING:
34
- from matplotlib.axes import Axes
35
- from matplotlib.figure import Figure
36
- from numpy.typing import NDArray
37
-
38
- __all__ = [
39
- "plot_bathtub_full",
40
- "plot_dcd",
41
- "plot_ddj",
42
- "plot_jitter_trend",
43
- "plot_tie_histogram",
44
- ]
45
-
46
-
47
- def _determine_tie_time_unit(
48
- tie_data: NDArray[np.floating[Any]], time_unit: str
49
- ) -> tuple[str, float]:
50
- """Determine time unit and multiplier for TIE display.
51
-
52
- Args:
53
- tie_data: TIE values in seconds.
54
- time_unit: Requested time unit or "auto".
55
-
56
- Returns:
57
- Tuple of (time_unit, time_multiplier).
58
- """
59
- if time_unit == "auto":
60
- max_tie = np.max(np.abs(tie_data))
61
- if max_tie < 1e-12:
62
- return "fs", 1e15
63
- elif max_tie < 1e-9:
64
- return "ps", 1e12
65
- elif max_tie < 1e-6:
66
- return "ns", 1e9
67
- else:
68
- return "us", 1e6
69
- else:
70
- time_mult_map = {
71
- "s": 1,
72
- "ms": 1e3,
73
- "us": 1e6,
74
- "ns": 1e9,
75
- "ps": 1e12,
76
- "fs": 1e15,
77
- }
78
- if time_unit in time_mult_map:
79
- return time_unit, time_mult_map[time_unit]
80
- else:
81
- # Fallback to ps for invalid unit
82
- return "ps", 1e12
83
-
84
-
85
- def _calculate_tie_statistics(
86
- tie_scaled: NDArray[np.floating[Any]],
87
- ) -> tuple[float, float, float, float]:
88
- """Calculate TIE statistical metrics.
89
-
90
- Args:
91
- tie_scaled: Scaled TIE values.
92
-
93
- Returns:
94
- Tuple of (mean, std, peak-to-peak, rms).
95
- """
96
- mean_val = float(np.mean(tie_scaled))
97
- std_val = float(np.std(tie_scaled))
98
- pp_val = float(np.ptp(tie_scaled))
99
- rms_val = float(np.sqrt(np.mean(tie_scaled**2)))
100
- return mean_val, std_val, pp_val, rms_val
101
-
102
-
103
- def _add_gaussian_fit(
104
- ax: Axes, bin_edges: NDArray[np.floating[Any]], mean_val: float, std_val: float, time_unit: str
105
- ) -> None:
106
- """Add Gaussian fit overlay to histogram.
107
-
108
- Args:
109
- ax: Matplotlib axes to plot on.
110
- bin_edges: Histogram bin edges.
111
- mean_val: Mean value.
112
- std_val: Standard deviation.
113
- time_unit: Time unit string for label.
114
- """
115
- if not HAS_SCIPY:
116
- return
117
-
118
- x_fit = np.linspace(bin_edges[0], bin_edges[-1], 200)
119
- y_fit = norm.pdf(x_fit, mean_val, std_val)
120
- ax.plot(
121
- x_fit, y_fit, "r-", linewidth=2, label=f"Gaussian Fit (sigma={std_val:.2f} {time_unit})"
122
- )
123
-
124
-
125
- def _add_rj_dj_indicators(ax: Axes, mean_val: float, std_val: float) -> None:
126
- """Add RJ/DJ separation indicators to plot.
127
-
128
- Args:
129
- ax: Matplotlib axes to plot on.
130
- mean_val: Mean value.
131
- std_val: Standard deviation.
132
- """
133
- # Mark ±3sigma region (RJ contribution)
134
- ax.axvline(mean_val - 3 * std_val, color="#E74C3C", linestyle="--", linewidth=1.5, alpha=0.7)
135
- ax.axvline(mean_val + 3 * std_val, color="#E74C3C", linestyle="--", linewidth=1.5, alpha=0.7)
136
-
137
- # Shade RJ region
138
- ax.axvspan(
139
- mean_val - 3 * std_val,
140
- mean_val + 3 * std_val,
141
- alpha=0.1,
142
- color="#E74C3C",
143
- label="±3sigma (99.7% RJ)",
144
- )
145
-
146
-
147
- def _add_statistics_box(
148
- ax: Axes, mean_val: float, rms_val: float, std_val: float, pp_val: float, time_unit: str
149
- ) -> None:
150
- """Add statistics text box to plot.
151
-
152
- Args:
153
- ax: Matplotlib axes to plot on.
154
- mean_val: Mean value.
155
- rms_val: RMS value.
156
- std_val: Standard deviation.
157
- pp_val: Peak-to-peak value.
158
- time_unit: Time unit string.
159
- """
160
- stats_text = (
161
- f"Mean: {mean_val:.2f} {time_unit}\n"
162
- f"RMS: {rms_val:.2f} {time_unit}\n"
163
- f"Std Dev: {std_val:.2f} {time_unit}\n"
164
- f"Peak-Peak: {pp_val:.2f} {time_unit}"
165
- )
166
- ax.text(
167
- 0.98,
168
- 0.98,
169
- stats_text,
170
- transform=ax.transAxes,
171
- fontsize=9,
172
- verticalalignment="top",
173
- horizontalalignment="right",
174
- bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.9},
175
- fontfamily="monospace",
176
- )
177
-
178
-
179
- def plot_tie_histogram(
180
- tie_data: NDArray[np.floating[Any]],
181
- *,
182
- ax: Axes | None = None,
183
- figsize: tuple[float, float] = (10, 6),
184
- title: str | None = None,
185
- time_unit: str = "auto",
186
- bins: int | str = "auto",
187
- show_gaussian_fit: bool = True,
188
- show_statistics: bool = True,
189
- show_rj_dj: bool = True,
190
- show: bool = True,
191
- save_path: str | Path | None = None,
192
- ) -> Figure:
193
- """Plot Time Interval Error (TIE) histogram with statistical analysis.
194
-
195
- Creates a histogram of TIE values with optional Gaussian fit overlay
196
- and RJ/DJ decomposition indicators.
197
-
198
- Args:
199
- tie_data: Array of TIE values in seconds.
200
- ax: Matplotlib axes. If None, creates new figure.
201
- figsize: Figure size in inches.
202
- title: Plot title.
203
- time_unit: Time unit ("s", "ms", "us", "ns", "ps", "fs", "auto").
204
- bins: Number of bins or "auto" for automatic selection.
205
- show_gaussian_fit: Overlay Gaussian fit for RJ estimation.
206
- show_statistics: Show statistics box.
207
- show_rj_dj: Show RJ/DJ separation indicators.
208
- show: Display plot interactively.
209
- save_path: Save plot to file.
210
-
211
- Returns:
212
- Matplotlib Figure object.
213
-
214
- Example:
215
- >>> tie = np.random.randn(10000) * 2e-12 # 2 ps RMS jitter
216
- >>> fig = plot_tie_histogram(tie, time_unit="ps")
217
- """
218
- if not HAS_MATPLOTLIB:
219
- raise ImportError("matplotlib is required for visualization")
220
-
221
- fig, ax = _setup_tie_figure(ax, figsize)
222
- time_unit, time_mult = _determine_tie_time_unit(tie_data, time_unit)
223
- tie_scaled = tie_data * time_mult
224
- mean_val, std_val, pp_val, rms_val = _calculate_tie_statistics(tie_scaled)
225
-
226
- counts, bin_edges, patches = _plot_tie_histogram_data(ax, tie_scaled, bins)
227
- _add_tie_overlays(ax, show_gaussian_fit, show_rj_dj, bin_edges, mean_val, std_val, time_unit)
228
- _format_tie_plot(ax, show_statistics, mean_val, rms_val, std_val, pp_val, time_unit, title)
229
-
230
- fig.tight_layout()
231
- _save_and_show_tie_plot(fig, save_path, show)
232
-
233
- return fig
234
-
235
-
236
- def _setup_tie_figure(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
237
- """Setup figure and axes for TIE plot.
238
-
239
- Args:
240
- ax: Existing axes or None.
241
- figsize: Figure size.
242
-
243
- Returns:
244
- Tuple of (figure, axes).
245
-
246
- Raises:
247
- ValueError: If axes has no figure.
248
- """
249
- if ax is None:
250
- fig, ax = plt.subplots(figsize=figsize)
251
- else:
252
- fig_temp = ax.get_figure()
253
- if fig_temp is None:
254
- raise ValueError("Axes must have an associated figure")
255
- fig = cast("Figure", fig_temp)
256
- return fig, ax
257
-
258
-
259
- def _plot_tie_histogram_data(
260
- ax: Axes, tie_scaled: NDArray[np.floating[Any]], bins: int | str
261
- ) -> tuple[Any, NDArray[Any], Any]:
262
- """Plot histogram data.
263
-
264
- Args:
265
- ax: Matplotlib axes.
266
- tie_scaled: Scaled TIE data.
267
- bins: Bin specification.
268
-
269
- Returns:
270
- Tuple of (counts, bin_edges, patches) from matplotlib hist.
271
- """
272
- result: tuple[Any, NDArray[Any], Any] = ax.hist(
273
- tie_scaled,
274
- bins=bins,
275
- density=True,
276
- color="#3498DB",
277
- alpha=0.7,
278
- edgecolor="black",
279
- linewidth=0.5,
280
- )
281
- return result
282
-
283
-
284
- def _add_tie_overlays(
285
- ax: Axes,
286
- show_gaussian_fit: bool,
287
- show_rj_dj: bool,
288
- bin_edges: NDArray[Any],
289
- mean_val: float,
290
- std_val: float,
291
- time_unit: str,
292
- ) -> None:
293
- """Add Gaussian fit and RJ/DJ overlays to TIE plot.
294
-
295
- Args:
296
- ax: Matplotlib axes.
297
- show_gaussian_fit: Whether to show Gaussian fit.
298
- show_rj_dj: Whether to show RJ/DJ indicators.
299
- bin_edges: Histogram bin edges.
300
- mean_val: Mean TIE value.
301
- std_val: Standard deviation.
302
- time_unit: Time unit string.
303
- """
304
- if show_gaussian_fit:
305
- _add_gaussian_fit(ax, bin_edges, mean_val, std_val, time_unit)
306
- if show_rj_dj:
307
- _add_rj_dj_indicators(ax, mean_val, std_val)
308
-
309
-
310
- def _format_tie_plot(
311
- ax: Axes,
312
- show_statistics: bool,
313
- mean_val: float,
314
- rms_val: float,
315
- std_val: float,
316
- pp_val: float,
317
- time_unit: str,
318
- title: str | None,
319
- ) -> None:
320
- """Format TIE plot axes and labels.
321
-
322
- Args:
323
- ax: Matplotlib axes.
324
- show_statistics: Whether to show statistics box.
325
- mean_val: Mean value.
326
- rms_val: RMS value.
327
- std_val: Standard deviation.
328
- pp_val: Peak-to-peak value.
329
- time_unit: Time unit.
330
- title: Plot title.
331
- """
332
- if show_statistics:
333
- _add_statistics_box(ax, mean_val, rms_val, std_val, pp_val, time_unit)
334
-
335
- ax.set_xlabel(f"TIE ({time_unit})", fontsize=11)
336
- ax.set_ylabel("Probability Density", fontsize=11)
337
- ax.grid(True, alpha=0.3)
338
- ax.legend(loc="upper left")
339
-
340
- final_title = title if title else "Time Interval Error Distribution"
341
- ax.set_title(final_title, fontsize=12, fontweight="bold")
342
-
343
-
344
- def _save_and_show_tie_plot(fig: Figure, save_path: str | Path | None, show: bool) -> None:
345
- """Save and/or show TIE plot.
346
-
347
- Args:
348
- fig: Matplotlib figure.
349
- save_path: Path to save file.
350
- show: Whether to display interactively.
351
- """
352
- if save_path is not None:
353
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
354
- if show:
355
- plt.show()
356
-
357
-
358
- def plot_bathtub_full(
359
- positions: NDArray[np.floating[Any]],
360
- ber_left: NDArray[np.floating[Any]],
361
- ber_right: NDArray[np.floating[Any]],
362
- *,
363
- ber_total: NDArray[np.floating[Any]] | None = None,
364
- target_ber: float = 1e-12,
365
- eye_opening: float | None = None,
366
- ax: Axes | None = None,
367
- figsize: tuple[float, float] = (10, 6),
368
- title: str | None = None,
369
- show_target: bool = True,
370
- show_eye_opening: bool = True,
371
- show: bool = True,
372
- save_path: str | Path | None = None,
373
- ) -> Figure:
374
- """Plot full bathtub curve with left/right BER and eye opening.
375
-
376
- Creates a bathtub curve showing bit error rate vs sampling position
377
- within the unit interval, with target BER marker and eye opening
378
- annotation.
379
-
380
- Args:
381
- positions: Sample positions in UI (0 to 1).
382
- ber_left: Left-side BER values.
383
- ber_right: Right-side BER values.
384
- ber_total: Total BER values (optional, computed if not provided).
385
- target_ber: Target BER for eye opening calculation.
386
- eye_opening: Pre-calculated eye opening in UI (optional).
387
- ax: Matplotlib axes.
388
- figsize: Figure size.
389
- title: Plot title.
390
- show_target: Show target BER line.
391
- show_eye_opening: Annotate eye opening.
392
- show: Display plot.
393
- save_path: Save path.
394
-
395
- Returns:
396
- Matplotlib Figure object.
397
-
398
- Example:
399
- >>> pos = np.linspace(0, 1, 100)
400
- >>> ber_l = 0.5 * erfc((pos - 0) / 0.1 / np.sqrt(2))
401
- >>> ber_r = 0.5 * erfc((1 - pos) / 0.1 / np.sqrt(2))
402
- >>> fig = plot_bathtub_full(pos, ber_l, ber_r, target_ber=1e-12)
403
- """
404
- if not HAS_MATPLOTLIB:
405
- raise ImportError("matplotlib is required for visualization")
406
-
407
- fig, ax = _get_or_create_figure(ax, figsize)
408
- ber_total = ber_total if ber_total is not None else ber_left + ber_right
409
-
410
- # Plot BER curves
411
- ber_total_plot = _plot_bathtub_ber_curves(ax, positions, ber_left, ber_right, ber_total)
412
-
413
- # Optional annotations
414
- if show_target:
415
- _add_target_ber_line(ax, target_ber)
416
-
417
- if show_eye_opening:
418
- _add_eye_opening_annotation(ax, positions, ber_total_plot, target_ber, eye_opening)
419
-
420
- # Styling
421
- _style_bathtub_plot(ax, positions, ber_total_plot, title)
422
-
423
- fig.tight_layout()
424
-
425
- if save_path is not None:
426
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
427
-
428
- if show:
429
- plt.show()
430
-
431
- return fig
432
-
433
-
434
- def _get_or_create_figure(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
435
- """Get existing figure or create new one."""
436
- if ax is None:
437
- fig, ax = plt.subplots(figsize=figsize)
438
- else:
439
- fig_temp = ax.get_figure()
440
- if fig_temp is None:
441
- raise ValueError("Axes must have an associated figure")
442
- fig = cast("Figure", fig_temp)
443
- return fig, ax
444
-
445
-
446
- def _plot_bathtub_ber_curves(
447
- ax: Axes,
448
- positions: NDArray[np.floating[Any]],
449
- ber_left: NDArray[np.floating[Any]],
450
- ber_right: NDArray[np.floating[Any]],
451
- ber_total: NDArray[np.floating[Any]],
452
- ) -> NDArray[np.floating[Any]]:
453
- """Plot BER curves and return clipped total BER."""
454
- ber_left_plot = np.clip(ber_left, 1e-18, 1)
455
- ber_right_plot = np.clip(ber_right, 1e-18, 1)
456
- ber_total_plot = np.clip(ber_total, 1e-18, 1)
457
-
458
- ax.semilogy(positions, ber_left_plot, "b-", linewidth=2, label="BER Left", alpha=0.8)
459
- ax.semilogy(positions, ber_right_plot, "r-", linewidth=2, label="BER Right", alpha=0.8)
460
- ax.semilogy(positions, ber_total_plot, "k-", linewidth=2.5, label="BER Total")
461
-
462
- return ber_total_plot
463
-
464
-
465
- def _add_target_ber_line(ax: Axes, target_ber: float) -> None:
466
- """Add target BER horizontal line."""
467
- ax.axhline(
468
- target_ber,
469
- color="#27AE60",
470
- linestyle="--",
471
- linewidth=2,
472
- label=f"Target BER = {target_ber:.0e}",
473
- )
474
-
475
-
476
- def _add_eye_opening_annotation(
477
- ax: Axes,
478
- positions: NDArray[np.floating[Any]],
479
- ber_total_plot: NDArray[np.floating[Any]],
480
- target_ber: float,
481
- eye_opening: float | None,
482
- ) -> None:
483
- """Add eye opening annotation if applicable."""
484
- if eye_opening is None:
485
- eye_opening = _calculate_eye_opening(positions, ber_total_plot, target_ber)
486
-
487
- if eye_opening <= 0:
488
- return
489
-
490
- center = 0.5
491
- left_edge = center - eye_opening / 2
492
- right_edge = center + eye_opening / 2
493
-
494
- ax.annotate(
495
- "",
496
- xy=(right_edge, target_ber),
497
- xytext=(left_edge, target_ber),
498
- arrowprops={"arrowstyle": "<->", "color": "#27AE60", "lw": 2},
499
- )
500
- ax.text(
501
- center,
502
- target_ber * 0.1,
503
- f"Eye Opening: {eye_opening:.3f} UI",
504
- ha="center",
505
- va="top",
506
- fontsize=10,
507
- fontweight="bold",
508
- color="#27AE60",
509
- )
510
-
511
-
512
- def _calculate_eye_opening(
513
- positions: NDArray[np.floating[Any]],
514
- ber_total: NDArray[np.floating[Any]],
515
- target_ber: float,
516
- ) -> float:
517
- """Calculate eye opening at target BER."""
518
- left_cross = np.where(ber_total < target_ber)[0]
519
- if len(left_cross) > 0:
520
- left_edge = positions[left_cross[0]]
521
- right_edge = positions[left_cross[-1]]
522
- return float(right_edge - left_edge)
523
- return 0.0
524
-
525
-
526
- def _style_bathtub_plot(
527
- ax: Axes,
528
- positions: NDArray[np.floating[Any]],
529
- ber_total_plot: NDArray[np.floating[Any]],
530
- title: str | None,
531
- ) -> None:
532
- """Apply styling to bathtub plot."""
533
- ax.fill_between(positions, 1e-18, ber_total_plot, alpha=0.1, color="gray")
534
- ax.set_xlabel("Sample Position (UI)", fontsize=11)
535
- ax.set_ylabel("Bit Error Rate", fontsize=11)
536
- ax.set_xlim(0, 1)
537
- ax.set_ylim(1e-15, 1)
538
- ax.grid(True, which="both", alpha=0.3)
539
- ax.legend(loc="upper right")
540
- ax.set_title(title or "Bathtub Curve", fontsize=12, fontweight="bold")
541
-
542
-
543
- def plot_ddj(
544
- patterns: list[str],
545
- jitter_values: NDArray[np.floating[Any]],
546
- *,
547
- ax: Axes | None = None,
548
- figsize: tuple[float, float] = (12, 6),
549
- title: str | None = None,
550
- time_unit: str = "ps",
551
- show: bool = True,
552
- save_path: str | Path | None = None,
553
- ) -> Figure:
554
- """Plot Data-Dependent Jitter (DDJ) by bit pattern.
555
-
556
- Creates a bar chart showing jitter contribution for each bit pattern,
557
- useful for identifying pattern-dependent timing variations.
558
-
559
- Args:
560
- patterns: List of bit pattern strings (e.g., ["010", "011", "100"]).
561
- jitter_values: Jitter values for each pattern.
562
- ax: Matplotlib axes.
563
- figsize: Figure size.
564
- title: Plot title.
565
- time_unit: Time unit for display.
566
- show: Display plot.
567
- save_path: Save path.
568
-
569
- Returns:
570
- Matplotlib Figure object.
571
-
572
- Example:
573
- >>> patterns = ["000", "001", "010", "011", "100", "101", "110", "111"]
574
- >>> ddj = np.array([0, 2.1, -1.5, 0.5, 0.8, -0.3, 1.2, -0.8]) # ps
575
- >>> fig = plot_ddj(patterns, ddj, time_unit="ps")
576
- """
577
- if not HAS_MATPLOTLIB:
578
- raise ImportError("matplotlib is required for visualization")
579
-
580
- # Validate input lengths match
581
- if len(patterns) != len(jitter_values):
582
- raise ValueError(
583
- f"Mismatched lengths: patterns has {len(patterns)} elements "
584
- f"but jitter_values has {len(jitter_values)} elements"
585
- )
586
-
587
- if ax is None:
588
- fig, ax = plt.subplots(figsize=figsize)
589
- else:
590
- fig_temp = ax.get_figure()
591
- if fig_temp is None:
592
- raise ValueError("Axes must have an associated figure")
593
- fig = cast("Figure", fig_temp)
594
-
595
- # Color bars based on sign
596
- colors = ["#E74C3C" if v < 0 else "#27AE60" for v in jitter_values]
597
-
598
- # Bar chart
599
- x_pos = np.arange(len(patterns))
600
- ax.bar(x_pos, jitter_values, color=colors, edgecolor="black", linewidth=0.5)
601
-
602
- # Reference line at zero
603
- ax.axhline(0, color="gray", linestyle="-", linewidth=1)
604
-
605
- # Labels
606
- ax.set_xticks(x_pos)
607
- ax.set_xticklabels(patterns, fontfamily="monospace", fontsize=10)
608
- ax.set_xlabel("Bit Pattern", fontsize=11)
609
- ax.set_ylabel(f"DDJ ({time_unit})", fontsize=11)
610
- ax.grid(True, axis="y", alpha=0.3)
611
-
612
- # Add DDJ pp annotation
613
- ddj_pp = np.ptp(jitter_values)
614
- ax.text(
615
- 0.98,
616
- 0.98,
617
- f"DDJ pk-pk: {ddj_pp:.2f} {time_unit}",
618
- transform=ax.transAxes,
619
- fontsize=10,
620
- ha="right",
621
- va="top",
622
- bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.9},
623
- )
624
-
625
- if title:
626
- ax.set_title(title, fontsize=12, fontweight="bold")
627
- else:
628
- ax.set_title("Data-Dependent Jitter by Pattern", fontsize=12, fontweight="bold")
629
-
630
- fig.tight_layout()
631
-
632
- if save_path is not None:
633
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
634
-
635
- if show:
636
- plt.show()
637
-
638
- return fig
639
-
640
-
641
- def _determine_dcd_time_unit(
642
- high_times: NDArray[np.floating[Any]], low_times: NDArray[np.floating[Any]], time_unit: str
643
- ) -> tuple[str, float]:
644
- """Determine time unit and scaling for DCD plot.
645
-
646
- Args:
647
- high_times: High-state durations.
648
- low_times: Low-state durations.
649
- time_unit: Requested time unit or "auto".
650
-
651
- Returns:
652
- Tuple of (time_unit, time_multiplier).
653
- """
654
- if time_unit == "auto":
655
- max_time = max(np.max(high_times), np.max(low_times))
656
- if max_time < 1e-9:
657
- return "ps", 1e12
658
- elif max_time < 1e-6:
659
- return "ns", 1e9
660
- else:
661
- return "us", 1e6
662
- else:
663
- time_mult = {"s": 1, "ms": 1e3, "us": 1e6, "ns": 1e9, "ps": 1e12}.get(time_unit, 1e9)
664
- return time_unit, time_mult
665
-
666
-
667
- def _compute_dcd_statistics(
668
- high_scaled: NDArray[np.floating[Any]], low_scaled: NDArray[np.floating[Any]]
669
- ) -> tuple[float, float, float, float]:
670
- """Compute DCD statistics.
671
-
672
- Args:
673
- high_scaled: Scaled high-state durations.
674
- low_scaled: Scaled low-state durations.
675
-
676
- Returns:
677
- Tuple of (mean_high, mean_low, duty_cycle, dcd).
678
- """
679
- mean_high = float(np.mean(high_scaled))
680
- mean_low = float(np.mean(low_scaled))
681
- period = mean_high + mean_low
682
- duty_cycle = mean_high / period * 100
683
- dcd = (mean_high - mean_low) / 2
684
- return mean_high, mean_low, duty_cycle, dcd
685
-
686
-
687
- def _plot_dcd_histograms(
688
- ax: Axes,
689
- high_scaled: NDArray[np.floating[Any]],
690
- low_scaled: NDArray[np.floating[Any]],
691
- mean_high: float,
692
- mean_low: float,
693
- ) -> None:
694
- """Plot DCD histograms with mean lines.
695
-
696
- Args:
697
- ax: Matplotlib axes.
698
- high_scaled: Scaled high-state durations.
699
- low_scaled: Scaled low-state durations.
700
- mean_high: Mean high value.
701
- mean_low: Mean low value.
702
- """
703
- all_times = np.concatenate([high_scaled, low_scaled])
704
- bins = np.linspace(np.min(all_times) * 0.95, np.max(all_times) * 1.05, 50)
705
-
706
- ax.hist(
707
- high_scaled,
708
- bins=bins,
709
- alpha=0.6,
710
- color="#E74C3C",
711
- label="High Time",
712
- edgecolor="black",
713
- linewidth=0.5,
714
- )
715
- ax.hist(
716
- low_scaled,
717
- bins=bins,
718
- alpha=0.6,
719
- color="#3498DB",
720
- label="Low Time",
721
- edgecolor="black",
722
- linewidth=0.5,
723
- )
724
-
725
- ax.axvline(mean_high, color="#E74C3C", linestyle="--", linewidth=2, alpha=0.8)
726
- ax.axvline(mean_low, color="#3498DB", linestyle="--", linewidth=2, alpha=0.8)
727
-
728
-
729
- def plot_dcd(
730
- high_times: NDArray[np.floating[Any]],
731
- low_times: NDArray[np.floating[Any]],
732
- *,
733
- ax: Axes | None = None,
734
- figsize: tuple[float, float] = (10, 6),
735
- title: str | None = None,
736
- time_unit: str = "auto",
737
- show: bool = True,
738
- save_path: str | Path | None = None,
739
- ) -> Figure:
740
- """Plot Duty Cycle Distortion (DCD) analysis.
741
-
742
- Creates overlaid histograms of high and low pulse times to visualize
743
- duty cycle distortion.
744
-
745
- Args:
746
- high_times: Array of high-state durations.
747
- low_times: Array of low-state durations.
748
- ax: Matplotlib axes.
749
- figsize: Figure size.
750
- title: Plot title.
751
- time_unit: Time unit.
752
- show: Display plot.
753
- save_path: Save path.
754
-
755
- Returns:
756
- Matplotlib Figure object.
757
- """
758
- if not HAS_MATPLOTLIB:
759
- raise ImportError("matplotlib is required for visualization")
760
-
761
- fig, ax = _get_or_create_figure(ax, figsize)
762
-
763
- # Scale times
764
- time_unit, time_mult = _determine_dcd_time_unit(high_times, low_times, time_unit)
765
- high_scaled = high_times * time_mult
766
- low_scaled = low_times * time_mult
767
-
768
- # Calculate statistics
769
- mean_high, mean_low, duty_cycle, dcd = _compute_dcd_statistics(high_scaled, low_scaled)
770
-
771
- # Plot histograms
772
- _plot_dcd_histograms(ax, high_scaled, low_scaled, mean_high, mean_low)
773
-
774
- # Statistics box
775
- stats_text = (
776
- f"Mean High: {mean_high:.2f} {time_unit}\n"
777
- f"Mean Low: {mean_low:.2f} {time_unit}\n"
778
- f"Duty Cycle: {duty_cycle:.1f}%\n"
779
- f"DCD: {dcd:.2f} {time_unit}"
780
- )
781
- ax.text(
782
- 0.98,
783
- 0.98,
784
- stats_text,
785
- transform=ax.transAxes,
786
- fontsize=9,
787
- va="top",
788
- ha="right",
789
- bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.9},
790
- fontfamily="monospace",
791
- )
792
-
793
- ax.set_xlabel(f"Pulse Width ({time_unit})", fontsize=11)
794
- ax.set_ylabel("Count", fontsize=11)
795
- ax.grid(True, alpha=0.3)
796
- ax.legend(loc="upper left")
797
-
798
- if title:
799
- ax.set_title(title, fontsize=12, fontweight="bold")
800
- else:
801
- ax.set_title("Duty Cycle Distortion Analysis", fontsize=12, fontweight="bold")
802
-
803
- fig.tight_layout()
804
-
805
- if save_path is not None:
806
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
807
-
808
- if show:
809
- plt.show()
810
-
811
- return fig
812
-
813
-
814
- def plot_jitter_trend(
815
- time_axis: NDArray[np.floating[Any]],
816
- jitter_values: NDArray[np.floating[Any]],
817
- *,
818
- ax: Axes | None = None,
819
- figsize: tuple[float, float] = (12, 5),
820
- title: str | None = None,
821
- time_unit: str = "auto",
822
- jitter_unit: str = "auto",
823
- show_trend: bool = True,
824
- show_bounds: bool = True,
825
- show: bool = True,
826
- save_path: str | Path | None = None,
827
- ) -> Figure:
828
- """Plot jitter trend over time.
829
-
830
- Creates a time series plot of jitter values with optional trend line
831
- and statistical bounds.
832
-
833
- Args:
834
- time_axis: Time values (e.g., cycle number or time in seconds).
835
- jitter_values: Jitter values at each time point.
836
- ax: Matplotlib axes.
837
- figsize: Figure size.
838
- title: Plot title.
839
- time_unit: Time axis unit.
840
- jitter_unit: Jitter axis unit.
841
- show_trend: Show linear trend line.
842
- show_bounds: Show ±3σ bounds.
843
- show: Display plot.
844
- save_path: Save path.
845
-
846
- Returns:
847
- Matplotlib Figure object.
848
- """
849
- if not HAS_MATPLOTLIB:
850
- raise ImportError("matplotlib is required for visualization")
851
-
852
- fig, ax = _setup_jitter_trend_figure(ax, figsize)
853
- jitter_unit, jitter_mult = _determine_jitter_unit(jitter_values, jitter_unit)
854
- jitter_scaled = jitter_values * jitter_mult
855
-
856
- mean_val, std_val = _plot_jitter_data(ax, time_axis, jitter_scaled, jitter_unit)
857
- _add_jitter_bounds(ax, time_axis, mean_val, std_val, jitter_unit, show_bounds)
858
- _add_jitter_trend(ax, time_axis, jitter_scaled, jitter_unit, show_trend)
859
- _format_jitter_trend_plot(ax, time_unit, jitter_unit, title)
860
-
861
- fig.tight_layout()
862
- _save_and_show_jitter_trend(fig, save_path, show)
863
-
864
- return fig
865
-
866
-
867
- def _setup_jitter_trend_figure(
868
- ax: Axes | None, figsize: tuple[float, float]
869
- ) -> tuple[Figure, Axes]:
870
- """Setup figure and axes for jitter trend plot.
871
-
872
- Args:
873
- ax: Existing axes or None.
874
- figsize: Figure size.
875
-
876
- Returns:
877
- Tuple of (figure, axes).
878
-
879
- Raises:
880
- ValueError: If axes has no figure.
881
- """
882
- if ax is None:
883
- fig, ax = plt.subplots(figsize=figsize)
884
- else:
885
- fig_temp = ax.get_figure()
886
- if fig_temp is None:
887
- raise ValueError("Axes must have an associated figure")
888
- fig = cast("Figure", fig_temp)
889
- return fig, ax
890
-
891
-
892
- def _determine_jitter_unit(
893
- jitter_values: NDArray[np.floating[Any]], jitter_unit: str
894
- ) -> tuple[str, float]:
895
- """Determine jitter unit and multiplier.
896
-
897
- Args:
898
- jitter_values: Jitter value array.
899
- jitter_unit: Requested unit or "auto".
900
-
901
- Returns:
902
- Tuple of (unit_str, multiplier).
903
- """
904
- if jitter_unit == "auto":
905
- max_jitter = np.max(np.abs(jitter_values))
906
- if max_jitter < 1e-9:
907
- return "ps", 1e12
908
- elif max_jitter < 1e-6:
909
- return "ns", 1e9
910
- else:
911
- return "us", 1e6
912
- else:
913
- jitter_mult = {"s": 1, "ms": 1e3, "us": 1e6, "ns": 1e9, "ps": 1e12}.get(jitter_unit, 1e12)
914
- return jitter_unit, jitter_mult
915
-
916
-
917
- def _plot_jitter_data(
918
- ax: Axes,
919
- time_axis: NDArray[np.floating[Any]],
920
- jitter_scaled: NDArray[np.floating[Any]],
921
- jitter_unit: str,
922
- ) -> tuple[float, float]:
923
- """Plot jitter data and mean line.
924
-
925
- Args:
926
- ax: Matplotlib axes.
927
- time_axis: Time array.
928
- jitter_scaled: Scaled jitter values.
929
- jitter_unit: Jitter unit string.
930
-
931
- Returns:
932
- Tuple of (mean_val, std_val).
933
- """
934
- ax.plot(time_axis, jitter_scaled, "b-", linewidth=0.8, alpha=0.7, label="Jitter")
935
-
936
- mean_val = float(np.mean(jitter_scaled))
937
- std_val = float(np.std(jitter_scaled))
938
-
939
- ax.axhline(
940
- mean_val,
941
- color="gray",
942
- linestyle="-",
943
- linewidth=1,
944
- label=f"Mean: {mean_val:.2f} {jitter_unit}",
945
- )
946
-
947
- return mean_val, std_val
948
-
949
-
950
- def _add_jitter_bounds(
951
- ax: Axes,
952
- time_axis: NDArray[np.floating[Any]],
953
- mean_val: float,
954
- std_val: float,
955
- jitter_unit: str,
956
- show_bounds: bool,
957
- ) -> None:
958
- """Add statistical bounds to plot.
959
-
960
- Args:
961
- ax: Matplotlib axes.
962
- time_axis: Time array.
963
- mean_val: Mean value.
964
- std_val: Standard deviation.
965
- jitter_unit: Unit string.
966
- show_bounds: Whether to show bounds.
967
- """
968
- if not show_bounds:
969
- return
970
-
971
- ax.axhline(mean_val + 3 * std_val, color="#E74C3C", linestyle="--", linewidth=1, alpha=0.7)
972
- ax.axhline(
973
- mean_val - 3 * std_val,
974
- color="#E74C3C",
975
- linestyle="--",
976
- linewidth=1,
977
- alpha=0.7,
978
- label=f"±3sigma: {3 * std_val:.2f} {jitter_unit}",
979
- )
980
- ax.fill_between(
981
- time_axis, mean_val - 3 * std_val, mean_val + 3 * std_val, alpha=0.1, color="#E74C3C"
982
- )
983
-
984
-
985
- def _add_jitter_trend(
986
- ax: Axes,
987
- time_axis: NDArray[np.floating[Any]],
988
- jitter_scaled: NDArray[np.floating[Any]],
989
- jitter_unit: str,
990
- show_trend: bool,
991
- ) -> None:
992
- """Add trend line to plot.
993
-
994
- Args:
995
- ax: Matplotlib axes.
996
- time_axis: Time array.
997
- jitter_scaled: Scaled jitter values.
998
- jitter_unit: Unit string.
999
- show_trend: Whether to show trend.
1000
- """
1001
- if not show_trend:
1002
- return
1003
-
1004
- z = np.polyfit(time_axis, jitter_scaled, 1)
1005
- p = np.poly1d(z)
1006
- ax.plot(
1007
- time_axis, p(time_axis), "g-", linewidth=2, label=f"Trend: {z[0]:.2e} {jitter_unit}/unit"
1008
- )
1009
-
1010
-
1011
- def _format_jitter_trend_plot(
1012
- ax: Axes, time_unit: str, jitter_unit: str, title: str | None
1013
- ) -> None:
1014
- """Format jitter trend plot axes and labels.
1015
-
1016
- Args:
1017
- ax: Matplotlib axes.
1018
- time_unit: Time unit string.
1019
- jitter_unit: Jitter unit string.
1020
- title: Plot title.
1021
- """
1022
- ax.set_xlabel(f"Time ({time_unit})" if time_unit != "auto" else "Sample Index", fontsize=11)
1023
- ax.set_ylabel(f"Jitter ({jitter_unit})", fontsize=11)
1024
- ax.grid(True, alpha=0.3)
1025
- ax.legend(loc="upper right")
1026
-
1027
- final_title = title if title else "Jitter Trend Analysis"
1028
- ax.set_title(final_title, fontsize=12, fontweight="bold")
1029
-
1030
-
1031
- def _save_and_show_jitter_trend(fig: Figure, save_path: str | Path | None, show: bool) -> None:
1032
- """Save and/or show jitter trend plot.
1033
-
1034
- Args:
1035
- fig: Matplotlib figure.
1036
- save_path: Path to save file.
1037
- show: Whether to display interactively.
1038
- """
1039
- if save_path is not None:
1040
- fig.savefig(save_path, dpi=300, bbox_inches="tight")
1041
- if show:
1042
- plt.show()