oscura 0.8.0__py3-none-any.whl → 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/__main__.py +4 -0
  3. oscura/analyzers/__init__.py +2 -0
  4. oscura/analyzers/digital/extraction.py +2 -3
  5. oscura/analyzers/digital/quality.py +1 -1
  6. oscura/analyzers/digital/timing.py +1 -1
  7. oscura/analyzers/ml/signal_classifier.py +6 -0
  8. oscura/analyzers/patterns/__init__.py +66 -0
  9. oscura/analyzers/power/basic.py +3 -3
  10. oscura/analyzers/power/soa.py +1 -1
  11. oscura/analyzers/power/switching.py +3 -3
  12. oscura/analyzers/signal_classification.py +529 -0
  13. oscura/analyzers/signal_integrity/sparams.py +3 -3
  14. oscura/analyzers/statistics/basic.py +10 -7
  15. oscura/analyzers/validation.py +1 -1
  16. oscura/analyzers/waveform/measurements.py +200 -156
  17. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  18. oscura/analyzers/waveform/spectral.py +182 -84
  19. oscura/api/dsl/commands.py +15 -6
  20. oscura/api/server/templates/base.html +137 -146
  21. oscura/api/server/templates/export.html +84 -110
  22. oscura/api/server/templates/home.html +248 -267
  23. oscura/api/server/templates/protocols.html +44 -48
  24. oscura/api/server/templates/reports.html +27 -35
  25. oscura/api/server/templates/session_detail.html +68 -78
  26. oscura/api/server/templates/sessions.html +62 -72
  27. oscura/api/server/templates/waveforms.html +54 -64
  28. oscura/automotive/__init__.py +1 -1
  29. oscura/automotive/can/session.py +1 -1
  30. oscura/automotive/dbc/generator.py +638 -23
  31. oscura/automotive/dtc/data.json +17 -102
  32. oscura/automotive/flexray/fibex.py +9 -1
  33. oscura/automotive/uds/decoder.py +99 -6
  34. oscura/cli/analyze.py +8 -2
  35. oscura/cli/batch.py +36 -5
  36. oscura/cli/characterize.py +18 -4
  37. oscura/cli/export.py +47 -5
  38. oscura/cli/main.py +2 -0
  39. oscura/cli/onboarding/wizard.py +10 -6
  40. oscura/cli/pipeline.py +585 -0
  41. oscura/cli/visualize.py +6 -4
  42. oscura/convenience.py +400 -32
  43. oscura/core/measurement_result.py +286 -0
  44. oscura/core/progress.py +1 -1
  45. oscura/core/schemas/device_mapping.json +2 -8
  46. oscura/core/schemas/packet_format.json +4 -24
  47. oscura/core/schemas/protocol_definition.json +2 -12
  48. oscura/core/types.py +232 -239
  49. oscura/correlation/multi_protocol.py +1 -1
  50. oscura/export/legacy/__init__.py +11 -0
  51. oscura/export/legacy/wav.py +75 -0
  52. oscura/exporters/__init__.py +19 -0
  53. oscura/exporters/wireshark.py +809 -0
  54. oscura/hardware/acquisition/file.py +5 -19
  55. oscura/hardware/acquisition/saleae.py +10 -10
  56. oscura/hardware/acquisition/socketcan.py +4 -6
  57. oscura/hardware/acquisition/synthetic.py +1 -5
  58. oscura/hardware/acquisition/visa.py +6 -6
  59. oscura/hardware/security/side_channel_detector.py +5 -508
  60. oscura/inference/message_format.py +686 -1
  61. oscura/jupyter/display.py +2 -2
  62. oscura/jupyter/magic.py +3 -3
  63. oscura/loaders/__init__.py +17 -12
  64. oscura/loaders/binary.py +1 -1
  65. oscura/loaders/chipwhisperer.py +1 -2
  66. oscura/loaders/configurable.py +1 -1
  67. oscura/loaders/csv_loader.py +2 -2
  68. oscura/loaders/hdf5_loader.py +1 -1
  69. oscura/loaders/lazy.py +6 -1
  70. oscura/loaders/mmap_loader.py +0 -1
  71. oscura/loaders/numpy_loader.py +8 -7
  72. oscura/loaders/preprocessing.py +3 -5
  73. oscura/loaders/rigol.py +21 -7
  74. oscura/loaders/sigrok.py +2 -5
  75. oscura/loaders/tdms.py +3 -2
  76. oscura/loaders/tektronix.py +38 -32
  77. oscura/loaders/tss.py +20 -27
  78. oscura/loaders/validation.py +17 -10
  79. oscura/loaders/vcd.py +13 -8
  80. oscura/loaders/wav.py +1 -6
  81. oscura/pipeline/__init__.py +76 -0
  82. oscura/pipeline/handlers/__init__.py +165 -0
  83. oscura/pipeline/handlers/analyzers.py +1045 -0
  84. oscura/pipeline/handlers/decoders.py +899 -0
  85. oscura/pipeline/handlers/exporters.py +1103 -0
  86. oscura/pipeline/handlers/filters.py +891 -0
  87. oscura/pipeline/handlers/loaders.py +640 -0
  88. oscura/pipeline/handlers/transforms.py +768 -0
  89. oscura/reporting/formatting/measurements.py +55 -14
  90. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  91. oscura/sessions/legacy.py +49 -1
  92. oscura/side_channel/__init__.py +38 -57
  93. oscura/utils/builders/signal_builder.py +5 -5
  94. oscura/utils/comparison/compare.py +7 -9
  95. oscura/utils/comparison/golden.py +1 -1
  96. oscura/utils/filtering/convenience.py +2 -2
  97. oscura/utils/math/arithmetic.py +38 -62
  98. oscura/utils/math/interpolation.py +20 -20
  99. oscura/utils/pipeline/__init__.py +4 -17
  100. oscura/utils/progressive.py +1 -4
  101. oscura/utils/triggering/edge.py +1 -1
  102. oscura/utils/triggering/pattern.py +2 -2
  103. oscura/utils/triggering/pulse.py +2 -2
  104. oscura/utils/triggering/window.py +3 -3
  105. oscura/validation/hil_testing.py +11 -11
  106. oscura/visualization/__init__.py +46 -284
  107. oscura/visualization/batch.py +72 -433
  108. oscura/visualization/plot.py +542 -53
  109. oscura/visualization/styles.py +184 -318
  110. oscura/workflows/batch/advanced.py +1 -1
  111. oscura/workflows/batch/aggregate.py +12 -9
  112. oscura/workflows/complete_re.py +251 -23
  113. oscura/workflows/digital.py +27 -4
  114. oscura/workflows/multi_trace.py +136 -17
  115. oscura/workflows/waveform.py +11 -6
  116. oscura-0.11.0.dist-info/METADATA +460 -0
  117. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/RECORD +120 -145
  118. oscura/side_channel/dpa.py +0 -1025
  119. oscura/utils/optimization/__init__.py +0 -19
  120. oscura/utils/optimization/parallel.py +0 -443
  121. oscura/utils/optimization/search.py +0 -532
  122. oscura/utils/pipeline/base.py +0 -338
  123. oscura/utils/pipeline/composition.py +0 -248
  124. oscura/utils/pipeline/parallel.py +0 -449
  125. oscura/utils/pipeline/pipeline.py +0 -375
  126. oscura/utils/search/__init__.py +0 -16
  127. oscura/utils/search/anomaly.py +0 -424
  128. oscura/utils/search/context.py +0 -294
  129. oscura/utils/search/pattern.py +0 -288
  130. oscura/utils/storage/__init__.py +0 -61
  131. oscura/utils/storage/database.py +0 -1166
  132. oscura/visualization/accessibility.py +0 -526
  133. oscura/visualization/annotations.py +0 -371
  134. oscura/visualization/axis_scaling.py +0 -305
  135. oscura/visualization/colors.py +0 -451
  136. oscura/visualization/digital.py +0 -436
  137. oscura/visualization/eye.py +0 -571
  138. oscura/visualization/histogram.py +0 -281
  139. oscura/visualization/interactive.py +0 -1035
  140. oscura/visualization/jitter.py +0 -1042
  141. oscura/visualization/keyboard.py +0 -394
  142. oscura/visualization/layout.py +0 -400
  143. oscura/visualization/optimization.py +0 -1079
  144. oscura/visualization/palettes.py +0 -446
  145. oscura/visualization/power.py +0 -508
  146. oscura/visualization/power_extended.py +0 -955
  147. oscura/visualization/presets.py +0 -469
  148. oscura/visualization/protocols.py +0 -1246
  149. oscura/visualization/render.py +0 -223
  150. oscura/visualization/rendering.py +0 -444
  151. oscura/visualization/reverse_engineering.py +0 -838
  152. oscura/visualization/signal_integrity.py +0 -989
  153. oscura/visualization/specialized.py +0 -643
  154. oscura/visualization/spectral.py +0 -1226
  155. oscura/visualization/thumbnails.py +0 -340
  156. oscura/visualization/time_axis.py +0 -351
  157. oscura/visualization/waveform.py +0 -454
  158. oscura-0.8.0.dist-info/METADATA +0 -661
  159. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/WHEEL +0 -0
  160. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/entry_points.txt +0 -0
  161. {oscura-0.8.0.dist-info → oscura-0.11.0.dist-info}/licenses/LICENSE +0 -0
oscura/core/types.py CHANGED
@@ -9,12 +9,14 @@ Requirements addressed:
9
9
  - CORE-003: DigitalTrace Data Class
10
10
  - CORE-004: ProtocolPacket Data Class
11
11
  - CORE-005: CalibrationInfo Data Class (regulatory compliance)
12
+ - CORE-006: MeasurementResult TypedDict (v0.9.0)
12
13
  """
13
14
 
14
15
  from __future__ import annotations
15
16
 
16
17
  from dataclasses import dataclass, field
17
- from typing import TYPE_CHECKING, Any
18
+ from datetime import UTC, datetime
19
+ from typing import TYPE_CHECKING, Any, TypedDict
18
20
 
19
21
  import numpy as np
20
22
 
@@ -24,6 +26,56 @@ if TYPE_CHECKING:
24
26
  from numpy.typing import NDArray
25
27
 
26
28
 
29
+ class MeasurementResult(TypedDict, total=False):
30
+ """Structured measurement result with metadata and applicability tracking.
31
+
32
+ This replaces raw float values to handle edge cases gracefully and provide
33
+ rich metadata for reporting and interpretation.
34
+
35
+ Attributes:
36
+ value: Measurement value (None if not applicable).
37
+ unit: Unit of measurement (e.g., "V", "Hz", "s", "dB", "%", "ratio").
38
+ applicable: Whether measurement is applicable to this signal type.
39
+ reason: Explanation if not applicable (e.g., "Aperiodic signal").
40
+ display: Human-readable formatted display string.
41
+
42
+ Example:
43
+ >>> # Applicable measurement
44
+ >>> freq_result: MeasurementResult = {
45
+ ... "value": 1000.0,
46
+ ... "unit": "Hz",
47
+ ... "applicable": True,
48
+ ... "reason": None,
49
+ ... "display": "1.000 kHz"
50
+ ... }
51
+
52
+ >>> # Inapplicable measurement (no NaN!)
53
+ >>> period_result: MeasurementResult = {
54
+ ... "value": None,
55
+ ... "unit": "s",
56
+ ... "applicable": False,
57
+ ... "reason": "Aperiodic signal (single impulse)",
58
+ ... "display": "N/A"
59
+ ... }
60
+
61
+ >>> # Access safely
62
+ >>> if period_result["applicable"]:
63
+ ... print(f"Period: {period_result['display']}")
64
+ ... else:
65
+ ... print(f"Period: {period_result['display']} ({period_result['reason']})")
66
+ Period: N/A (Aperiodic signal (single impulse))
67
+
68
+ References:
69
+ API Improvement Recommendation #3 (v0.9.0)
70
+ """
71
+
72
+ value: float | None
73
+ unit: str
74
+ applicable: bool
75
+ reason: str | None
76
+ display: str
77
+
78
+
27
79
  @dataclass
28
80
  class CalibrationInfo:
29
81
  """Calibration and instrument provenance information.
@@ -99,146 +151,139 @@ class CalibrationInfo:
99
151
  """
100
152
  if self.calibration_date is None or self.calibration_due_date is None:
101
153
  return None
102
- from datetime import datetime
103
154
 
104
- return datetime.now() < self.calibration_due_date
155
+ from datetime import datetime
105
156
 
106
- @property
107
- def traceability_summary(self) -> str:
108
- """Generate a traceability summary string.
157
+ now = datetime.now(UTC)
158
+ # Ensure dates are timezone-aware for comparison
159
+ due_date = self.calibration_due_date
160
+ if due_date.tzinfo is None:
161
+ due_date = due_date.replace(tzinfo=UTC)
109
162
 
110
- Returns:
111
- Human-readable summary of calibration traceability.
112
- """
113
- parts = [f"Instrument: {self.instrument}"]
114
- if self.serial_number:
115
- parts.append(f"S/N: {self.serial_number}")
116
- if self.calibration_date:
117
- parts.append(f"Cal Date: {self.calibration_date.strftime('%Y-%m-%d')}")
118
- if self.calibration_due_date:
119
- parts.append(f"Due: {self.calibration_due_date.strftime('%Y-%m-%d')}")
120
- if self.calibration_cert_number:
121
- parts.append(f"Cert: {self.calibration_cert_number}")
122
- return ", ".join(parts)
163
+ return now < due_date
123
164
 
124
165
 
125
166
  @dataclass
126
167
  class TraceMetadata:
127
- """Metadata describing a captured trace.
168
+ """Metadata for waveform and digital traces.
128
169
 
129
- Contains sample rate, scaling information, acquisition details,
130
- and provenance information for a captured waveform or digital trace.
170
+ Stores acquisition parameters, channel information, and optional
171
+ calibration data for oscilloscope/logic analyzer captures.
131
172
 
132
173
  Attributes:
133
- sample_rate: Sample rate in Hz (required).
134
- vertical_scale: Vertical scale in volts/division (optional).
135
- vertical_offset: Vertical offset in volts (optional).
136
- acquisition_time: Time of acquisition (optional).
137
- trigger_info: Trigger configuration dictionary (optional).
138
- source_file: Path to source file (optional).
139
- channel_name: Name of the channel (optional).
140
- calibration_info: Calibration and instrument traceability information (optional).
174
+ sample_rate: Sample rate in Hz.
175
+ start_time: Start time in seconds (relative to trigger).
176
+ channel: Channel name or number.
177
+ units: Physical units (e.g., "V", "A", "Pa").
178
+ calibration: Optional calibration and provenance information.
179
+ trigger_time: Trigger timestamp in seconds (optional).
180
+ coupling: Input coupling mode ("DC", "AC", "GND") (optional).
181
+ probe_attenuation: Probe attenuation factor (optional).
182
+ bandwidth_limit: Bandwidth limit in Hz (optional).
183
+ vertical_offset: Vertical offset in physical units (optional).
184
+ vertical_scale: Vertical scale in units/division (optional).
185
+ horizontal_scale: Horizontal scale in seconds/division (optional).
186
+ source_file: Source file path for loaded data (optional).
187
+ trigger_info: Additional trigger metadata as dict (optional).
188
+ acquisition_time: Timestamp when data was acquired (optional).
141
189
 
142
190
  Example:
143
- >>> metadata = TraceMetadata(sample_rate=1e9) # 1 GSa/s
144
- >>> print(f"Time base: {metadata.time_base} s/sample")
145
- Time base: 1e-09 s/sample
146
-
147
- Example with calibration info:
148
- >>> from datetime import datetime
149
- >>> cal = CalibrationInfo(
150
- ... instrument="Tektronix DPO7254C",
151
- ... calibration_date=datetime(2024, 12, 15)
191
+ >>> meta = TraceMetadata(
192
+ ... sample_rate=1e9,
193
+ ... start_time=-0.001,
194
+ ... channel="CH1",
195
+ ... units="V"
152
196
  ... )
153
- >>> metadata = TraceMetadata(sample_rate=1e9, calibration_info=cal)
154
- >>> print(metadata.calibration_info.traceability_summary)
155
- Instrument: Tektronix DPO7254C, Cal Date: 2024-12-15
156
-
157
- References:
158
- IEEE 181-2011: Standard for Transitional Waveform Definitions
159
- ISO/IEC 17025: General Requirements for Testing/Calibration Laboratories
197
+ >>> print(f"Sample rate: {meta.sample_rate/1e6:.1f} MS/s")
198
+ Sample rate: 1000.0 MS/s
160
199
  """
161
200
 
162
201
  sample_rate: float
163
- vertical_scale: float | None = None
202
+ start_time: float = 0.0
203
+ channel: str = "CH1"
204
+ units: str = "V"
205
+ calibration: CalibrationInfo | None = None
206
+ trigger_time: float | None = None
207
+ coupling: str | None = None
208
+ probe_attenuation: float | None = None
209
+ bandwidth_limit: float | None = None
164
210
  vertical_offset: float | None = None
165
- acquisition_time: datetime | None = None
166
- trigger_info: dict[str, Any] | None = None
211
+ vertical_scale: float | None = None
212
+ horizontal_scale: float | None = None
167
213
  source_file: str | None = None
168
- channel_name: str | None = None
169
- calibration_info: CalibrationInfo | None = None
214
+ trigger_info: dict[str, Any] | None = None
215
+ acquisition_time: datetime | None = None
170
216
 
171
217
  def __post_init__(self) -> None:
172
218
  """Validate metadata after initialization."""
173
219
  if self.sample_rate <= 0:
174
220
  raise ValueError(f"sample_rate must be positive, got {self.sample_rate}")
175
221
 
176
- @property
177
- def time_base(self) -> float:
178
- """Time between samples in seconds (derived from sample_rate).
179
-
180
- Returns:
181
- Time per sample in seconds (1 / sample_rate).
182
- """
183
- return 1.0 / self.sample_rate
184
-
185
222
 
186
223
  @dataclass
187
224
  class WaveformTrace:
188
- """Analog waveform data with metadata.
225
+ """Analog waveform trace with metadata.
189
226
 
190
- Stores sampled analog voltage data as a numpy array along with
191
- associated metadata for timing and scaling.
227
+ Represents time-series voltage/current data from an oscilloscope or
228
+ similar instrument. Provides properties for signal type detection.
192
229
 
193
230
  Attributes:
194
- data: Waveform samples as numpy float array.
195
- metadata: Associated trace metadata.
231
+ data: Waveform data array (voltage/current values).
232
+ metadata: Trace metadata (sample rate, channel, units).
233
+ processing_history: List of processing operations applied to this trace.
234
+ Each entry is a dict with keys: operation, params, timestamp.
235
+ Enables tracking the full processing chain for reproducibility.
236
+
237
+ Properties:
238
+ is_analog: Always True for WaveformTrace.
239
+ is_digital: Always False for WaveformTrace.
240
+ is_iq: Always False for WaveformTrace.
241
+ signal_type: Returns "analog".
196
242
 
197
243
  Example:
198
244
  >>> import numpy as np
199
- >>> data = np.sin(2 * np.pi * 1e6 * np.linspace(0, 1e-3, 1000))
200
- >>> trace = WaveformTrace(data=data, metadata=TraceMetadata(sample_rate=1e6))
201
- >>> print(f"Duration: {trace.time_vector[-1]:.6f} seconds")
202
- Duration: 0.000999 seconds
203
-
204
- References:
205
- IEEE 1241-2010: Standard for Terminology and Test Methods for ADCs
245
+ >>> from datetime import datetime, UTC
246
+ >>> data = np.sin(2 * np.pi * 1000 * np.linspace(0, 0.001, 1000))
247
+ >>> meta = TraceMetadata(sample_rate=1e6, units="V")
248
+ >>> trace = WaveformTrace(data=data, metadata=meta)
249
+ >>> print(f"Signal type: {trace.signal_type}")
250
+ Signal type: analog
251
+ >>> print(f"Is analog: {trace.is_analog}")
252
+ Is analog: True
253
+ >>> # Track processing operations
254
+ >>> trace.processing_history.append({
255
+ ... "operation": "low_pass",
256
+ ... "params": {"cutoff_hz": 5000, "order": 4},
257
+ ... "timestamp": datetime.now(UTC).isoformat()
258
+ ... })
259
+ >>> print(f"Processing steps: {len(trace.processing_history)}")
260
+ Processing steps: 1
206
261
  """
207
262
 
208
263
  data: NDArray[np.floating[Any]]
209
264
  metadata: TraceMetadata
265
+ processing_history: list[dict[str, Any]] = field(default_factory=list)
210
266
 
211
267
  def __post_init__(self) -> None:
212
- """Validate waveform data after initialization."""
268
+ """Validate trace data after initialization."""
213
269
  if not isinstance(self.data, np.ndarray):
214
- raise TypeError(f"data must be a numpy array, got {type(self.data).__name__}")
215
- if not np.issubdtype(self.data.dtype, np.floating):
216
- # Convert to float64 if not already floating point
217
- self.data = self.data.astype(np.float64)
218
-
219
- @property
220
- def time_vector(self) -> NDArray[np.float64]:
221
- """Time axis in seconds.
222
-
223
- Computes a time vector starting from 0, with intervals
224
- determined by the sample rate.
225
-
226
- Returns:
227
- Array of time values in seconds, same length as data.
228
- """
229
- n_samples = len(self.data)
230
- return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base
270
+ raise TypeError(f"data must be numpy array, got {type(self.data).__name__}")
271
+ if self.data.ndim != 1:
272
+ raise ValueError(f"data must be 1-D array, got shape {self.data.shape}")
273
+ if len(self.data) == 0:
274
+ raise ValueError("data array cannot be empty")
231
275
 
232
276
  @property
233
277
  def duration(self) -> float:
234
- """Total duration of the trace in seconds.
235
-
236
- Returns:
237
- Duration from first to last sample in seconds.
238
- """
239
- if len(self.data) == 0:
278
+ """Duration of the trace in seconds (time from first to last sample)."""
279
+ if len(self.data) <= 1:
240
280
  return 0.0
241
- return (len(self.data) - 1) * self.metadata.time_base
281
+ return (len(self.data) - 1) / self.metadata.sample_rate
282
+
283
+ @property
284
+ def time(self) -> NDArray[np.floating[Any]]:
285
+ """Time axis array for the trace."""
286
+ return np.arange(len(self.data)) / self.metadata.sample_rate + self.metadata.start_time
242
287
 
243
288
  @property
244
289
  def is_analog(self) -> bool:
@@ -280,84 +325,63 @@ class WaveformTrace:
280
325
  """Return number of samples in the trace."""
281
326
  return len(self.data)
282
327
 
328
+ def __getitem__(self, key: int | slice) -> float | NDArray[np.floating[Any]]:
329
+ """Get sample(s) by index."""
330
+ return self.data[key]
331
+
283
332
 
284
333
  @dataclass
285
334
  class DigitalTrace:
286
- """Digital/logic signal data with metadata.
335
+ """Digital logic trace with metadata.
287
336
 
288
- Stores sampled digital signal data as a boolean numpy array,
289
- with optional edge timestamp information.
337
+ Represents binary logic level data from a logic analyzer or
338
+ digital channel. Provides properties for signal type detection.
290
339
 
291
340
  Attributes:
292
- data: Digital samples as numpy boolean array.
293
- metadata: Associated trace metadata.
294
- edges: Optional list of (timestamp, is_rising) tuples.
341
+ data: Boolean array representing logic levels.
342
+ metadata: Trace metadata (sample rate, channel, units).
295
343
 
296
- Example:
297
- >>> import numpy as np
298
- >>> data = np.array([False, False, True, True, False], dtype=bool)
299
- >>> trace = DigitalTrace(data=data, metadata=TraceMetadata(sample_rate=1e6))
300
- >>> print(f"High samples: {np.sum(trace.data)}")
301
- High samples: 2
344
+ Properties:
345
+ is_analog: Always False for DigitalTrace.
346
+ is_digital: Always True for DigitalTrace.
347
+ is_iq: Always False for DigitalTrace.
348
+ signal_type: Returns "digital".
302
349
 
303
- References:
304
- IEEE 1076.6-2004: Standard for VHDL Register Transfer Level Synthesis
350
+ Example:
351
+ >>> data = np.array([0, 0, 1, 1, 0, 1, 0, 0], dtype=bool)
352
+ >>> meta = TraceMetadata(sample_rate=1e6, units="logic")
353
+ >>> trace = DigitalTrace(data=data, metadata=meta)
354
+ >>> print(f"Signal type: {trace.signal_type}")
355
+ Signal type: digital
356
+ >>> print(f"Is digital: {trace.is_digital}")
357
+ Is digital: True
305
358
  """
306
359
 
307
360
  data: NDArray[np.bool_]
308
361
  metadata: TraceMetadata
309
- edges: list[tuple[float, bool]] | None = None
310
362
 
311
363
  def __post_init__(self) -> None:
312
- """Validate digital data after initialization."""
364
+ """Validate trace data after initialization."""
313
365
  if not isinstance(self.data, np.ndarray):
314
- raise TypeError(f"data must be a numpy array, got {type(self.data).__name__}")
315
- if self.data.dtype != np.bool_:
316
- # Convert to boolean if not already
317
- self.data = self.data.astype(np.bool_)
318
-
319
- @property
320
- def time_vector(self) -> NDArray[np.float64]:
321
- """Time axis in seconds.
322
-
323
- Returns:
324
- Array of time values in seconds, same length as data.
325
- """
326
- n_samples = len(self.data)
327
- return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base
366
+ raise TypeError(f"data must be numpy array, got {type(self.data).__name__}")
367
+ if self.data.dtype != bool:
368
+ raise TypeError(f"data must be boolean array, got dtype {self.data.dtype}")
369
+ if self.data.ndim != 1:
370
+ raise ValueError(f"data must be 1-D array, got shape {self.data.shape}")
371
+ if len(self.data) == 0:
372
+ raise ValueError("data array cannot be empty")
328
373
 
329
374
  @property
330
375
  def duration(self) -> float:
331
- """Total duration of the trace in seconds.
332
-
333
- Returns:
334
- Duration from first to last sample in seconds.
335
- """
336
- if len(self.data) == 0:
376
+ """Duration of the trace in seconds (time from first to last sample)."""
377
+ if len(self.data) <= 1:
337
378
  return 0.0
338
- return (len(self.data) - 1) * self.metadata.time_base
339
-
340
- @property
341
- def rising_edges(self) -> list[float]:
342
- """Timestamps of rising edges.
343
-
344
- Returns:
345
- List of timestamps where signal transitions from low to high.
346
- """
347
- if self.edges is None:
348
- return []
349
- return [ts for ts, is_rising in self.edges if is_rising]
379
+ return (len(self.data) - 1) / self.metadata.sample_rate
350
380
 
351
381
  @property
352
- def falling_edges(self) -> list[float]:
353
- """Timestamps of falling edges.
354
-
355
- Returns:
356
- List of timestamps where signal transitions from high to low.
357
- """
358
- if self.edges is None:
359
- return []
360
- return [ts for ts, is_rising in self.edges if not is_rising]
382
+ def time(self) -> NDArray[np.floating[Any]]:
383
+ """Time axis array for the trace."""
384
+ return np.arange(len(self.data)) / self.metadata.sample_rate + self.metadata.start_time
361
385
 
362
386
  @property
363
387
  def is_analog(self) -> bool:
@@ -399,99 +423,63 @@ class DigitalTrace:
399
423
  """Return number of samples in the trace."""
400
424
  return len(self.data)
401
425
 
426
+ def __getitem__(self, key: int | slice) -> bool | NDArray[np.bool_]:
427
+ """Get sample(s) by index."""
428
+ return self.data[key]
429
+
402
430
 
403
431
  @dataclass
404
432
  class IQTrace:
405
- """I/Q (In-phase/Quadrature) waveform data with metadata.
433
+ """I/Q (complex) trace for RF/SDR applications.
406
434
 
407
- Stores complex-valued signal data as separate I and Q components,
408
- commonly used for RF and software-defined radio applications.
435
+ Represents complex-valued I/Q data from software-defined radios
436
+ or RF measurement equipment. Provides properties for signal type detection.
409
437
 
410
438
  Attributes:
411
- i_data: In-phase component samples as numpy float array.
412
- q_data: Quadrature component samples as numpy float array.
413
- metadata: Associated trace metadata.
439
+ data: Complex-valued I/Q data array.
440
+ metadata: Trace metadata (sample rate, channel, units).
414
441
 
415
- Example:
416
- >>> import numpy as np
417
- >>> t = np.linspace(0, 1e-3, 1000)
418
- >>> i_data = np.cos(2 * np.pi * 1e6 * t)
419
- >>> q_data = np.sin(2 * np.pi * 1e6 * t)
420
- >>> trace = IQTrace(i_data=i_data, q_data=q_data, metadata=TraceMetadata(sample_rate=1e6))
421
- >>> print(f"Complex samples: {len(trace)}")
422
- Complex samples: 1000
442
+ Properties:
443
+ is_analog: Always False for IQTrace.
444
+ is_digital: Always False for IQTrace.
445
+ is_iq: Always True for IQTrace.
446
+ signal_type: Returns "iq".
423
447
 
424
- References:
425
- IEEE Std 181-2011: Transitional Waveform Definitions
448
+ Example:
449
+ >>> data = np.exp(1j * 2 * np.pi * np.linspace(0, 1, 100))
450
+ >>> meta = TraceMetadata(sample_rate=1e6, units="V")
451
+ >>> trace = IQTrace(data=data, metadata=meta)
452
+ >>> print(f"Signal type: {trace.signal_type}")
453
+ Signal type: iq
454
+ >>> print(f"Is I/Q: {trace.is_iq}")
455
+ Is I/Q: True
426
456
  """
427
457
 
428
- i_data: NDArray[np.floating[Any]]
429
- q_data: NDArray[np.floating[Any]]
458
+ data: NDArray[np.complexfloating[Any, Any]]
430
459
  metadata: TraceMetadata
431
460
 
432
461
  def __post_init__(self) -> None:
433
- """Validate I/Q data after initialization."""
434
- if not isinstance(self.i_data, np.ndarray):
435
- raise TypeError(f"i_data must be a numpy array, got {type(self.i_data).__name__}")
436
- if not isinstance(self.q_data, np.ndarray):
437
- raise TypeError(f"q_data must be a numpy array, got {type(self.q_data).__name__}")
438
- if len(self.i_data) != len(self.q_data):
439
- raise ValueError(
440
- f"I and Q data must have same length, got {len(self.i_data)} and {len(self.q_data)}"
441
- )
442
- # Convert to float64 if not already floating point
443
- if not np.issubdtype(self.i_data.dtype, np.floating):
444
- self.i_data = self.i_data.astype(np.float64)
445
- if not np.issubdtype(self.q_data.dtype, np.floating):
446
- self.q_data = self.q_data.astype(np.float64)
447
-
448
- @property
449
- def complex_data(self) -> NDArray[np.complex128]:
450
- """Return I/Q data as complex array.
451
-
452
- Returns:
453
- Complex array where real=I, imag=Q.
454
- """
455
- return self.i_data + 1j * self.q_data
456
-
457
- @property
458
- def magnitude(self) -> NDArray[np.float64]:
459
- """Magnitude (amplitude) of the complex signal.
460
-
461
- Returns:
462
- Array of magnitude values sqrt(I² + Q²).
463
- """
464
- return np.sqrt(self.i_data**2 + self.q_data**2)
465
-
466
- @property
467
- def phase(self) -> NDArray[np.float64]:
468
- """Phase angle of the complex signal in radians.
469
-
470
- Returns:
471
- Array of phase values atan2(Q, I).
472
- """
473
- return np.arctan2(self.q_data, self.i_data)
474
-
475
- @property
476
- def time_vector(self) -> NDArray[np.float64]:
477
- """Time axis in seconds.
478
-
479
- Returns:
480
- Array of time values in seconds, same length as data.
481
- """
482
- n_samples = len(self.i_data)
483
- return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base
462
+ """Validate trace data after initialization."""
463
+ if not isinstance(self.data, np.ndarray):
464
+ raise TypeError(f"data must be numpy array, got {type(self.data).__name__}")
465
+ if not np.iscomplexobj(self.data):
466
+ raise TypeError(f"data must be complex array, got dtype {self.data.dtype}")
467
+ if self.data.ndim != 1:
468
+ raise ValueError(f"data must be 1-D array, got shape {self.data.shape}")
469
+ if len(self.data) == 0:
470
+ raise ValueError("data array cannot be empty")
484
471
 
485
472
  @property
486
473
  def duration(self) -> float:
487
- """Total duration of the trace in seconds.
488
-
489
- Returns:
490
- Duration from first to last sample in seconds.
491
- """
492
- if len(self.i_data) == 0:
474
+ """Duration of the trace in seconds (time from first to last sample)."""
475
+ if len(self.data) <= 1:
493
476
  return 0.0
494
- return (len(self.i_data) - 1) * self.metadata.time_base
477
+ return (len(self.data) - 1) / self.metadata.sample_rate
478
+
479
+ @property
480
+ def time(self) -> NDArray[np.floating[Any]]:
481
+ """Time axis array for the trace."""
482
+ return np.arange(len(self.data)) / self.metadata.sample_rate + self.metadata.start_time
495
483
 
496
484
  @property
497
485
  def is_analog(self) -> bool:
@@ -531,22 +519,26 @@ class IQTrace:
531
519
 
532
520
  def __len__(self) -> int:
533
521
  """Return number of samples in the trace."""
534
- return len(self.i_data)
522
+ return len(self.data)
523
+
524
+ def __getitem__(self, key: int | slice) -> complex | NDArray[np.complexfloating[Any, Any]]:
525
+ """Get sample(s) by index."""
526
+ return self.data[key]
535
527
 
536
528
 
537
529
  @dataclass
538
530
  class ProtocolPacket:
539
- """Decoded protocol packet data.
531
+ """Decoded protocol packet with metadata.
540
532
 
541
- Represents a decoded packet from a serial protocol (UART, SPI, I2C, etc.)
542
- with timing, data content, annotations, and error information.
533
+ Represents a decoded packet/frame from protocol analysis
534
+ (UART, SPI, I2C, CAN, etc.).
543
535
 
544
536
  Attributes:
545
- timestamp: Start time of the packet in seconds.
546
- protocol: Name of the protocol (e.g., "UART", "SPI", "I2C").
547
- data: Decoded data bytes.
548
- annotations: Multi-level annotations dictionary (optional).
549
- errors: List of detected errors (optional).
537
+ timestamp: Packet timestamp in seconds.
538
+ protocol: Protocol name (e.g., "UART", "SPI", "I2C").
539
+ data: Raw packet data as bytes.
540
+ annotations: Protocol-specific annotations (e.g., address, command).
541
+ errors: List of decoding errors (empty if no errors).
550
542
  end_timestamp: End time of the packet in seconds (optional).
551
543
 
552
544
  Example:
@@ -609,6 +601,7 @@ __all__ = [
609
601
  "CalibrationInfo",
610
602
  "DigitalTrace",
611
603
  "IQTrace",
604
+ "MeasurementResult",
612
605
  "ProtocolPacket",
613
606
  "Trace",
614
607
  "TraceMetadata",
@@ -94,7 +94,7 @@ except ImportError:
94
94
  try:
95
95
  import networkx
96
96
  except ImportError:
97
- networkx = None # type: ignore[assignment]
97
+ networkx = None
98
98
 
99
99
 
100
100
  @dataclass
@@ -0,0 +1,11 @@
1
+ """Legacy export functions for backward compatibility.
2
+
3
+ This module provides backward-compatible export functions that wrap
4
+ the modern pipeline handler implementations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from oscura.export.legacy.wav import export_wav
10
+
11
+ __all__ = ["export_wav"]