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,430 @@
1
+ """WaveDrom timing diagram generation.
2
+
3
+ Creates WaveDrom JSON format timing diagrams from digital signals.
4
+ WaveDrom format can be rendered as SVG/PNG using wavedrom-cli or online tools.
5
+
6
+ Example:
7
+ >>> from oscura.export.wavedrom import export_wavedrom, WaveDromBuilder
8
+ >>> builder = WaveDromBuilder()
9
+ >>> builder.add_clock("CLK", period=100e-9)
10
+ >>> builder.add_signal("DATA", edges=[10e-9, 50e-9, 150e-9])
11
+ >>> builder.add_arrow(10e-9, 40e-9, "t_su = 30ns")
12
+ >>> json_output = builder.to_json()
13
+ >>> export_wavedrom(json_output, "timing_diagram.json")
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any, Literal
22
+
23
+ import numpy as np
24
+
25
+ if TYPE_CHECKING:
26
+ from oscura.core.types import DigitalTrace, WaveformTrace
27
+
28
+
29
+ @dataclass
30
+ class WaveDromSignal:
31
+ """A WaveDrom signal definition.
32
+
33
+ Attributes:
34
+ name: Signal name.
35
+ wave: Wave string (WaveDrom format).
36
+ data: Optional data labels.
37
+ node: Optional node markers for arrows.
38
+ """
39
+
40
+ name: str
41
+ wave: str
42
+ data: list[str] | None = None
43
+ node: str | None = None
44
+
45
+
46
+ @dataclass
47
+ class WaveDromEdge:
48
+ """A WaveDrom edge/arrow annotation.
49
+
50
+ Attributes:
51
+ from_node: Source node name.
52
+ to_node: Destination node name.
53
+ label: Arrow label text.
54
+ style: Arrow style.
55
+ """
56
+
57
+ from_node: str
58
+ to_node: str
59
+ label: str
60
+ style: str = ""
61
+
62
+
63
+ class WaveDromBuilder:
64
+ """Builder for WaveDrom timing diagrams.
65
+
66
+ Example:
67
+ >>> builder = WaveDromBuilder(title="74LS74 Setup Time")
68
+ >>> builder.add_clock("CLK", period=100e-9, start_time=0)
69
+ >>> builder.add_signal("D", edges=[10e-9, 50e-9])
70
+ >>> builder.add_arrow(10e-9, 40e-9, "t_su = 30ns")
71
+ >>> json_str = builder.to_json()
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ *,
77
+ title: str | None = None,
78
+ time_scale: float = 1e-9, # Default to nanoseconds
79
+ ):
80
+ """Initialize WaveDrom builder.
81
+
82
+ Args:
83
+ title: Optional diagram title.
84
+ time_scale: Time scale for signal conversion (default 1ns).
85
+ """
86
+ self.title = title
87
+ self.time_scale = time_scale
88
+ self.signals: list[WaveDromSignal] = []
89
+ self.edges: list[WaveDromEdge] = []
90
+ self.config: dict[str, Any] = {"hscale": 2, "skin": "narrow"}
91
+ self._time_offset = 0.0
92
+ self._time_end = 0.0
93
+
94
+ def add_clock(
95
+ self,
96
+ name: str,
97
+ *,
98
+ period: float,
99
+ start_time: float = 0.0,
100
+ duty_cycle: float = 0.5,
101
+ initial_state: Literal["high", "low"] = "low",
102
+ duration: float | None = None,
103
+ ) -> None:
104
+ """Add a clock signal.
105
+
106
+ Args:
107
+ name: Signal name.
108
+ period: Clock period in seconds.
109
+ start_time: Start time in seconds.
110
+ duty_cycle: Duty cycle (0.0-1.0).
111
+ initial_state: Initial clock state.
112
+ duration: Optional duration (defaults to auto-calculated).
113
+ """
114
+ if duration is None:
115
+ duration = max(self._time_end - start_time, period * 10)
116
+
117
+ # Convert to time steps
118
+ time_steps = int((start_time - self._time_offset) / self.time_scale)
119
+ num_periods = int(duration / period)
120
+
121
+ # Build wave string
122
+ wave = "." * time_steps # Leading dots
123
+
124
+ if initial_state == "low":
125
+ wave += "p" * num_periods # pulsing clock
126
+ else:
127
+ wave += "n" * num_periods # inverted pulsing clock
128
+
129
+ self.signals.append(WaveDromSignal(name=name, wave=wave))
130
+ self._time_end = max(self._time_end, start_time + duration)
131
+
132
+ def add_signal(
133
+ self,
134
+ name: str,
135
+ *,
136
+ edges: list[float] | None = None,
137
+ wave_string: str | None = None,
138
+ data: list[str] | None = None,
139
+ nodes: str | None = None,
140
+ ) -> None:
141
+ """Add a digital signal.
142
+
143
+ Args:
144
+ name: Signal name.
145
+ edges: List of edge timestamps (rising/falling alternating).
146
+ wave_string: Direct WaveDrom wave string (overrides edges).
147
+ data: Optional data labels.
148
+ nodes: Optional node markers (e.g., "A.B.C").
149
+ """
150
+ if wave_string is not None:
151
+ # Use direct wave string
152
+ self.signals.append(WaveDromSignal(name=name, wave=wave_string, data=data, node=nodes))
153
+ return
154
+
155
+ if edges is None:
156
+ raise ValueError("Must provide either edges or wave_string")
157
+
158
+ # Convert edges to wave string
159
+ wave = self._edges_to_wave(edges)
160
+ self.signals.append(WaveDromSignal(name=name, wave=wave, data=data, node=nodes))
161
+
162
+ def add_data_bus(
163
+ self,
164
+ name: str,
165
+ *,
166
+ transitions: list[tuple[float, str]],
167
+ initial_value: str = "x",
168
+ ) -> None:
169
+ """Add a data bus with labeled values.
170
+
171
+ Args:
172
+ name: Bus name.
173
+ transitions: List of (timestamp, value) tuples.
174
+ initial_value: Initial bus value.
175
+ """
176
+ if not transitions:
177
+ raise ValueError("Must provide at least one transition")
178
+
179
+ # Sort by timestamp
180
+ transitions_sorted = sorted(transitions, key=lambda x: x[0])
181
+
182
+ # Build wave string and data labels
183
+ wave = ""
184
+ data: list[str] = []
185
+ current_time = self._time_offset
186
+
187
+ for timestamp, value in transitions_sorted:
188
+ # Add stable periods
189
+ steps = int((timestamp - current_time) / self.time_scale)
190
+ if steps > 0:
191
+ wave += "." * (steps - 1)
192
+
193
+ # Add transition
194
+ wave += "="
195
+ data.append(value)
196
+ current_time = timestamp
197
+
198
+ self.signals.append(WaveDromSignal(name=name, wave=wave, data=data))
199
+
200
+ def add_arrow(
201
+ self,
202
+ from_time: float,
203
+ to_time: float,
204
+ label: str,
205
+ *,
206
+ from_signal_idx: int = 0,
207
+ to_signal_idx: int = 0,
208
+ ) -> None:
209
+ """Add an arrow annotation between two time points.
210
+
211
+ Args:
212
+ from_time: Start time in seconds.
213
+ to_time: End time in seconds.
214
+ label: Arrow label text.
215
+ from_signal_idx: Source signal index.
216
+ to_signal_idx: Destination signal index.
217
+ """
218
+ # Create node markers
219
+ from_node = f"A{len(self.edges)}"
220
+ to_node = f"B{len(self.edges)}"
221
+
222
+ # Add nodes to signals (simplified - would need to track positions)
223
+ self.edges.append(WaveDromEdge(from_node=from_node, to_node=to_node, label=label))
224
+
225
+ def add_group(self, name: str, signals: list[WaveDromSignal]) -> None:
226
+ """Add a group of signals.
227
+
228
+ Args:
229
+ name: Group name.
230
+ signals: List of signals in group.
231
+ """
232
+ # WaveDrom groups are represented as nested lists
233
+ # This is a simplified implementation
234
+
235
+ def _edges_to_wave(self, edges: list[float]) -> str:
236
+ """Convert edge timestamps to WaveDrom wave string.
237
+
238
+ Args:
239
+ edges: List of edge timestamps.
240
+
241
+ Returns:
242
+ WaveDrom wave string.
243
+ """
244
+ if not edges:
245
+ return "0"
246
+
247
+ wave = ""
248
+ current_time = self._time_offset
249
+ state = 0 # Start low
250
+
251
+ for edge_time in sorted(edges):
252
+ # Calculate steps to this edge
253
+ steps = int((edge_time - current_time) / self.time_scale)
254
+
255
+ # Add stable period
256
+ if steps > 0:
257
+ wave += "." * (steps - 1)
258
+
259
+ # Add transition
260
+ if state == 0:
261
+ wave += "1" # Rising edge
262
+ state = 1
263
+ else:
264
+ wave += "0" # Falling edge
265
+ state = 0
266
+
267
+ current_time = edge_time
268
+
269
+ return wave if wave else "0"
270
+
271
+ def to_dict(self) -> dict[str, Any]:
272
+ """Export to WaveDrom dictionary.
273
+
274
+ Returns:
275
+ Dictionary in WaveDrom JSON format.
276
+ """
277
+ result: dict[str, Any] = {}
278
+
279
+ if self.title:
280
+ result["head"] = {"text": self.title}
281
+
282
+ # Build signal list
283
+ signal_list: list[dict[str, Any]] = []
284
+ for sig in self.signals:
285
+ sig_dict: dict[str, Any] = {"name": sig.name, "wave": sig.wave}
286
+ if sig.data:
287
+ sig_dict["data"] = sig.data
288
+ if sig.node:
289
+ sig_dict["node"] = sig.node
290
+ signal_list.append(sig_dict)
291
+
292
+ result["signal"] = signal_list
293
+
294
+ # Add edges if any
295
+ if self.edges:
296
+ edge_list = [f"{e.from_node}{e.style}>{e.to_node} {e.label}" for e in self.edges]
297
+ result["edge"] = edge_list
298
+
299
+ # Add configuration
300
+ if self.config:
301
+ result["config"] = self.config
302
+
303
+ return result
304
+
305
+ def to_json(self, *, indent: int = 2) -> str:
306
+ """Export to WaveDrom JSON string.
307
+
308
+ Args:
309
+ indent: JSON indentation level.
310
+
311
+ Returns:
312
+ JSON string.
313
+ """
314
+ return json.dumps(self.to_dict(), indent=indent)
315
+
316
+ def save(self, filepath: str | Path) -> None:
317
+ """Save to file.
318
+
319
+ Args:
320
+ filepath: Output file path.
321
+ """
322
+ filepath = Path(filepath)
323
+ with filepath.open("w") as f:
324
+ f.write(self.to_json())
325
+
326
+
327
+ def from_digital_trace(
328
+ trace: DigitalTrace,
329
+ *,
330
+ name: str = "signal",
331
+ start_time: float = 0.0,
332
+ duration: float | None = None,
333
+ ) -> WaveDromSignal:
334
+ """Create WaveDrom signal from DigitalTrace.
335
+
336
+ Args:
337
+ trace: Input digital trace.
338
+ name: Signal name.
339
+ start_time: Start time offset.
340
+ duration: Optional duration limit.
341
+
342
+ Returns:
343
+ WaveDromSignal object.
344
+ """
345
+ # Extract edges from digital trace
346
+ data = trace.data
347
+ transitions = np.diff(data.astype(np.int8))
348
+
349
+ edges: list[float] = []
350
+ time_base = trace.metadata.time_base
351
+
352
+ for i, trans in enumerate(transitions):
353
+ if trans != 0:
354
+ edges.append((i + 1) * time_base + start_time)
355
+
356
+ # Limit duration if specified
357
+ if duration is not None:
358
+ edges = [e for e in edges if e < start_time + duration]
359
+
360
+ # Build wave string
361
+ builder = WaveDromBuilder(time_scale=time_base)
362
+ builder.add_signal(name, edges=edges)
363
+
364
+ return builder.signals[0]
365
+
366
+
367
+ def export_wavedrom(
368
+ signals: dict[str, WaveformTrace | DigitalTrace],
369
+ filepath: str | Path,
370
+ *,
371
+ title: str | None = None,
372
+ time_scale: float = 1e-9,
373
+ annotations: list[tuple[float, float, str]] | None = None,
374
+ ) -> None:
375
+ """Export signals to WaveDrom JSON file.
376
+
377
+ Args:
378
+ signals: Dictionary mapping signal names to traces.
379
+ filepath: Output file path.
380
+ title: Optional diagram title.
381
+ time_scale: Time scale for conversion (default 1ns).
382
+ annotations: Optional list of (from_time, to_time, label) tuples.
383
+
384
+ Example:
385
+ >>> signals = {
386
+ ... "CLK": clock_trace,
387
+ ... "DATA": data_trace,
388
+ ... "CS": cs_trace,
389
+ ... }
390
+ >>> export_wavedrom(signals, "timing.json", title="SPI Transaction")
391
+ """
392
+ builder = WaveDromBuilder(title=title, time_scale=time_scale)
393
+
394
+ # Add signals
395
+ for name, trace in signals.items():
396
+ # Detect if clock-like
397
+ from oscura.analyzers.waveform.measurements import frequency
398
+ from oscura.core.types import WaveformTrace
399
+
400
+ # frequency() only works with WaveformTrace, skip for DigitalTrace
401
+ if isinstance(trace, WaveformTrace):
402
+ freq = frequency(trace)
403
+ else:
404
+ freq = np.nan
405
+ if not np.isnan(freq) and freq > 0:
406
+ # Looks like a clock
407
+ period = float(1.0 / freq)
408
+ builder.add_clock(name, period=period)
409
+ else:
410
+ # Regular signal - extract edges
411
+ from oscura.analyzers.digital import detect_edges
412
+
413
+ edges = detect_edges(trace)
414
+ builder.add_signal(name, edges=list(edges))
415
+
416
+ # Add annotations
417
+ if annotations:
418
+ for from_t, to_t, label in annotations:
419
+ builder.add_arrow(from_t, to_t, label)
420
+
421
+ builder.save(filepath)
422
+
423
+
424
+ __all__ = [
425
+ "WaveDromBuilder",
426
+ "WaveDromEdge",
427
+ "WaveDromSignal",
428
+ "export_wavedrom",
429
+ "from_digital_trace",
430
+ ]
@@ -263,6 +263,52 @@ def export_protocol_decode(
263
263
  json.dump(output, f, cls=OscuraJSONEncoder)
264
264
 
265
265
 
266
+ def export_vintage_logic_json(
267
+ result: Any,
268
+ path: str | Path,
269
+ *,
270
+ indent: int = 2,
271
+ include_metadata: bool = True,
272
+ ) -> None:
273
+ """Export vintage logic analysis results to JSON.
274
+
275
+ Convenience function for exporting VintageLogicAnalysisResult objects.
276
+ The dataclass is automatically serialized to JSON with all nested objects.
277
+
278
+ Args:
279
+ result: VintageLogicAnalysisResult object.
280
+ path: Output JSON file path.
281
+ indent: Indentation level for pretty printing (default: 2).
282
+ include_metadata: Include export metadata (default: True).
283
+
284
+ Example:
285
+ >>> from oscura.analyzers.digital.vintage import analyze_vintage_logic
286
+ >>> result = analyze_vintage_logic(traces)
287
+ >>> export_vintage_logic_json(result, "analysis_results.json")
288
+ """
289
+ path = Path(path)
290
+
291
+ output: dict[str, Any] = {}
292
+
293
+ if include_metadata:
294
+ output["_metadata"] = {
295
+ "format": "oscura_vintage_logic",
296
+ "version": "1.0",
297
+ "exported_at": datetime.now().isoformat(),
298
+ "analysis_timestamp": result.timestamp.isoformat(),
299
+ }
300
+
301
+ output["analysis_result"] = result
302
+
303
+ # Sanitize to handle inf/nan
304
+ from oscura.reporting.output import _sanitize_for_serialization
305
+
306
+ output = _sanitize_for_serialization(output)
307
+
308
+ with open(path, "w") as f:
309
+ json.dump(output, f, cls=OscuraJSONEncoder, indent=indent)
310
+
311
+
266
312
  def load_json(path: str | Path) -> dict[str, Any]:
267
313
  """Load JSON data file.
268
314
 
@@ -287,5 +333,6 @@ __all__ = [
287
333
  "export_json",
288
334
  "export_measurements",
289
335
  "export_protocol_decode",
336
+ "export_vintage_logic_json",
290
337
  "load_json",
291
338
  ]