oscura 0.7.0__py3-none-any.whl → 0.8.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 (40) hide show
  1. oscura/__init__.py +1 -1
  2. oscura/analyzers/eye/__init__.py +5 -1
  3. oscura/analyzers/eye/generation.py +501 -0
  4. oscura/analyzers/jitter/__init__.py +6 -6
  5. oscura/analyzers/jitter/timing.py +419 -0
  6. oscura/analyzers/patterns/__init__.py +28 -0
  7. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  8. oscura/analyzers/power/__init__.py +35 -12
  9. oscura/analyzers/statistics/__init__.py +4 -0
  10. oscura/analyzers/statistics/basic.py +149 -0
  11. oscura/analyzers/statistics/correlation.py +47 -6
  12. oscura/analyzers/waveform/__init__.py +2 -0
  13. oscura/analyzers/waveform/measurements.py +145 -23
  14. oscura/analyzers/waveform/spectral.py +361 -8
  15. oscura/automotive/__init__.py +1 -1
  16. oscura/automotive/dtc/data.json +102 -17
  17. oscura/core/config/loader.py +0 -1
  18. oscura/core/schemas/device_mapping.json +8 -2
  19. oscura/core/schemas/packet_format.json +24 -4
  20. oscura/core/schemas/protocol_definition.json +12 -2
  21. oscura/core/types.py +108 -0
  22. oscura/reporting/__init__.py +88 -1
  23. oscura/reporting/automation.py +348 -0
  24. oscura/reporting/citations.py +374 -0
  25. oscura/reporting/core.py +54 -0
  26. oscura/reporting/formatting/__init__.py +11 -0
  27. oscura/reporting/formatting/measurements.py +279 -0
  28. oscura/reporting/html.py +57 -0
  29. oscura/reporting/interpretation.py +431 -0
  30. oscura/reporting/summary.py +329 -0
  31. oscura/reporting/visualization.py +542 -0
  32. oscura/visualization/__init__.py +2 -1
  33. oscura/visualization/batch.py +521 -0
  34. oscura/workflows/__init__.py +2 -0
  35. oscura/workflows/waveform.py +783 -0
  36. {oscura-0.7.0.dist-info → oscura-0.8.0.dist-info}/METADATA +1 -1
  37. {oscura-0.7.0.dist-info → oscura-0.8.0.dist-info}/RECORD +40 -29
  38. {oscura-0.7.0.dist-info → oscura-0.8.0.dist-info}/WHEEL +0 -0
  39. {oscura-0.7.0.dist-info → oscura-0.8.0.dist-info}/entry_points.txt +0 -0
  40. {oscura-0.7.0.dist-info → oscura-0.8.0.dist-info}/licenses/LICENSE +0 -0
oscura/__init__.py CHANGED
@@ -53,7 +53,7 @@ try:
53
53
  __version__ = version("oscura")
54
54
  except Exception:
55
55
  # Fallback for development/testing when package not installed
56
- __version__ = "0.7.0"
56
+ __version__ = "0.8.0"
57
57
 
58
58
  __author__ = "Oscura Contributors"
59
59
 
@@ -15,7 +15,9 @@ References:
15
15
  OIF CEI: Common Electrical I/O
16
16
  """
17
17
 
18
- from oscura.analyzers.eye.diagram import (
18
+ # Backward compatibility
19
+ from oscura.analyzers.eye import generation as diagram
20
+ from oscura.analyzers.eye.generation import (
19
21
  EyeDiagram,
20
22
  generate_eye,
21
23
  generate_eye_from_edges,
@@ -36,6 +38,8 @@ __all__ = [
36
38
  # Metrics
37
39
  "EyeMetrics",
38
40
  "crossing_percentage",
41
+ # Backward compatibility
42
+ "diagram",
39
43
  "eye_contour",
40
44
  "eye_height",
41
45
  "eye_width",
@@ -0,0 +1,501 @@
1
+ """Eye diagram generation from serial data.
2
+
3
+ This module generates eye diagrams by folding waveform data
4
+ at the unit interval boundary.
5
+
6
+ Example:
7
+ >>> from oscura.analyzers.eye.diagram import generate_eye
8
+ >>> eye = generate_eye(trace, unit_interval=1e-9)
9
+ >>> print(f"Eye diagram: {eye.n_traces} traces, {eye.samples_per_ui} samples/UI")
10
+
11
+ References:
12
+ IEEE 802.3: Ethernet Physical Layer Specifications
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING
19
+
20
+ import numpy as np
21
+
22
+ from oscura.core.exceptions import AnalysisError, InsufficientDataError
23
+
24
+ if TYPE_CHECKING:
25
+ from numpy.typing import NDArray
26
+
27
+ from oscura.core.types import WaveformTrace
28
+
29
+
30
+ @dataclass
31
+ class EyeDiagram:
32
+ """Eye diagram data structure.
33
+
34
+ Attributes:
35
+ data: 2D array of eye traces (n_traces x samples_per_ui).
36
+ time_axis: Time axis in UI (0.0 to 2.0 for 2-UI eye).
37
+ unit_interval: Unit interval in seconds.
38
+ samples_per_ui: Number of samples per unit interval.
39
+ n_traces: Number of overlaid traces.
40
+ sample_rate: Original sample rate in Hz.
41
+ histogram: Optional 2D histogram (voltage x time bins).
42
+ voltage_bins: Bin edges for voltage axis.
43
+ time_bins: Bin edges for time axis.
44
+ """
45
+
46
+ data: NDArray[np.float64]
47
+ time_axis: NDArray[np.float64]
48
+ unit_interval: float
49
+ samples_per_ui: int
50
+ n_traces: int
51
+ sample_rate: float
52
+ histogram: NDArray[np.float64] | None = None
53
+ voltage_bins: NDArray[np.float64] | None = None
54
+ time_bins: NDArray[np.float64] | None = None
55
+
56
+
57
+ def generate_eye(
58
+ trace: WaveformTrace,
59
+ unit_interval: float,
60
+ *,
61
+ n_ui: int = 2,
62
+ trigger_level: float = 0.5,
63
+ trigger_edge: str = "rising",
64
+ max_traces: int | None = None,
65
+ generate_histogram: bool = True,
66
+ histogram_bins: tuple[int, int] = (100, 100),
67
+ ) -> EyeDiagram:
68
+ """Generate eye diagram from waveform data.
69
+
70
+ Folds the waveform at unit interval boundaries to create
71
+ an overlaid eye pattern for signal quality analysis.
72
+
73
+ Args:
74
+ trace: Input waveform trace.
75
+ unit_interval: Unit interval (bit period) in seconds.
76
+ n_ui: Number of unit intervals to display (1 or 2).
77
+ trigger_level: Trigger level as fraction of amplitude.
78
+ trigger_edge: Trigger on "rising" or "falling" edges.
79
+ max_traces: Maximum number of traces to include.
80
+ generate_histogram: Generate 2D histogram for persistence.
81
+ histogram_bins: (voltage_bins, time_bins) for histogram.
82
+
83
+ Returns:
84
+ EyeDiagram with overlaid traces and optional histogram.
85
+
86
+ Raises:
87
+ AnalysisError: If unit interval is too short.
88
+ InsufficientDataError: If not enough data for eye generation.
89
+
90
+ Example:
91
+ >>> eye = generate_eye(trace, unit_interval=1e-9)
92
+ >>> print(f"Generated {eye.n_traces} traces")
93
+
94
+ References:
95
+ OIF CEI: Common Electrical I/O Eye Diagram Methodology
96
+ """
97
+ data = trace.data
98
+ sample_rate = trace.metadata.sample_rate
99
+
100
+ samples_per_ui = _validate_unit_interval(unit_interval, sample_rate)
101
+ total_ui_samples = samples_per_ui * n_ui
102
+ _validate_data_length(len(data), total_ui_samples)
103
+
104
+ trigger_indices = _find_trigger_points(data, trigger_level, trigger_edge)
105
+ eye_traces = _extract_eye_traces(
106
+ data, trigger_indices, samples_per_ui, total_ui_samples, max_traces
107
+ )
108
+ eye_data = np.array(eye_traces, dtype=np.float64)
109
+ time_axis = np.linspace(0, n_ui, total_ui_samples, endpoint=False)
110
+
111
+ histogram, voltage_bins, time_bins = _generate_histogram_if_requested(
112
+ eye_data, time_axis, n_ui, generate_histogram, histogram_bins
113
+ )
114
+
115
+ return EyeDiagram(
116
+ data=eye_data,
117
+ time_axis=time_axis,
118
+ unit_interval=unit_interval,
119
+ samples_per_ui=samples_per_ui,
120
+ n_traces=len(eye_traces),
121
+ sample_rate=sample_rate,
122
+ histogram=histogram,
123
+ voltage_bins=voltage_bins,
124
+ time_bins=time_bins,
125
+ )
126
+
127
+
128
+ def _validate_unit_interval(unit_interval: float, sample_rate: float) -> int:
129
+ """Validate unit interval and calculate samples per UI."""
130
+ samples_per_ui = round(unit_interval * sample_rate)
131
+ if samples_per_ui < 4:
132
+ raise AnalysisError(
133
+ f"Unit interval too short: {samples_per_ui} samples/UI. Need at least 4 samples per UI."
134
+ )
135
+ return samples_per_ui
136
+
137
+
138
+ def _validate_data_length(n_samples: int, total_ui_samples: int) -> None:
139
+ """Validate that we have enough data for eye generation."""
140
+ if n_samples < total_ui_samples * 2:
141
+ raise InsufficientDataError(
142
+ f"Need at least {total_ui_samples * 2} samples for eye diagram",
143
+ required=total_ui_samples * 2,
144
+ available=n_samples,
145
+ analysis_type="eye_diagram_generation",
146
+ )
147
+
148
+
149
+ def _find_trigger_points(
150
+ data: NDArray[np.float64],
151
+ trigger_level: float,
152
+ trigger_edge: str,
153
+ ) -> NDArray[np.intp]:
154
+ """Find trigger points in the data."""
155
+ low = np.percentile(data, 10)
156
+ high = np.percentile(data, 90)
157
+ threshold = low + trigger_level * (high - low)
158
+
159
+ if trigger_edge == "rising":
160
+ trigger_mask = (data[:-1] < threshold) & (data[1:] >= threshold)
161
+ else:
162
+ trigger_mask = (data[:-1] >= threshold) & (data[1:] < threshold)
163
+
164
+ trigger_indices = np.where(trigger_mask)[0]
165
+
166
+ if len(trigger_indices) < 2:
167
+ raise InsufficientDataError(
168
+ "Not enough trigger events for eye diagram",
169
+ required=2,
170
+ available=len(trigger_indices),
171
+ analysis_type="eye_diagram_generation",
172
+ )
173
+
174
+ return trigger_indices
175
+
176
+
177
+ def _extract_eye_traces(
178
+ data: NDArray[np.float64],
179
+ trigger_indices: NDArray[np.intp],
180
+ samples_per_ui: int,
181
+ total_ui_samples: int,
182
+ max_traces: int | None,
183
+ ) -> list[NDArray[np.float64]]:
184
+ """Extract eye traces from data using trigger points."""
185
+ eye_traces = []
186
+ half_ui = samples_per_ui // 2
187
+ n_samples = len(data)
188
+
189
+ for trig_idx in trigger_indices:
190
+ start_idx = trig_idx - half_ui
191
+ end_idx = start_idx + total_ui_samples
192
+
193
+ if start_idx >= 0 and end_idx <= n_samples:
194
+ eye_traces.append(data[start_idx:end_idx])
195
+
196
+ if max_traces is not None and len(eye_traces) >= max_traces:
197
+ break
198
+
199
+ if len(eye_traces) == 0:
200
+ raise InsufficientDataError(
201
+ "Could not extract any complete eye traces",
202
+ required=1,
203
+ available=0,
204
+ analysis_type="eye_diagram_generation",
205
+ )
206
+
207
+ return eye_traces
208
+
209
+
210
+ def _generate_histogram_if_requested(
211
+ eye_data: NDArray[np.float64],
212
+ time_axis: NDArray[np.float64],
213
+ n_ui: int,
214
+ generate_histogram: bool,
215
+ histogram_bins: tuple[int, int],
216
+ ) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None, NDArray[np.float64] | None]:
217
+ """Generate 2D histogram if requested."""
218
+ if not generate_histogram:
219
+ return None, None, None
220
+
221
+ all_voltages = eye_data.flatten()
222
+ all_times = np.tile(time_axis, len(eye_data))
223
+
224
+ voltage_range = (np.min(all_voltages), np.max(all_voltages))
225
+ time_range = (0, n_ui)
226
+
227
+ histogram, voltage_edges, time_edges = np.histogram2d(
228
+ all_voltages,
229
+ all_times,
230
+ bins=histogram_bins,
231
+ range=[voltage_range, time_range],
232
+ )
233
+
234
+ return histogram, voltage_edges, time_edges
235
+
236
+
237
+ def generate_eye_from_edges(
238
+ trace: WaveformTrace,
239
+ edge_timestamps: NDArray[np.float64],
240
+ *,
241
+ n_ui: int = 2,
242
+ samples_per_ui: int = 100,
243
+ max_traces: int | None = None,
244
+ ) -> EyeDiagram:
245
+ """Generate eye diagram using recovered clock edges.
246
+
247
+ Uses pre-recovered clock edges for triggering, which can provide
248
+ more accurate alignment than threshold-based triggering.
249
+
250
+ Args:
251
+ trace: Input waveform trace.
252
+ edge_timestamps: Array of clock edge timestamps in seconds.
253
+ n_ui: Number of unit intervals to display.
254
+ samples_per_ui: Samples per UI in resampled eye.
255
+ max_traces: Maximum traces to include.
256
+
257
+ Returns:
258
+ EyeDiagram with overlaid traces.
259
+
260
+ Raises:
261
+ InsufficientDataError: If not enough edge timestamps or traces.
262
+
263
+ Example:
264
+ >>> edges = recover_clock_edges(trace)
265
+ >>> eye = generate_eye_from_edges(trace, edges)
266
+ """
267
+ data = trace.data
268
+ sample_rate = trace.metadata.sample_rate
269
+
270
+ if len(edge_timestamps) < 3:
271
+ raise InsufficientDataError(
272
+ "Need at least 3 edge timestamps",
273
+ required=3,
274
+ available=len(edge_timestamps),
275
+ analysis_type="eye_diagram_generation",
276
+ )
277
+
278
+ # Calculate unit interval from edges
279
+ periods = np.diff(edge_timestamps)
280
+ unit_interval = float(np.median(periods))
281
+
282
+ # Create time vector for original data
283
+ original_time = np.arange(len(data)) / sample_rate
284
+
285
+ # Extract and resample traces around each edge
286
+ eye_traces = []
287
+ total_samples = samples_per_ui * n_ui
288
+ half_ui = unit_interval / 2
289
+
290
+ for edge_time in edge_timestamps:
291
+ # Define window around edge
292
+ start_time = edge_time - half_ui
293
+ end_time = start_time + unit_interval * n_ui
294
+
295
+ if start_time < 0 or end_time > original_time[-1]:
296
+ continue
297
+
298
+ # Find samples within window
299
+ mask = (original_time >= start_time) & (original_time <= end_time)
300
+ window_time = original_time[mask] - start_time
301
+ window_data = data[mask]
302
+
303
+ if len(window_data) < 4:
304
+ continue
305
+
306
+ # Resample to consistent samples_per_ui
307
+ resample_time = np.linspace(0, unit_interval * n_ui, total_samples)
308
+ resampled = np.interp(resample_time, window_time, window_data)
309
+
310
+ eye_traces.append(resampled)
311
+
312
+ if max_traces is not None and len(eye_traces) >= max_traces:
313
+ break
314
+
315
+ if len(eye_traces) == 0:
316
+ raise InsufficientDataError(
317
+ "Could not extract any eye traces",
318
+ required=1,
319
+ available=0,
320
+ analysis_type="eye_diagram_generation",
321
+ )
322
+
323
+ eye_data = np.array(eye_traces, dtype=np.float64)
324
+ time_axis = np.linspace(0, n_ui, total_samples, endpoint=False)
325
+
326
+ return EyeDiagram(
327
+ data=eye_data,
328
+ time_axis=time_axis,
329
+ unit_interval=unit_interval,
330
+ samples_per_ui=samples_per_ui,
331
+ n_traces=len(eye_traces),
332
+ sample_rate=sample_rate,
333
+ )
334
+
335
+
336
+ def _calculate_trigger_threshold(data: NDArray[np.float64], trigger_fraction: float) -> float:
337
+ """Calculate trigger threshold from data amplitude.
338
+
339
+ Args:
340
+ data: Eye diagram data.
341
+ trigger_fraction: Trigger level as fraction of amplitude.
342
+
343
+ Returns:
344
+ Threshold value.
345
+ """
346
+ low = np.percentile(data, 10)
347
+ high = np.percentile(data, 90)
348
+ amplitude_range = high - low
349
+ threshold: float = float(low + trigger_fraction * amplitude_range)
350
+ return threshold
351
+
352
+
353
+ def _find_trace_crossings(data: NDArray[np.float64], threshold: float) -> list[int]:
354
+ """Find crossing indices for all traces.
355
+
356
+ Args:
357
+ data: Eye diagram trace data (n_traces x samples_per_trace).
358
+ threshold: Crossing threshold.
359
+
360
+ Returns:
361
+ List of crossing indices for traces with crossings.
362
+ """
363
+ n_traces, _samples_per_trace = data.shape
364
+ crossing_indices = []
365
+
366
+ for trace_idx in range(n_traces):
367
+ trace = data[trace_idx, :]
368
+ crossings = np.where((trace[:-1] < threshold) & (trace[1:] >= threshold))[0]
369
+
370
+ if len(crossings) > 0:
371
+ crossing_indices.append(crossings[0])
372
+
373
+ return crossing_indices
374
+
375
+
376
+ def _align_traces_to_target(
377
+ data: NDArray[np.float64], threshold: float, target_crossing: int
378
+ ) -> NDArray[np.float64]:
379
+ """Align all traces to target crossing position.
380
+
381
+ Args:
382
+ data: Eye diagram trace data.
383
+ threshold: Crossing threshold.
384
+ target_crossing: Target crossing position.
385
+
386
+ Returns:
387
+ Aligned trace data.
388
+ """
389
+ n_traces, _samples_per_trace = data.shape
390
+ aligned_data = np.zeros_like(data)
391
+
392
+ for trace_idx in range(n_traces):
393
+ trace = data[trace_idx, :]
394
+ crossings = np.where((trace[:-1] < threshold) & (trace[1:] >= threshold))[0]
395
+
396
+ if len(crossings) > 0:
397
+ crossing = crossings[0]
398
+ shift = target_crossing - crossing
399
+
400
+ if shift != 0:
401
+ aligned_data[trace_idx, :] = np.roll(trace, shift)
402
+ else:
403
+ aligned_data[trace_idx, :] = trace
404
+ else:
405
+ aligned_data[trace_idx, :] = trace
406
+
407
+ return aligned_data
408
+
409
+
410
+ def _apply_symmetric_centering(data: NDArray[np.float64]) -> NDArray[np.float64]:
411
+ """Apply symmetric amplitude centering if enabled.
412
+
413
+ Args:
414
+ data: Aligned trace data.
415
+
416
+ Returns:
417
+ Symmetrically centered data.
418
+ """
419
+ max_abs = np.max(np.abs(data))
420
+ if max_abs > 0:
421
+ data = data - np.mean(data)
422
+ return data
423
+
424
+
425
+ def auto_center_eye_diagram(
426
+ eye: EyeDiagram,
427
+ *,
428
+ trigger_fraction: float = 0.5,
429
+ symmetric_range: bool = True,
430
+ ) -> EyeDiagram:
431
+ """Auto-center eye diagram on optimal crossing point.
432
+
433
+ Automatically centers eye diagrams on the optimal trigger point
434
+ and scales amplitude for maximum eye opening visibility with
435
+ symmetric vertical centering.
436
+
437
+ Args:
438
+ eye: Input EyeDiagram to center.
439
+ trigger_fraction: Trigger level as fraction of amplitude (default 0.5 = 50%).
440
+ symmetric_range: Use symmetric amplitude range ±max(abs(signal)).
441
+
442
+ Returns:
443
+ Centered EyeDiagram with adjusted data.
444
+
445
+ Raises:
446
+ ValueError: If trigger_fraction is not in [0, 1].
447
+
448
+ Example:
449
+ >>> eye = generate_eye(trace, unit_interval=1e-9)
450
+ >>> centered = auto_center_eye_diagram(eye)
451
+ >>> # Centered at 50% crossing with symmetric amplitude
452
+
453
+ References:
454
+ VIS-021: Eye Diagram Auto-Centering
455
+ """
456
+ if not 0 <= trigger_fraction <= 1:
457
+ raise ValueError(f"trigger_fraction must be in [0, 1], got {trigger_fraction}")
458
+
459
+ # Setup: calculate threshold and find crossings
460
+ data = eye.data
461
+ threshold = _calculate_trigger_threshold(data, trigger_fraction)
462
+ crossing_indices = _find_trace_crossings(data, threshold)
463
+
464
+ if len(crossing_indices) == 0:
465
+ import warnings
466
+
467
+ warnings.warn(
468
+ "No crossing points found, cannot auto-center eye diagram",
469
+ UserWarning,
470
+ stacklevel=2,
471
+ )
472
+ return eye
473
+
474
+ # Processing: align traces to target crossing point
475
+ _n_traces, samples_per_trace = data.shape
476
+ target_crossing = samples_per_trace // 2
477
+ aligned_data = _align_traces_to_target(data, threshold, target_crossing)
478
+
479
+ # Result building: apply symmetric centering and create result
480
+ if symmetric_range:
481
+ aligned_data = _apply_symmetric_centering(aligned_data)
482
+
483
+ return EyeDiagram(
484
+ data=aligned_data,
485
+ time_axis=eye.time_axis,
486
+ unit_interval=eye.unit_interval,
487
+ samples_per_ui=eye.samples_per_ui,
488
+ n_traces=eye.n_traces,
489
+ sample_rate=eye.sample_rate,
490
+ histogram=None,
491
+ voltage_bins=None,
492
+ time_bins=None,
493
+ )
494
+
495
+
496
+ __all__ = [
497
+ "EyeDiagram",
498
+ "auto_center_eye_diagram",
499
+ "generate_eye",
500
+ "generate_eye_from_edges",
501
+ ]
@@ -40,7 +40,12 @@ from oscura.analyzers.jitter.decomposition import (
40
40
  extract_pj,
41
41
  extract_rj,
42
42
  )
43
- from oscura.analyzers.jitter.measurements import (
43
+ from oscura.analyzers.jitter.spectrum import (
44
+ JitterSpectrumResult,
45
+ identify_periodic_components,
46
+ jitter_spectrum,
47
+ )
48
+ from oscura.analyzers.jitter.timing import (
44
49
  CycleJitterResult,
45
50
  DutyCycleDistortionResult,
46
51
  cycle_to_cycle_jitter,
@@ -48,11 +53,6 @@ from oscura.analyzers.jitter.measurements import (
48
53
  period_jitter,
49
54
  tie_from_edges,
50
55
  )
51
- from oscura.analyzers.jitter.spectrum import (
52
- JitterSpectrumResult,
53
- identify_periodic_components,
54
- jitter_spectrum,
55
- )
56
56
 
57
57
  __all__ = [
58
58
  "BathtubCurveResult",