Coverage for src / tracekit / analyzers / waveform / measurements.py: 76%
301 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Waveform timing and amplitude measurements.
3This module provides IEEE 181-2011 and IEEE 1057-2017 compliant
4waveform measurements including rise/fall time, period, frequency,
5amplitude, and RMS.
8Example:
9 >>> from tracekit.analyzers.waveform.measurements import rise_time, measure
10 >>> t_rise = rise_time(trace)
11 >>> results = measure(trace, parameters=["rise_time", "frequency"])
13References:
14 IEEE 181-2011: Standard for Transitional Waveform Definitions
15 IEEE 1057-2017: Standard for Digitizing Waveform Recorders
16"""
18from __future__ import annotations
20from typing import TYPE_CHECKING, Any, Literal, overload
22import numpy as np
23from numpy import floating as np_floating
25if TYPE_CHECKING:
26 from numpy.typing import NDArray
28 from tracekit.core.types import WaveformTrace
31def rise_time(
32 trace: WaveformTrace,
33 *,
34 ref_levels: tuple[float, float] = (0.1, 0.9),
35) -> float | np_floating[Any]:
36 """Measure rise time between reference levels.
38 Computes the time for a signal to transition from the lower
39 reference level to the upper reference level, per IEEE 181-2011.
41 Args:
42 trace: Input waveform trace.
43 ref_levels: Reference levels as fractions (0.0 to 1.0).
44 Default (0.1, 0.9) for 10%-90% rise time.
46 Returns:
47 Rise time in seconds, or np.nan if no valid rising edge found.
49 Example:
50 >>> t_rise = rise_time(trace)
51 >>> print(f"Rise time: {t_rise * 1e9:.2f} ns")
53 References:
54 IEEE 181-2011 Section 5.2
55 """
56 if len(trace.data) < 3:
57 return np.nan
59 data = trace.data
60 low, high = _find_levels(data)
61 amplitude = high - low
63 if amplitude <= 0: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true
64 return np.nan
66 # Calculate reference voltages
67 low_ref = low + ref_levels[0] * amplitude
68 high_ref = low + ref_levels[1] * amplitude
70 # Find rising edge: where signal crosses from below low_ref to above high_ref
71 sample_period = trace.metadata.time_base
73 # Find first crossing of low reference (going up)
74 below_low = data < low_ref
75 above_low = data >= low_ref
77 # Find transitions from below to above low_ref
78 transitions = np.where(below_low[:-1] & above_low[1:])[0]
80 if len(transitions) == 0:
81 return np.nan
83 best_rise_time: float | np_floating[Any] = np.nan
85 for start_idx in transitions:
86 # Find where signal crosses high reference
87 remaining = data[start_idx:]
88 above_high = remaining >= high_ref
90 if not np.any(above_high): 90 ↛ 91line 90 didn't jump to line 91 because the condition on line 90 was never true
91 continue
93 end_offset = np.argmax(above_high)
94 end_idx = start_idx + end_offset
96 # Ensure monotonic rise (no dips)
97 segment = data[start_idx : end_idx + 1]
98 if len(segment) < 2: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 continue
101 # Interpolate for sub-sample accuracy
102 t_low = _interpolate_crossing_time(data, start_idx, low_ref, sample_period, rising=True)
103 t_high = _interpolate_crossing_time(data, end_idx - 1, high_ref, sample_period, rising=True)
105 if t_low is not None and t_high is not None: 105 ↛ 85line 105 didn't jump to line 85 because the condition on line 105 was always true
106 rt = t_high - t_low
107 if rt > 0 and (np.isnan(best_rise_time) or rt < best_rise_time):
108 best_rise_time = rt
110 return best_rise_time
113def fall_time(
114 trace: WaveformTrace,
115 *,
116 ref_levels: tuple[float, float] = (0.9, 0.1),
117) -> float | np_floating[Any]:
118 """Measure fall time between reference levels.
120 Computes the time for a signal to transition from the upper
121 reference level to the lower reference level, per IEEE 181-2011.
123 Args:
124 trace: Input waveform trace.
125 ref_levels: Reference levels as fractions (0.0 to 1.0).
126 Default (0.9, 0.1) for 90%-10% fall time.
128 Returns:
129 Fall time in seconds, or np.nan if no valid falling edge found.
131 Example:
132 >>> t_fall = fall_time(trace)
133 >>> print(f"Fall time: {t_fall * 1e9:.2f} ns")
135 References:
136 IEEE 181-2011 Section 5.2
137 """
138 if len(trace.data) < 3:
139 return np.nan
141 data = trace.data
142 low, high = _find_levels(data)
143 amplitude = high - low
145 if amplitude <= 0: 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true
146 return np.nan
148 # Calculate reference voltages (note: ref_levels[0] is the higher one for fall)
149 high_ref = low + ref_levels[0] * amplitude
150 low_ref = low + ref_levels[1] * amplitude
152 sample_period = trace.metadata.time_base
154 # Find where signal is above high reference
155 above_high = data >= high_ref
156 below_high = data < high_ref
158 # Find transitions from above to below high_ref
159 transitions = np.where(above_high[:-1] & below_high[1:])[0]
161 if len(transitions) == 0:
162 return np.nan
164 best_fall_time: float | np_floating[Any] = np.nan
166 for start_idx in transitions:
167 # Find where signal crosses low reference
168 remaining = data[start_idx:]
169 below_low = remaining <= low_ref
171 if not np.any(below_low): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 continue
174 end_offset = np.argmax(below_low)
175 end_idx = start_idx + end_offset
177 segment = data[start_idx : end_idx + 1]
178 if len(segment) < 2: 178 ↛ 179line 178 didn't jump to line 179 because the condition on line 178 was never true
179 continue
181 # Interpolate for sub-sample accuracy
182 t_high = _interpolate_crossing_time(data, start_idx, high_ref, sample_period, rising=False)
183 t_low = _interpolate_crossing_time(data, end_idx - 1, low_ref, sample_period, rising=False)
185 if t_high is not None and t_low is not None: 185 ↛ 166line 185 didn't jump to line 166 because the condition on line 185 was always true
186 ft = t_low - t_high
187 if ft > 0 and (np.isnan(best_fall_time) or ft < best_fall_time):
188 best_fall_time = ft
190 return best_fall_time
193@overload
194def period(
195 trace: WaveformTrace,
196 *,
197 edge_type: Literal["rising", "falling"] = "rising",
198 return_all: Literal[False] = False,
199) -> float | np_floating[Any]: ...
202@overload
203def period(
204 trace: WaveformTrace,
205 *,
206 edge_type: Literal["rising", "falling"] = "rising",
207 return_all: Literal[True],
208) -> NDArray[np.float64]: ...
211def period(
212 trace: WaveformTrace,
213 *,
214 edge_type: Literal["rising", "falling"] = "rising",
215 return_all: bool = False,
216) -> float | np_floating[Any] | NDArray[np.float64]:
217 """Measure signal period between consecutive edges.
219 Computes the time between consecutive rising or falling edges.
221 Args:
222 trace: Input waveform trace.
223 edge_type: Type of edges to use ("rising" or "falling").
224 return_all: If True, return array of all periods. If False, return mean.
226 Returns:
227 Period in seconds (mean if return_all=False), or array of periods.
229 Example:
230 >>> T = period(trace)
231 >>> print(f"Period: {T * 1e6:.2f} us")
233 References:
234 IEEE 181-2011 Section 5.3
235 """
236 edges = _find_edges(trace, edge_type)
238 if len(edges) < 2:
239 if return_all: 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true
240 return np.array([], dtype=np.float64)
241 return np.nan
243 periods = np.diff(edges)
245 if return_all:
246 return periods
247 return float(np.mean(periods))
250def frequency(
251 trace: WaveformTrace,
252 *,
253 method: Literal["edge", "fft"] = "edge",
254) -> float | np_floating[Any]:
255 """Measure signal frequency.
257 Computes frequency either from edge-to-edge period or using FFT.
259 Args:
260 trace: Input waveform trace.
261 method: Measurement method:
262 - "edge": 1/period from edge timing (default, more accurate)
263 - "fft": Peak of FFT magnitude spectrum
265 Returns:
266 Frequency in Hz, or np.nan if measurement not possible.
268 Raises:
269 ValueError: If method is not one of the supported types.
271 Example:
272 >>> f = frequency(trace)
273 >>> print(f"Frequency: {f / 1e6:.3f} MHz")
275 References:
276 IEEE 181-2011 Section 5.3
277 """
278 if method == "edge":
279 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
284 elif method == "fft":
285 if len(trace.data) < 16: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true
286 return np.nan
288 data = trace.data - np.mean(trace.data) # Remove DC
289 n = len(data)
290 fft_mag = np.abs(np.fft.rfft(data))
292 # Find peak (skip DC component)
293 peak_idx = np.argmax(fft_mag[1:]) + 1
295 # Calculate frequency
296 freq_resolution = trace.metadata.sample_rate / n
297 return float(peak_idx * freq_resolution)
299 else:
300 raise ValueError(f"Unknown method: {method}")
303def duty_cycle(
304 trace: WaveformTrace,
305 *,
306 percentage: bool = False,
307) -> float | np_floating[Any]:
308 """Measure duty cycle.
310 Computes duty cycle as the ratio of positive pulse width to period.
312 Args:
313 trace: Input waveform trace.
314 percentage: If True, return as percentage (0-100). If False, return ratio (0-1).
316 Returns:
317 Duty cycle as ratio or percentage.
319 Example:
320 >>> dc = duty_cycle(trace, percentage=True)
321 >>> print(f"Duty cycle: {dc:.1f}%")
323 References:
324 IEEE 181-2011 Section 5.4
325 """
326 pw_pos = pulse_width(trace, polarity="positive", return_all=False)
327 T = period(trace, edge_type="rising", return_all=False)
329 if np.isnan(pw_pos) or np.isnan(T) or T <= 0:
330 return np.nan
332 dc = pw_pos / T
334 if percentage:
335 return dc * 100
336 return dc
339@overload
340def pulse_width(
341 trace: WaveformTrace,
342 *,
343 polarity: Literal["positive", "negative"] = "positive",
344 ref_level: float = 0.5,
345 return_all: Literal[False] = False,
346) -> float | np_floating[Any]: ...
349@overload
350def pulse_width(
351 trace: WaveformTrace,
352 *,
353 polarity: Literal["positive", "negative"] = "positive",
354 ref_level: float = 0.5,
355 return_all: Literal[True],
356) -> NDArray[np.float64]: ...
359def pulse_width(
360 trace: WaveformTrace,
361 *,
362 polarity: Literal["positive", "negative"] = "positive",
363 ref_level: float = 0.5,
364 return_all: bool = False,
365) -> float | np_floating[Any] | NDArray[np.float64]:
366 """Measure pulse width.
368 Computes positive or negative pulse width at the specified reference level.
370 Args:
371 trace: Input waveform trace.
372 polarity: "positive" for high pulses, "negative" for low pulses.
373 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.
376 Returns:
377 Pulse width in seconds.
379 Example:
380 >>> pw = pulse_width(trace, polarity="positive")
381 >>> print(f"Pulse width: {pw * 1e6:.2f} us")
383 References:
384 IEEE 181-2011 Section 5.4
385 """
386 rising_edges = _find_edges(trace, "rising", ref_level)
387 falling_edges = _find_edges(trace, "falling", ref_level)
389 if len(rising_edges) == 0 or len(falling_edges) == 0:
390 if return_all: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true
391 return np.array([], dtype=np.float64)
392 return np.nan
394 widths: list[float] = []
396 if polarity == "positive":
397 # Rising to falling
398 for r in rising_edges:
399 # Find next falling edge after this rising edge
400 next_falling = falling_edges[falling_edges > r]
401 if len(next_falling) > 0: 401 ↛ 398line 401 didn't jump to line 398 because the condition on line 401 was always true
402 widths.append(next_falling[0] - r)
403 else:
404 # Falling to rising
405 for f in falling_edges:
406 # Find next rising edge after this falling edge
407 next_rising = rising_edges[rising_edges > f]
408 if len(next_rising) > 0:
409 widths.append(next_rising[0] - f)
411 if len(widths) == 0: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 if return_all:
413 return np.array([], dtype=np.float64)
414 return np.nan
416 widths_arr = np.array(widths, dtype=np.float64)
418 if return_all:
419 return widths_arr
420 return float(np.mean(widths_arr))
423def overshoot(trace: WaveformTrace) -> float | np_floating[Any]:
424 """Measure overshoot percentage.
426 Computes overshoot as (max - high) / amplitude * 100%.
428 Args:
429 trace: Input waveform trace.
431 Returns:
432 Overshoot as percentage, or np.nan if not applicable.
434 Example:
435 >>> os = overshoot(trace)
436 >>> print(f"Overshoot: {os:.1f}%")
438 References:
439 IEEE 181-2011 Section 5.5
440 """
441 if len(trace.data) < 3:
442 return np.nan
444 data = trace.data
445 low, high = _find_levels(data)
446 amplitude = high - low
448 if amplitude <= 0: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 return np.nan
451 max_val = np.max(data)
453 if max_val <= high: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true
454 return 0.0
456 return float((max_val - high) / amplitude * 100)
459def undershoot(trace: WaveformTrace) -> float | np_floating[Any]:
460 """Measure undershoot percentage.
462 Computes undershoot as (low - min) / amplitude * 100%.
464 Args:
465 trace: Input waveform trace.
467 Returns:
468 Undershoot as percentage, or np.nan if not applicable.
470 Example:
471 >>> us = undershoot(trace)
472 >>> print(f"Undershoot: {us:.1f}%")
474 References:
475 IEEE 181-2011 Section 5.5
476 """
477 if len(trace.data) < 3:
478 return np.nan
480 data = trace.data
481 low, high = _find_levels(data)
482 amplitude = high - low
484 if amplitude <= 0: 484 ↛ 485line 484 didn't jump to line 485 because the condition on line 484 was never true
485 return np.nan
487 min_val = np.min(data)
489 if min_val >= low: 489 ↛ 490line 489 didn't jump to line 490 because the condition on line 489 was never true
490 return 0.0
492 return float((low - min_val) / amplitude * 100)
495def preshoot(
496 trace: WaveformTrace,
497 *,
498 edge_type: Literal["rising", "falling"] = "rising",
499) -> float | np_floating[Any]:
500 """Measure preshoot percentage.
502 Computes preshoot before transitions as percentage of amplitude.
504 Args:
505 trace: Input waveform trace.
506 edge_type: Type of edge to analyze ("rising" or "falling").
508 Returns:
509 Preshoot as percentage, or np.nan if not applicable.
511 Example:
512 >>> ps = preshoot(trace)
513 >>> print(f"Preshoot: {ps:.1f}%")
515 References:
516 IEEE 181-2011 Section 5.5
517 """
518 if len(trace.data) < 10:
519 return np.nan
521 # Convert memoryview to ndarray if needed
522 data = np.asarray(trace.data)
523 low, high = _find_levels(data)
524 amplitude = high - low
526 if amplitude <= 0: 526 ↛ 527line 526 didn't jump to line 527 because the condition on line 526 was never true
527 return np.nan
529 # Find edge crossings at 50%
530 mid = (low + high) / 2
532 if edge_type == "rising": 532 ↛ 552line 532 didn't jump to line 552 because the condition on line 532 was always true
533 # Look for minimum before rising edge that goes below low level
534 crossings = np.where((data[:-1] < mid) & (data[1:] >= mid))[0]
535 if len(crossings) == 0:
536 return np.nan
538 max_preshoot = 0.0
539 for idx in crossings:
540 # Look at samples before crossing
541 pre_samples = max(0, idx - 10)
542 pre_region = data[pre_samples:idx]
543 if len(pre_region) > 0:
544 min_pre = np.min(pre_region)
545 if min_pre < low:
546 preshoot_val = (low - min_pre) / amplitude * 100
547 max_preshoot = max(max_preshoot, preshoot_val)
549 return max_preshoot
551 else: # falling
552 crossings = np.where((data[:-1] >= mid) & (data[1:] < mid))[0]
553 if len(crossings) == 0:
554 return np.nan
556 max_preshoot = 0.0
557 for idx in crossings:
558 pre_samples = max(0, idx - 10)
559 pre_region = data[pre_samples:idx]
560 if len(pre_region) > 0:
561 max_pre = np.max(pre_region)
562 if max_pre > high:
563 preshoot_val = (max_pre - high) / amplitude * 100
564 max_preshoot = max(max_preshoot, preshoot_val)
566 return max_preshoot
569def amplitude(trace: WaveformTrace) -> float | np_floating[Any]:
570 """Measure peak-to-peak amplitude.
572 Computes Vpp as the difference between histogram-based high and low levels.
574 Args:
575 trace: Input waveform trace.
577 Returns:
578 Amplitude in volts (or input units).
580 Example:
581 >>> vpp = amplitude(trace)
582 >>> print(f"Amplitude: {vpp:.3f} V")
584 References:
585 IEEE 1057-2017 Section 4.2
586 """
587 if len(trace.data) < 2:
588 return np.nan
590 low, high = _find_levels(trace.data)
591 return high - low
594def rms(
595 trace: WaveformTrace,
596 *,
597 ac_coupled: bool = False,
598 nan_policy: Literal["propagate", "omit", "raise"] = "propagate",
599) -> float | np_floating[Any]:
600 """Compute RMS voltage.
602 Calculates root-mean-square voltage of the waveform.
604 Args:
605 trace: Input waveform trace.
606 ac_coupled: If True, remove DC offset before computing RMS.
607 nan_policy: How to handle NaN values:
608 - "propagate": Return NaN if any NaN present (default, NumPy behavior)
609 - "omit": Ignore NaN values in calculation
610 - "raise": Raise ValueError if any NaN present
612 Returns:
613 RMS voltage in volts (or input units).
615 Raises:
616 ValueError: If nan_policy="raise" and data contains NaN.
618 Example:
619 >>> v_rms = rms(trace)
620 >>> print(f"RMS: {v_rms:.3f} V")
622 >>> # Handle traces with NaN values
623 >>> v_rms = rms(trace, nan_policy="omit")
626 References:
627 IEEE 1057-2017 Section 4.3
628 """
629 if len(trace.data) == 0: 629 ↛ 630line 629 didn't jump to line 630 because the condition on line 629 was never true
630 return np.nan
632 # Convert memoryview to ndarray if needed
633 data = np.asarray(trace.data)
635 # Handle NaN based on policy
636 if nan_policy == "raise":
637 if np.any(np.isnan(data)): 637 ↛ 646line 637 didn't jump to line 646 because the condition on line 637 was always true
638 raise ValueError("Input data contains NaN values")
639 elif nan_policy == "omit":
640 # Use nanmean and nansum for NaN-safe calculation
641 if ac_coupled: 641 ↛ 642line 641 didn't jump to line 642 because the condition on line 641 was never true
642 data = data - np.nanmean(data)
643 return float(np.sqrt(np.nanmean(data**2)))
644 # else propagate - default NumPy behavior
646 if ac_coupled:
647 data = data - np.mean(data)
649 return float(np.sqrt(np.mean(data**2)))
652def mean(
653 trace: WaveformTrace,
654 *,
655 nan_policy: Literal["propagate", "omit", "raise"] = "propagate",
656) -> float | np_floating[Any]:
657 """Compute mean (DC) voltage.
659 Calculates arithmetic mean of the waveform.
661 Args:
662 trace: Input waveform trace.
663 nan_policy: How to handle NaN values:
664 - "propagate": Return NaN if any NaN present (default, NumPy behavior)
665 - "omit": Ignore NaN values in calculation
666 - "raise": Raise ValueError if any NaN present
668 Returns:
669 Mean voltage in volts (or input units).
671 Raises:
672 ValueError: If nan_policy="raise" and data contains NaN.
674 Example:
675 >>> v_dc = mean(trace)
676 >>> print(f"DC: {v_dc:.3f} V")
678 >>> # Handle traces with NaN values
679 >>> v_dc = mean(trace, nan_policy="omit")
682 References:
683 IEEE 1057-2017 Section 4.3
684 """
685 if len(trace.data) == 0: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true
686 return np.nan
688 # Convert memoryview to ndarray if needed
689 data = np.asarray(trace.data)
691 # Handle NaN based on policy
692 if nan_policy == "raise": 692 ↛ 693line 692 didn't jump to line 693 because the condition on line 692 was never true
693 if np.any(np.isnan(data)):
694 raise ValueError("Input data contains NaN values")
695 return float(np.mean(data))
696 elif nan_policy == "omit":
697 return float(np.nanmean(data))
698 else: # propagate
699 return float(np.mean(data))
702def measure(
703 trace: WaveformTrace,
704 *,
705 parameters: list[str] | None = None,
706 include_units: bool = True,
707) -> dict[str, Any]:
708 """Compute multiple waveform measurements.
710 Unified function for computing all or selected waveform measurements.
712 Args:
713 trace: Input waveform trace.
714 parameters: List of measurement names to compute. If None, compute all.
715 Valid names: rise_time, fall_time, period, frequency, duty_cycle,
716 amplitude, rms, mean, overshoot, undershoot, preshoot
717 include_units: If True, include units in output.
719 Returns:
720 Dictionary mapping measurement names to values (and units if requested).
722 Example:
723 >>> results = measure(trace)
724 >>> print(f"Rise time: {results['rise_time']['value']} {results['rise_time']['unit']}")
726 >>> results = measure(trace, parameters=["frequency", "amplitude"])
728 References:
729 IEEE 181-2011, IEEE 1057-2017
730 """
731 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, "%"),
751 }
753 if parameters is None:
754 selected = all_measurements
755 else:
756 selected = {k: v for k, v in all_measurements.items() if k in parameters}
758 results: dict[str, Any] = {}
760 for name, (func, unit) in selected.items():
761 try:
762 value = func(trace) # type: ignore[operator]
763 except Exception:
764 value = np.nan
766 if include_units:
767 results[name] = {"value": value, "unit": unit}
768 else:
769 results[name] = value
771 return results
774# =============================================================================
775# Helper Functions
776# =============================================================================
779def _find_levels(data: NDArray[np_floating[Any]]) -> tuple[float, float]:
780 """Find low and high levels using histogram method.
782 Args:
783 data: Waveform data array.
785 Returns:
786 Tuple of (low_level, high_level).
787 """
788 # Use percentiles for robust level detection
789 p10, p90 = np.percentile(data, [10, 90])
791 # Refine using histogram peaks
792 hist, bin_edges = np.histogram(data, bins=50)
793 bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
795 # Find peaks in lower and upper halves
796 mid_idx = len(hist) // 2
797 low_idx = np.argmax(hist[:mid_idx])
798 high_idx = mid_idx + np.argmax(hist[mid_idx:])
800 low = bin_centers[low_idx]
801 high = bin_centers[high_idx]
803 # Sanity check
804 if high <= low: 804 ↛ 805line 804 didn't jump to line 805 because the condition on line 804 was never true
805 return float(p10), float(p90)
807 return float(low), float(high)
810def _find_edges(
811 trace: WaveformTrace,
812 edge_type: Literal["rising", "falling"],
813 ref_level: float = 0.5,
814) -> NDArray[np.float64]:
815 """Find edge timestamps in a waveform.
817 Args:
818 trace: Input waveform.
819 edge_type: Type of edges to find.
820 ref_level: Reference level as fraction (0.0 to 1.0). Default 0.5 (50%).
822 Returns:
823 Array of edge timestamps in seconds.
824 """
825 data = trace.data
826 sample_period = trace.metadata.time_base
828 if len(data) < 3:
829 return np.array([], dtype=np.float64)
831 low, high = _find_levels(data)
832 # Use ref_level parameter to compute threshold
833 mid = low + ref_level * (high - low)
835 if edge_type == "rising":
836 crossings = np.where((data[:-1] < mid) & (data[1:] >= mid))[0]
837 else:
838 crossings = np.where((data[:-1] >= mid) & (data[1:] < mid))[0]
840 # Convert to timestamps with interpolation
841 timestamps = np.zeros(len(crossings), dtype=np.float64)
843 for i, idx in enumerate(crossings):
844 base_time = idx * sample_period
846 # Linear interpolation
847 if idx < len(data) - 1: 847 ↛ 856line 847 didn't jump to line 856 because the condition on line 847 was always true
848 v1, v2 = data[idx], data[idx + 1]
849 if abs(v2 - v1) > 1e-12: 849 ↛ 854line 849 didn't jump to line 854 because the condition on line 849 was always true
850 t_offset = (mid - v1) / (v2 - v1) * sample_period
851 t_offset = max(0, min(sample_period, t_offset))
852 timestamps[i] = base_time + t_offset
853 else:
854 timestamps[i] = base_time + sample_period / 2
855 else:
856 timestamps[i] = base_time
858 return timestamps
861def _interpolate_crossing_time(
862 data: NDArray[np_floating[Any]],
863 idx: int,
864 threshold: float,
865 sample_period: float,
866 rising: bool,
867) -> float | None:
868 """Interpolate threshold crossing time.
870 Args:
871 data: Waveform data.
872 idx: Sample index near crossing.
873 threshold: Threshold level.
874 sample_period: Time between samples.
875 rising: True for rising edge, False for falling.
877 Returns:
878 Time of crossing in seconds, or None if not found.
879 """
880 if idx < 0 or idx >= len(data) - 1: 880 ↛ 881line 880 didn't jump to line 881 because the condition on line 880 was never true
881 return None
883 v1, v2 = data[idx], data[idx + 1]
885 # Check direction
886 if rising and not (v1 < threshold <= v2): 886 ↛ 888line 886 didn't jump to line 888 because the condition on line 886 was never true
887 # Search nearby
888 for offset in range(-2, 3):
889 check_idx = idx + offset
890 if 0 <= check_idx < len(data) - 1:
891 v1, v2 = data[check_idx], data[check_idx + 1]
892 if v1 < threshold <= v2:
893 idx = check_idx
894 break
895 else:
896 return None
898 if not rising and not (v1 >= threshold > v2): 898 ↛ 899line 898 didn't jump to line 899 because the condition on line 898 was never true
899 for offset in range(-2, 3):
900 check_idx = idx + offset
901 if 0 <= check_idx < len(data) - 1:
902 v1, v2 = data[check_idx], data[check_idx + 1]
903 if v1 >= threshold > v2:
904 idx = check_idx
905 break
906 else:
907 return None
909 v1, v2 = data[idx], data[idx + 1]
910 dv = v2 - v1
912 if abs(dv) < 1e-12: 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true
913 t_offset = sample_period / 2
914 else:
915 t_offset = (threshold - v1) / dv * sample_period
916 t_offset = max(0, min(sample_period, t_offset))
918 return idx * sample_period + t_offset
921__all__ = [
922 "amplitude",
923 "duty_cycle",
924 "fall_time",
925 "frequency",
926 "mean",
927 "measure",
928 "overshoot",
929 "period",
930 "preshoot",
931 "pulse_width",
932 "rise_time",
933 "rms",
934 "undershoot",
935]