oscura 0.3.0__py3-none-any.whl → 0.5.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 (59) hide show
  1. oscura/__init__.py +1 -7
  2. oscura/acquisition/__init__.py +147 -0
  3. oscura/acquisition/file.py +255 -0
  4. oscura/acquisition/hardware.py +186 -0
  5. oscura/acquisition/saleae.py +340 -0
  6. oscura/acquisition/socketcan.py +315 -0
  7. oscura/acquisition/streaming.py +38 -0
  8. oscura/acquisition/synthetic.py +229 -0
  9. oscura/acquisition/visa.py +376 -0
  10. oscura/analyzers/__init__.py +3 -0
  11. oscura/analyzers/digital/__init__.py +48 -0
  12. oscura/analyzers/digital/clock.py +9 -1
  13. oscura/analyzers/digital/edges.py +1 -1
  14. oscura/analyzers/digital/extraction.py +195 -0
  15. oscura/analyzers/digital/ic_database.py +498 -0
  16. oscura/analyzers/digital/timing.py +41 -11
  17. oscura/analyzers/digital/timing_paths.py +339 -0
  18. oscura/analyzers/digital/vintage.py +377 -0
  19. oscura/analyzers/digital/vintage_result.py +148 -0
  20. oscura/analyzers/protocols/__init__.py +22 -1
  21. oscura/analyzers/protocols/parallel_bus.py +449 -0
  22. oscura/analyzers/side_channel/__init__.py +52 -0
  23. oscura/analyzers/side_channel/power.py +690 -0
  24. oscura/analyzers/side_channel/timing.py +369 -0
  25. oscura/analyzers/signal_integrity/sparams.py +1 -1
  26. oscura/automotive/__init__.py +4 -2
  27. oscura/automotive/can/patterns.py +3 -1
  28. oscura/automotive/can/session.py +277 -78
  29. oscura/automotive/can/state_machine.py +5 -2
  30. oscura/builders/__init__.py +9 -11
  31. oscura/builders/signal_builder.py +99 -191
  32. oscura/core/exceptions.py +5 -1
  33. oscura/export/__init__.py +12 -0
  34. oscura/export/wavedrom.py +430 -0
  35. oscura/exporters/json_export.py +47 -0
  36. oscura/exporters/vintage_logic_csv.py +247 -0
  37. oscura/loaders/__init__.py +1 -0
  38. oscura/loaders/chipwhisperer.py +393 -0
  39. oscura/loaders/touchstone.py +1 -1
  40. oscura/reporting/__init__.py +7 -0
  41. oscura/reporting/vintage_logic_report.py +523 -0
  42. oscura/session/session.py +54 -46
  43. oscura/sessions/__init__.py +70 -0
  44. oscura/sessions/base.py +323 -0
  45. oscura/sessions/blackbox.py +640 -0
  46. oscura/sessions/generic.py +189 -0
  47. oscura/utils/autodetect.py +5 -1
  48. oscura/visualization/digital_advanced.py +718 -0
  49. oscura/visualization/figure_manager.py +156 -0
  50. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/METADATA +86 -5
  51. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/RECORD +54 -33
  52. oscura/automotive/dtc/data.json +0 -2763
  53. oscura/schemas/bus_configuration.json +0 -322
  54. oscura/schemas/device_mapping.json +0 -182
  55. oscura/schemas/packet_format.json +0 -418
  56. oscura/schemas/protocol_definition.json +0 -363
  57. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/WHEEL +0 -0
  58. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/entry_points.txt +0 -0
  59. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,718 @@
1
+ """Advanced digital logic visualizations.
2
+
3
+ State-of-the-art visualization tools for digital logic analysis including:
4
+ - Logic analyzer-style timeline displays
5
+ - Multi-channel bus views with bus decoding
6
+ - Timing diagram annotations
7
+ - IC timing validation overlays
8
+ - Eye diagrams for signal quality
9
+
10
+ Example:
11
+ >>> from oscura.visualization.digital_advanced import plot_logic_analyzer_view
12
+ >>> plot_logic_analyzer_view(channels, title="8-bit Data Bus")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ import matplotlib.pyplot as plt
21
+ import numpy as np
22
+
23
+ if TYPE_CHECKING:
24
+ from matplotlib.axes import Axes
25
+ from matplotlib.figure import Figure
26
+ from numpy.typing import NDArray
27
+
28
+ from oscura.core.types import DigitalTrace, WaveformTrace
29
+
30
+
31
+ def plot_logic_analyzer_view(
32
+ channels: dict[str, WaveformTrace | DigitalTrace],
33
+ *,
34
+ title: str | None = None,
35
+ time_range: tuple[float, float] | None = None,
36
+ group_buses: dict[str, list[str]] | None = None,
37
+ show_hex: bool = True,
38
+ show_cursors: bool = True,
39
+ figsize: tuple[float, float] = (14, 8),
40
+ ) -> tuple[Figure, Axes]:
41
+ """Create logic analyzer-style timeline display.
42
+
43
+ Args:
44
+ channels: Dictionary mapping channel names to traces.
45
+ title: Optional plot title.
46
+ time_range: Optional (start, end) time range in seconds.
47
+ group_buses: Optional dict mapping bus names to channel lists.
48
+ show_hex: Show hexadecimal values for buses.
49
+ show_cursors: Show timing cursors.
50
+ figsize: Figure size in inches.
51
+
52
+ Returns:
53
+ Tuple of (figure, axes).
54
+
55
+ Example:
56
+ >>> channels = {f"D{i}": trace for i, trace in enumerate(data_lines)}
57
+ >>> group_buses = {"DATA": [f"D{i}" for i in range(8)]}
58
+ >>> fig, ax = plot_logic_analyzer_view(channels, group_buses=group_buses)
59
+ """
60
+ fig, ax = plt.subplots(figsize=figsize)
61
+
62
+ # Determine time range
63
+ first_trace = next(iter(channels.values()))
64
+ time_base = first_trace.metadata.time_base
65
+ total_time = len(first_trace.data) * time_base
66
+
67
+ t_start: float
68
+ t_end: float
69
+ if time_range is None:
70
+ t_start, t_end = 0.0, total_time
71
+ else:
72
+ t_start, t_end = time_range
73
+
74
+ # Calculate display window
75
+ start_idx = int(t_start / time_base)
76
+ end_idx = int(t_end / time_base)
77
+
78
+ # Create time array
79
+ time_array = np.arange(start_idx, end_idx) * time_base
80
+
81
+ # Plot channels from bottom to top
82
+ y_offset = 0.0
83
+ channel_positions: dict[str, float] = {}
84
+
85
+ # Handle bus grouping
86
+ if group_buses:
87
+ for bus_name, bus_channels in group_buses.items():
88
+ # Combine bus channels into values
89
+ bus_values = _combine_bus_channels(
90
+ {name: channels[name] for name in bus_channels}, start_idx, end_idx
91
+ )
92
+
93
+ # Plot bus as combined signal with hex labels
94
+ _plot_bus_signal(ax, time_array, bus_values, y_offset, bus_name, show_hex=show_hex)
95
+ channel_positions[bus_name] = y_offset
96
+ y_offset += 1.5 # Extra spacing for buses
97
+
98
+ # Remove individual channels from main list
99
+ for ch_name in bus_channels:
100
+ channels.pop(ch_name, None)
101
+ else:
102
+ group_buses = {}
103
+
104
+ # Plot remaining individual channels
105
+ for ch_name, trace in channels.items():
106
+ # Extract data in window
107
+ data = np.asarray(trace.data[start_idx:end_idx])
108
+
109
+ # Convert to digital if needed
110
+ if hasattr(data, "dtype") and data.dtype == bool:
111
+ digital_data = data.astype(float)
112
+ else:
113
+ # Threshold analog signal
114
+ threshold = (np.max(data) + np.min(data)) / 2
115
+ digital_data = (data >= threshold).astype(float)
116
+
117
+ # Plot digital waveform
118
+ _plot_digital_waveform(ax, time_array, digital_data, y_offset, ch_name)
119
+ channel_positions[ch_name] = y_offset
120
+ y_offset += 1
121
+
122
+ # Style the plot
123
+ ax.set_xlim(t_start, t_end)
124
+ ax.set_ylim(-0.5, y_offset + 0.5)
125
+ ax.set_xlabel("Time (s)", fontsize=12, fontweight="bold")
126
+ ax.set_ylabel("Channel", fontsize=12, fontweight="bold")
127
+
128
+ if title:
129
+ ax.set_title(title, fontsize=14, fontweight="bold")
130
+
131
+ # Add grid
132
+ ax.grid(True, which="both", alpha=0.3, linestyle="--")
133
+ ax.set_yticks([])
134
+
135
+ # Format time axis
136
+ _format_time_axis(ax, t_start, t_end)
137
+
138
+ # Add cursors if requested
139
+ if show_cursors:
140
+ _add_timing_cursors(ax, t_start, t_end, y_offset)
141
+
142
+ plt.tight_layout()
143
+ return fig, ax
144
+
145
+
146
+ def plot_timing_diagram_with_annotations(
147
+ signals: dict[str, WaveformTrace | DigitalTrace],
148
+ *,
149
+ timing_params: dict[str, tuple[float, float, str]] | None = None,
150
+ title: str | None = None,
151
+ reference_edges: dict[str, str] | None = None,
152
+ figsize: tuple[float, float] = (12, 6),
153
+ ) -> tuple[Figure, Axes]:
154
+ """Plot timing diagram with measurement annotations.
155
+
156
+ Args:
157
+ signals: Dictionary mapping signal names to traces.
158
+ timing_params: Dict of {name: (start_time, end_time, label)}.
159
+ title: Optional plot title.
160
+ reference_edges: Dict mapping signal names to edge types.
161
+ figsize: Figure size.
162
+
163
+ Returns:
164
+ Tuple of (figure, axes).
165
+
166
+ Example:
167
+ >>> signals = {"CLK": clk, "DATA": data}
168
+ >>> timing_params = {"t_su": (10e-9, 40e-9, "Setup = 30ns")}
169
+ >>> fig, ax = plot_timing_diagram_with_annotations(signals, timing_params=timing_params)
170
+ """
171
+ fig, ax = plt.subplots(figsize=figsize)
172
+
173
+ # Plot signals
174
+ y_offset = 0
175
+ signal_positions = {}
176
+
177
+ for sig_name, trace in signals.items():
178
+ # Get time array
179
+ time_base = trace.metadata.time_base
180
+ time_array = np.arange(len(trace.data)) * time_base
181
+ data = np.asarray(trace.data)
182
+
183
+ # Convert to digital
184
+ if data.dtype == bool:
185
+ digital_data = data.astype(float)
186
+ else:
187
+ threshold = (np.max(data) + np.min(data)) / 2
188
+ digital_data = (data >= threshold).astype(float)
189
+
190
+ # Offset for stacked view
191
+ plot_data = digital_data + y_offset
192
+
193
+ # Plot with nice edges
194
+ ax.plot(time_array, plot_data, linewidth=2, color="royalblue")
195
+ ax.fill_between(time_array, y_offset, plot_data, alpha=0.2, color="royalblue")
196
+
197
+ # Add label
198
+ ax.text(
199
+ -time_array[-1] * 0.02,
200
+ y_offset + 0.5,
201
+ sig_name,
202
+ ha="right",
203
+ va="center",
204
+ fontweight="bold",
205
+ )
206
+
207
+ signal_positions[sig_name] = y_offset
208
+ y_offset += 2
209
+
210
+ # Add timing annotations
211
+ if timing_params:
212
+ for t_start, t_end, label in timing_params.values():
213
+ _add_timing_annotation(ax, t_start, t_end, y_offset - 1, label)
214
+
215
+ # Style
216
+ ax.set_ylim(-0.5, y_offset + 0.5)
217
+ ax.set_xlabel("Time (s)", fontsize=12, fontweight="bold")
218
+ if title:
219
+ ax.set_title(title, fontsize=14, fontweight="bold")
220
+
221
+ ax.grid(True, alpha=0.3, linestyle="--")
222
+ ax.set_yticks([])
223
+
224
+ plt.tight_layout()
225
+ return fig, ax
226
+
227
+
228
+ def plot_ic_timing_validation(
229
+ signals: dict[str, WaveformTrace | DigitalTrace],
230
+ ic_name: str,
231
+ measured_timings: dict[str, float],
232
+ *,
233
+ figsize: tuple[float, float] = (14, 8),
234
+ ) -> tuple[Figure, Axes]:
235
+ """Plot timing diagram with IC specification overlay.
236
+
237
+ Args:
238
+ signals: Dictionary of signals.
239
+ ic_name: IC part number (e.g., "74LS74").
240
+ measured_timings: Measured timing parameters.
241
+ figsize: Figure size.
242
+
243
+ Returns:
244
+ Tuple of (figure, axes).
245
+
246
+ Example:
247
+ >>> signals = {"CLK": clk, "D": data, "Q": output}
248
+ >>> measured = {"t_su": 25e-9, "t_h": 5e-9, "t_pd": 40e-9}
249
+ >>> fig, ax = plot_ic_timing_validation(signals, "74LS74", measured)
250
+ """
251
+ from oscura.analyzers.digital.ic_database import validate_ic_timing
252
+
253
+ # Validate timings
254
+ validation = validate_ic_timing(ic_name, measured_timings)
255
+
256
+ # Plot timing diagram with custom figsize
257
+ fig, ax = plot_timing_diagram_with_annotations(
258
+ signals, title=f"{ic_name} Timing Validation", figsize=figsize
259
+ )
260
+
261
+ # Add validation results as text box
262
+ results_text = _format_validation_results(validation)
263
+ ax.text(
264
+ 0.98,
265
+ 0.98,
266
+ results_text,
267
+ transform=ax.transAxes,
268
+ fontsize=10,
269
+ verticalalignment="top",
270
+ horizontalalignment="right",
271
+ bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.8},
272
+ family="monospace",
273
+ )
274
+
275
+ return fig, ax
276
+
277
+
278
+ def plot_multi_ic_timing_path(
279
+ ic_chain: list[tuple[str, dict[str, WaveformTrace | DigitalTrace]]],
280
+ *,
281
+ title: str = "Multi-IC Timing Path Analysis",
282
+ figsize: tuple[float, float] = (16, 10),
283
+ ) -> tuple[Figure, Axes]:
284
+ """Plot timing analysis for cascaded ICs.
285
+
286
+ Args:
287
+ ic_chain: List of (ic_name, signals) tuples for each IC in chain.
288
+ title: Plot title.
289
+ figsize: Figure size.
290
+
291
+ Returns:
292
+ Tuple of (figure, axes).
293
+
294
+ Example:
295
+ >>> chain = [
296
+ ... ("74LS74", {"CLK": clk1, "Q": q1}),
297
+ ... ("74LS00", {"A": q1, "Y": y1}),
298
+ ... ("74LS74", {"D": y1, "Q": q2}),
299
+ ... ]
300
+ >>> fig, ax = plot_multi_ic_timing_path(chain)
301
+ """
302
+ fig, ax = plt.subplots(figsize=figsize)
303
+
304
+ y_offset = 0.0
305
+
306
+ for ic_name, signals in ic_chain:
307
+ # Plot this IC's signals
308
+ for sig_name, trace in signals.items():
309
+ time_base = trace.metadata.time_base
310
+ time_array = np.arange(len(trace.data)) * time_base
311
+ data = np.asarray(trace.data)
312
+
313
+ # Convert to digital
314
+ if data.dtype == bool:
315
+ digital_data = data.astype(float)
316
+ else:
317
+ threshold = (np.max(data) + np.min(data)) / 2
318
+ digital_data = (data >= threshold).astype(float)
319
+
320
+ # Plot
321
+ plot_data = digital_data + y_offset
322
+ ax.plot(time_array, plot_data, linewidth=2, label=f"{ic_name}.{sig_name}")
323
+
324
+ y_offset += 1.5
325
+
326
+ # Add IC boundary
327
+ ax.axhline(y_offset, color="gray", linestyle="--", alpha=0.5)
328
+ y_offset += 0.5
329
+
330
+ ax.set_xlabel("Time (s)", fontsize=12, fontweight="bold")
331
+ ax.set_title(title, fontsize=14, fontweight="bold")
332
+ ax.legend(loc="upper right")
333
+ ax.grid(True, alpha=0.3)
334
+ ax.set_yticks([])
335
+
336
+ plt.tight_layout()
337
+ return fig, ax
338
+
339
+
340
+ def plot_bus_eye_diagram(
341
+ bus_traces: list[WaveformTrace],
342
+ *,
343
+ symbol_period: float,
344
+ num_symbols: int = 100,
345
+ title: str | None = None,
346
+ figsize: tuple[float, float] = (10, 6),
347
+ ) -> tuple[Figure, Axes]:
348
+ """Plot eye diagram for bus signal quality analysis.
349
+
350
+ Args:
351
+ bus_traces: List of traces (one per bit).
352
+ symbol_period: Symbol period in seconds.
353
+ num_symbols: Number of symbols to overlay.
354
+ title: Optional plot title.
355
+ figsize: Figure size.
356
+
357
+ Returns:
358
+ Tuple of (figure, axes).
359
+ """
360
+ fig, axes = plt.subplots(len(bus_traces), 1, figsize=figsize, sharex=True)
361
+ if len(bus_traces) == 1:
362
+ axes = [axes]
363
+
364
+ for idx, trace in enumerate(bus_traces):
365
+ ax = axes[idx]
366
+
367
+ # Extract eye diagram data
368
+ sample_rate = trace.metadata.sample_rate
369
+ samples_per_symbol = int(symbol_period * sample_rate)
370
+
371
+ data = np.asarray(trace.data)
372
+
373
+ # Overlay symbols
374
+ for i in range(num_symbols):
375
+ start = i * samples_per_symbol
376
+ end = start + samples_per_symbol * 2 # Two symbol periods
377
+
378
+ if end > len(data):
379
+ break
380
+
381
+ segment = data[start:end]
382
+ time_segment = np.arange(len(segment)) / sample_rate
383
+
384
+ ax.plot(time_segment * 1e9, segment, alpha=0.1, color="blue")
385
+
386
+ ax.set_ylabel(f"Bit {idx}", fontweight="bold")
387
+ ax.grid(True, alpha=0.3)
388
+
389
+ axes[-1].set_xlabel("Time (ns)", fontweight="bold")
390
+
391
+ if title:
392
+ fig.suptitle(title, fontsize=14, fontweight="bold")
393
+
394
+ plt.tight_layout()
395
+ return fig, axes
396
+
397
+
398
+ # =============================================================================
399
+ # Helper Functions
400
+ # =============================================================================
401
+
402
+
403
+ def _plot_digital_waveform(
404
+ ax: Axes, time: NDArray[np.float64], data: NDArray[np.float64], y_offset: float, label: str
405
+ ) -> None:
406
+ """Plot a single digital waveform."""
407
+ # Offset data
408
+ plot_data = data + y_offset
409
+
410
+ # Plot with thick lines
411
+ ax.plot(time, plot_data, linewidth=2, color="royalblue", drawstyle="steps-post")
412
+
413
+ # Fill area
414
+ ax.fill_between(time, y_offset, plot_data, alpha=0.2, color="royalblue", step="post")
415
+
416
+ # Add label
417
+ ax.text(
418
+ time[0] - (time[-1] - time[0]) * 0.02,
419
+ y_offset + 0.5,
420
+ label,
421
+ ha="right",
422
+ va="center",
423
+ fontweight="bold",
424
+ fontsize=10,
425
+ )
426
+
427
+
428
+ def _plot_bus_signal(
429
+ ax: Axes,
430
+ time: NDArray[np.float64],
431
+ values: NDArray[np.uint32],
432
+ y_offset: float,
433
+ label: str,
434
+ *,
435
+ show_hex: bool = True,
436
+ ) -> None:
437
+ """Plot a bus signal with hex value labels."""
438
+ # Plot as multi-level signal
439
+ plot_data = values.astype(float) / np.max(values) if np.max(values) > 0 else values
440
+ plot_data = plot_data + y_offset
441
+
442
+ ax.plot(time, plot_data, linewidth=2, color="green", drawstyle="steps-post")
443
+ ax.fill_between(time, y_offset, plot_data, alpha=0.2, color="green", step="post")
444
+
445
+ # Add bus label
446
+ ax.text(
447
+ time[0] - (time[-1] - time[0]) * 0.02,
448
+ y_offset + 0.5,
449
+ label,
450
+ ha="right",
451
+ va="center",
452
+ fontweight="bold",
453
+ fontsize=10,
454
+ color="green",
455
+ )
456
+
457
+ # Add hex values at transitions
458
+ if show_hex:
459
+ transitions = np.where(np.diff(values) != 0)[0]
460
+ for trans_idx in transitions[:10]: # Limit to first 10 transitions
461
+ value = values[trans_idx + 1]
462
+ ax.text(
463
+ time[trans_idx + 1],
464
+ y_offset + 0.5,
465
+ f"0x{value:02X}",
466
+ ha="left",
467
+ va="bottom",
468
+ fontsize=8,
469
+ color="green",
470
+ )
471
+
472
+
473
+ def _combine_bus_channels(
474
+ bus_channels: dict[str, WaveformTrace | DigitalTrace], start_idx: int, end_idx: int
475
+ ) -> NDArray[np.uint32]:
476
+ """Combine individual bus lines into values."""
477
+ # Sort channels by name (assume D0, D1, D2, etc.)
478
+ sorted_channels = sorted(bus_channels.items(), key=lambda x: x[0])
479
+
480
+ # Initialize result
481
+ sorted_channels[0][1]
482
+ num_samples = end_idx - start_idx
483
+ result = np.zeros(num_samples, dtype=np.uint32)
484
+
485
+ # Combine bits
486
+ for bit_idx, (_ch_name, trace) in enumerate(sorted_channels):
487
+ data = np.asarray(trace.data[start_idx:end_idx])
488
+
489
+ # Convert to digital
490
+ if data.dtype == bool:
491
+ digital_data = data.astype(np.uint32)
492
+ else:
493
+ threshold = (np.max(data) + np.min(data)) / 2
494
+ digital_data = (data >= threshold).astype(np.uint32)
495
+
496
+ result |= (digital_data << bit_idx).astype(np.uint32)
497
+
498
+ return result
499
+
500
+
501
+ def _format_time_axis(ax: Axes, t_start: float, t_end: float) -> None:
502
+ """Format time axis with appropriate units."""
503
+ duration = t_end - t_start
504
+
505
+ # Get current tick locations
506
+ ticks = ax.get_xticks()
507
+
508
+ if duration < 1e-6: # Nanoseconds
509
+ ax.set_xlabel("Time (ns)", fontweight="bold")
510
+ ax.set_xticks(ticks) # Set ticks before labels
511
+ ax.set_xticklabels([f"{t * 1e9:.1f}" for t in ticks])
512
+ elif duration < 1e-3: # Microseconds
513
+ ax.set_xlabel("Time (μs)", fontweight="bold")
514
+ ax.set_xticks(ticks) # Set ticks before labels
515
+ ax.set_xticklabels([f"{t * 1e6:.1f}" for t in ticks])
516
+ elif duration < 1: # Milliseconds
517
+ ax.set_xlabel("Time (ms)", fontweight="bold")
518
+ ax.set_xticks(ticks) # Set ticks before labels
519
+ ax.set_xticklabels([f"{t * 1e3:.1f}" for t in ticks])
520
+ else: # Seconds
521
+ ax.set_xlabel("Time (s)", fontweight="bold")
522
+
523
+
524
+ def _add_timing_cursors(ax: Axes, t_start: float, t_end: float, y_max: float) -> None:
525
+ """Add timing measurement cursors."""
526
+ # Add two cursors at 1/4 and 3/4 of time range
527
+ cursor1_time = t_start + (t_end - t_start) * 0.25
528
+ cursor2_time = t_start + (t_end - t_start) * 0.75
529
+
530
+ ax.axvline(cursor1_time, color="red", linestyle="--", alpha=0.7, linewidth=1.5)
531
+ ax.axvline(cursor2_time, color="red", linestyle="--", alpha=0.7, linewidth=1.5)
532
+
533
+ # Add delta time label
534
+ delta_t = cursor2_time - cursor1_time
535
+ mid_y = y_max / 2
536
+
537
+ ax.annotate(
538
+ "",
539
+ xy=(cursor2_time, mid_y),
540
+ xytext=(cursor1_time, mid_y),
541
+ arrowprops={"arrowstyle": "<->", "color": "red", "lw": 2},
542
+ )
543
+
544
+ ax.text(
545
+ (cursor1_time + cursor2_time) / 2,
546
+ mid_y + 0.3,
547
+ f"Δt = {delta_t * 1e9:.1f} ns",
548
+ ha="center",
549
+ fontweight="bold",
550
+ bbox={"boxstyle": "round", "facecolor": "yellow", "alpha": 0.8},
551
+ )
552
+
553
+
554
+ def _add_timing_annotation(
555
+ ax: Axes, t_start: float, t_end: float, y_pos: float, label: str
556
+ ) -> None:
557
+ """Add timing measurement annotation."""
558
+ ax.annotate(
559
+ "",
560
+ xy=(t_end, y_pos),
561
+ xytext=(t_start, y_pos),
562
+ arrowprops={"arrowstyle": "<->", "color": "red", "lw": 2},
563
+ )
564
+
565
+ ax.text(
566
+ (t_start + t_end) / 2,
567
+ y_pos + 0.3,
568
+ label,
569
+ ha="center",
570
+ fontweight="bold",
571
+ bbox={"boxstyle": "round", "facecolor": "yellow", "alpha": 0.8},
572
+ )
573
+
574
+
575
+ def _format_validation_results(validation: dict[str, dict[str, Any]]) -> str:
576
+ """Format IC timing validation results."""
577
+ lines = ["Timing Validation:\n"]
578
+
579
+ for param, result in validation.items():
580
+ passes = result.get("passes")
581
+ if passes is None:
582
+ continue
583
+
584
+ measured = result["measured"]
585
+ spec = result["spec"]
586
+ error = result["error"]
587
+
588
+ status = "✓ PASS" if passes else "✗ FAIL"
589
+ lines.append(
590
+ f"{param}: {status}\n Measured: {measured * 1e9:.1f}ns\n Spec: {spec * 1e9:.1f}ns\n Error: {error * 100:.1f}%\n"
591
+ )
592
+
593
+ return "".join(lines)
594
+
595
+
596
+ def generate_all_vintage_logic_plots(
597
+ result: Any,
598
+ traces: dict[str, WaveformTrace | DigitalTrace],
599
+ *,
600
+ output_dir: str | Path | None = None,
601
+ save_formats: list[str] | None = None,
602
+ ) -> dict[str, tuple[Figure, Axes] | Figure]:
603
+ """Generate complete visualization suite for vintage logic analysis.
604
+
605
+ Creates all relevant plots based on analysis results. Optionally saves
606
+ figures to disk in multiple formats.
607
+
608
+ Args:
609
+ result: VintageLogicAnalysisResult object.
610
+ traces: Dictionary of channel names to traces.
611
+ output_dir: If provided, saves all figures to this directory.
612
+ save_formats: Formats to save ("png", "svg", "pdf"). Default: ["png"].
613
+
614
+ Returns:
615
+ Dictionary mapping plot names to Figure/Axes tuples.
616
+
617
+ Example:
618
+ >>> from oscura.analyzers.digital.vintage import analyze_vintage_logic
619
+ >>> result = analyze_vintage_logic(traces)
620
+ >>> plots = generate_all_vintage_logic_plots(result, traces, output_dir="./plots")
621
+ >>> # plots = {
622
+ >>> # "logic_analyzer": (fig, ax),
623
+ >>> # "timing_validation": (fig, ax),
624
+ >>> # ...
625
+ >>> # }
626
+ """
627
+ from oscura.visualization.figure_manager import FigureManager
628
+
629
+ plots: dict[str, tuple[Figure, Axes] | Figure] = {}
630
+
631
+ # Initialize figure manager if output directory provided
632
+ if output_dir:
633
+ output_path = Path(output_dir)
634
+ output_path.mkdir(parents=True, exist_ok=True)
635
+ fig_manager = FigureManager(output_path)
636
+ if save_formats is None:
637
+ save_formats = ["png"]
638
+ else:
639
+ fig_manager = None
640
+ save_formats = []
641
+
642
+ # 1. Logic analyzer view
643
+ try:
644
+ fig, ax = plot_logic_analyzer_view(
645
+ traces,
646
+ title=f"Logic Analyzer View - {result.detected_family}",
647
+ show_cursors=True,
648
+ )
649
+ plots["logic_analyzer"] = (fig, ax)
650
+ if fig_manager:
651
+ fig_manager.save_figure(fig, "logic_analyzer", formats=save_formats)
652
+ except Exception:
653
+ pass # Skip if plot fails
654
+
655
+ # 2. IC timing validation plots for each identified IC
656
+ for idx, ic_result in enumerate(result.identified_ics):
657
+ try:
658
+ # Create signals dictionary for timing validation
659
+ fig, ax = plot_ic_timing_validation(
660
+ signals=traces,
661
+ ic_name=ic_result.ic_name,
662
+ measured_timings=ic_result.timing_params,
663
+ )
664
+ plot_name = f"timing_validation_{ic_result.ic_name}_{idx}"
665
+ plots[plot_name] = (fig, ax)
666
+ if fig_manager:
667
+ fig_manager.save_figure(fig, plot_name, formats=save_formats)
668
+ except Exception:
669
+ pass # Skip if plot fails
670
+
671
+ # 3. Multi-IC timing path visualization
672
+ if result.timing_paths:
673
+ for idx, path_result in enumerate(result.timing_paths):
674
+ try:
675
+ fig, ax = plot_multi_ic_timing_path(path_result)
676
+ plot_name = f"timing_path_{idx}"
677
+ plots[plot_name] = (fig, ax)
678
+ if fig_manager:
679
+ fig_manager.save_figure(fig, plot_name, formats=save_formats)
680
+ except Exception:
681
+ pass # Skip if plot fails
682
+
683
+ # 4. Timing diagram with annotations (for first 2-3 channels)
684
+ if len(traces) >= 2:
685
+ try:
686
+ # Select first 2-3 channels
687
+ selected_traces = dict(list(traces.items())[: min(3, len(traces))])
688
+
689
+ # Create timing annotations from measurements
690
+ timing_params: dict[str, tuple[float, float, str]] = {}
691
+ for key, value in result.timing_measurements.items():
692
+ # Extract channel names from key like "CLK→DATA_t_pd"
693
+ if "→" in key:
694
+ label = key.split("_")[-1] # Get timing parameter
695
+ timing_params[label] = (0.0, float(value), label)
696
+
697
+ fig, ax = plot_timing_diagram_with_annotations(
698
+ selected_traces,
699
+ timing_params=timing_params or None,
700
+ title="Timing Diagram with Measurements",
701
+ )
702
+ plots["timing_diagram"] = (fig, ax)
703
+ if fig_manager:
704
+ fig_manager.save_figure(fig, "timing_diagram", formats=save_formats)
705
+ except Exception:
706
+ pass # Skip if plot fails
707
+
708
+ return plots
709
+
710
+
711
+ __all__ = [
712
+ "generate_all_vintage_logic_plots",
713
+ "plot_bus_eye_diagram",
714
+ "plot_ic_timing_validation",
715
+ "plot_logic_analyzer_view",
716
+ "plot_multi_ic_timing_path",
717
+ "plot_timing_diagram_with_annotations",
718
+ ]