oscura 0.8.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 (151) 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/patterns/__init__.py +66 -0
  7. oscura/analyzers/power/basic.py +3 -3
  8. oscura/analyzers/power/soa.py +1 -1
  9. oscura/analyzers/power/switching.py +3 -3
  10. oscura/analyzers/signal_classification.py +529 -0
  11. oscura/analyzers/signal_integrity/sparams.py +3 -3
  12. oscura/analyzers/statistics/basic.py +10 -7
  13. oscura/analyzers/validation.py +1 -1
  14. oscura/analyzers/waveform/measurements.py +200 -156
  15. oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
  16. oscura/analyzers/waveform/spectral.py +164 -73
  17. oscura/api/dsl/commands.py +15 -6
  18. oscura/api/server/templates/base.html +137 -146
  19. oscura/api/server/templates/export.html +84 -110
  20. oscura/api/server/templates/home.html +248 -267
  21. oscura/api/server/templates/protocols.html +44 -48
  22. oscura/api/server/templates/reports.html +27 -35
  23. oscura/api/server/templates/session_detail.html +68 -78
  24. oscura/api/server/templates/sessions.html +62 -72
  25. oscura/api/server/templates/waveforms.html +54 -64
  26. oscura/automotive/__init__.py +1 -1
  27. oscura/automotive/can/session.py +1 -1
  28. oscura/automotive/dbc/generator.py +638 -23
  29. oscura/automotive/uds/decoder.py +99 -6
  30. oscura/cli/analyze.py +8 -2
  31. oscura/cli/batch.py +36 -5
  32. oscura/cli/characterize.py +18 -4
  33. oscura/cli/export.py +47 -5
  34. oscura/cli/main.py +2 -0
  35. oscura/cli/onboarding/wizard.py +10 -6
  36. oscura/cli/pipeline.py +585 -0
  37. oscura/cli/visualize.py +6 -4
  38. oscura/convenience.py +400 -32
  39. oscura/core/measurement_result.py +286 -0
  40. oscura/core/progress.py +1 -1
  41. oscura/core/types.py +232 -239
  42. oscura/correlation/multi_protocol.py +1 -1
  43. oscura/export/legacy/__init__.py +11 -0
  44. oscura/export/legacy/wav.py +75 -0
  45. oscura/exporters/__init__.py +19 -0
  46. oscura/exporters/wireshark.py +809 -0
  47. oscura/hardware/acquisition/file.py +5 -19
  48. oscura/hardware/acquisition/saleae.py +10 -10
  49. oscura/hardware/acquisition/socketcan.py +4 -6
  50. oscura/hardware/acquisition/synthetic.py +1 -5
  51. oscura/hardware/acquisition/visa.py +6 -6
  52. oscura/hardware/security/side_channel_detector.py +5 -508
  53. oscura/inference/message_format.py +686 -1
  54. oscura/jupyter/display.py +2 -2
  55. oscura/jupyter/magic.py +3 -3
  56. oscura/loaders/__init__.py +17 -12
  57. oscura/loaders/binary.py +1 -1
  58. oscura/loaders/chipwhisperer.py +1 -2
  59. oscura/loaders/configurable.py +1 -1
  60. oscura/loaders/csv_loader.py +2 -2
  61. oscura/loaders/hdf5_loader.py +1 -1
  62. oscura/loaders/lazy.py +6 -1
  63. oscura/loaders/mmap_loader.py +0 -1
  64. oscura/loaders/numpy_loader.py +8 -7
  65. oscura/loaders/preprocessing.py +3 -5
  66. oscura/loaders/rigol.py +21 -7
  67. oscura/loaders/sigrok.py +2 -5
  68. oscura/loaders/tdms.py +3 -2
  69. oscura/loaders/tektronix.py +38 -32
  70. oscura/loaders/tss.py +20 -27
  71. oscura/loaders/vcd.py +13 -8
  72. oscura/loaders/wav.py +1 -6
  73. oscura/pipeline/__init__.py +76 -0
  74. oscura/pipeline/handlers/__init__.py +165 -0
  75. oscura/pipeline/handlers/analyzers.py +1045 -0
  76. oscura/pipeline/handlers/decoders.py +899 -0
  77. oscura/pipeline/handlers/exporters.py +1103 -0
  78. oscura/pipeline/handlers/filters.py +891 -0
  79. oscura/pipeline/handlers/loaders.py +640 -0
  80. oscura/pipeline/handlers/transforms.py +768 -0
  81. oscura/reporting/formatting/measurements.py +55 -14
  82. oscura/reporting/templates/enhanced/protocol_re.html +504 -503
  83. oscura/side_channel/__init__.py +38 -57
  84. oscura/utils/builders/signal_builder.py +5 -5
  85. oscura/utils/comparison/compare.py +7 -9
  86. oscura/utils/comparison/golden.py +1 -1
  87. oscura/utils/filtering/convenience.py +2 -2
  88. oscura/utils/math/arithmetic.py +38 -62
  89. oscura/utils/math/interpolation.py +20 -20
  90. oscura/utils/pipeline/__init__.py +4 -17
  91. oscura/utils/progressive.py +1 -4
  92. oscura/utils/triggering/edge.py +1 -1
  93. oscura/utils/triggering/pattern.py +2 -2
  94. oscura/utils/triggering/pulse.py +2 -2
  95. oscura/utils/triggering/window.py +3 -3
  96. oscura/validation/hil_testing.py +11 -11
  97. oscura/visualization/__init__.py +46 -284
  98. oscura/visualization/batch.py +72 -433
  99. oscura/visualization/plot.py +542 -53
  100. oscura/visualization/styles.py +184 -318
  101. oscura/workflows/batch/advanced.py +1 -1
  102. oscura/workflows/batch/aggregate.py +7 -8
  103. oscura/workflows/complete_re.py +251 -23
  104. oscura/workflows/digital.py +27 -4
  105. oscura/workflows/multi_trace.py +136 -17
  106. oscura/workflows/waveform.py +11 -6
  107. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
  108. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/RECORD +111 -136
  109. oscura/side_channel/dpa.py +0 -1025
  110. oscura/utils/optimization/__init__.py +0 -19
  111. oscura/utils/optimization/parallel.py +0 -443
  112. oscura/utils/optimization/search.py +0 -532
  113. oscura/utils/pipeline/base.py +0 -338
  114. oscura/utils/pipeline/composition.py +0 -248
  115. oscura/utils/pipeline/parallel.py +0 -449
  116. oscura/utils/pipeline/pipeline.py +0 -375
  117. oscura/utils/search/__init__.py +0 -16
  118. oscura/utils/search/anomaly.py +0 -424
  119. oscura/utils/search/context.py +0 -294
  120. oscura/utils/search/pattern.py +0 -288
  121. oscura/utils/storage/__init__.py +0 -61
  122. oscura/utils/storage/database.py +0 -1166
  123. oscura/visualization/accessibility.py +0 -526
  124. oscura/visualization/annotations.py +0 -371
  125. oscura/visualization/axis_scaling.py +0 -305
  126. oscura/visualization/colors.py +0 -451
  127. oscura/visualization/digital.py +0 -436
  128. oscura/visualization/eye.py +0 -571
  129. oscura/visualization/histogram.py +0 -281
  130. oscura/visualization/interactive.py +0 -1035
  131. oscura/visualization/jitter.py +0 -1042
  132. oscura/visualization/keyboard.py +0 -394
  133. oscura/visualization/layout.py +0 -400
  134. oscura/visualization/optimization.py +0 -1079
  135. oscura/visualization/palettes.py +0 -446
  136. oscura/visualization/power.py +0 -508
  137. oscura/visualization/power_extended.py +0 -955
  138. oscura/visualization/presets.py +0 -469
  139. oscura/visualization/protocols.py +0 -1246
  140. oscura/visualization/render.py +0 -223
  141. oscura/visualization/rendering.py +0 -444
  142. oscura/visualization/reverse_engineering.py +0 -838
  143. oscura/visualization/signal_integrity.py +0 -989
  144. oscura/visualization/specialized.py +0 -643
  145. oscura/visualization/spectral.py +0 -1226
  146. oscura/visualization/thumbnails.py +0 -340
  147. oscura/visualization/time_axis.py +0 -351
  148. oscura/visualization/waveform.py +0 -454
  149. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
  150. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
  151. {oscura-0.8.0.dist-info → oscura-0.10.0.dist-info}/licenses/LICENSE +0 -0
@@ -22,10 +22,12 @@ from typing import TYPE_CHECKING, Any, Literal, overload
22
22
  import numpy as np
23
23
  from numpy import floating as np_floating
24
24
 
25
+ from oscura.core.measurement_result import make_inapplicable, make_measurement
26
+
25
27
  if TYPE_CHECKING:
26
28
  from numpy.typing import NDArray
27
29
 
28
- from oscura.core.types import WaveformTrace
30
+ from oscura.core.types import MeasurementResult, WaveformTrace
29
31
 
30
32
 
31
33
  # Measurement metadata: unit information for all waveform measurements
@@ -73,7 +75,7 @@ def rise_time(
73
75
  trace: WaveformTrace,
74
76
  *,
75
77
  ref_levels: tuple[float, float] = (0.1, 0.9),
76
- ) -> float | np_floating[Any]:
78
+ ) -> MeasurementResult:
77
79
  """Measure rise time between reference levels.
78
80
 
79
81
  Computes the time for a signal to transition from the lower
@@ -85,31 +87,32 @@ def rise_time(
85
87
  Default (0.1, 0.9) for 10%-90% rise time.
86
88
 
87
89
  Returns:
88
- Rise time in seconds, or np.nan if no valid rising edge found.
90
+ MeasurementResult with rise time in seconds, or inapplicable if no rising edge.
89
91
 
90
92
  Example:
91
- >>> t_rise = rise_time(trace)
92
- >>> print(f"Rise time: {t_rise * 1e9:.2f} ns")
93
+ >>> result = rise_time(trace)
94
+ >>> if result["applicable"]:
95
+ ... print(f"Rise time: {result['display']}")
93
96
 
94
97
  References:
95
98
  IEEE 181-2011 Section 5.2
96
99
  """
97
100
  if len(trace.data) < 3:
98
- return np.nan
101
+ return make_inapplicable("s", "Insufficient data (need ≥3 samples)")
99
102
 
100
103
  data = trace.data
101
104
  low, high = _find_levels(data)
102
105
  amplitude = high - low
103
106
 
104
- if amplitude <= 0:
105
- return np.nan
107
+ if amplitude <= 0 or np.isnan(amplitude):
108
+ return make_inapplicable("s", "Constant signal (no transitions)")
106
109
 
107
110
  # Calculate reference voltages
108
111
  low_ref = low + ref_levels[0] * amplitude
109
112
  high_ref = low + ref_levels[1] * amplitude
110
113
 
111
114
  # Find rising edge: where signal crosses from below low_ref to above high_ref
112
- sample_period = trace.metadata.time_base
115
+ sample_period = 1.0 / trace.metadata.sample_rate
113
116
 
114
117
  # Find first crossing of low reference (going up)
115
118
  below_low = data < low_ref
@@ -119,7 +122,7 @@ def rise_time(
119
122
  transitions = np.where(below_low[:-1] & above_low[1:])[0]
120
123
 
121
124
  if len(transitions) == 0:
122
- return np.nan
125
+ return make_inapplicable("s", "No rising edges detected")
123
126
 
124
127
  best_rise_time: float | np_floating[Any] = np.nan
125
128
 
@@ -148,14 +151,17 @@ def rise_time(
148
151
  if rt > 0 and (np.isnan(best_rise_time) or rt < best_rise_time):
149
152
  best_rise_time = rt
150
153
 
151
- return best_rise_time
154
+ if np.isnan(best_rise_time):
155
+ return make_inapplicable("s", "No valid rising edge found")
156
+
157
+ return make_measurement(float(best_rise_time), "s")
152
158
 
153
159
 
154
160
  def fall_time(
155
161
  trace: WaveformTrace,
156
162
  *,
157
163
  ref_levels: tuple[float, float] = (0.9, 0.1),
158
- ) -> float | np_floating[Any]:
164
+ ) -> MeasurementResult:
159
165
  """Measure fall time between reference levels.
160
166
 
161
167
  Computes the time for a signal to transition from the upper
@@ -167,30 +173,31 @@ def fall_time(
167
173
  Default (0.9, 0.1) for 90%-10% fall time.
168
174
 
169
175
  Returns:
170
- Fall time in seconds, or np.nan if no valid falling edge found.
176
+ MeasurementResult with fall time in seconds, or inapplicable if no falling edge.
171
177
 
172
178
  Example:
173
- >>> t_fall = fall_time(trace)
174
- >>> print(f"Fall time: {t_fall * 1e9:.2f} ns")
179
+ >>> result = fall_time(trace)
180
+ >>> if result["applicable"]:
181
+ ... print(f"Fall time: {result['display']}")
175
182
 
176
183
  References:
177
184
  IEEE 181-2011 Section 5.2
178
185
  """
179
186
  if len(trace.data) < 3:
180
- return np.nan
187
+ return make_inapplicable("s", "Insufficient data (need ≥3 samples)")
181
188
 
182
189
  data = trace.data
183
190
  low, high = _find_levels(data)
184
191
  amplitude = high - low
185
192
 
186
- if amplitude <= 0:
187
- return np.nan
193
+ if amplitude <= 0 or np.isnan(amplitude):
194
+ return make_inapplicable("s", "Constant signal (no transitions)")
188
195
 
189
196
  # Calculate reference voltages (note: ref_levels[0] is the higher one for fall)
190
197
  high_ref = low + ref_levels[0] * amplitude
191
198
  low_ref = low + ref_levels[1] * amplitude
192
199
 
193
- sample_period = trace.metadata.time_base
200
+ sample_period = 1.0 / trace.metadata.sample_rate
194
201
 
195
202
  # Find where signal is above high reference
196
203
  above_high = data >= high_ref
@@ -200,7 +207,7 @@ def fall_time(
200
207
  transitions = np.where(above_high[:-1] & below_high[1:])[0]
201
208
 
202
209
  if len(transitions) == 0:
203
- return np.nan
210
+ return make_inapplicable("s", "No falling edges detected")
204
211
 
205
212
  best_fall_time: float | np_floating[Any] = np.nan
206
213
 
@@ -228,7 +235,10 @@ def fall_time(
228
235
  if ft > 0 and (np.isnan(best_fall_time) or ft < best_fall_time):
229
236
  best_fall_time = ft
230
237
 
231
- return best_fall_time
238
+ if np.isnan(best_fall_time):
239
+ return make_inapplicable("s", "No valid falling edge found")
240
+
241
+ return make_measurement(float(best_fall_time), "s")
232
242
 
233
243
 
234
244
  @overload
@@ -237,7 +247,7 @@ def period(
237
247
  *,
238
248
  edge_type: Literal["rising", "falling"] = "rising",
239
249
  return_all: Literal[False] = False,
240
- ) -> float | np_floating[Any]: ...
250
+ ) -> MeasurementResult: ...
241
251
 
242
252
 
243
253
  @overload
@@ -254,7 +264,7 @@ def period(
254
264
  *,
255
265
  edge_type: Literal["rising", "falling"] = "rising",
256
266
  return_all: bool = False,
257
- ) -> float | np_floating[Any] | NDArray[np.float64]:
267
+ ) -> MeasurementResult | NDArray[np.float64]:
258
268
  """Measure signal period between consecutive edges.
259
269
 
260
270
  Computes the time between consecutive rising or falling edges.
@@ -262,14 +272,15 @@ def period(
262
272
  Args:
263
273
  trace: Input waveform trace.
264
274
  edge_type: Type of edges to use ("rising" or "falling").
265
- return_all: If True, return array of all periods. If False, return mean.
275
+ return_all: If True, return array of all periods. If False, return MeasurementResult.
266
276
 
267
277
  Returns:
268
- Period in seconds (mean if return_all=False), or array of periods.
278
+ MeasurementResult with period in seconds (mean), or array of periods if return_all=True.
269
279
 
270
280
  Example:
271
- >>> T = period(trace)
272
- >>> print(f"Period: {T * 1e6:.2f} us")
281
+ >>> result = period(trace)
282
+ >>> if result["applicable"]:
283
+ ... print(f"Period: {result['display']}")
273
284
 
274
285
  References:
275
286
  IEEE 181-2011 Section 5.3
@@ -279,20 +290,20 @@ def period(
279
290
  if len(edges) < 2:
280
291
  if return_all:
281
292
  return np.array([], dtype=np.float64)
282
- return np.nan
293
+ return make_inapplicable("s", f"Insufficient {edge_type} edges (need ≥2)")
283
294
 
284
295
  periods = np.diff(edges)
285
296
 
286
297
  if return_all:
287
298
  return periods
288
- return float(np.mean(periods))
299
+ return make_measurement(float(np.mean(periods)), "s")
289
300
 
290
301
 
291
302
  def frequency(
292
303
  trace: WaveformTrace,
293
304
  *,
294
305
  method: Literal["edge", "fft"] = "edge",
295
- ) -> float | np_floating[Any]:
306
+ ) -> MeasurementResult:
296
307
  """Measure signal frequency.
297
308
 
298
309
  Computes frequency either from edge-to-edge period or using FFT.
@@ -306,17 +317,18 @@ def frequency(
306
317
  - "fft": Peak of FFT magnitude spectrum (always use FFT)
307
318
 
308
319
  Returns:
309
- Frequency in Hz, or np.nan if measurement not possible.
320
+ MeasurementResult with frequency in Hz, or inapplicable if measurement not possible.
310
321
 
311
322
  Raises:
312
323
  ValueError: If method is not one of the supported types.
313
324
 
314
325
  Example:
315
- >>> f = frequency(trace)
316
- >>> print(f"Frequency: {f / 1e6:.3f} MHz")
326
+ >>> result = frequency(trace)
327
+ >>> if result["applicable"]:
328
+ ... print(f"Frequency: {result['display']}")
317
329
 
318
330
  >>> # Force FFT method for smooth waveforms
319
- >>> f = frequency(trace, method="fft")
331
+ >>> result = frequency(trace, method="fft")
320
332
 
321
333
  References:
322
334
  IEEE 181-2011 Section 5.3
@@ -326,11 +338,11 @@ def frequency(
326
338
  T = period(trace, edge_type="rising", return_all=False)
327
339
 
328
340
  # Fall back to FFT if edge detection fails
329
- if np.isnan(T) or T <= 0:
341
+ if not T["applicable"] or not T["value"] or T["value"] <= 0:
330
342
  # Try FFT fallback for smooth waveforms (sine, triangle)
331
343
  return _frequency_fft(trace)
332
344
 
333
- return 1.0 / T
345
+ return make_measurement(1.0 / T["value"], "Hz")
334
346
 
335
347
  elif method == "fft":
336
348
  return _frequency_fft(trace)
@@ -339,7 +351,7 @@ def frequency(
339
351
  raise ValueError(f"Unknown method: {method}")
340
352
 
341
353
 
342
- def _frequency_fft(trace: WaveformTrace) -> float | np_floating[Any]:
354
+ def _frequency_fft(trace: WaveformTrace) -> MeasurementResult:
343
355
  """Compute frequency using FFT peak detection.
344
356
 
345
357
  Internal helper function for FFT-based frequency measurement.
@@ -348,17 +360,17 @@ def _frequency_fft(trace: WaveformTrace) -> float | np_floating[Any]:
348
360
  trace: Input waveform trace.
349
361
 
350
362
  Returns:
351
- Frequency in Hz, or np.nan if measurement not possible.
363
+ MeasurementResult with frequency in Hz, or inapplicable if measurement not possible.
352
364
  """
353
365
  if len(trace.data) < 16:
354
- return np.nan
366
+ return make_inapplicable("Hz", "Insufficient data for FFT (need ≥16 samples)")
355
367
 
356
368
  # Remove DC offset before FFT
357
369
  data = trace.data - np.mean(trace.data)
358
370
 
359
371
  # Check if signal is essentially constant (DC only)
360
372
  if np.std(data) < 1e-10:
361
- return np.nan
373
+ return make_inapplicable("Hz", "Constant signal (DC only)")
362
374
 
363
375
  n = len(data)
364
376
  fft_mag = np.abs(np.fft.rfft(data))
@@ -369,18 +381,18 @@ def _frequency_fft(trace: WaveformTrace) -> float | np_floating[Any]:
369
381
  # Verify peak is significant (SNR check)
370
382
  # If the peak is not at least 3x the mean, it's likely noise
371
383
  if fft_mag[peak_idx] < 3.0 * np.mean(fft_mag[1:]):
372
- return np.nan
384
+ return make_inapplicable("Hz", "No dominant frequency (noisy signal)")
373
385
 
374
386
  # Calculate frequency from peak index
375
387
  freq_resolution = trace.metadata.sample_rate / n
376
- return float(peak_idx * freq_resolution)
388
+ return make_measurement(float(peak_idx * freq_resolution), "Hz")
377
389
 
378
390
 
379
391
  def duty_cycle(
380
392
  trace: WaveformTrace,
381
393
  *,
382
394
  percentage: bool = False,
383
- ) -> float | np_floating[Any]:
395
+ ) -> MeasurementResult:
384
396
  """Measure duty cycle.
385
397
 
386
398
  Computes duty cycle as the ratio of positive pulse width to period.
@@ -391,14 +403,15 @@ def duty_cycle(
391
403
 
392
404
  Args:
393
405
  trace: Input waveform trace.
394
- percentage: If True, return as percentage (0-100). If False, return ratio (0-1).
406
+ percentage: Ignored (always returns ratio, display format shows %).
395
407
 
396
408
  Returns:
397
- Duty cycle as ratio or percentage, or np.nan if measurement not possible.
409
+ MeasurementResult with duty cycle as ratio (0-1), or inapplicable if not possible.
398
410
 
399
411
  Example:
400
- >>> dc = duty_cycle(trace, percentage=True)
401
- >>> print(f"Duty cycle: {dc:.1f}%")
412
+ >>> result = duty_cycle(trace)
413
+ >>> if result["applicable"]:
414
+ ... print(f"Duty cycle: {result['display']}") # Shows as percentage
402
415
 
403
416
  References:
404
417
  IEEE 181-2011 Section 5.4
@@ -411,17 +424,19 @@ def duty_cycle(
411
424
  T = period(trace, edge_type="rising", return_all=False)
412
425
 
413
426
  # Method 1: Standard period-based calculation
414
- if not np.isnan(pw_pos) and not np.isnan(T) and T > 0:
415
- dc = pw_pos / T
416
- if percentage:
417
- return dc * 100
418
- return dc
427
+ if isinstance(pw_pos, dict) and pw_pos.get("applicable") and pw_pos.get("value"):
428
+ T_value = T.get("value") if isinstance(T, dict) else None
429
+ if isinstance(T, dict) and T.get("applicable") and T_value and T_value > 0:
430
+ pw_value = pw_pos["value"]
431
+ if pw_value:
432
+ dc = pw_value / T_value
433
+ return make_measurement(dc, "ratio")
419
434
 
420
435
  # Method 2: Fallback for incomplete waveforms - time-domain calculation
421
436
  # Calculate fraction of time signal spends above midpoint threshold
422
437
  data = trace.data
423
438
  if len(data) < 3:
424
- return np.nan
439
+ return make_inapplicable("ratio", "Insufficient data (need ≥3 samples)")
425
440
 
426
441
  # Convert boolean data to float if needed
427
442
  if data.dtype == bool:
@@ -432,7 +447,7 @@ def duty_cycle(
432
447
 
433
448
  # Check for invalid levels
434
449
  if amplitude <= 0 or np.isnan(amplitude):
435
- return np.nan
450
+ return make_inapplicable("ratio", "Constant signal (no transitions)")
436
451
 
437
452
  # Calculate threshold at 50% of amplitude
438
453
  mid = low + 0.5 * amplitude
@@ -443,13 +458,10 @@ def duty_cycle(
443
458
  total_samples = len(data)
444
459
 
445
460
  if total_samples == 0:
446
- return np.nan
461
+ return make_inapplicable("ratio", "No data available")
447
462
 
448
463
  dc = float(samples_high) / total_samples
449
-
450
- if percentage:
451
- return dc * 100
452
- return dc
464
+ return make_measurement(dc, "ratio")
453
465
 
454
466
 
455
467
  @overload
@@ -459,7 +471,7 @@ def pulse_width(
459
471
  polarity: Literal["positive", "negative"] = "positive",
460
472
  ref_level: float = 0.5,
461
473
  return_all: Literal[False] = False,
462
- ) -> float | np_floating[Any]: ...
474
+ ) -> MeasurementResult: ...
463
475
 
464
476
 
465
477
  @overload
@@ -478,7 +490,7 @@ def pulse_width(
478
490
  polarity: Literal["positive", "negative"] = "positive",
479
491
  ref_level: float = 0.5,
480
492
  return_all: bool = False,
481
- ) -> float | np_floating[Any] | NDArray[np.float64]:
493
+ ) -> MeasurementResult | NDArray[np.float64]:
482
494
  """Measure pulse width.
483
495
 
484
496
  Computes positive or negative pulse width at the specified reference level.
@@ -487,14 +499,15 @@ def pulse_width(
487
499
  trace: Input waveform trace.
488
500
  polarity: "positive" for high pulses, "negative" for low pulses.
489
501
  ref_level: Reference level as fraction (0.0 to 1.0). Default 0.5 (50%).
490
- return_all: If True, return array of all widths. If False, return mean.
502
+ return_all: If True, return array of all widths. If False, return MeasurementResult.
491
503
 
492
504
  Returns:
493
- Pulse width in seconds.
505
+ MeasurementResult with pulse width in seconds (mean), or array if return_all=True.
494
506
 
495
507
  Example:
496
- >>> pw = pulse_width(trace, polarity="positive")
497
- >>> print(f"Pulse width: {pw * 1e6:.2f} us")
508
+ >>> result = pulse_width(trace, polarity="positive")
509
+ >>> if result["applicable"]:
510
+ ... print(f"Pulse width: {result['display']}")
498
511
 
499
512
  References:
500
513
  IEEE 181-2011 Section 5.4
@@ -505,7 +518,12 @@ def pulse_width(
505
518
  if len(rising_edges) == 0 or len(falling_edges) == 0:
506
519
  if return_all:
507
520
  return np.array([], dtype=np.float64)
508
- return np.nan
521
+ edge_type = (
522
+ "rising and falling"
523
+ if len(rising_edges) == 0 and len(falling_edges) == 0
524
+ else ("rising" if len(rising_edges) == 0 else "falling")
525
+ )
526
+ return make_inapplicable("s", f"No {edge_type} edges found")
509
527
 
510
528
  widths: list[float] = []
511
529
 
@@ -527,16 +545,16 @@ def pulse_width(
527
545
  if len(widths) == 0:
528
546
  if return_all:
529
547
  return np.array([], dtype=np.float64)
530
- return np.nan
548
+ return make_inapplicable("s", f"No {polarity} pulses found")
531
549
 
532
550
  widths_arr = np.array(widths, dtype=np.float64)
533
551
 
534
552
  if return_all:
535
553
  return widths_arr
536
- return float(np.mean(widths_arr))
554
+ return make_measurement(float(np.mean(widths_arr)), "s")
537
555
 
538
556
 
539
- def overshoot(trace: WaveformTrace) -> float | np_floating[Any]:
557
+ def overshoot(trace: WaveformTrace) -> MeasurementResult:
540
558
  """Measure overshoot percentage.
541
559
 
542
560
  Computes overshoot as (max - high) / amplitude * 100%.
@@ -545,34 +563,35 @@ def overshoot(trace: WaveformTrace) -> float | np_floating[Any]:
545
563
  trace: Input waveform trace.
546
564
 
547
565
  Returns:
548
- Overshoot as percentage, or np.nan if not applicable.
566
+ MeasurementResult with overshoot as percentage, or inapplicable if not applicable.
549
567
 
550
568
  Example:
551
- >>> os = overshoot(trace)
552
- >>> print(f"Overshoot: {os:.1f}%")
569
+ >>> result = overshoot(trace)
570
+ >>> if result["applicable"]:
571
+ ... print(f"Overshoot: {result['display']}")
553
572
 
554
573
  References:
555
574
  IEEE 181-2011 Section 5.5
556
575
  """
557
576
  if len(trace.data) < 3:
558
- return np.nan
577
+ return make_inapplicable("%", "Insufficient data (need ≥3 samples)")
559
578
 
560
579
  data = trace.data
561
580
  low, high = _find_levels(data)
562
581
  amplitude = high - low
563
582
 
564
- if amplitude <= 0:
565
- return np.nan
583
+ if amplitude <= 0 or np.isnan(amplitude):
584
+ return make_inapplicable("%", "Constant signal (no amplitude)")
566
585
 
567
586
  max_val = np.max(data)
568
587
 
569
588
  if max_val <= high:
570
- return 0.0
589
+ return make_measurement(0.0, "%")
571
590
 
572
- return float((max_val - high) / amplitude * 100)
591
+ return make_measurement(float((max_val - high) / amplitude * 100), "%")
573
592
 
574
593
 
575
- def undershoot(trace: WaveformTrace) -> float | np_floating[Any]:
594
+ def undershoot(trace: WaveformTrace) -> MeasurementResult:
576
595
  """Measure undershoot percentage.
577
596
 
578
597
  Computes undershoot as (low - min) / amplitude * 100%.
@@ -581,38 +600,39 @@ def undershoot(trace: WaveformTrace) -> float | np_floating[Any]:
581
600
  trace: Input waveform trace.
582
601
 
583
602
  Returns:
584
- Undershoot as percentage, or np.nan if not applicable.
603
+ MeasurementResult with undershoot as percentage, or inapplicable if not applicable.
585
604
 
586
605
  Example:
587
- >>> us = undershoot(trace)
588
- >>> print(f"Undershoot: {us:.1f}%")
606
+ >>> result = undershoot(trace)
607
+ >>> if result["applicable"]:
608
+ ... print(f"Undershoot: {result['display']}")
589
609
 
590
610
  References:
591
611
  IEEE 181-2011 Section 5.5
592
612
  """
593
613
  if len(trace.data) < 3:
594
- return np.nan
614
+ return make_inapplicable("%", "Insufficient data (need ≥3 samples)")
595
615
 
596
616
  data = trace.data
597
617
  low, high = _find_levels(data)
598
618
  amplitude = high - low
599
619
 
600
- if amplitude <= 0:
601
- return np.nan
620
+ if amplitude <= 0 or np.isnan(amplitude):
621
+ return make_inapplicable("%", "Constant signal (no amplitude)")
602
622
 
603
623
  min_val = np.min(data)
604
624
 
605
625
  if min_val >= low:
606
- return 0.0
626
+ return make_measurement(0.0, "%")
607
627
 
608
- return float((low - min_val) / amplitude * 100)
628
+ return make_measurement(float((low - min_val) / amplitude * 100), "%")
609
629
 
610
630
 
611
631
  def preshoot(
612
632
  trace: WaveformTrace,
613
633
  *,
614
634
  edge_type: Literal["rising", "falling"] = "rising",
615
- ) -> float | np_floating[Any]:
635
+ ) -> MeasurementResult:
616
636
  """Measure preshoot percentage.
617
637
 
618
638
  Computes preshoot before transitions as percentage of amplitude.
@@ -622,25 +642,26 @@ def preshoot(
622
642
  edge_type: Type of edge to analyze ("rising" or "falling").
623
643
 
624
644
  Returns:
625
- Preshoot as percentage, or np.nan if not applicable.
645
+ MeasurementResult with preshoot as percentage, or inapplicable if not applicable.
626
646
 
627
647
  Example:
628
- >>> ps = preshoot(trace)
629
- >>> print(f"Preshoot: {ps:.1f}%")
648
+ >>> result = preshoot(trace)
649
+ >>> if result["applicable"]:
650
+ ... print(f"Preshoot: {result['display']}")
630
651
 
631
652
  References:
632
653
  IEEE 181-2011 Section 5.5
633
654
  """
634
655
  if len(trace.data) < 10:
635
- return np.nan
656
+ return make_inapplicable("%", "Insufficient data (need ≥10 samples)")
636
657
 
637
658
  # Convert memoryview to ndarray if needed
638
659
  data = np.asarray(trace.data)
639
660
  low, high = _find_levels(data)
640
661
  amplitude = high - low
641
662
 
642
- if amplitude <= 0:
643
- return np.nan
663
+ if amplitude <= 0 or np.isnan(amplitude):
664
+ return make_inapplicable("%", "Constant signal (no amplitude)")
644
665
 
645
666
  # Find edge crossings at 50%
646
667
  mid = (low + high) / 2
@@ -649,7 +670,7 @@ def preshoot(
649
670
  # Look for minimum before rising edge that goes below low level
650
671
  crossings = np.where((data[:-1] < mid) & (data[1:] >= mid))[0]
651
672
  if len(crossings) == 0:
652
- return np.nan
673
+ return make_inapplicable("%", "No rising edges found")
653
674
 
654
675
  max_preshoot = 0.0
655
676
  for idx in crossings:
@@ -662,12 +683,12 @@ def preshoot(
662
683
  preshoot_val = (low - min_pre) / amplitude * 100
663
684
  max_preshoot = max(max_preshoot, preshoot_val)
664
685
 
665
- return max_preshoot
686
+ return make_measurement(max_preshoot, "%")
666
687
 
667
688
  else: # falling
668
689
  crossings = np.where((data[:-1] >= mid) & (data[1:] < mid))[0]
669
690
  if len(crossings) == 0:
670
- return np.nan
691
+ return make_inapplicable("%", "No falling edges found")
671
692
 
672
693
  max_preshoot = 0.0
673
694
  for idx in crossings:
@@ -679,10 +700,10 @@ def preshoot(
679
700
  preshoot_val = (max_pre - high) / amplitude * 100
680
701
  max_preshoot = max(max_preshoot, preshoot_val)
681
702
 
682
- return max_preshoot
703
+ return make_measurement(max_preshoot, "%")
683
704
 
684
705
 
685
- def amplitude(trace: WaveformTrace) -> float | np_floating[Any]:
706
+ def amplitude(trace: WaveformTrace) -> MeasurementResult:
686
707
  """Measure peak-to-peak amplitude.
687
708
 
688
709
  Computes Vpp as the difference between histogram-based high and low levels.
@@ -691,20 +712,26 @@ def amplitude(trace: WaveformTrace) -> float | np_floating[Any]:
691
712
  trace: Input waveform trace.
692
713
 
693
714
  Returns:
694
- Amplitude in volts (or input units).
715
+ MeasurementResult with amplitude in volts (or input units).
695
716
 
696
717
  Example:
697
- >>> vpp = amplitude(trace)
698
- >>> print(f"Amplitude: {vpp:.3f} V")
718
+ >>> result = amplitude(trace)
719
+ >>> if result["applicable"]:
720
+ ... print(f"Amplitude: {result['display']}")
699
721
 
700
722
  References:
701
723
  IEEE 1057-2017 Section 4.2
702
724
  """
703
725
  if len(trace.data) < 2:
704
- return np.nan
726
+ return make_inapplicable("V", "Insufficient data (need ≥2 samples)")
705
727
 
706
728
  low, high = _find_levels(trace.data)
707
- return high - low
729
+ amp = high - low
730
+
731
+ if np.isnan(amp):
732
+ return make_inapplicable("V", "Cannot determine amplitude")
733
+
734
+ return make_measurement(amp, "V")
708
735
 
709
736
 
710
737
  def rms(
@@ -712,7 +739,7 @@ def rms(
712
739
  *,
713
740
  ac_coupled: bool = False,
714
741
  nan_policy: Literal["propagate", "omit", "raise"] = "propagate",
715
- ) -> float | np_floating[Any]:
742
+ ) -> MeasurementResult:
716
743
  """Compute RMS voltage.
717
744
 
718
745
  Calculates root-mean-square voltage of the waveform.
@@ -721,29 +748,29 @@ def rms(
721
748
  trace: Input waveform trace.
722
749
  ac_coupled: If True, remove DC offset before computing RMS.
723
750
  nan_policy: How to handle NaN values:
724
- - "propagate": Return NaN if any NaN present (default, NumPy behavior)
751
+ - "propagate": Return inapplicable if any NaN present (default)
725
752
  - "omit": Ignore NaN values in calculation
726
753
  - "raise": Raise ValueError if any NaN present
727
754
 
728
755
  Returns:
729
- RMS voltage in volts (or input units).
756
+ MeasurementResult with RMS voltage in volts (or input units).
730
757
 
731
758
  Raises:
732
759
  ValueError: If nan_policy="raise" and data contains NaN.
733
760
 
734
761
  Example:
735
- >>> v_rms = rms(trace)
736
- >>> print(f"RMS: {v_rms:.3f} V")
762
+ >>> result = rms(trace)
763
+ >>> if result["applicable"]:
764
+ ... print(f"RMS: {result['display']}")
737
765
 
738
766
  >>> # Handle traces with NaN values
739
- >>> v_rms = rms(trace, nan_policy="omit")
740
-
767
+ >>> result = rms(trace, nan_policy="omit")
741
768
 
742
769
  References:
743
770
  IEEE 1057-2017 Section 4.3
744
771
  """
745
772
  if len(trace.data) == 0:
746
- return np.nan
773
+ return make_inapplicable("V", "Empty trace")
747
774
 
748
775
  # Convert memoryview to ndarray if needed
749
776
  data = np.asarray(trace.data)
@@ -756,20 +783,22 @@ def rms(
756
783
  # Use nanmean and nansum for NaN-safe calculation
757
784
  if ac_coupled:
758
785
  data = data - np.nanmean(data)
759
- return float(np.sqrt(np.nanmean(data**2)))
760
- # else propagate - default NumPy behavior
786
+ return make_measurement(float(np.sqrt(np.nanmean(data**2))), "V")
787
+ else: # propagate
788
+ if np.any(np.isnan(data)):
789
+ return make_inapplicable("V", "Data contains NaN values")
761
790
 
762
791
  if ac_coupled:
763
792
  data = data - np.mean(data)
764
793
 
765
- return float(np.sqrt(np.mean(data**2)))
794
+ return make_measurement(float(np.sqrt(np.mean(data**2))), "V")
766
795
 
767
796
 
768
797
  def mean(
769
798
  trace: WaveformTrace,
770
799
  *,
771
800
  nan_policy: Literal["propagate", "omit", "raise"] = "propagate",
772
- ) -> float | np_floating[Any]:
801
+ ) -> MeasurementResult:
773
802
  """Compute mean (DC) voltage.
774
803
 
775
804
  Calculates arithmetic mean of the waveform.
@@ -777,29 +806,29 @@ def mean(
777
806
  Args:
778
807
  trace: Input waveform trace.
779
808
  nan_policy: How to handle NaN values:
780
- - "propagate": Return NaN if any NaN present (default, NumPy behavior)
809
+ - "propagate": Return inapplicable if any NaN present (default)
781
810
  - "omit": Ignore NaN values in calculation
782
811
  - "raise": Raise ValueError if any NaN present
783
812
 
784
813
  Returns:
785
- Mean voltage in volts (or input units).
814
+ MeasurementResult with mean voltage in volts (or input units).
786
815
 
787
816
  Raises:
788
817
  ValueError: If nan_policy="raise" and data contains NaN.
789
818
 
790
819
  Example:
791
- >>> v_dc = mean(trace)
792
- >>> print(f"DC: {v_dc:.3f} V")
820
+ >>> result = mean(trace)
821
+ >>> if result["applicable"]:
822
+ ... print(f"DC: {result['display']}")
793
823
 
794
824
  >>> # Handle traces with NaN values
795
- >>> v_dc = mean(trace, nan_policy="omit")
796
-
825
+ >>> result = mean(trace, nan_policy="omit")
797
826
 
798
827
  References:
799
828
  IEEE 1057-2017 Section 4.3
800
829
  """
801
830
  if len(trace.data) == 0:
802
- return np.nan
831
+ return make_inapplicable("V", "Empty trace")
803
832
 
804
833
  # Convert memoryview to ndarray if needed
805
834
  data = np.asarray(trace.data)
@@ -808,11 +837,13 @@ def mean(
808
837
  if nan_policy == "raise":
809
838
  if np.any(np.isnan(data)):
810
839
  raise ValueError("Input data contains NaN values")
811
- return float(np.mean(data))
840
+ return make_measurement(float(np.mean(data)), "V")
812
841
  elif nan_policy == "omit":
813
- return float(np.nanmean(data))
842
+ return make_measurement(float(np.nanmean(data)), "V")
814
843
  else: # propagate
815
- return float(np.mean(data))
844
+ if np.any(np.isnan(data)):
845
+ return make_inapplicable("V", "Data contains NaN values")
846
+ return make_measurement(float(np.mean(data)), "V")
816
847
 
817
848
 
818
849
  def measure(
@@ -824,46 +855,49 @@ def measure(
824
855
  """Compute multiple waveform measurements.
825
856
 
826
857
  Unified function for computing all or selected waveform measurements.
858
+ Returns MeasurementResult format with applicability tracking.
827
859
 
828
860
  Args:
829
861
  trace: Input waveform trace.
830
862
  parameters: List of measurement names to compute. If None, compute all.
831
863
  Valid names: rise_time, fall_time, period, frequency, duty_cycle,
832
864
  amplitude, rms, mean, overshoot, undershoot, preshoot
833
- include_units: If True, include units in output.
865
+ include_units: If True, include units in output (returns MeasurementResult).
866
+ If False, return raw values (with NaN for inapplicable).
834
867
 
835
868
  Returns:
836
- Dictionary mapping measurement names to values (and units if requested).
869
+ Dictionary mapping measurement names to MeasurementResults (if include_units=True)
870
+ or raw values (if include_units=False).
837
871
 
838
872
  Example:
839
873
  >>> results = measure(trace)
840
- >>> print(f"Rise time: {results['rise_time']['value']} {results['rise_time']['unit']}")
874
+ >>> if results['rise_time']['applicable']:
875
+ ... print(f"Rise time: {results['rise_time']['display']}")
841
876
 
877
+ >>> # Get specific measurements
842
878
  >>> results = measure(trace, parameters=["frequency", "amplitude"])
843
879
 
880
+ >>> # Get raw values (legacy compatibility)
881
+ >>> results = measure(trace, include_units=False)
882
+ >>> freq = results["frequency"] # float or np.nan
883
+
844
884
  References:
845
885
  IEEE 181-2011, IEEE 1057-2017
846
886
  """
847
887
  all_measurements = {
848
- "rise_time": (rise_time, "s"),
849
- "fall_time": (fall_time, "s"),
850
- "period": (lambda t: period(t, return_all=False), "s"),
851
- "frequency": (frequency, "Hz"),
852
- "duty_cycle": (lambda t: duty_cycle(t, percentage=True), "%"),
853
- "pulse_width_pos": (
854
- lambda t: pulse_width(t, polarity="positive", return_all=False),
855
- "s",
856
- ),
857
- "pulse_width_neg": (
858
- lambda t: pulse_width(t, polarity="negative", return_all=False),
859
- "s",
860
- ),
861
- "amplitude": (amplitude, "V"),
862
- "rms": (rms, "V"),
863
- "mean": (mean, "V"),
864
- "overshoot": (overshoot, "%"),
865
- "undershoot": (undershoot, "%"),
866
- "preshoot": (preshoot, "%"),
888
+ "rise_time": rise_time,
889
+ "fall_time": fall_time,
890
+ "period": lambda t: period(t, return_all=False),
891
+ "frequency": frequency,
892
+ "duty_cycle": duty_cycle,
893
+ "pulse_width_pos": lambda t: pulse_width(t, polarity="positive", return_all=False),
894
+ "pulse_width_neg": lambda t: pulse_width(t, polarity="negative", return_all=False),
895
+ "amplitude": amplitude,
896
+ "rms": rms,
897
+ "mean": mean,
898
+ "overshoot": overshoot,
899
+ "undershoot": undershoot,
900
+ "preshoot": preshoot,
867
901
  }
868
902
 
869
903
  if parameters is None:
@@ -873,16 +907,26 @@ def measure(
873
907
 
874
908
  results: dict[str, Any] = {}
875
909
 
876
- for name, (func, unit) in selected.items():
910
+ for name, func in selected.items():
877
911
  try:
878
- value = func(trace) # type: ignore[operator]
879
- except Exception:
880
- value = np.nan
912
+ measurement_result = func(trace) # type: ignore[operator]
881
913
 
882
- if include_units:
883
- results[name] = {"value": value, "unit": unit}
884
- else:
885
- results[name] = value
914
+ if include_units:
915
+ # Return full MeasurementResult
916
+ results[name] = measurement_result
917
+ else:
918
+ # Legacy mode: extract raw value (NaN if inapplicable)
919
+ if measurement_result["applicable"]:
920
+ results[name] = measurement_result["value"]
921
+ else:
922
+ results[name] = np.nan
923
+
924
+ except Exception:
925
+ # On error, create inapplicable result
926
+ if include_units:
927
+ results[name] = make_inapplicable("", "Measurement failed")
928
+ else:
929
+ results[name] = np.nan
886
930
 
887
931
  return results
888
932
 
@@ -958,7 +1002,7 @@ def _find_edges(
958
1002
  Array of edge timestamps in seconds.
959
1003
  """
960
1004
  data = trace.data
961
- sample_period = trace.metadata.time_base
1005
+ sample_period = 1.0 / trace.metadata.sample_rate
962
1006
 
963
1007
  if len(data) < 3:
964
1008
  return np.array([], dtype=np.float64)