oscura 0.7.0__py3-none-any.whl → 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. oscura/__init__.py +19 -19
  2. oscura/analyzers/__init__.py +2 -0
  3. oscura/analyzers/digital/extraction.py +2 -3
  4. oscura/analyzers/digital/quality.py +1 -1
  5. oscura/analyzers/digital/timing.py +1 -1
  6. oscura/analyzers/eye/__init__.py +5 -1
  7. oscura/analyzers/eye/generation.py +501 -0
  8. oscura/analyzers/jitter/__init__.py +6 -6
  9. oscura/analyzers/jitter/timing.py +419 -0
  10. oscura/analyzers/patterns/__init__.py +94 -0
  11. oscura/analyzers/patterns/reverse_engineering.py +991 -0
  12. oscura/analyzers/power/__init__.py +35 -12
  13. oscura/analyzers/power/basic.py +3 -3
  14. oscura/analyzers/power/soa.py +1 -1
  15. oscura/analyzers/power/switching.py +3 -3
  16. oscura/analyzers/signal_classification.py +529 -0
  17. oscura/analyzers/signal_integrity/sparams.py +3 -3
  18. oscura/analyzers/statistics/__init__.py +4 -0
  19. oscura/analyzers/statistics/basic.py +152 -0
  20. oscura/analyzers/statistics/correlation.py +47 -6
  21. oscura/analyzers/validation.py +1 -1
  22. oscura/analyzers/waveform/__init__.py +2 -0
  23. oscura/analyzers/waveform/measurements.py +329 -163
  24. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  25. oscura/analyzers/waveform/spectral.py +498 -54
  26. oscura/api/dsl/commands.py +15 -6
  27. oscura/api/server/templates/base.html +137 -146
  28. oscura/api/server/templates/export.html +84 -110
  29. oscura/api/server/templates/home.html +248 -267
  30. oscura/api/server/templates/protocols.html +44 -48
  31. oscura/api/server/templates/reports.html +27 -35
  32. oscura/api/server/templates/session_detail.html +68 -78
  33. oscura/api/server/templates/sessions.html +62 -72
  34. oscura/api/server/templates/waveforms.html +54 -64
  35. oscura/automotive/__init__.py +1 -1
  36. oscura/automotive/can/session.py +1 -1
  37. oscura/automotive/dbc/generator.py +638 -23
  38. oscura/automotive/dtc/data.json +102 -17
  39. oscura/automotive/uds/decoder.py +99 -6
  40. oscura/cli/analyze.py +8 -2
  41. oscura/cli/batch.py +36 -5
  42. oscura/cli/characterize.py +18 -4
  43. oscura/cli/export.py +47 -5
  44. oscura/cli/main.py +2 -0
  45. oscura/cli/onboarding/wizard.py +10 -6
  46. oscura/cli/pipeline.py +585 -0
  47. oscura/cli/visualize.py +6 -4
  48. oscura/convenience.py +400 -32
  49. oscura/core/config/loader.py +0 -1
  50. oscura/core/measurement_result.py +286 -0
  51. oscura/core/progress.py +1 -1
  52. oscura/core/schemas/device_mapping.json +8 -2
  53. oscura/core/schemas/packet_format.json +24 -4
  54. oscura/core/schemas/protocol_definition.json +12 -2
  55. oscura/core/types.py +300 -199
  56. oscura/correlation/multi_protocol.py +1 -1
  57. oscura/export/legacy/__init__.py +11 -0
  58. oscura/export/legacy/wav.py +75 -0
  59. oscura/exporters/__init__.py +19 -0
  60. oscura/exporters/wireshark.py +809 -0
  61. oscura/hardware/acquisition/file.py +5 -19
  62. oscura/hardware/acquisition/saleae.py +10 -10
  63. oscura/hardware/acquisition/socketcan.py +4 -6
  64. oscura/hardware/acquisition/synthetic.py +1 -5
  65. oscura/hardware/acquisition/visa.py +6 -6
  66. oscura/hardware/security/side_channel_detector.py +5 -508
  67. oscura/inference/message_format.py +686 -1
  68. oscura/jupyter/display.py +2 -2
  69. oscura/jupyter/magic.py +3 -3
  70. oscura/loaders/__init__.py +17 -12
  71. oscura/loaders/binary.py +1 -1
  72. oscura/loaders/chipwhisperer.py +1 -2
  73. oscura/loaders/configurable.py +1 -1
  74. oscura/loaders/csv_loader.py +2 -2
  75. oscura/loaders/hdf5_loader.py +1 -1
  76. oscura/loaders/lazy.py +6 -1
  77. oscura/loaders/mmap_loader.py +0 -1
  78. oscura/loaders/numpy_loader.py +8 -7
  79. oscura/loaders/preprocessing.py +3 -5
  80. oscura/loaders/rigol.py +21 -7
  81. oscura/loaders/sigrok.py +2 -5
  82. oscura/loaders/tdms.py +3 -2
  83. oscura/loaders/tektronix.py +38 -32
  84. oscura/loaders/tss.py +20 -27
  85. oscura/loaders/vcd.py +13 -8
  86. oscura/loaders/wav.py +1 -6
  87. oscura/pipeline/__init__.py +76 -0
  88. oscura/pipeline/handlers/__init__.py +165 -0
  89. oscura/pipeline/handlers/analyzers.py +1045 -0
  90. oscura/pipeline/handlers/decoders.py +899 -0
  91. oscura/pipeline/handlers/exporters.py +1103 -0
  92. oscura/pipeline/handlers/filters.py +891 -0
  93. oscura/pipeline/handlers/loaders.py +640 -0
  94. oscura/pipeline/handlers/transforms.py +768 -0
  95. oscura/reporting/__init__.py +88 -1
  96. oscura/reporting/automation.py +348 -0
  97. oscura/reporting/citations.py +374 -0
  98. oscura/reporting/core.py +54 -0
  99. oscura/reporting/formatting/__init__.py +11 -0
  100. oscura/reporting/formatting/measurements.py +320 -0
  101. oscura/reporting/html.py +57 -0
  102. oscura/reporting/interpretation.py +431 -0
  103. oscura/reporting/summary.py +329 -0
  104. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  105. oscura/reporting/visualization.py +542 -0
  106. oscura/side_channel/__init__.py +38 -57
  107. oscura/utils/builders/signal_builder.py +5 -5
  108. oscura/utils/comparison/compare.py +7 -9
  109. oscura/utils/comparison/golden.py +1 -1
  110. oscura/utils/filtering/convenience.py +2 -2
  111. oscura/utils/math/arithmetic.py +38 -62
  112. oscura/utils/math/interpolation.py +20 -20
  113. oscura/utils/pipeline/__init__.py +4 -17
  114. oscura/utils/progressive.py +1 -4
  115. oscura/utils/triggering/edge.py +1 -1
  116. oscura/utils/triggering/pattern.py +2 -2
  117. oscura/utils/triggering/pulse.py +2 -2
  118. oscura/utils/triggering/window.py +3 -3
  119. oscura/validation/hil_testing.py +11 -11
  120. oscura/visualization/__init__.py +47 -284
  121. oscura/visualization/batch.py +160 -0
  122. oscura/visualization/plot.py +542 -53
  123. oscura/visualization/styles.py +184 -318
  124. oscura/workflows/__init__.py +2 -0
  125. oscura/workflows/batch/advanced.py +1 -1
  126. oscura/workflows/batch/aggregate.py +7 -8
  127. oscura/workflows/complete_re.py +251 -23
  128. oscura/workflows/digital.py +27 -4
  129. oscura/workflows/multi_trace.py +136 -17
  130. oscura/workflows/waveform.py +788 -0
  131. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  132. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
  133. oscura/side_channel/dpa.py +0 -1025
  134. oscura/utils/optimization/__init__.py +0 -19
  135. oscura/utils/optimization/parallel.py +0 -443
  136. oscura/utils/optimization/search.py +0 -532
  137. oscura/utils/pipeline/base.py +0 -338
  138. oscura/utils/pipeline/composition.py +0 -248
  139. oscura/utils/pipeline/parallel.py +0 -449
  140. oscura/utils/pipeline/pipeline.py +0 -375
  141. oscura/utils/search/__init__.py +0 -16
  142. oscura/utils/search/anomaly.py +0 -424
  143. oscura/utils/search/context.py +0 -294
  144. oscura/utils/search/pattern.py +0 -288
  145. oscura/utils/storage/__init__.py +0 -61
  146. oscura/utils/storage/database.py +0 -1166
  147. oscura/visualization/accessibility.py +0 -526
  148. oscura/visualization/annotations.py +0 -371
  149. oscura/visualization/axis_scaling.py +0 -305
  150. oscura/visualization/colors.py +0 -451
  151. oscura/visualization/digital.py +0 -436
  152. oscura/visualization/eye.py +0 -571
  153. oscura/visualization/histogram.py +0 -281
  154. oscura/visualization/interactive.py +0 -1035
  155. oscura/visualization/jitter.py +0 -1042
  156. oscura/visualization/keyboard.py +0 -394
  157. oscura/visualization/layout.py +0 -400
  158. oscura/visualization/optimization.py +0 -1079
  159. oscura/visualization/palettes.py +0 -446
  160. oscura/visualization/power.py +0 -508
  161. oscura/visualization/power_extended.py +0 -955
  162. oscura/visualization/presets.py +0 -469
  163. oscura/visualization/protocols.py +0 -1246
  164. oscura/visualization/render.py +0 -223
  165. oscura/visualization/rendering.py +0 -444
  166. oscura/visualization/reverse_engineering.py +0 -838
  167. oscura/visualization/signal_integrity.py +0 -989
  168. oscura/visualization/specialized.py +0 -643
  169. oscura/visualization/spectral.py +0 -1226
  170. oscura/visualization/thumbnails.py +0 -340
  171. oscura/visualization/time_axis.py +0 -351
  172. oscura/visualization/waveform.py +0 -454
  173. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  174. {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  175. {oscura-0.7.0.dist-info → oscura-0.10.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,346 +151,394 @@ 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)
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")
218
275
 
219
276
  @property
220
- def time_vector(self) -> NDArray[np.float64]:
221
- """Time axis in seconds.
277
+ def duration(self) -> float:
278
+ """Duration of the trace in seconds (time from first to last sample)."""
279
+ if len(self.data) <= 1:
280
+ return 0.0
281
+ return (len(self.data) - 1) / self.metadata.sample_rate
222
282
 
223
- Computes a time vector starting from 0, with intervals
224
- determined by the sample rate.
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
287
+
288
+ @property
289
+ def is_analog(self) -> bool:
290
+ """Check if this is an analog signal trace.
225
291
 
226
292
  Returns:
227
- Array of time values in seconds, same length as data.
293
+ True for WaveformTrace (always analog).
228
294
  """
229
- n_samples = len(self.data)
230
- return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base
295
+ return True
231
296
 
232
297
  @property
233
- def duration(self) -> float:
234
- """Total duration of the trace in seconds.
298
+ def is_digital(self) -> bool:
299
+ """Check if this is a digital signal trace.
235
300
 
236
301
  Returns:
237
- Duration from first to last sample in seconds.
302
+ False for WaveformTrace (always analog).
238
303
  """
239
- if len(self.data) == 0:
240
- return 0.0
241
- return (len(self.data) - 1) * self.metadata.time_base
304
+ return False
305
+
306
+ @property
307
+ def is_iq(self) -> bool:
308
+ """Check if this is an I/Q signal trace.
309
+
310
+ Returns:
311
+ False for WaveformTrace.
312
+ """
313
+ return False
314
+
315
+ @property
316
+ def signal_type(self) -> str:
317
+ """Get the signal type identifier.
318
+
319
+ Returns:
320
+ "analog" for WaveformTrace.
321
+ """
322
+ return "analog"
242
323
 
243
324
  def __len__(self) -> int:
244
325
  """Return number of samples in the trace."""
245
326
  return len(self.data)
246
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
+
247
332
 
248
333
  @dataclass
249
334
  class DigitalTrace:
250
- """Digital/logic signal data with metadata.
335
+ """Digital logic trace with metadata.
251
336
 
252
- Stores sampled digital signal data as a boolean numpy array,
253
- 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.
254
339
 
255
340
  Attributes:
256
- data: Digital samples as numpy boolean array.
257
- metadata: Associated trace metadata.
258
- edges: Optional list of (timestamp, is_rising) tuples.
341
+ data: Boolean array representing logic levels.
342
+ metadata: Trace metadata (sample rate, channel, units).
259
343
 
260
- Example:
261
- >>> import numpy as np
262
- >>> data = np.array([False, False, True, True, False], dtype=bool)
263
- >>> trace = DigitalTrace(data=data, metadata=TraceMetadata(sample_rate=1e6))
264
- >>> print(f"High samples: {np.sum(trace.data)}")
265
- 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".
266
349
 
267
- References:
268
- 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
269
358
  """
270
359
 
271
360
  data: NDArray[np.bool_]
272
361
  metadata: TraceMetadata
273
- edges: list[tuple[float, bool]] | None = None
274
362
 
275
363
  def __post_init__(self) -> None:
276
- """Validate digital data after initialization."""
364
+ """Validate trace data after initialization."""
277
365
  if not isinstance(self.data, np.ndarray):
278
- raise TypeError(f"data must be a numpy array, got {type(self.data).__name__}")
279
- if self.data.dtype != np.bool_:
280
- # Convert to boolean if not already
281
- self.data = self.data.astype(np.bool_)
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")
282
373
 
283
374
  @property
284
- def time_vector(self) -> NDArray[np.float64]:
285
- """Time axis in seconds.
375
+ def duration(self) -> float:
376
+ """Duration of the trace in seconds (time from first to last sample)."""
377
+ if len(self.data) <= 1:
378
+ return 0.0
379
+ return (len(self.data) - 1) / self.metadata.sample_rate
380
+
381
+ @property
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
385
+
386
+ @property
387
+ def is_analog(self) -> bool:
388
+ """Check if this is an analog signal trace.
286
389
 
287
390
  Returns:
288
- Array of time values in seconds, same length as data.
391
+ False for DigitalTrace (always digital).
289
392
  """
290
- n_samples = len(self.data)
291
- return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base
393
+ return False
292
394
 
293
395
  @property
294
- def duration(self) -> float:
295
- """Total duration of the trace in seconds.
396
+ def is_digital(self) -> bool:
397
+ """Check if this is a digital signal trace.
296
398
 
297
399
  Returns:
298
- Duration from first to last sample in seconds.
400
+ True for DigitalTrace (always digital).
299
401
  """
300
- if len(self.data) == 0:
301
- return 0.0
302
- return (len(self.data) - 1) * self.metadata.time_base
402
+ return True
303
403
 
304
404
  @property
305
- def rising_edges(self) -> list[float]:
306
- """Timestamps of rising edges.
405
+ def is_iq(self) -> bool:
406
+ """Check if this is an I/Q signal trace.
307
407
 
308
408
  Returns:
309
- List of timestamps where signal transitions from low to high.
409
+ False for DigitalTrace.
310
410
  """
311
- if self.edges is None:
312
- return []
313
- return [ts for ts, is_rising in self.edges if is_rising]
411
+ return False
314
412
 
315
413
  @property
316
- def falling_edges(self) -> list[float]:
317
- """Timestamps of falling edges.
414
+ def signal_type(self) -> str:
415
+ """Get the signal type identifier.
318
416
 
319
417
  Returns:
320
- List of timestamps where signal transitions from high to low.
418
+ "digital" for DigitalTrace.
321
419
  """
322
- if self.edges is None:
323
- return []
324
- return [ts for ts, is_rising in self.edges if not is_rising]
420
+ return "digital"
325
421
 
326
422
  def __len__(self) -> int:
327
423
  """Return number of samples in the trace."""
328
424
  return len(self.data)
329
425
 
426
+ def __getitem__(self, key: int | slice) -> bool | NDArray[np.bool_]:
427
+ """Get sample(s) by index."""
428
+ return self.data[key]
429
+
330
430
 
331
431
  @dataclass
332
432
  class IQTrace:
333
- """I/Q (In-phase/Quadrature) waveform data with metadata.
433
+ """I/Q (complex) trace for RF/SDR applications.
334
434
 
335
- Stores complex-valued signal data as separate I and Q components,
336
- 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.
337
437
 
338
438
  Attributes:
339
- i_data: In-phase component samples as numpy float array.
340
- q_data: Quadrature component samples as numpy float array.
341
- metadata: Associated trace metadata.
439
+ data: Complex-valued I/Q data array.
440
+ metadata: Trace metadata (sample rate, channel, units).
342
441
 
343
- Example:
344
- >>> import numpy as np
345
- >>> t = np.linspace(0, 1e-3, 1000)
346
- >>> i_data = np.cos(2 * np.pi * 1e6 * t)
347
- >>> q_data = np.sin(2 * np.pi * 1e6 * t)
348
- >>> trace = IQTrace(i_data=i_data, q_data=q_data, metadata=TraceMetadata(sample_rate=1e6))
349
- >>> print(f"Complex samples: {len(trace)}")
350
- 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".
351
447
 
352
- References:
353
- 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
354
456
  """
355
457
 
356
- i_data: NDArray[np.floating[Any]]
357
- q_data: NDArray[np.floating[Any]]
458
+ data: NDArray[np.complexfloating[Any, Any]]
358
459
  metadata: TraceMetadata
359
460
 
360
461
  def __post_init__(self) -> None:
361
- """Validate I/Q data after initialization."""
362
- if not isinstance(self.i_data, np.ndarray):
363
- raise TypeError(f"i_data must be a numpy array, got {type(self.i_data).__name__}")
364
- if not isinstance(self.q_data, np.ndarray):
365
- raise TypeError(f"q_data must be a numpy array, got {type(self.q_data).__name__}")
366
- if len(self.i_data) != len(self.q_data):
367
- raise ValueError(
368
- f"I and Q data must have same length, got {len(self.i_data)} and {len(self.q_data)}"
369
- )
370
- # Convert to float64 if not already floating point
371
- if not np.issubdtype(self.i_data.dtype, np.floating):
372
- self.i_data = self.i_data.astype(np.float64)
373
- if not np.issubdtype(self.q_data.dtype, np.floating):
374
- self.q_data = self.q_data.astype(np.float64)
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")
375
471
 
376
472
  @property
377
- def complex_data(self) -> NDArray[np.complex128]:
378
- """Return I/Q data as complex array.
473
+ def duration(self) -> float:
474
+ """Duration of the trace in seconds (time from first to last sample)."""
475
+ if len(self.data) <= 1:
476
+ return 0.0
477
+ return (len(self.data) - 1) / self.metadata.sample_rate
379
478
 
380
- Returns:
381
- Complex array where real=I, imag=Q.
382
- """
383
- return self.i_data + 1j * self.q_data
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
384
483
 
385
484
  @property
386
- def magnitude(self) -> NDArray[np.float64]:
387
- """Magnitude (amplitude) of the complex signal.
485
+ def is_analog(self) -> bool:
486
+ """Check if this is an analog signal trace.
388
487
 
389
488
  Returns:
390
- Array of magnitude values sqrt(I² + Q²).
489
+ False for IQTrace (complex I/Q data).
391
490
  """
392
- return np.sqrt(self.i_data**2 + self.q_data**2)
491
+ return False
393
492
 
394
493
  @property
395
- def phase(self) -> NDArray[np.float64]:
396
- """Phase angle of the complex signal in radians.
494
+ def is_digital(self) -> bool:
495
+ """Check if this is a digital signal trace.
397
496
 
398
497
  Returns:
399
- Array of phase values atan2(Q, I).
498
+ False for IQTrace (complex I/Q data).
400
499
  """
401
- return np.arctan2(self.q_data, self.i_data)
500
+ return False
402
501
 
403
502
  @property
404
- def time_vector(self) -> NDArray[np.float64]:
405
- """Time axis in seconds.
503
+ def is_iq(self) -> bool:
504
+ """Check if this is an I/Q signal trace.
406
505
 
407
506
  Returns:
408
- Array of time values in seconds, same length as data.
507
+ True for IQTrace (always I/Q).
409
508
  """
410
- n_samples = len(self.i_data)
411
- return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base
509
+ return True
412
510
 
413
511
  @property
414
- def duration(self) -> float:
415
- """Total duration of the trace in seconds.
512
+ def signal_type(self) -> str:
513
+ """Get the signal type identifier.
416
514
 
417
515
  Returns:
418
- Duration from first to last sample in seconds.
516
+ "iq" for IQTrace.
419
517
  """
420
- if len(self.i_data) == 0:
421
- return 0.0
422
- return (len(self.i_data) - 1) * self.metadata.time_base
518
+ return "iq"
423
519
 
424
520
  def __len__(self) -> int:
425
521
  """Return number of samples in the trace."""
426
- 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]
427
527
 
428
528
 
429
529
  @dataclass
430
530
  class ProtocolPacket:
431
- """Decoded protocol packet data.
531
+ """Decoded protocol packet with metadata.
432
532
 
433
- Represents a decoded packet from a serial protocol (UART, SPI, I2C, etc.)
434
- with timing, data content, annotations, and error information.
533
+ Represents a decoded packet/frame from protocol analysis
534
+ (UART, SPI, I2C, CAN, etc.).
435
535
 
436
536
  Attributes:
437
- timestamp: Start time of the packet in seconds.
438
- protocol: Name of the protocol (e.g., "UART", "SPI", "I2C").
439
- data: Decoded data bytes.
440
- annotations: Multi-level annotations dictionary (optional).
441
- 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).
442
542
  end_timestamp: End time of the packet in seconds (optional).
443
543
 
444
544
  Example:
@@ -501,6 +601,7 @@ __all__ = [
501
601
  "CalibrationInfo",
502
602
  "DigitalTrace",
503
603
  "IQTrace",
604
+ "MeasurementResult",
504
605
  "ProtocolPacket",
505
606
  "Trace",
506
607
  "TraceMetadata",