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
@@ -22,17 +22,60 @@ 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
31
+
32
+
33
+ # Measurement metadata: unit information for all waveform measurements
34
+ MEASUREMENT_METADATA: dict[str, dict[str, str]] = {
35
+ # Time-domain measurements
36
+ "rise_time": {"unit": "s", "description": "Rise time (10%-90%)"},
37
+ "fall_time": {"unit": "s", "description": "Fall time (90%-10%)"},
38
+ "period": {"unit": "s", "description": "Signal period"},
39
+ "pulse_width": {"unit": "s", "description": "Pulse width"},
40
+ "jitter": {"unit": "s", "description": "Period jitter"},
41
+ # Frequency measurements
42
+ "frequency": {"unit": "Hz", "description": "Signal frequency"},
43
+ "clock_frequency": {"unit": "Hz", "description": "Clock frequency"},
44
+ "dominant_freq": {"unit": "Hz", "description": "Dominant frequency"},
45
+ # Voltage measurements
46
+ "amplitude": {"unit": "V", "description": "Peak-to-peak amplitude"},
47
+ "mean": {"unit": "V", "description": "Mean voltage"},
48
+ "rms": {"unit": "V", "description": "RMS voltage"},
49
+ "threshold": {"unit": "V", "description": "Logic threshold"},
50
+ "min": {"unit": "V", "description": "Minimum voltage"},
51
+ "max": {"unit": "V", "description": "Maximum voltage"},
52
+ "std": {"unit": "V", "description": "Standard deviation"},
53
+ "median": {"unit": "V", "description": "Median voltage"},
54
+ # Ratio measurements (0-1, displayed as percentage)
55
+ "duty_cycle": {"unit": "ratio", "description": "Duty cycle"},
56
+ # Percentage measurements (already 0-100)
57
+ "overshoot": {"unit": "%", "description": "Overshoot percentage"},
58
+ "undershoot": {"unit": "%", "description": "Undershoot percentage"},
59
+ "thd": {"unit": "%", "description": "Total harmonic distortion"},
60
+ # Decibel measurements
61
+ "snr": {"unit": "dB", "description": "Signal-to-noise ratio"},
62
+ "sinad": {"unit": "dB", "description": "SINAD"},
63
+ "sfdr": {"unit": "dB", "description": "Spurious-free dynamic range"},
64
+ # Dimensionless measurements
65
+ "enob": {"unit": "", "description": "Effective number of bits"},
66
+ "rising_edges": {"unit": "", "description": "Rising edge count"},
67
+ "falling_edges": {"unit": "", "description": "Falling edge count"},
68
+ "outliers": {"unit": "", "description": "Outlier count"},
69
+ # Statistical measurements (squared units)
70
+ "variance": {"unit": "V²", "description": "Variance"},
71
+ }
29
72
 
30
73
 
31
74
  def rise_time(
32
75
  trace: WaveformTrace,
33
76
  *,
34
77
  ref_levels: tuple[float, float] = (0.1, 0.9),
35
- ) -> float | np_floating[Any]:
78
+ ) -> MeasurementResult:
36
79
  """Measure rise time between reference levels.
37
80
 
38
81
  Computes the time for a signal to transition from the lower
@@ -44,31 +87,32 @@ def rise_time(
44
87
  Default (0.1, 0.9) for 10%-90% rise time.
45
88
 
46
89
  Returns:
47
- 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.
48
91
 
49
92
  Example:
50
- >>> t_rise = rise_time(trace)
51
- >>> 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']}")
52
96
 
53
97
  References:
54
98
  IEEE 181-2011 Section 5.2
55
99
  """
56
100
  if len(trace.data) < 3:
57
- return np.nan
101
+ return make_inapplicable("s", "Insufficient data (need ≥3 samples)")
58
102
 
59
103
  data = trace.data
60
104
  low, high = _find_levels(data)
61
105
  amplitude = high - low
62
106
 
63
- if amplitude <= 0:
64
- return np.nan
107
+ if amplitude <= 0 or np.isnan(amplitude):
108
+ return make_inapplicable("s", "Constant signal (no transitions)")
65
109
 
66
110
  # Calculate reference voltages
67
111
  low_ref = low + ref_levels[0] * amplitude
68
112
  high_ref = low + ref_levels[1] * amplitude
69
113
 
70
114
  # Find rising edge: where signal crosses from below low_ref to above high_ref
71
- sample_period = trace.metadata.time_base
115
+ sample_period = 1.0 / trace.metadata.sample_rate
72
116
 
73
117
  # Find first crossing of low reference (going up)
74
118
  below_low = data < low_ref
@@ -78,7 +122,7 @@ def rise_time(
78
122
  transitions = np.where(below_low[:-1] & above_low[1:])[0]
79
123
 
80
124
  if len(transitions) == 0:
81
- return np.nan
125
+ return make_inapplicable("s", "No rising edges detected")
82
126
 
83
127
  best_rise_time: float | np_floating[Any] = np.nan
84
128
 
@@ -107,14 +151,17 @@ def rise_time(
107
151
  if rt > 0 and (np.isnan(best_rise_time) or rt < best_rise_time):
108
152
  best_rise_time = rt
109
153
 
110
- 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")
111
158
 
112
159
 
113
160
  def fall_time(
114
161
  trace: WaveformTrace,
115
162
  *,
116
163
  ref_levels: tuple[float, float] = (0.9, 0.1),
117
- ) -> float | np_floating[Any]:
164
+ ) -> MeasurementResult:
118
165
  """Measure fall time between reference levels.
119
166
 
120
167
  Computes the time for a signal to transition from the upper
@@ -126,30 +173,31 @@ def fall_time(
126
173
  Default (0.9, 0.1) for 90%-10% fall time.
127
174
 
128
175
  Returns:
129
- 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.
130
177
 
131
178
  Example:
132
- >>> t_fall = fall_time(trace)
133
- >>> 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']}")
134
182
 
135
183
  References:
136
184
  IEEE 181-2011 Section 5.2
137
185
  """
138
186
  if len(trace.data) < 3:
139
- return np.nan
187
+ return make_inapplicable("s", "Insufficient data (need ≥3 samples)")
140
188
 
141
189
  data = trace.data
142
190
  low, high = _find_levels(data)
143
191
  amplitude = high - low
144
192
 
145
- if amplitude <= 0:
146
- return np.nan
193
+ if amplitude <= 0 or np.isnan(amplitude):
194
+ return make_inapplicable("s", "Constant signal (no transitions)")
147
195
 
148
196
  # Calculate reference voltages (note: ref_levels[0] is the higher one for fall)
149
197
  high_ref = low + ref_levels[0] * amplitude
150
198
  low_ref = low + ref_levels[1] * amplitude
151
199
 
152
- sample_period = trace.metadata.time_base
200
+ sample_period = 1.0 / trace.metadata.sample_rate
153
201
 
154
202
  # Find where signal is above high reference
155
203
  above_high = data >= high_ref
@@ -159,7 +207,7 @@ def fall_time(
159
207
  transitions = np.where(above_high[:-1] & below_high[1:])[0]
160
208
 
161
209
  if len(transitions) == 0:
162
- return np.nan
210
+ return make_inapplicable("s", "No falling edges detected")
163
211
 
164
212
  best_fall_time: float | np_floating[Any] = np.nan
165
213
 
@@ -187,7 +235,10 @@ def fall_time(
187
235
  if ft > 0 and (np.isnan(best_fall_time) or ft < best_fall_time):
188
236
  best_fall_time = ft
189
237
 
190
- 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")
191
242
 
192
243
 
193
244
  @overload
@@ -196,7 +247,7 @@ def period(
196
247
  *,
197
248
  edge_type: Literal["rising", "falling"] = "rising",
198
249
  return_all: Literal[False] = False,
199
- ) -> float | np_floating[Any]: ...
250
+ ) -> MeasurementResult: ...
200
251
 
201
252
 
202
253
  @overload
@@ -213,7 +264,7 @@ def period(
213
264
  *,
214
265
  edge_type: Literal["rising", "falling"] = "rising",
215
266
  return_all: bool = False,
216
- ) -> float | np_floating[Any] | NDArray[np.float64]:
267
+ ) -> MeasurementResult | NDArray[np.float64]:
217
268
  """Measure signal period between consecutive edges.
218
269
 
219
270
  Computes the time between consecutive rising or falling edges.
@@ -221,14 +272,15 @@ def period(
221
272
  Args:
222
273
  trace: Input waveform trace.
223
274
  edge_type: Type of edges to use ("rising" or "falling").
224
- 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.
225
276
 
226
277
  Returns:
227
- 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.
228
279
 
229
280
  Example:
230
- >>> T = period(trace)
231
- >>> print(f"Period: {T * 1e6:.2f} us")
281
+ >>> result = period(trace)
282
+ >>> if result["applicable"]:
283
+ ... print(f"Period: {result['display']}")
232
284
 
233
285
  References:
234
286
  IEEE 181-2011 Section 5.3
@@ -238,102 +290,178 @@ def period(
238
290
  if len(edges) < 2:
239
291
  if return_all:
240
292
  return np.array([], dtype=np.float64)
241
- return np.nan
293
+ return make_inapplicable("s", f"Insufficient {edge_type} edges (need ≥2)")
242
294
 
243
295
  periods = np.diff(edges)
244
296
 
245
297
  if return_all:
246
298
  return periods
247
- return float(np.mean(periods))
299
+ return make_measurement(float(np.mean(periods)), "s")
248
300
 
249
301
 
250
302
  def frequency(
251
303
  trace: WaveformTrace,
252
304
  *,
253
305
  method: Literal["edge", "fft"] = "edge",
254
- ) -> float | np_floating[Any]:
306
+ ) -> MeasurementResult:
255
307
  """Measure signal frequency.
256
308
 
257
309
  Computes frequency either from edge-to-edge period or using FFT.
310
+ The "edge" method automatically falls back to FFT if edge detection fails
311
+ (e.g., for sine or triangle waves without clear rising edges).
258
312
 
259
313
  Args:
260
314
  trace: Input waveform trace.
261
315
  method: Measurement method:
262
- - "edge": 1/period from edge timing (default, more accurate)
263
- - "fft": Peak of FFT magnitude spectrum
316
+ - "edge": 1/period from edge timing with automatic FFT fallback (default)
317
+ - "fft": Peak of FFT magnitude spectrum (always use FFT)
264
318
 
265
319
  Returns:
266
- Frequency in Hz, or np.nan if measurement not possible.
320
+ MeasurementResult with frequency in Hz, or inapplicable if measurement not possible.
267
321
 
268
322
  Raises:
269
323
  ValueError: If method is not one of the supported types.
270
324
 
271
325
  Example:
272
- >>> f = frequency(trace)
273
- >>> print(f"Frequency: {f / 1e6:.3f} MHz")
326
+ >>> result = frequency(trace)
327
+ >>> if result["applicable"]:
328
+ ... print(f"Frequency: {result['display']}")
329
+
330
+ >>> # Force FFT method for smooth waveforms
331
+ >>> result = frequency(trace, method="fft")
274
332
 
275
333
  References:
276
334
  IEEE 181-2011 Section 5.3
277
335
  """
278
336
  if method == "edge":
337
+ # Try edge detection first (faster and more accurate for square waves)
279
338
  T = period(trace, edge_type="rising", return_all=False)
280
- if np.isnan(T) or T <= 0:
281
- return np.nan
282
- return 1.0 / T
283
339
 
284
- elif method == "fft":
285
- if len(trace.data) < 16:
286
- return np.nan
287
-
288
- data = trace.data - np.mean(trace.data) # Remove DC
289
- n = len(data)
290
- fft_mag = np.abs(np.fft.rfft(data))
340
+ # Fall back to FFT if edge detection fails
341
+ if not T["applicable"] or not T["value"] or T["value"] <= 0:
342
+ # Try FFT fallback for smooth waveforms (sine, triangle)
343
+ return _frequency_fft(trace)
291
344
 
292
- # Find peak (skip DC component)
293
- peak_idx = np.argmax(fft_mag[1:]) + 1
345
+ return make_measurement(1.0 / T["value"], "Hz")
294
346
 
295
- # Calculate frequency
296
- freq_resolution = trace.metadata.sample_rate / n
297
- return float(peak_idx * freq_resolution)
347
+ elif method == "fft":
348
+ return _frequency_fft(trace)
298
349
 
299
350
  else:
300
351
  raise ValueError(f"Unknown method: {method}")
301
352
 
302
353
 
354
+ def _frequency_fft(trace: WaveformTrace) -> MeasurementResult:
355
+ """Compute frequency using FFT peak detection.
356
+
357
+ Internal helper function for FFT-based frequency measurement.
358
+
359
+ Args:
360
+ trace: Input waveform trace.
361
+
362
+ Returns:
363
+ MeasurementResult with frequency in Hz, or inapplicable if measurement not possible.
364
+ """
365
+ if len(trace.data) < 16:
366
+ return make_inapplicable("Hz", "Insufficient data for FFT (need ≥16 samples)")
367
+
368
+ # Remove DC offset before FFT
369
+ data = trace.data - np.mean(trace.data)
370
+
371
+ # Check if signal is essentially constant (DC only)
372
+ if np.std(data) < 1e-10:
373
+ return make_inapplicable("Hz", "Constant signal (DC only)")
374
+
375
+ n = len(data)
376
+ fft_mag = np.abs(np.fft.rfft(data))
377
+
378
+ # Find peak (skip DC component at index 0)
379
+ peak_idx = np.argmax(fft_mag[1:]) + 1
380
+
381
+ # Verify peak is significant (SNR check)
382
+ # If the peak is not at least 3x the mean, it's likely noise
383
+ if fft_mag[peak_idx] < 3.0 * np.mean(fft_mag[1:]):
384
+ return make_inapplicable("Hz", "No dominant frequency (noisy signal)")
385
+
386
+ # Calculate frequency from peak index
387
+ freq_resolution = trace.metadata.sample_rate / n
388
+ return make_measurement(float(peak_idx * freq_resolution), "Hz")
389
+
390
+
303
391
  def duty_cycle(
304
392
  trace: WaveformTrace,
305
393
  *,
306
394
  percentage: bool = False,
307
- ) -> float | np_floating[Any]:
395
+ ) -> MeasurementResult:
308
396
  """Measure duty cycle.
309
397
 
310
398
  Computes duty cycle as the ratio of positive pulse width to period.
399
+ Uses robust algorithm that handles extreme duty cycles (1%-99%) and
400
+ incomplete waveforms (fewer than 2 complete cycles visible).
401
+
402
+ Falls back to time-domain calculation when edge-based measurement fails.
311
403
 
312
404
  Args:
313
405
  trace: Input waveform trace.
314
- percentage: If True, return as percentage (0-100). If False, return ratio (0-1).
406
+ percentage: Ignored (always returns ratio, display format shows %).
315
407
 
316
408
  Returns:
317
- Duty cycle as ratio or percentage.
409
+ MeasurementResult with duty cycle as ratio (0-1), or inapplicable if not possible.
318
410
 
319
411
  Example:
320
- >>> dc = duty_cycle(trace, percentage=True)
321
- >>> 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
322
415
 
323
416
  References:
324
417
  IEEE 181-2011 Section 5.4
325
418
  """
419
+ # Strategy: Use multiple methods depending on what edges are available
420
+ # Method 1 (best): period + pulse width (needs 2+ rising edges, 1+ falling edge)
421
+ # Method 2 (fallback): time-based calculation from data samples
422
+
326
423
  pw_pos = pulse_width(trace, polarity="positive", return_all=False)
327
424
  T = period(trace, edge_type="rising", return_all=False)
328
425
 
329
- if np.isnan(pw_pos) or np.isnan(T) or T <= 0:
330
- return np.nan
426
+ # Method 1: Standard period-based calculation
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")
434
+
435
+ # Method 2: Fallback for incomplete waveforms - time-domain calculation
436
+ # Calculate fraction of time signal spends above midpoint threshold
437
+ data = trace.data
438
+ if len(data) < 3:
439
+ return make_inapplicable("ratio", "Insufficient data (need ≥3 samples)")
440
+
441
+ # Convert boolean data to float if needed
442
+ if data.dtype == bool:
443
+ data = data.astype(np.float64)
444
+
445
+ low, high = _find_levels(data)
446
+ amplitude = high - low
331
447
 
332
- dc = pw_pos / T
448
+ # Check for invalid levels
449
+ if amplitude <= 0 or np.isnan(amplitude):
450
+ return make_inapplicable("ratio", "Constant signal (no transitions)")
333
451
 
334
- if percentage:
335
- return dc * 100
336
- return dc
452
+ # Calculate threshold at 50% of amplitude
453
+ mid = low + 0.5 * amplitude
454
+
455
+ # Count samples above threshold
456
+ above_threshold = data >= mid
457
+ samples_high = np.sum(above_threshold)
458
+ total_samples = len(data)
459
+
460
+ if total_samples == 0:
461
+ return make_inapplicable("ratio", "No data available")
462
+
463
+ dc = float(samples_high) / total_samples
464
+ return make_measurement(dc, "ratio")
337
465
 
338
466
 
339
467
  @overload
@@ -343,7 +471,7 @@ def pulse_width(
343
471
  polarity: Literal["positive", "negative"] = "positive",
344
472
  ref_level: float = 0.5,
345
473
  return_all: Literal[False] = False,
346
- ) -> float | np_floating[Any]: ...
474
+ ) -> MeasurementResult: ...
347
475
 
348
476
 
349
477
  @overload
@@ -362,7 +490,7 @@ def pulse_width(
362
490
  polarity: Literal["positive", "negative"] = "positive",
363
491
  ref_level: float = 0.5,
364
492
  return_all: bool = False,
365
- ) -> float | np_floating[Any] | NDArray[np.float64]:
493
+ ) -> MeasurementResult | NDArray[np.float64]:
366
494
  """Measure pulse width.
367
495
 
368
496
  Computes positive or negative pulse width at the specified reference level.
@@ -371,14 +499,15 @@ def pulse_width(
371
499
  trace: Input waveform trace.
372
500
  polarity: "positive" for high pulses, "negative" for low pulses.
373
501
  ref_level: Reference level as fraction (0.0 to 1.0). Default 0.5 (50%).
374
- 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.
375
503
 
376
504
  Returns:
377
- Pulse width in seconds.
505
+ MeasurementResult with pulse width in seconds (mean), or array if return_all=True.
378
506
 
379
507
  Example:
380
- >>> pw = pulse_width(trace, polarity="positive")
381
- >>> 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']}")
382
511
 
383
512
  References:
384
513
  IEEE 181-2011 Section 5.4
@@ -389,7 +518,12 @@ def pulse_width(
389
518
  if len(rising_edges) == 0 or len(falling_edges) == 0:
390
519
  if return_all:
391
520
  return np.array([], dtype=np.float64)
392
- 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")
393
527
 
394
528
  widths: list[float] = []
395
529
 
@@ -411,16 +545,16 @@ def pulse_width(
411
545
  if len(widths) == 0:
412
546
  if return_all:
413
547
  return np.array([], dtype=np.float64)
414
- return np.nan
548
+ return make_inapplicable("s", f"No {polarity} pulses found")
415
549
 
416
550
  widths_arr = np.array(widths, dtype=np.float64)
417
551
 
418
552
  if return_all:
419
553
  return widths_arr
420
- return float(np.mean(widths_arr))
554
+ return make_measurement(float(np.mean(widths_arr)), "s")
421
555
 
422
556
 
423
- def overshoot(trace: WaveformTrace) -> float | np_floating[Any]:
557
+ def overshoot(trace: WaveformTrace) -> MeasurementResult:
424
558
  """Measure overshoot percentage.
425
559
 
426
560
  Computes overshoot as (max - high) / amplitude * 100%.
@@ -429,34 +563,35 @@ def overshoot(trace: WaveformTrace) -> float | np_floating[Any]:
429
563
  trace: Input waveform trace.
430
564
 
431
565
  Returns:
432
- Overshoot as percentage, or np.nan if not applicable.
566
+ MeasurementResult with overshoot as percentage, or inapplicable if not applicable.
433
567
 
434
568
  Example:
435
- >>> os = overshoot(trace)
436
- >>> print(f"Overshoot: {os:.1f}%")
569
+ >>> result = overshoot(trace)
570
+ >>> if result["applicable"]:
571
+ ... print(f"Overshoot: {result['display']}")
437
572
 
438
573
  References:
439
574
  IEEE 181-2011 Section 5.5
440
575
  """
441
576
  if len(trace.data) < 3:
442
- return np.nan
577
+ return make_inapplicable("%", "Insufficient data (need ≥3 samples)")
443
578
 
444
579
  data = trace.data
445
580
  low, high = _find_levels(data)
446
581
  amplitude = high - low
447
582
 
448
- if amplitude <= 0:
449
- return np.nan
583
+ if amplitude <= 0 or np.isnan(amplitude):
584
+ return make_inapplicable("%", "Constant signal (no amplitude)")
450
585
 
451
586
  max_val = np.max(data)
452
587
 
453
588
  if max_val <= high:
454
- return 0.0
589
+ return make_measurement(0.0, "%")
455
590
 
456
- return float((max_val - high) / amplitude * 100)
591
+ return make_measurement(float((max_val - high) / amplitude * 100), "%")
457
592
 
458
593
 
459
- def undershoot(trace: WaveformTrace) -> float | np_floating[Any]:
594
+ def undershoot(trace: WaveformTrace) -> MeasurementResult:
460
595
  """Measure undershoot percentage.
461
596
 
462
597
  Computes undershoot as (low - min) / amplitude * 100%.
@@ -465,38 +600,39 @@ def undershoot(trace: WaveformTrace) -> float | np_floating[Any]:
465
600
  trace: Input waveform trace.
466
601
 
467
602
  Returns:
468
- Undershoot as percentage, or np.nan if not applicable.
603
+ MeasurementResult with undershoot as percentage, or inapplicable if not applicable.
469
604
 
470
605
  Example:
471
- >>> us = undershoot(trace)
472
- >>> print(f"Undershoot: {us:.1f}%")
606
+ >>> result = undershoot(trace)
607
+ >>> if result["applicable"]:
608
+ ... print(f"Undershoot: {result['display']}")
473
609
 
474
610
  References:
475
611
  IEEE 181-2011 Section 5.5
476
612
  """
477
613
  if len(trace.data) < 3:
478
- return np.nan
614
+ return make_inapplicable("%", "Insufficient data (need ≥3 samples)")
479
615
 
480
616
  data = trace.data
481
617
  low, high = _find_levels(data)
482
618
  amplitude = high - low
483
619
 
484
- if amplitude <= 0:
485
- return np.nan
620
+ if amplitude <= 0 or np.isnan(amplitude):
621
+ return make_inapplicable("%", "Constant signal (no amplitude)")
486
622
 
487
623
  min_val = np.min(data)
488
624
 
489
625
  if min_val >= low:
490
- return 0.0
626
+ return make_measurement(0.0, "%")
491
627
 
492
- return float((low - min_val) / amplitude * 100)
628
+ return make_measurement(float((low - min_val) / amplitude * 100), "%")
493
629
 
494
630
 
495
631
  def preshoot(
496
632
  trace: WaveformTrace,
497
633
  *,
498
634
  edge_type: Literal["rising", "falling"] = "rising",
499
- ) -> float | np_floating[Any]:
635
+ ) -> MeasurementResult:
500
636
  """Measure preshoot percentage.
501
637
 
502
638
  Computes preshoot before transitions as percentage of amplitude.
@@ -506,25 +642,26 @@ def preshoot(
506
642
  edge_type: Type of edge to analyze ("rising" or "falling").
507
643
 
508
644
  Returns:
509
- Preshoot as percentage, or np.nan if not applicable.
645
+ MeasurementResult with preshoot as percentage, or inapplicable if not applicable.
510
646
 
511
647
  Example:
512
- >>> ps = preshoot(trace)
513
- >>> print(f"Preshoot: {ps:.1f}%")
648
+ >>> result = preshoot(trace)
649
+ >>> if result["applicable"]:
650
+ ... print(f"Preshoot: {result['display']}")
514
651
 
515
652
  References:
516
653
  IEEE 181-2011 Section 5.5
517
654
  """
518
655
  if len(trace.data) < 10:
519
- return np.nan
656
+ return make_inapplicable("%", "Insufficient data (need ≥10 samples)")
520
657
 
521
658
  # Convert memoryview to ndarray if needed
522
659
  data = np.asarray(trace.data)
523
660
  low, high = _find_levels(data)
524
661
  amplitude = high - low
525
662
 
526
- if amplitude <= 0:
527
- return np.nan
663
+ if amplitude <= 0 or np.isnan(amplitude):
664
+ return make_inapplicable("%", "Constant signal (no amplitude)")
528
665
 
529
666
  # Find edge crossings at 50%
530
667
  mid = (low + high) / 2
@@ -533,7 +670,7 @@ def preshoot(
533
670
  # Look for minimum before rising edge that goes below low level
534
671
  crossings = np.where((data[:-1] < mid) & (data[1:] >= mid))[0]
535
672
  if len(crossings) == 0:
536
- return np.nan
673
+ return make_inapplicable("%", "No rising edges found")
537
674
 
538
675
  max_preshoot = 0.0
539
676
  for idx in crossings:
@@ -546,12 +683,12 @@ def preshoot(
546
683
  preshoot_val = (low - min_pre) / amplitude * 100
547
684
  max_preshoot = max(max_preshoot, preshoot_val)
548
685
 
549
- return max_preshoot
686
+ return make_measurement(max_preshoot, "%")
550
687
 
551
688
  else: # falling
552
689
  crossings = np.where((data[:-1] >= mid) & (data[1:] < mid))[0]
553
690
  if len(crossings) == 0:
554
- return np.nan
691
+ return make_inapplicable("%", "No falling edges found")
555
692
 
556
693
  max_preshoot = 0.0
557
694
  for idx in crossings:
@@ -563,10 +700,10 @@ def preshoot(
563
700
  preshoot_val = (max_pre - high) / amplitude * 100
564
701
  max_preshoot = max(max_preshoot, preshoot_val)
565
702
 
566
- return max_preshoot
703
+ return make_measurement(max_preshoot, "%")
567
704
 
568
705
 
569
- def amplitude(trace: WaveformTrace) -> float | np_floating[Any]:
706
+ def amplitude(trace: WaveformTrace) -> MeasurementResult:
570
707
  """Measure peak-to-peak amplitude.
571
708
 
572
709
  Computes Vpp as the difference between histogram-based high and low levels.
@@ -575,20 +712,26 @@ def amplitude(trace: WaveformTrace) -> float | np_floating[Any]:
575
712
  trace: Input waveform trace.
576
713
 
577
714
  Returns:
578
- Amplitude in volts (or input units).
715
+ MeasurementResult with amplitude in volts (or input units).
579
716
 
580
717
  Example:
581
- >>> vpp = amplitude(trace)
582
- >>> print(f"Amplitude: {vpp:.3f} V")
718
+ >>> result = amplitude(trace)
719
+ >>> if result["applicable"]:
720
+ ... print(f"Amplitude: {result['display']}")
583
721
 
584
722
  References:
585
723
  IEEE 1057-2017 Section 4.2
586
724
  """
587
725
  if len(trace.data) < 2:
588
- return np.nan
726
+ return make_inapplicable("V", "Insufficient data (need ≥2 samples)")
589
727
 
590
728
  low, high = _find_levels(trace.data)
591
- 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")
592
735
 
593
736
 
594
737
  def rms(
@@ -596,7 +739,7 @@ def rms(
596
739
  *,
597
740
  ac_coupled: bool = False,
598
741
  nan_policy: Literal["propagate", "omit", "raise"] = "propagate",
599
- ) -> float | np_floating[Any]:
742
+ ) -> MeasurementResult:
600
743
  """Compute RMS voltage.
601
744
 
602
745
  Calculates root-mean-square voltage of the waveform.
@@ -605,29 +748,29 @@ def rms(
605
748
  trace: Input waveform trace.
606
749
  ac_coupled: If True, remove DC offset before computing RMS.
607
750
  nan_policy: How to handle NaN values:
608
- - "propagate": Return NaN if any NaN present (default, NumPy behavior)
751
+ - "propagate": Return inapplicable if any NaN present (default)
609
752
  - "omit": Ignore NaN values in calculation
610
753
  - "raise": Raise ValueError if any NaN present
611
754
 
612
755
  Returns:
613
- RMS voltage in volts (or input units).
756
+ MeasurementResult with RMS voltage in volts (or input units).
614
757
 
615
758
  Raises:
616
759
  ValueError: If nan_policy="raise" and data contains NaN.
617
760
 
618
761
  Example:
619
- >>> v_rms = rms(trace)
620
- >>> print(f"RMS: {v_rms:.3f} V")
762
+ >>> result = rms(trace)
763
+ >>> if result["applicable"]:
764
+ ... print(f"RMS: {result['display']}")
621
765
 
622
766
  >>> # Handle traces with NaN values
623
- >>> v_rms = rms(trace, nan_policy="omit")
624
-
767
+ >>> result = rms(trace, nan_policy="omit")
625
768
 
626
769
  References:
627
770
  IEEE 1057-2017 Section 4.3
628
771
  """
629
772
  if len(trace.data) == 0:
630
- return np.nan
773
+ return make_inapplicable("V", "Empty trace")
631
774
 
632
775
  # Convert memoryview to ndarray if needed
633
776
  data = np.asarray(trace.data)
@@ -640,20 +783,22 @@ def rms(
640
783
  # Use nanmean and nansum for NaN-safe calculation
641
784
  if ac_coupled:
642
785
  data = data - np.nanmean(data)
643
- return float(np.sqrt(np.nanmean(data**2)))
644
- # 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")
645
790
 
646
791
  if ac_coupled:
647
792
  data = data - np.mean(data)
648
793
 
649
- return float(np.sqrt(np.mean(data**2)))
794
+ return make_measurement(float(np.sqrt(np.mean(data**2))), "V")
650
795
 
651
796
 
652
797
  def mean(
653
798
  trace: WaveformTrace,
654
799
  *,
655
800
  nan_policy: Literal["propagate", "omit", "raise"] = "propagate",
656
- ) -> float | np_floating[Any]:
801
+ ) -> MeasurementResult:
657
802
  """Compute mean (DC) voltage.
658
803
 
659
804
  Calculates arithmetic mean of the waveform.
@@ -661,29 +806,29 @@ def mean(
661
806
  Args:
662
807
  trace: Input waveform trace.
663
808
  nan_policy: How to handle NaN values:
664
- - "propagate": Return NaN if any NaN present (default, NumPy behavior)
809
+ - "propagate": Return inapplicable if any NaN present (default)
665
810
  - "omit": Ignore NaN values in calculation
666
811
  - "raise": Raise ValueError if any NaN present
667
812
 
668
813
  Returns:
669
- Mean voltage in volts (or input units).
814
+ MeasurementResult with mean voltage in volts (or input units).
670
815
 
671
816
  Raises:
672
817
  ValueError: If nan_policy="raise" and data contains NaN.
673
818
 
674
819
  Example:
675
- >>> v_dc = mean(trace)
676
- >>> print(f"DC: {v_dc:.3f} V")
820
+ >>> result = mean(trace)
821
+ >>> if result["applicable"]:
822
+ ... print(f"DC: {result['display']}")
677
823
 
678
824
  >>> # Handle traces with NaN values
679
- >>> v_dc = mean(trace, nan_policy="omit")
680
-
825
+ >>> result = mean(trace, nan_policy="omit")
681
826
 
682
827
  References:
683
828
  IEEE 1057-2017 Section 4.3
684
829
  """
685
830
  if len(trace.data) == 0:
686
- return np.nan
831
+ return make_inapplicable("V", "Empty trace")
687
832
 
688
833
  # Convert memoryview to ndarray if needed
689
834
  data = np.asarray(trace.data)
@@ -692,11 +837,13 @@ def mean(
692
837
  if nan_policy == "raise":
693
838
  if np.any(np.isnan(data)):
694
839
  raise ValueError("Input data contains NaN values")
695
- return float(np.mean(data))
840
+ return make_measurement(float(np.mean(data)), "V")
696
841
  elif nan_policy == "omit":
697
- return float(np.nanmean(data))
842
+ return make_measurement(float(np.nanmean(data)), "V")
698
843
  else: # propagate
699
- 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")
700
847
 
701
848
 
702
849
  def measure(
@@ -708,46 +855,49 @@ def measure(
708
855
  """Compute multiple waveform measurements.
709
856
 
710
857
  Unified function for computing all or selected waveform measurements.
858
+ Returns MeasurementResult format with applicability tracking.
711
859
 
712
860
  Args:
713
861
  trace: Input waveform trace.
714
862
  parameters: List of measurement names to compute. If None, compute all.
715
863
  Valid names: rise_time, fall_time, period, frequency, duty_cycle,
716
864
  amplitude, rms, mean, overshoot, undershoot, preshoot
717
- 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).
718
867
 
719
868
  Returns:
720
- 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).
721
871
 
722
872
  Example:
723
873
  >>> results = measure(trace)
724
- >>> 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']}")
725
876
 
877
+ >>> # Get specific measurements
726
878
  >>> results = measure(trace, parameters=["frequency", "amplitude"])
727
879
 
880
+ >>> # Get raw values (legacy compatibility)
881
+ >>> results = measure(trace, include_units=False)
882
+ >>> freq = results["frequency"] # float or np.nan
883
+
728
884
  References:
729
885
  IEEE 181-2011, IEEE 1057-2017
730
886
  """
731
887
  all_measurements = {
732
- "rise_time": (rise_time, "s"),
733
- "fall_time": (fall_time, "s"),
734
- "period": (lambda t: period(t, return_all=False), "s"),
735
- "frequency": (frequency, "Hz"),
736
- "duty_cycle": (lambda t: duty_cycle(t, percentage=True), "%"),
737
- "pulse_width_pos": (
738
- lambda t: pulse_width(t, polarity="positive", return_all=False),
739
- "s",
740
- ),
741
- "pulse_width_neg": (
742
- lambda t: pulse_width(t, polarity="negative", return_all=False),
743
- "s",
744
- ),
745
- "amplitude": (amplitude, "V"),
746
- "rms": (rms, "V"),
747
- "mean": (mean, "V"),
748
- "overshoot": (overshoot, "%"),
749
- "undershoot": (undershoot, "%"),
750
- "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,
751
901
  }
752
902
 
753
903
  if parameters is None:
@@ -757,16 +907,26 @@ def measure(
757
907
 
758
908
  results: dict[str, Any] = {}
759
909
 
760
- for name, (func, unit) in selected.items():
910
+ for name, func in selected.items():
761
911
  try:
762
- value = func(trace) # type: ignore[operator]
763
- except Exception:
764
- value = np.nan
912
+ measurement_result = func(trace) # type: ignore[operator]
765
913
 
766
- if include_units:
767
- results[name] = {"value": value, "unit": unit}
768
- else:
769
- 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
770
930
 
771
931
  return results
772
932
 
@@ -779,6 +939,9 @@ def measure(
779
939
  def _find_levels(data: NDArray[np_floating[Any]]) -> tuple[float, float]:
780
940
  """Find low and high levels using histogram method.
781
941
 
942
+ Robust algorithm that handles extreme duty cycles (1%-99%) by using
943
+ adaptive percentile-based level detection when histogram method fails.
944
+
782
945
  Args:
783
946
  data: Waveform data array.
784
947
 
@@ -794,12 +957,13 @@ def _find_levels(data: NDArray[np_floating[Any]]) -> tuple[float, float]:
794
957
  return float(np.nan), float(np.nan)
795
958
 
796
959
  # Use percentiles for robust level detection
797
- p10, p90 = np.percentile(data, [10, 90])
960
+ # For extreme duty cycles, use wider percentile range
961
+ p01, p05, p10, p50, p90, p95, p99 = np.percentile(data, [1, 5, 10, 50, 90, 95, 99])
798
962
 
799
963
  # Check for constant or near-constant signal
800
- data_range = p90 - p10
964
+ data_range = p99 - p01
801
965
  if data_range < 1e-10 or np.isnan(data_range): # Essentially constant or NaN
802
- return float(p10), float(p10)
966
+ return float(p50), float(p50)
803
967
 
804
968
  # Refine using histogram peaks
805
969
  hist, bin_edges = np.histogram(data, bins=50)
@@ -813,9 +977,11 @@ def _find_levels(data: NDArray[np_floating[Any]]) -> tuple[float, float]:
813
977
  low = bin_centers[low_idx]
814
978
  high = bin_centers[high_idx]
815
979
 
816
- # Sanity check
980
+ # Sanity check - if histogram method failed, use adaptive percentiles
817
981
  if high <= low:
818
- return float(p10), float(p90)
982
+ # For extreme duty cycles, use min/max with small outlier rejection
983
+ # p01 and p99 remove top/bottom 1% outliers (noise, ringing)
984
+ return float(p01), float(p99)
819
985
 
820
986
  return float(low), float(high)
821
987
 
@@ -836,7 +1002,7 @@ def _find_edges(
836
1002
  Array of edge timestamps in seconds.
837
1003
  """
838
1004
  data = trace.data
839
- sample_period = trace.metadata.time_base
1005
+ sample_period = 1.0 / trace.metadata.sample_rate
840
1006
 
841
1007
  if len(data) < 3:
842
1008
  return np.array([], dtype=np.float64)