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

1"""Waveform timing and amplitude measurements. 

2 

3This module provides IEEE 181-2011 and IEEE 1057-2017 compliant 

4waveform measurements including rise/fall time, period, frequency, 

5amplitude, and RMS. 

6 

7 

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"]) 

12 

13References: 

14 IEEE 181-2011: Standard for Transitional Waveform Definitions 

15 IEEE 1057-2017: Standard for Digitizing Waveform Recorders 

16""" 

17 

18from __future__ import annotations 

19 

20from typing import TYPE_CHECKING, Any, Literal, overload 

21 

22import numpy as np 

23from numpy import floating as np_floating 

24 

25if TYPE_CHECKING: 

26 from numpy.typing import NDArray 

27 

28 from tracekit.core.types import WaveformTrace 

29 

30 

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. 

37 

38 Computes the time for a signal to transition from the lower 

39 reference level to the upper reference level, per IEEE 181-2011. 

40 

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. 

45 

46 Returns: 

47 Rise time in seconds, or np.nan if no valid rising edge found. 

48 

49 Example: 

50 >>> t_rise = rise_time(trace) 

51 >>> print(f"Rise time: {t_rise * 1e9:.2f} ns") 

52 

53 References: 

54 IEEE 181-2011 Section 5.2 

55 """ 

56 if len(trace.data) < 3: 

57 return np.nan 

58 

59 data = trace.data 

60 low, high = _find_levels(data) 

61 amplitude = high - low 

62 

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 

65 

66 # Calculate reference voltages 

67 low_ref = low + ref_levels[0] * amplitude 

68 high_ref = low + ref_levels[1] * amplitude 

69 

70 # Find rising edge: where signal crosses from below low_ref to above high_ref 

71 sample_period = trace.metadata.time_base 

72 

73 # Find first crossing of low reference (going up) 

74 below_low = data < low_ref 

75 above_low = data >= low_ref 

76 

77 # Find transitions from below to above low_ref 

78 transitions = np.where(below_low[:-1] & above_low[1:])[0] 

79 

80 if len(transitions) == 0: 

81 return np.nan 

82 

83 best_rise_time: float | np_floating[Any] = np.nan 

84 

85 for start_idx in transitions: 

86 # Find where signal crosses high reference 

87 remaining = data[start_idx:] 

88 above_high = remaining >= high_ref 

89 

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 

92 

93 end_offset = np.argmax(above_high) 

94 end_idx = start_idx + end_offset 

95 

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 

100 

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) 

104 

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 

109 

110 return best_rise_time 

111 

112 

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. 

119 

120 Computes the time for a signal to transition from the upper 

121 reference level to the lower reference level, per IEEE 181-2011. 

122 

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. 

127 

128 Returns: 

129 Fall time in seconds, or np.nan if no valid falling edge found. 

130 

131 Example: 

132 >>> t_fall = fall_time(trace) 

133 >>> print(f"Fall time: {t_fall * 1e9:.2f} ns") 

134 

135 References: 

136 IEEE 181-2011 Section 5.2 

137 """ 

138 if len(trace.data) < 3: 

139 return np.nan 

140 

141 data = trace.data 

142 low, high = _find_levels(data) 

143 amplitude = high - low 

144 

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 

147 

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 

151 

152 sample_period = trace.metadata.time_base 

153 

154 # Find where signal is above high reference 

155 above_high = data >= high_ref 

156 below_high = data < high_ref 

157 

158 # Find transitions from above to below high_ref 

159 transitions = np.where(above_high[:-1] & below_high[1:])[0] 

160 

161 if len(transitions) == 0: 

162 return np.nan 

163 

164 best_fall_time: float | np_floating[Any] = np.nan 

165 

166 for start_idx in transitions: 

167 # Find where signal crosses low reference 

168 remaining = data[start_idx:] 

169 below_low = remaining <= low_ref 

170 

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 

173 

174 end_offset = np.argmax(below_low) 

175 end_idx = start_idx + end_offset 

176 

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 

180 

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) 

184 

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 

189 

190 return best_fall_time 

191 

192 

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]: ... 

200 

201 

202@overload 

203def period( 

204 trace: WaveformTrace, 

205 *, 

206 edge_type: Literal["rising", "falling"] = "rising", 

207 return_all: Literal[True], 

208) -> NDArray[np.float64]: ... 

209 

210 

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. 

218 

219 Computes the time between consecutive rising or falling edges. 

220 

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. 

225 

226 Returns: 

227 Period in seconds (mean if return_all=False), or array of periods. 

228 

229 Example: 

230 >>> T = period(trace) 

231 >>> print(f"Period: {T * 1e6:.2f} us") 

232 

233 References: 

234 IEEE 181-2011 Section 5.3 

235 """ 

236 edges = _find_edges(trace, edge_type) 

237 

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 

242 

243 periods = np.diff(edges) 

244 

245 if return_all: 

246 return periods 

247 return float(np.mean(periods)) 

248 

249 

250def frequency( 

251 trace: WaveformTrace, 

252 *, 

253 method: Literal["edge", "fft"] = "edge", 

254) -> float | np_floating[Any]: 

255 """Measure signal frequency. 

256 

257 Computes frequency either from edge-to-edge period or using FFT. 

258 

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 

264 

265 Returns: 

266 Frequency in Hz, or np.nan if measurement not possible. 

267 

268 Raises: 

269 ValueError: If method is not one of the supported types. 

270 

271 Example: 

272 >>> f = frequency(trace) 

273 >>> print(f"Frequency: {f / 1e6:.3f} MHz") 

274 

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 

283 

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 

287 

288 data = trace.data - np.mean(trace.data) # Remove DC 

289 n = len(data) 

290 fft_mag = np.abs(np.fft.rfft(data)) 

291 

292 # Find peak (skip DC component) 

293 peak_idx = np.argmax(fft_mag[1:]) + 1 

294 

295 # Calculate frequency 

296 freq_resolution = trace.metadata.sample_rate / n 

297 return float(peak_idx * freq_resolution) 

298 

299 else: 

300 raise ValueError(f"Unknown method: {method}") 

301 

302 

303def duty_cycle( 

304 trace: WaveformTrace, 

305 *, 

306 percentage: bool = False, 

307) -> float | np_floating[Any]: 

308 """Measure duty cycle. 

309 

310 Computes duty cycle as the ratio of positive pulse width to period. 

311 

312 Args: 

313 trace: Input waveform trace. 

314 percentage: If True, return as percentage (0-100). If False, return ratio (0-1). 

315 

316 Returns: 

317 Duty cycle as ratio or percentage. 

318 

319 Example: 

320 >>> dc = duty_cycle(trace, percentage=True) 

321 >>> print(f"Duty cycle: {dc:.1f}%") 

322 

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) 

328 

329 if np.isnan(pw_pos) or np.isnan(T) or T <= 0: 

330 return np.nan 

331 

332 dc = pw_pos / T 

333 

334 if percentage: 

335 return dc * 100 

336 return dc 

337 

338 

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]: ... 

347 

348 

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]: ... 

357 

358 

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. 

367 

368 Computes positive or negative pulse width at the specified reference level. 

369 

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. 

375 

376 Returns: 

377 Pulse width in seconds. 

378 

379 Example: 

380 >>> pw = pulse_width(trace, polarity="positive") 

381 >>> print(f"Pulse width: {pw * 1e6:.2f} us") 

382 

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) 

388 

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 

393 

394 widths: list[float] = [] 

395 

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) 

410 

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 

415 

416 widths_arr = np.array(widths, dtype=np.float64) 

417 

418 if return_all: 

419 return widths_arr 

420 return float(np.mean(widths_arr)) 

421 

422 

423def overshoot(trace: WaveformTrace) -> float | np_floating[Any]: 

424 """Measure overshoot percentage. 

425 

426 Computes overshoot as (max - high) / amplitude * 100%. 

427 

428 Args: 

429 trace: Input waveform trace. 

430 

431 Returns: 

432 Overshoot as percentage, or np.nan if not applicable. 

433 

434 Example: 

435 >>> os = overshoot(trace) 

436 >>> print(f"Overshoot: {os:.1f}%") 

437 

438 References: 

439 IEEE 181-2011 Section 5.5 

440 """ 

441 if len(trace.data) < 3: 

442 return np.nan 

443 

444 data = trace.data 

445 low, high = _find_levels(data) 

446 amplitude = high - low 

447 

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 

450 

451 max_val = np.max(data) 

452 

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 

455 

456 return float((max_val - high) / amplitude * 100) 

457 

458 

459def undershoot(trace: WaveformTrace) -> float | np_floating[Any]: 

460 """Measure undershoot percentage. 

461 

462 Computes undershoot as (low - min) / amplitude * 100%. 

463 

464 Args: 

465 trace: Input waveform trace. 

466 

467 Returns: 

468 Undershoot as percentage, or np.nan if not applicable. 

469 

470 Example: 

471 >>> us = undershoot(trace) 

472 >>> print(f"Undershoot: {us:.1f}%") 

473 

474 References: 

475 IEEE 181-2011 Section 5.5 

476 """ 

477 if len(trace.data) < 3: 

478 return np.nan 

479 

480 data = trace.data 

481 low, high = _find_levels(data) 

482 amplitude = high - low 

483 

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 

486 

487 min_val = np.min(data) 

488 

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 

491 

492 return float((low - min_val) / amplitude * 100) 

493 

494 

495def preshoot( 

496 trace: WaveformTrace, 

497 *, 

498 edge_type: Literal["rising", "falling"] = "rising", 

499) -> float | np_floating[Any]: 

500 """Measure preshoot percentage. 

501 

502 Computes preshoot before transitions as percentage of amplitude. 

503 

504 Args: 

505 trace: Input waveform trace. 

506 edge_type: Type of edge to analyze ("rising" or "falling"). 

507 

508 Returns: 

509 Preshoot as percentage, or np.nan if not applicable. 

510 

511 Example: 

512 >>> ps = preshoot(trace) 

513 >>> print(f"Preshoot: {ps:.1f}%") 

514 

515 References: 

516 IEEE 181-2011 Section 5.5 

517 """ 

518 if len(trace.data) < 10: 

519 return np.nan 

520 

521 # Convert memoryview to ndarray if needed 

522 data = np.asarray(trace.data) 

523 low, high = _find_levels(data) 

524 amplitude = high - low 

525 

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 

528 

529 # Find edge crossings at 50% 

530 mid = (low + high) / 2 

531 

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 

537 

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) 

548 

549 return max_preshoot 

550 

551 else: # falling 

552 crossings = np.where((data[:-1] >= mid) & (data[1:] < mid))[0] 

553 if len(crossings) == 0: 

554 return np.nan 

555 

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) 

565 

566 return max_preshoot 

567 

568 

569def amplitude(trace: WaveformTrace) -> float | np_floating[Any]: 

570 """Measure peak-to-peak amplitude. 

571 

572 Computes Vpp as the difference between histogram-based high and low levels. 

573 

574 Args: 

575 trace: Input waveform trace. 

576 

577 Returns: 

578 Amplitude in volts (or input units). 

579 

580 Example: 

581 >>> vpp = amplitude(trace) 

582 >>> print(f"Amplitude: {vpp:.3f} V") 

583 

584 References: 

585 IEEE 1057-2017 Section 4.2 

586 """ 

587 if len(trace.data) < 2: 

588 return np.nan 

589 

590 low, high = _find_levels(trace.data) 

591 return high - low 

592 

593 

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. 

601 

602 Calculates root-mean-square voltage of the waveform. 

603 

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 

611 

612 Returns: 

613 RMS voltage in volts (or input units). 

614 

615 Raises: 

616 ValueError: If nan_policy="raise" and data contains NaN. 

617 

618 Example: 

619 >>> v_rms = rms(trace) 

620 >>> print(f"RMS: {v_rms:.3f} V") 

621 

622 >>> # Handle traces with NaN values 

623 >>> v_rms = rms(trace, nan_policy="omit") 

624 

625 

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 

631 

632 # Convert memoryview to ndarray if needed 

633 data = np.asarray(trace.data) 

634 

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 

645 

646 if ac_coupled: 

647 data = data - np.mean(data) 

648 

649 return float(np.sqrt(np.mean(data**2))) 

650 

651 

652def mean( 

653 trace: WaveformTrace, 

654 *, 

655 nan_policy: Literal["propagate", "omit", "raise"] = "propagate", 

656) -> float | np_floating[Any]: 

657 """Compute mean (DC) voltage. 

658 

659 Calculates arithmetic mean of the waveform. 

660 

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 

667 

668 Returns: 

669 Mean voltage in volts (or input units). 

670 

671 Raises: 

672 ValueError: If nan_policy="raise" and data contains NaN. 

673 

674 Example: 

675 >>> v_dc = mean(trace) 

676 >>> print(f"DC: {v_dc:.3f} V") 

677 

678 >>> # Handle traces with NaN values 

679 >>> v_dc = mean(trace, nan_policy="omit") 

680 

681 

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 

687 

688 # Convert memoryview to ndarray if needed 

689 data = np.asarray(trace.data) 

690 

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)) 

700 

701 

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. 

709 

710 Unified function for computing all or selected waveform measurements. 

711 

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. 

718 

719 Returns: 

720 Dictionary mapping measurement names to values (and units if requested). 

721 

722 Example: 

723 >>> results = measure(trace) 

724 >>> print(f"Rise time: {results['rise_time']['value']} {results['rise_time']['unit']}") 

725 

726 >>> results = measure(trace, parameters=["frequency", "amplitude"]) 

727 

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 } 

752 

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} 

757 

758 results: dict[str, Any] = {} 

759 

760 for name, (func, unit) in selected.items(): 

761 try: 

762 value = func(trace) # type: ignore[operator] 

763 except Exception: 

764 value = np.nan 

765 

766 if include_units: 

767 results[name] = {"value": value, "unit": unit} 

768 else: 

769 results[name] = value 

770 

771 return results 

772 

773 

774# ============================================================================= 

775# Helper Functions 

776# ============================================================================= 

777 

778 

779def _find_levels(data: NDArray[np_floating[Any]]) -> tuple[float, float]: 

780 """Find low and high levels using histogram method. 

781 

782 Args: 

783 data: Waveform data array. 

784 

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]) 

790 

791 # Refine using histogram peaks 

792 hist, bin_edges = np.histogram(data, bins=50) 

793 bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 

794 

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:]) 

799 

800 low = bin_centers[low_idx] 

801 high = bin_centers[high_idx] 

802 

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) 

806 

807 return float(low), float(high) 

808 

809 

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. 

816 

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%). 

821 

822 Returns: 

823 Array of edge timestamps in seconds. 

824 """ 

825 data = trace.data 

826 sample_period = trace.metadata.time_base 

827 

828 if len(data) < 3: 

829 return np.array([], dtype=np.float64) 

830 

831 low, high = _find_levels(data) 

832 # Use ref_level parameter to compute threshold 

833 mid = low + ref_level * (high - low) 

834 

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] 

839 

840 # Convert to timestamps with interpolation 

841 timestamps = np.zeros(len(crossings), dtype=np.float64) 

842 

843 for i, idx in enumerate(crossings): 

844 base_time = idx * sample_period 

845 

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 

857 

858 return timestamps 

859 

860 

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. 

869 

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. 

876 

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 

882 

883 v1, v2 = data[idx], data[idx + 1] 

884 

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 

897 

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 

908 

909 v1, v2 = data[idx], data[idx + 1] 

910 dv = v2 - v1 

911 

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)) 

917 

918 return idx * sample_period + t_offset 

919 

920 

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]