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.
- oscura/__init__.py +19 -19
- oscura/analyzers/__init__.py +2 -0
- oscura/analyzers/digital/extraction.py +2 -3
- oscura/analyzers/digital/quality.py +1 -1
- oscura/analyzers/digital/timing.py +1 -1
- oscura/analyzers/eye/__init__.py +5 -1
- oscura/analyzers/eye/generation.py +501 -0
- oscura/analyzers/jitter/__init__.py +6 -6
- oscura/analyzers/jitter/timing.py +419 -0
- oscura/analyzers/patterns/__init__.py +94 -0
- oscura/analyzers/patterns/reverse_engineering.py +991 -0
- oscura/analyzers/power/__init__.py +35 -12
- oscura/analyzers/power/basic.py +3 -3
- oscura/analyzers/power/soa.py +1 -1
- oscura/analyzers/power/switching.py +3 -3
- oscura/analyzers/signal_classification.py +529 -0
- oscura/analyzers/signal_integrity/sparams.py +3 -3
- oscura/analyzers/statistics/__init__.py +4 -0
- oscura/analyzers/statistics/basic.py +152 -0
- oscura/analyzers/statistics/correlation.py +47 -6
- oscura/analyzers/validation.py +1 -1
- oscura/analyzers/waveform/__init__.py +2 -0
- oscura/analyzers/waveform/measurements.py +329 -163
- oscura/analyzers/waveform/measurements_with_uncertainty.py +91 -35
- oscura/analyzers/waveform/spectral.py +498 -54
- oscura/api/dsl/commands.py +15 -6
- oscura/api/server/templates/base.html +137 -146
- oscura/api/server/templates/export.html +84 -110
- oscura/api/server/templates/home.html +248 -267
- oscura/api/server/templates/protocols.html +44 -48
- oscura/api/server/templates/reports.html +27 -35
- oscura/api/server/templates/session_detail.html +68 -78
- oscura/api/server/templates/sessions.html +62 -72
- oscura/api/server/templates/waveforms.html +54 -64
- oscura/automotive/__init__.py +1 -1
- oscura/automotive/can/session.py +1 -1
- oscura/automotive/dbc/generator.py +638 -23
- oscura/automotive/dtc/data.json +102 -17
- oscura/automotive/uds/decoder.py +99 -6
- oscura/cli/analyze.py +8 -2
- oscura/cli/batch.py +36 -5
- oscura/cli/characterize.py +18 -4
- oscura/cli/export.py +47 -5
- oscura/cli/main.py +2 -0
- oscura/cli/onboarding/wizard.py +10 -6
- oscura/cli/pipeline.py +585 -0
- oscura/cli/visualize.py +6 -4
- oscura/convenience.py +400 -32
- oscura/core/config/loader.py +0 -1
- oscura/core/measurement_result.py +286 -0
- oscura/core/progress.py +1 -1
- oscura/core/schemas/device_mapping.json +8 -2
- oscura/core/schemas/packet_format.json +24 -4
- oscura/core/schemas/protocol_definition.json +12 -2
- oscura/core/types.py +300 -199
- oscura/correlation/multi_protocol.py +1 -1
- oscura/export/legacy/__init__.py +11 -0
- oscura/export/legacy/wav.py +75 -0
- oscura/exporters/__init__.py +19 -0
- oscura/exporters/wireshark.py +809 -0
- oscura/hardware/acquisition/file.py +5 -19
- oscura/hardware/acquisition/saleae.py +10 -10
- oscura/hardware/acquisition/socketcan.py +4 -6
- oscura/hardware/acquisition/synthetic.py +1 -5
- oscura/hardware/acquisition/visa.py +6 -6
- oscura/hardware/security/side_channel_detector.py +5 -508
- oscura/inference/message_format.py +686 -1
- oscura/jupyter/display.py +2 -2
- oscura/jupyter/magic.py +3 -3
- oscura/loaders/__init__.py +17 -12
- oscura/loaders/binary.py +1 -1
- oscura/loaders/chipwhisperer.py +1 -2
- oscura/loaders/configurable.py +1 -1
- oscura/loaders/csv_loader.py +2 -2
- oscura/loaders/hdf5_loader.py +1 -1
- oscura/loaders/lazy.py +6 -1
- oscura/loaders/mmap_loader.py +0 -1
- oscura/loaders/numpy_loader.py +8 -7
- oscura/loaders/preprocessing.py +3 -5
- oscura/loaders/rigol.py +21 -7
- oscura/loaders/sigrok.py +2 -5
- oscura/loaders/tdms.py +3 -2
- oscura/loaders/tektronix.py +38 -32
- oscura/loaders/tss.py +20 -27
- oscura/loaders/vcd.py +13 -8
- oscura/loaders/wav.py +1 -6
- oscura/pipeline/__init__.py +76 -0
- oscura/pipeline/handlers/__init__.py +165 -0
- oscura/pipeline/handlers/analyzers.py +1045 -0
- oscura/pipeline/handlers/decoders.py +899 -0
- oscura/pipeline/handlers/exporters.py +1103 -0
- oscura/pipeline/handlers/filters.py +891 -0
- oscura/pipeline/handlers/loaders.py +640 -0
- oscura/pipeline/handlers/transforms.py +768 -0
- oscura/reporting/__init__.py +88 -1
- oscura/reporting/automation.py +348 -0
- oscura/reporting/citations.py +374 -0
- oscura/reporting/core.py +54 -0
- oscura/reporting/formatting/__init__.py +11 -0
- oscura/reporting/formatting/measurements.py +320 -0
- oscura/reporting/html.py +57 -0
- oscura/reporting/interpretation.py +431 -0
- oscura/reporting/summary.py +329 -0
- oscura/reporting/templates/enhanced/protocol_re.html +504 -503
- oscura/reporting/visualization.py +542 -0
- oscura/side_channel/__init__.py +38 -57
- oscura/utils/builders/signal_builder.py +5 -5
- oscura/utils/comparison/compare.py +7 -9
- oscura/utils/comparison/golden.py +1 -1
- oscura/utils/filtering/convenience.py +2 -2
- oscura/utils/math/arithmetic.py +38 -62
- oscura/utils/math/interpolation.py +20 -20
- oscura/utils/pipeline/__init__.py +4 -17
- oscura/utils/progressive.py +1 -4
- oscura/utils/triggering/edge.py +1 -1
- oscura/utils/triggering/pattern.py +2 -2
- oscura/utils/triggering/pulse.py +2 -2
- oscura/utils/triggering/window.py +3 -3
- oscura/validation/hil_testing.py +11 -11
- oscura/visualization/__init__.py +47 -284
- oscura/visualization/batch.py +160 -0
- oscura/visualization/plot.py +542 -53
- oscura/visualization/styles.py +184 -318
- oscura/workflows/__init__.py +2 -0
- oscura/workflows/batch/advanced.py +1 -1
- oscura/workflows/batch/aggregate.py +7 -8
- oscura/workflows/complete_re.py +251 -23
- oscura/workflows/digital.py +27 -4
- oscura/workflows/multi_trace.py +136 -17
- oscura/workflows/waveform.py +788 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/METADATA +59 -79
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/RECORD +135 -149
- oscura/side_channel/dpa.py +0 -1025
- oscura/utils/optimization/__init__.py +0 -19
- oscura/utils/optimization/parallel.py +0 -443
- oscura/utils/optimization/search.py +0 -532
- oscura/utils/pipeline/base.py +0 -338
- oscura/utils/pipeline/composition.py +0 -248
- oscura/utils/pipeline/parallel.py +0 -449
- oscura/utils/pipeline/pipeline.py +0 -375
- oscura/utils/search/__init__.py +0 -16
- oscura/utils/search/anomaly.py +0 -424
- oscura/utils/search/context.py +0 -294
- oscura/utils/search/pattern.py +0 -288
- oscura/utils/storage/__init__.py +0 -61
- oscura/utils/storage/database.py +0 -1166
- oscura/visualization/accessibility.py +0 -526
- oscura/visualization/annotations.py +0 -371
- oscura/visualization/axis_scaling.py +0 -305
- oscura/visualization/colors.py +0 -451
- oscura/visualization/digital.py +0 -436
- oscura/visualization/eye.py +0 -571
- oscura/visualization/histogram.py +0 -281
- oscura/visualization/interactive.py +0 -1035
- oscura/visualization/jitter.py +0 -1042
- oscura/visualization/keyboard.py +0 -394
- oscura/visualization/layout.py +0 -400
- oscura/visualization/optimization.py +0 -1079
- oscura/visualization/palettes.py +0 -446
- oscura/visualization/power.py +0 -508
- oscura/visualization/power_extended.py +0 -955
- oscura/visualization/presets.py +0 -469
- oscura/visualization/protocols.py +0 -1246
- oscura/visualization/render.py +0 -223
- oscura/visualization/rendering.py +0 -444
- oscura/visualization/reverse_engineering.py +0 -838
- oscura/visualization/signal_integrity.py +0 -989
- oscura/visualization/specialized.py +0 -643
- oscura/visualization/spectral.py +0 -1226
- oscura/visualization/thumbnails.py +0 -340
- oscura/visualization/time_axis.py +0 -351
- oscura/visualization/waveform.py +0 -454
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/WHEEL +0 -0
- {oscura-0.7.0.dist-info → oscura-0.10.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
) ->
|
|
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
|
-
|
|
90
|
+
MeasurementResult with rise time in seconds, or inapplicable if no rising edge.
|
|
48
91
|
|
|
49
92
|
Example:
|
|
50
|
-
>>>
|
|
51
|
-
>>>
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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
|
-
|
|
176
|
+
MeasurementResult with fall time in seconds, or inapplicable if no falling edge.
|
|
130
177
|
|
|
131
178
|
Example:
|
|
132
|
-
>>>
|
|
133
|
-
>>>
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
) ->
|
|
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
|
-
) ->
|
|
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
|
|
275
|
+
return_all: If True, return array of all periods. If False, return MeasurementResult.
|
|
225
276
|
|
|
226
277
|
Returns:
|
|
227
|
-
|
|
278
|
+
MeasurementResult with period in seconds (mean), or array of periods if return_all=True.
|
|
228
279
|
|
|
229
280
|
Example:
|
|
230
|
-
>>>
|
|
231
|
-
>>>
|
|
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
|
|
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
|
-
) ->
|
|
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
|
|
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
|
-
|
|
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
|
-
>>>
|
|
273
|
-
>>>
|
|
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
|
-
|
|
285
|
-
if
|
|
286
|
-
|
|
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
|
-
|
|
293
|
-
peak_idx = np.argmax(fft_mag[1:]) + 1
|
|
345
|
+
return make_measurement(1.0 / T["value"], "Hz")
|
|
294
346
|
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
) ->
|
|
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:
|
|
406
|
+
percentage: Ignored (always returns ratio, display format shows %).
|
|
315
407
|
|
|
316
408
|
Returns:
|
|
317
|
-
|
|
409
|
+
MeasurementResult with duty cycle as ratio (0-1), or inapplicable if not possible.
|
|
318
410
|
|
|
319
411
|
Example:
|
|
320
|
-
>>>
|
|
321
|
-
>>>
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
) ->
|
|
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
|
-
) ->
|
|
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
|
|
502
|
+
return_all: If True, return array of all widths. If False, return MeasurementResult.
|
|
375
503
|
|
|
376
504
|
Returns:
|
|
377
|
-
|
|
505
|
+
MeasurementResult with pulse width in seconds (mean), or array if return_all=True.
|
|
378
506
|
|
|
379
507
|
Example:
|
|
380
|
-
>>>
|
|
381
|
-
>>>
|
|
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
|
-
|
|
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
|
|
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) ->
|
|
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
|
-
|
|
566
|
+
MeasurementResult with overshoot as percentage, or inapplicable if not applicable.
|
|
433
567
|
|
|
434
568
|
Example:
|
|
435
|
-
>>>
|
|
436
|
-
>>>
|
|
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
|
|
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
|
|
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) ->
|
|
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
|
-
|
|
603
|
+
MeasurementResult with undershoot as percentage, or inapplicable if not applicable.
|
|
469
604
|
|
|
470
605
|
Example:
|
|
471
|
-
>>>
|
|
472
|
-
>>>
|
|
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
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
645
|
+
MeasurementResult with preshoot as percentage, or inapplicable if not applicable.
|
|
510
646
|
|
|
511
647
|
Example:
|
|
512
|
-
>>>
|
|
513
|
-
>>>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) ->
|
|
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
|
-
|
|
715
|
+
MeasurementResult with amplitude in volts (or input units).
|
|
579
716
|
|
|
580
717
|
Example:
|
|
581
|
-
>>>
|
|
582
|
-
>>>
|
|
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
|
|
726
|
+
return make_inapplicable("V", "Insufficient data (need ≥2 samples)")
|
|
589
727
|
|
|
590
728
|
low, high = _find_levels(trace.data)
|
|
591
|
-
|
|
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
|
-
) ->
|
|
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
|
|
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
|
-
>>>
|
|
620
|
-
>>>
|
|
762
|
+
>>> result = rms(trace)
|
|
763
|
+
>>> if result["applicable"]:
|
|
764
|
+
... print(f"RMS: {result['display']}")
|
|
621
765
|
|
|
622
766
|
>>> # Handle traces with NaN values
|
|
623
|
-
>>>
|
|
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
|
|
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
|
-
#
|
|
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
|
-
) ->
|
|
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
|
|
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
|
-
|
|
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
|
-
>>>
|
|
676
|
-
>>>
|
|
820
|
+
>>> result = mean(trace)
|
|
821
|
+
>>> if result["applicable"]:
|
|
822
|
+
... print(f"DC: {result['display']}")
|
|
677
823
|
|
|
678
824
|
>>> # Handle traces with NaN values
|
|
679
|
-
>>>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
>>>
|
|
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":
|
|
733
|
-
"fall_time":
|
|
734
|
-
"period":
|
|
735
|
-
"frequency":
|
|
736
|
-
"duty_cycle":
|
|
737
|
-
"pulse_width_pos": (
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
"
|
|
742
|
-
|
|
743
|
-
|
|
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,
|
|
910
|
+
for name, func in selected.items():
|
|
761
911
|
try:
|
|
762
|
-
|
|
763
|
-
except Exception:
|
|
764
|
-
value = np.nan
|
|
912
|
+
measurement_result = func(trace) # type: ignore[operator]
|
|
765
913
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
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 =
|
|
964
|
+
data_range = p99 - p01
|
|
801
965
|
if data_range < 1e-10 or np.isnan(data_range): # Essentially constant or NaN
|
|
802
|
-
return float(
|
|
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
|
-
|
|
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.
|
|
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)
|