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/convenience.py CHANGED
@@ -11,6 +11,14 @@ Example:
11
11
  >>> metrics = osc.quick_spectral(trace, fundamental=1000)
12
12
  >>> print(f"THD: {metrics.thd_db:.1f} dB, SNR: {metrics.snr_db:.1f} dB")
13
13
  >>>
14
+ >>> # One-call power analysis
15
+ >>> power_metrics = osc.quick_power(v_trace, i_trace, frequency=60)
16
+ >>> print(f"Power Factor: {power_metrics.power_factor_value:.3f}")
17
+ >>>
18
+ >>> # One-call timing analysis
19
+ >>> timing = osc.quick_timing(trace)
20
+ >>> print(f"Rise Time: {timing.rise_time_seconds:.2e} s")
21
+ >>>
14
22
  >>> # Auto-detect and decode protocol
15
23
  >>> result = osc.auto_decode(trace)
16
24
  >>> print(f"Protocol: {result.protocol}, Frames: {len(result.frames)}")
@@ -21,6 +29,8 @@ Example:
21
29
  References:
22
30
  - Oscura API Design Guidelines
23
31
  - IEEE 1241-2010 (ADC Characterization)
32
+ - IEEE 181-2011 (Transitional Waveform Definitions)
33
+ - IEEE 1459-2010 (Power Quality Definitions)
24
34
  """
25
35
 
26
36
  from __future__ import annotations
@@ -31,35 +41,242 @@ from typing import TYPE_CHECKING, Any, Literal
31
41
  import numpy as np
32
42
 
33
43
  if TYPE_CHECKING:
34
- from oscura.core.types import DigitalTrace, WaveformTrace
44
+ from oscura.core.types import DigitalTrace, MeasurementResult, WaveformTrace
35
45
 
36
46
 
37
47
  @dataclass
38
48
  class SpectralMetrics:
39
49
  """Results from quick_spectral analysis.
40
50
 
51
+ All measurements are stored as MeasurementResult objects for proper
52
+ error handling and metadata tracking.
53
+
41
54
  Attributes:
42
- thd_db: Total Harmonic Distortion in dB.
43
- thd_percent: Total Harmonic Distortion as percentage.
44
- snr_db: Signal-to-Noise Ratio in dB.
45
- sinad_db: Signal-to-Noise and Distortion in dB.
46
- enob: Effective Number of Bits.
47
- sfdr_db: Spurious-Free Dynamic Range in dBc.
55
+ thd: Total Harmonic Distortion MeasurementResult.
56
+ snr: Signal-to-Noise Ratio MeasurementResult.
57
+ sinad: Signal-to-Noise and Distortion MeasurementResult.
58
+ enob: Effective Number of Bits MeasurementResult.
59
+ sfdr: Spurious-Free Dynamic Range MeasurementResult.
48
60
  fundamental_freq: Detected fundamental frequency in Hz.
49
61
  fundamental_mag_db: Fundamental magnitude in dB.
50
62
  noise_floor_db: Estimated noise floor in dB.
51
63
  """
52
64
 
53
- thd_db: float
54
- thd_percent: float
55
- snr_db: float
56
- sinad_db: float
57
- enob: float
58
- sfdr_db: float
65
+ thd: MeasurementResult
66
+ snr: MeasurementResult
67
+ sinad: MeasurementResult
68
+ enob: MeasurementResult
69
+ sfdr: MeasurementResult
59
70
  fundamental_freq: float
60
71
  fundamental_mag_db: float
61
72
  noise_floor_db: float
62
73
 
74
+ @property
75
+ def thd_db(self) -> float:
76
+ """Extract THD value in dB (or NaN if not applicable)."""
77
+ if self.thd["applicable"] and self.thd["value"] is not None:
78
+ thd_pct = self.thd["value"]
79
+ # Convert percentage to dB: dB = 20 * log10(ratio) where ratio = thd_pct / 100
80
+ return float(20 * np.log10(thd_pct / 100)) if thd_pct > 0 else -np.inf
81
+ return np.nan
82
+
83
+ @property
84
+ def thd_percent(self) -> float:
85
+ """Extract THD value as percentage (or NaN if not applicable)."""
86
+ return float(self.thd["value"]) if self.thd["applicable"] and self.thd["value"] else np.nan
87
+
88
+ @property
89
+ def snr_db(self) -> float:
90
+ """Extract SNR value in dB (or NaN if not applicable)."""
91
+ return float(self.snr["value"]) if self.snr["applicable"] and self.snr["value"] else np.nan
92
+
93
+ @property
94
+ def sinad_db(self) -> float:
95
+ """Extract SINAD value in dB (or NaN if not applicable)."""
96
+ return (
97
+ float(self.sinad["value"])
98
+ if self.sinad["applicable"] and self.sinad["value"]
99
+ else np.nan
100
+ )
101
+
102
+ @property
103
+ def enob_value(self) -> float:
104
+ """Extract ENOB value (or NaN if not applicable)."""
105
+ return (
106
+ float(self.enob["value"]) if self.enob["applicable"] and self.enob["value"] else np.nan
107
+ )
108
+
109
+ @property
110
+ def sfdr_db(self) -> float:
111
+ """Extract SFDR value in dB (or NaN if not applicable)."""
112
+ return (
113
+ float(self.sfdr["value"]) if self.sfdr["applicable"] and self.sfdr["value"] else np.nan
114
+ )
115
+
116
+
117
+ @dataclass
118
+ class PowerMetrics:
119
+ """Results from quick_power analysis per IEEE 1459.
120
+
121
+ All measurements are stored as MeasurementResult objects for proper
122
+ error handling and metadata tracking.
123
+
124
+ Attributes:
125
+ rms_voltage: RMS voltage MeasurementResult.
126
+ rms_current: RMS current MeasurementResult (None if single trace).
127
+ active_power: Active power (P) MeasurementResult.
128
+ reactive_power: Reactive power (Q) MeasurementResult (None if single trace).
129
+ apparent_power: Apparent power (S) MeasurementResult (None if single trace).
130
+ power_factor: Power factor MeasurementResult (None if single trace).
131
+ """
132
+
133
+ rms_voltage: MeasurementResult
134
+ rms_current: MeasurementResult | None
135
+ active_power: MeasurementResult
136
+ reactive_power: MeasurementResult | None
137
+ apparent_power: MeasurementResult | None
138
+ power_factor: MeasurementResult | None
139
+
140
+ @property
141
+ def rms_voltage_value(self) -> float:
142
+ """Extract RMS voltage value (or NaN if not applicable)."""
143
+ return (
144
+ float(self.rms_voltage["value"])
145
+ if self.rms_voltage["applicable"] and self.rms_voltage["value"]
146
+ else np.nan
147
+ )
148
+
149
+ @property
150
+ def rms_current_value(self) -> float:
151
+ """Extract RMS current value (or NaN if not applicable)."""
152
+ if self.rms_current is None:
153
+ return np.nan
154
+ return (
155
+ float(self.rms_current["value"])
156
+ if self.rms_current["applicable"] and self.rms_current["value"]
157
+ else np.nan
158
+ )
159
+
160
+ @property
161
+ def active_power_value(self) -> float:
162
+ """Extract active power value (or NaN if not applicable)."""
163
+ return (
164
+ float(self.active_power["value"])
165
+ if self.active_power["applicable"] and self.active_power["value"]
166
+ else np.nan
167
+ )
168
+
169
+ @property
170
+ def reactive_power_value(self) -> float:
171
+ """Extract reactive power value (or NaN if not applicable)."""
172
+ if self.reactive_power is None:
173
+ return np.nan
174
+ return (
175
+ float(self.reactive_power["value"])
176
+ if self.reactive_power["applicable"] and self.reactive_power["value"]
177
+ else np.nan
178
+ )
179
+
180
+ @property
181
+ def apparent_power_value(self) -> float:
182
+ """Extract apparent power value (or NaN if not applicable)."""
183
+ if self.apparent_power is None:
184
+ return np.nan
185
+ return (
186
+ float(self.apparent_power["value"])
187
+ if self.apparent_power["applicable"] and self.apparent_power["value"]
188
+ else np.nan
189
+ )
190
+
191
+ @property
192
+ def power_factor_value(self) -> float:
193
+ """Extract power factor value (or NaN if not applicable)."""
194
+ if self.power_factor is None:
195
+ return np.nan
196
+ return (
197
+ float(self.power_factor["value"])
198
+ if self.power_factor["applicable"] and self.power_factor["value"]
199
+ else np.nan
200
+ )
201
+
202
+
203
+ @dataclass
204
+ class TimingMetrics:
205
+ """Results from quick_timing analysis per IEEE 181.
206
+
207
+ All measurements are stored as MeasurementResult objects for proper
208
+ error handling and metadata tracking.
209
+
210
+ Attributes:
211
+ rise_time: Rise time (10%-90%) MeasurementResult.
212
+ fall_time: Fall time (90%-10%) MeasurementResult.
213
+ period: Signal period MeasurementResult.
214
+ frequency: Signal frequency MeasurementResult.
215
+ duty_cycle: Duty cycle MeasurementResult.
216
+ pulse_width: Pulse width MeasurementResult.
217
+ """
218
+
219
+ rise_time: MeasurementResult
220
+ fall_time: MeasurementResult
221
+ period: MeasurementResult
222
+ frequency: MeasurementResult
223
+ duty_cycle: MeasurementResult
224
+ pulse_width: MeasurementResult
225
+
226
+ @property
227
+ def rise_time_seconds(self) -> float:
228
+ """Extract rise time value in seconds (or NaN if not applicable)."""
229
+ return (
230
+ float(self.rise_time["value"])
231
+ if self.rise_time["applicable"] and self.rise_time["value"]
232
+ else np.nan
233
+ )
234
+
235
+ @property
236
+ def fall_time_seconds(self) -> float:
237
+ """Extract fall time value in seconds (or NaN if not applicable)."""
238
+ return (
239
+ float(self.fall_time["value"])
240
+ if self.fall_time["applicable"] and self.fall_time["value"]
241
+ else np.nan
242
+ )
243
+
244
+ @property
245
+ def period_seconds(self) -> float:
246
+ """Extract period value in seconds (or NaN if not applicable)."""
247
+ return (
248
+ float(self.period["value"])
249
+ if self.period["applicable"] and self.period["value"]
250
+ else np.nan
251
+ )
252
+
253
+ @property
254
+ def frequency_hz(self) -> float:
255
+ """Extract frequency value in Hz (or NaN if not applicable)."""
256
+ return (
257
+ float(self.frequency["value"])
258
+ if self.frequency["applicable"] and self.frequency["value"]
259
+ else np.nan
260
+ )
261
+
262
+ @property
263
+ def duty_cycle_ratio(self) -> float:
264
+ """Extract duty cycle value as ratio (or NaN if not applicable)."""
265
+ return (
266
+ float(self.duty_cycle["value"])
267
+ if self.duty_cycle["applicable"] and self.duty_cycle["value"]
268
+ else np.nan
269
+ )
270
+
271
+ @property
272
+ def pulse_width_seconds(self) -> float:
273
+ """Extract pulse width value in seconds (or NaN if not applicable)."""
274
+ return (
275
+ float(self.pulse_width["value"])
276
+ if self.pulse_width["applicable"] and self.pulse_width["value"]
277
+ else np.nan
278
+ )
279
+
63
280
 
64
281
  @dataclass
65
282
  class DecodeResult:
@@ -103,14 +320,14 @@ def quick_spectral(
103
320
  window: Window function for FFT (default "hann").
104
321
 
105
322
  Returns:
106
- SpectralMetrics with all computed values.
323
+ SpectralMetrics with all computed values as MeasurementResults.
107
324
 
108
325
  Example:
109
326
  >>> trace = osc.load("audio_1khz.wfm")
110
327
  >>> metrics = osc.quick_spectral(trace, fundamental=1000)
111
328
  >>> print(f"THD: {metrics.thd_db:.1f} dB")
112
329
  >>> print(f"SNR: {metrics.snr_db:.1f} dB")
113
- >>> print(f"ENOB: {metrics.enob:.1f} bits")
330
+ >>> print(f"ENOB: {metrics.enob_value:.1f} bits")
114
331
 
115
332
  References:
116
333
  IEEE 1241-2010 Section 4.1 (ADC Characterization)
@@ -145,59 +362,210 @@ def quick_spectral(
145
362
  noise_bins = mag_db[10 : len(mag_db) // 2]
146
363
  noise_floor = float(np.median(noise_bins))
147
364
 
148
- # Compute metrics (these functions don't accept 'fundamental' parameter)
149
- thd_db_val = thd(trace, n_harmonics=n_harmonics, window=window)
150
- thd_pct = 100 * 10 ** (thd_db_val / 20) if not np.isnan(thd_db_val) else np.nan
151
- snr_db_val = snr(trace, n_harmonics=n_harmonics, window=window)
152
- sinad_db_val = sinad(trace, window=window)
153
- enob_val = enob(trace, window=window)
154
- sfdr_db_val = sfdr(trace, window=window)
365
+ # Compute metrics (these functions now return MeasurementResult dicts)
366
+ thd_result = thd(trace, n_harmonics=n_harmonics, window=window)
367
+ snr_result = snr(trace, n_harmonics=n_harmonics, window=window)
368
+ sinad_result = sinad(trace, window=window)
369
+ enob_result = enob(trace, window=window)
370
+ sfdr_result = sfdr(trace, window=window)
155
371
 
156
372
  return SpectralMetrics(
157
- thd_db=thd_db_val,
158
- thd_percent=thd_pct,
159
- snr_db=snr_db_val,
160
- sinad_db=sinad_db_val,
161
- enob=enob_val,
162
- sfdr_db=sfdr_db_val,
373
+ thd=thd_result,
374
+ snr=snr_result,
375
+ sinad=sinad_result,
376
+ enob=enob_result,
377
+ sfdr=sfdr_result,
163
378
  fundamental_freq=fundamental,
164
379
  fundamental_mag_db=fundamental_mag,
165
380
  noise_floor_db=noise_floor,
166
381
  )
167
382
 
168
383
 
384
+ def quick_power(
385
+ voltage_trace: WaveformTrace,
386
+ current_trace: WaveformTrace | None = None,
387
+ frequency: float | None = None,
388
+ ) -> PowerMetrics:
389
+ """One-call power analysis per IEEE 1459.
390
+
391
+ Computes RMS voltage/current, active/reactive/apparent power, and power
392
+ factor in a single call. If only voltage trace is provided, computes
393
+ voltage-only metrics.
394
+
395
+ Args:
396
+ voltage_trace: Voltage waveform trace.
397
+ current_trace: Current waveform trace (optional).
398
+ frequency: Fundamental frequency in Hz (optional, for AC analysis).
399
+
400
+ Returns:
401
+ PowerMetrics with all computed values as MeasurementResults.
402
+
403
+ Example:
404
+ >>> # AC power analysis with voltage and current
405
+ >>> metrics = osc.quick_power(v_trace, i_trace, frequency=60)
406
+ >>> print(f"Active Power: {metrics.active_power_value:.2f} W")
407
+ >>> print(f"Power Factor: {metrics.power_factor_value:.3f}")
408
+ >>>
409
+ >>> # Voltage-only analysis
410
+ >>> metrics = osc.quick_power(v_trace)
411
+ >>> print(f"RMS Voltage: {metrics.rms_voltage_value:.2f} V")
412
+
413
+ References:
414
+ IEEE 1459-2010 (Power Quality Definitions)
415
+ """
416
+ from oscura.analyzers.waveform.measurements import rms as waveform_rms
417
+ from oscura.core.measurement_result import make_measurement
418
+
419
+ # Compute RMS voltage
420
+ rms_v = waveform_rms(voltage_trace)
421
+
422
+ # Single trace mode: voltage-only metrics
423
+ if current_trace is None:
424
+ return PowerMetrics(
425
+ rms_voltage=rms_v,
426
+ rms_current=None,
427
+ active_power=make_measurement(0.0, "W"), # No power without current
428
+ reactive_power=None,
429
+ apparent_power=None,
430
+ power_factor=None,
431
+ )
432
+
433
+ # Two-trace mode: full power analysis
434
+ from oscura.analyzers.power.ac_power import (
435
+ apparent_power as calc_apparent_power,
436
+ )
437
+ from oscura.analyzers.power.ac_power import (
438
+ power_factor as calc_power_factor,
439
+ )
440
+ from oscura.analyzers.power.ac_power import (
441
+ reactive_power as calc_reactive_power,
442
+ )
443
+ from oscura.analyzers.power.basic import average_power
444
+
445
+ # Compute RMS current
446
+ rms_i = waveform_rms(current_trace)
447
+
448
+ # Compute power metrics
449
+ p_active = average_power(voltage=voltage_trace, current=current_trace)
450
+ q_reactive = calc_reactive_power(voltage_trace, current_trace, frequency=frequency)
451
+ s_apparent = calc_apparent_power(voltage_trace, current_trace)
452
+ pf = calc_power_factor(voltage_trace, current_trace)
453
+
454
+ return PowerMetrics(
455
+ rms_voltage=rms_v,
456
+ rms_current=rms_i,
457
+ active_power=make_measurement(p_active, "W"),
458
+ reactive_power=make_measurement(q_reactive, "VAR"),
459
+ apparent_power=make_measurement(s_apparent, "VA"),
460
+ power_factor=make_measurement(pf, "ratio"),
461
+ )
462
+
463
+
464
+ def quick_timing(trace: WaveformTrace) -> TimingMetrics:
465
+ """One-call timing analysis per IEEE 181.
466
+
467
+ Computes rise/fall time, period, frequency, duty cycle, and pulse width
468
+ in a single call with proper error handling.
469
+
470
+ Args:
471
+ trace: Input waveform trace.
472
+
473
+ Returns:
474
+ TimingMetrics with all computed values as MeasurementResults.
475
+
476
+ Example:
477
+ >>> trace = osc.load("pulse_capture.wfm")
478
+ >>> metrics = osc.quick_timing(trace)
479
+ >>> print(f"Rise Time: {metrics.rise_time_seconds:.2e} s")
480
+ >>> print(f"Frequency: {metrics.frequency_hz:.2f} Hz")
481
+ >>> print(f"Duty Cycle: {metrics.duty_cycle_ratio*100:.1f}%")
482
+
483
+ References:
484
+ IEEE 181-2011 (Transitional Waveform Definitions)
485
+ """
486
+ from oscura.analyzers.waveform.measurements import (
487
+ duty_cycle,
488
+ fall_time,
489
+ frequency,
490
+ period,
491
+ pulse_width,
492
+ rise_time,
493
+ )
494
+
495
+ # Compute all timing measurements
496
+ rt = rise_time(trace)
497
+ ft = fall_time(trace)
498
+ T = period(trace, return_all=False)
499
+ freq = frequency(trace)
500
+ dc = duty_cycle(trace)
501
+ pw = pulse_width(trace, polarity="positive", return_all=False)
502
+
503
+ return TimingMetrics(
504
+ rise_time=rt,
505
+ fall_time=ft,
506
+ period=T,
507
+ frequency=freq,
508
+ duty_cycle=dc,
509
+ pulse_width=pw,
510
+ )
511
+
512
+
169
513
  def auto_decode(
170
514
  trace: WaveformTrace | DigitalTrace,
171
515
  protocol: str | None = None,
172
516
  min_confidence: float = 0.5,
517
+ channel_mapping: dict[str, int] | None = None,
173
518
  ) -> DecodeResult:
174
519
  """Auto-detect protocol and decode frames in one call.
175
520
 
176
521
  Automatically detects the protocol type (UART, SPI, I2C, CAN, etc.)
177
- if not specified, then decodes all frames.
522
+ if not specified, then decodes all frames. Supports multi-channel traces
523
+ via channel mapping.
178
524
 
179
525
  Args:
180
- trace: Input trace (waveform or digital).
526
+ trace: Input trace (waveform, digital, or multi-channel).
181
527
  protocol: Force specific protocol (None for auto-detect).
182
528
  min_confidence: Minimum confidence for auto-detection (0-1).
529
+ channel_mapping: Channel mapping for multi-channel traces.
530
+ Example: {"data": 0, "clock": 1} for SPI on channels 0 and 1.
183
531
 
184
532
  Returns:
185
533
  DecodeResult with protocol name, decoded frames, and statistics.
186
534
 
187
535
  Example:
536
+ >>> # Single-channel auto-detection
188
537
  >>> trace = osc.load("serial_capture.wfm")
189
538
  >>> result = osc.auto_decode(trace)
190
539
  >>> print(f"Protocol: {result.protocol}")
191
540
  >>> print(f"Frames decoded: {len(result.frames)}")
192
541
  >>> for frame in result.frames[:5]:
193
542
  ... print(f" {frame.data.hex()}")
543
+ >>>
544
+ >>> # Multi-channel with explicit mapping
545
+ >>> multi_trace = osc.load("spi_capture.wfm") # 2 channels
546
+ >>> result = osc.auto_decode(
547
+ ... multi_trace,
548
+ ... protocol="SPI",
549
+ ... channel_mapping={"mosi": 0, "clock": 1}
550
+ ... )
194
551
 
195
552
  References:
196
553
  sigrok Protocol Decoder API
197
554
  """
555
+ # Handle multi-channel traces
556
+ if hasattr(trace, "channels") and channel_mapping is not None:
557
+ # Extract channels based on mapping
558
+ # Note: MultiChannelTrace support would go here when implemented
559
+ # For now, use first channel as fallback
560
+ from oscura.core.types import WaveformTrace
198
561
 
199
- # Prepare digital trace
200
- digital_trace = _prepare_digital_trace(trace)
562
+ if isinstance(trace, WaveformTrace):
563
+ digital_trace = _prepare_digital_trace(trace)
564
+ else:
565
+ digital_trace = trace # type: ignore[assignment]
566
+ else:
567
+ # Prepare digital trace
568
+ digital_trace = _prepare_digital_trace(trace)
201
569
 
202
570
  # Detect or use specified protocol
203
571
  protocol, config, confidence = _detect_or_select_protocol(
@@ -225,7 +225,6 @@ def load_config(
225
225
  config = copy.deepcopy(DEFAULT_CONFIG)
226
226
 
227
227
  # Search for config files if no explicit path provided
228
- # use_defaults flag only controls whether to merge with DEFAULT_CONFIG
229
228
  if config_path is None:
230
229
  # Search standard locations
231
230
  search_paths = [