Coverage for src / tracekit / analyzers / digital / signal_quality.py: 93%

339 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""Signal quality and integrity analysis. 

2 

3This module provides comprehensive signal integrity analysis for digital signals, 

4including noise margin measurements, transition characterization, overshoot/ 

5undershoot detection, and ringing analysis. 

6 

7 

8Example: 

9 >>> import numpy as np 

10 >>> from tracekit.analyzers.digital.signal_quality import SignalQualityAnalyzer 

11 >>> # Generate test signal 

12 >>> signal = np.concatenate([np.zeros(100), np.ones(100)]) 

13 >>> analyzer = SignalQualityAnalyzer(sample_rate=100e6, logic_family='TTL') 

14 >>> report = analyzer.analyze(signal) 

15""" 

16 

17from __future__ import annotations 

18 

19from dataclasses import dataclass 

20from typing import TYPE_CHECKING, Any, Literal 

21 

22import numpy as np 

23from scipy import signal as scipy_signal 

24 

25if TYPE_CHECKING: 

26 from numpy.typing import NDArray 

27 

28 

29# Logic family thresholds (from existing extraction.py) 

30LOGIC_THRESHOLDS = { 

31 "ttl": {"VIL": 0.8, "VIH": 2.0, "VOL": 0.4, "VOH": 2.4, "VCC": 5.0}, 

32 "cmos": {"VIL": 1.5, "VIH": 3.5, "VOL": 0.1, "VOH": 4.9, "VCC": 5.0}, 

33 "lvttl": {"VIL": 0.8, "VIH": 1.5, "VOL": 0.4, "VOH": 2.4, "VCC": 3.3}, 

34 "lvcmos": {"VIL": 0.99, "VIH": 2.31, "VOL": 0.1, "VOH": 3.2, "VCC": 3.3}, 

35} 

36 

37 

38@dataclass 

39class NoiseMargins: 

40 """Noise margins for digital signal. 

41 

42 Attributes: 

43 high_margin: Distance from threshold to logic high level (V). 

44 low_margin: Distance from threshold to logic low level (V). 

45 high_mean: Mean high level voltage. 

46 low_mean: Mean low level voltage. 

47 high_std: Standard deviation of high level (noise). 

48 low_std: Standard deviation of low level (noise). 

49 threshold: Detection threshold used. 

50 """ 

51 

52 high_margin: float # Distance from threshold to logic high 

53 low_margin: float # Distance from threshold to logic low 

54 high_mean: float # Mean high level 

55 low_mean: float # Mean low level 

56 high_std: float # High level noise 

57 low_std: float # Low level noise 

58 threshold: float # Detection threshold 

59 

60 

61@dataclass 

62class TransitionMetrics: 

63 """Metrics for signal transitions. 

64 

65 Attributes: 

66 rise_time: 10-90% rise time in seconds. 

67 fall_time: 90-10% fall time in seconds. 

68 slew_rate_rising: Rising edge slew rate (V/s). 

69 slew_rate_falling: Falling edge slew rate (V/s). 

70 overshoot: Overshoot as percentage of signal swing. 

71 undershoot: Undershoot as percentage of signal swing. 

72 ringing_frequency: Ringing frequency in Hz (None if no ringing). 

73 ringing_amplitude: Ringing amplitude in volts (None if no ringing). 

74 settling_time: Time to settle within tolerance (None if not measured). 

75 """ 

76 

77 rise_time: float # 10-90% rise time 

78 fall_time: float # 90-10% fall time 

79 slew_rate_rising: float 

80 slew_rate_falling: float 

81 overshoot: float # Percentage overshoot 

82 undershoot: float # Percentage undershoot 

83 ringing_frequency: float | None = None 

84 ringing_amplitude: float | None = None 

85 settling_time: float | None = None 

86 

87 

88@dataclass 

89class SignalIntegrityReport: 

90 """Complete signal integrity report. 

91 

92 Attributes: 

93 noise_margins: Noise margin measurements. 

94 transitions: Transition quality metrics. 

95 snr_db: Signal-to-noise ratio in dB. 

96 signal_quality: Overall quality assessment. 

97 issues: List of detected issues. 

98 recommendations: List of recommendations for improvement. 

99 """ 

100 

101 noise_margins: NoiseMargins 

102 transitions: TransitionMetrics 

103 snr_db: float 

104 signal_quality: Literal["excellent", "good", "fair", "poor"] 

105 issues: list[str] 

106 recommendations: list[str] 

107 

108 

109@dataclass 

110class SimpleQualityMetrics: 

111 """Simplified quality metrics for test compatibility. 

112 

113 Provides a flat interface with direct attribute access for common metrics. 

114 

115 Attributes: 

116 noise_margin_low: Low-side noise margin in volts. 

117 noise_margin_high: High-side noise margin in volts. 

118 rise_time: Rise time in samples (or seconds depending on context). 

119 fall_time: Fall time in samples (or seconds depending on context). 

120 has_overshoot: Whether overshoot was detected. 

121 max_overshoot: Maximum overshoot value in volts. 

122 duty_cycle: Signal duty cycle (0.0 to 1.0). 

123 """ 

124 

125 noise_margin_low: float 

126 noise_margin_high: float 

127 rise_time: float 

128 fall_time: float 

129 has_overshoot: bool 

130 max_overshoot: float 

131 duty_cycle: float 

132 

133 

134class SignalQualityAnalyzer: 

135 """Analyze digital signal quality and integrity. 

136 

137 Provides comprehensive signal integrity analysis including noise margins, 

138 transition metrics, overshoot/undershoot, and ringing detection. 

139 

140 Supports two initialization modes: 

141 1. Full mode: SignalQualityAnalyzer(sample_rate=1e9, logic_family='TTL') 

142 2. Simple mode: SignalQualityAnalyzer(v_il=0.8, v_ih=2.0) - for test compatibility 

143 

144 Attributes: 

145 sample_rate: Sample rate of input signals in Hz. 

146 logic_family: Logic family for threshold determination. 

147 v_il: Input low threshold voltage. 

148 v_ih: Input high threshold voltage. 

149 vdd: Supply voltage for overshoot reference. 

150 

151 Example: 

152 >>> analyzer = SignalQualityAnalyzer(sample_rate=1e9, logic_family='TTL') 

153 >>> report = analyzer.analyze(signal_trace) 

154 """ 

155 

156 def __init__( 

157 self, 

158 sample_rate: float | None = None, 

159 logic_family: str = "auto", 

160 v_il: float | None = None, 

161 v_ih: float | None = None, 

162 vdd: float | None = None, 

163 ): 

164 """Initialize analyzer. 

165 

166 Args: 

167 sample_rate: Sample rate in Hz (optional for simple mode). 

168 logic_family: Logic family ('TTL', 'CMOS', 'LVTTL', 'LVCMOS', 'auto'). 

169 v_il: Input low threshold voltage (for simple mode). 

170 v_ih: Input high threshold voltage (for simple mode). 

171 vdd: Supply voltage for overshoot reference (for simple mode). 

172 

173 Raises: 

174 ValueError: If sample rate is invalid (when provided). 

175 """ 

176 # Simple mode: thresholds provided directly 

177 self.v_il = v_il 

178 self.v_ih = v_ih 

179 self.vdd = vdd 

180 

181 # Full mode: sample rate and logic family 

182 if sample_rate is not None: 

183 if sample_rate <= 0: 

184 raise ValueError(f"Sample rate must be positive, got {sample_rate}") 

185 self.sample_rate = sample_rate 

186 self._time_base = 1.0 / sample_rate 

187 else: 

188 # Default sample rate for simple mode (samples per second = 1) 

189 self.sample_rate = 1.0 

190 self._time_base = 1.0 

191 

192 self.logic_family = logic_family.lower() if logic_family else "auto" 

193 

194 # If thresholds provided, use them to determine logic family settings 

195 self._threshold: float | None 

196 if v_il is not None and v_ih is not None: 

197 self._threshold = (v_il + v_ih) / 2.0 

198 else: 

199 self._threshold = None 

200 

201 def analyze( 

202 self, trace: NDArray[np.float64], clock_trace: NDArray[np.float64] | None = None 

203 ) -> Any: 

204 """Perform complete signal integrity analysis. 

205 

206 Returns SimpleQualityMetrics in simple mode (when v_il/v_ih provided), 

207 or SignalIntegrityReport in full mode. 

208 

209 Args: 

210 trace: Input signal trace (analog voltage values). 

211 clock_trace: Optional clock signal for synchronized analysis. 

212 

213 Returns: 

214 SimpleQualityMetrics or SignalIntegrityReport with analysis results. 

215 

216 Example: 

217 >>> report = analyzer.analyze(signal_trace) 

218 >>> print(f"Signal quality: {report.signal_quality}") 

219 """ 

220 trace = np.asarray(trace, dtype=np.float64) 

221 

222 # Simple mode: return SimpleQualityMetrics 

223 if self.v_il is not None or self.v_ih is not None or self.vdd is not None: 

224 return self._analyze_simple(trace) 

225 

226 # Full mode: return SignalIntegrityReport 

227 return self._analyze_full(trace, clock_trace) 

228 

229 def _analyze_simple(self, trace: NDArray[np.float64]) -> SimpleQualityMetrics: 

230 """Simple analysis mode returning flat metrics. 

231 

232 Args: 

233 trace: Input signal trace. 

234 

235 Returns: 

236 SimpleQualityMetrics with measured values. 

237 """ 

238 # Determine threshold 

239 threshold: float 

240 if self._threshold is not None: 

241 threshold = self._threshold 

242 else: 

243 threshold = float((np.max(trace) + np.min(trace)) / 2.0) 

244 

245 # Separate high and low samples 

246 high_samples = trace[trace > threshold] 

247 low_samples = trace[trace <= threshold] 

248 

249 # Calculate noise margins 

250 if len(high_samples) > 0: 250 ↛ 257line 250 didn't jump to line 257 because the condition on line 250 was always true

251 high_mean = np.mean(high_samples) 

252 if self.v_ih is not None: 

253 noise_margin_high = high_mean - self.v_ih 

254 else: 

255 noise_margin_high = high_mean - threshold 

256 else: 

257 noise_margin_high = 0.0 

258 

259 if len(low_samples) > 0: 259 ↛ 266line 259 didn't jump to line 266 because the condition on line 259 was always true

260 low_mean = np.mean(low_samples) 

261 if self.v_il is not None: 

262 noise_margin_low = self.v_il - low_mean 

263 else: 

264 noise_margin_low = threshold - low_mean 

265 else: 

266 noise_margin_low = 0.0 

267 

268 # Measure rise/fall times in samples 

269 rise_time, fall_time = self._measure_rise_fall_samples(trace, threshold) 

270 

271 # Detect overshoot 

272 has_overshoot, max_overshoot = self._detect_overshoot_simple(trace) 

273 

274 # Calculate duty cycle 

275 duty_cycle = self._calculate_duty_cycle(trace, threshold) 

276 

277 return SimpleQualityMetrics( 

278 noise_margin_low=float(noise_margin_low), 

279 noise_margin_high=float(noise_margin_high), 

280 rise_time=float(rise_time), 

281 fall_time=float(fall_time), 

282 has_overshoot=has_overshoot, 

283 max_overshoot=float(max_overshoot), 

284 duty_cycle=float(duty_cycle), 

285 ) 

286 

287 def _measure_rise_fall_samples( 

288 self, trace: NDArray[np.float64], threshold: float 

289 ) -> tuple[float, float]: 

290 """Measure rise and fall times in samples. 

291 

292 Args: 

293 trace: Input signal trace. 

294 threshold: Detection threshold. 

295 

296 Returns: 

297 Tuple of (rise_time_samples, fall_time_samples). 

298 """ 

299 # Detect edges 

300 crossings = np.diff((trace > threshold).astype(int)) 

301 rising_edges = np.where(crossings > 0)[0] 

302 falling_edges = np.where(crossings < 0)[0] 

303 

304 # Measure rise times 

305 rise_times = [] 

306 for edge_idx in rising_edges: 

307 window_size = min(10, edge_idx, len(trace) - edge_idx - 1) 

308 if window_size < 2: 

309 continue 

310 

311 window = trace[edge_idx - window_size : edge_idx + window_size + 1] 

312 v_min = np.min(window) 

313 v_max = np.max(window) 

314 

315 if v_max - v_min < 1e-6: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 continue 

317 

318 # Find 10% and 90% points 

319 v_10 = v_min + 0.1 * (v_max - v_min) 

320 v_90 = v_min + 0.9 * (v_max - v_min) 

321 

322 idx_10 = np.where(window >= v_10)[0] 

323 idx_90 = np.where(window >= v_90)[0] 

324 

325 if len(idx_10) > 0 and len(idx_90) > 0: 325 ↛ 306line 325 didn't jump to line 306 because the condition on line 325 was always true

326 rise_time = idx_90[0] - idx_10[0] 

327 if rise_time > 0: 

328 rise_times.append(rise_time) 

329 

330 # Measure fall times 

331 fall_times = [] 

332 for edge_idx in falling_edges: 

333 window_size = min(10, edge_idx, len(trace) - edge_idx - 1) 

334 if window_size < 2: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 continue 

336 

337 window = trace[edge_idx - window_size : edge_idx + window_size + 1] 

338 v_min = np.min(window) 

339 v_max = np.max(window) 

340 

341 if v_max - v_min < 1e-6: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 continue 

343 

344 v_90 = v_min + 0.9 * (v_max - v_min) 

345 v_10 = v_min + 0.1 * (v_max - v_min) 

346 

347 idx_90 = np.where(window <= v_90)[0] 

348 idx_10 = np.where(window <= v_10)[0] 

349 

350 if len(idx_90) > 0 and len(idx_10) > 0: 350 ↛ 332line 350 didn't jump to line 332 because the condition on line 350 was always true

351 fall_time = idx_10[-1] - idx_90[0] 

352 if fall_time > 0: 352 ↛ 332line 352 didn't jump to line 332 because the condition on line 352 was always true

353 fall_times.append(fall_time) 

354 

355 rise_time = np.mean(rise_times) if rise_times else 0.0 

356 fall_time = np.mean(fall_times) if fall_times else 0.0 

357 

358 return rise_time, fall_time 

359 

360 def _detect_overshoot_simple(self, trace: NDArray[np.float64]) -> tuple[bool, float]: 

361 """Detect overshoot in simple mode. 

362 

363 Args: 

364 trace: Input signal trace. 

365 

366 Returns: 

367 Tuple of (has_overshoot, max_overshoot_value). 

368 """ 

369 threshold = self._threshold or (np.max(trace) + np.min(trace)) / 2.0 

370 high_samples = trace[trace > threshold] 

371 

372 if len(high_samples) == 0: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 return False, 0.0 

374 

375 high_median = np.median(high_samples) 

376 max_val = np.max(trace) 

377 

378 # Check if max exceeds expected high level 

379 if self.vdd is not None: 

380 # Check against VDD 

381 overshoot = float(max_val - self.vdd) 

382 has_overshoot = overshoot > 0.05 # 50mV threshold 

383 else: 

384 # Check against median high level 

385 overshoot = float(max_val - high_median) 

386 # Only count as overshoot if significantly above stable level 

387 _high_level = high_median 

388 signal_swing = high_median - np.min(trace) 

389 has_overshoot = overshoot > float(signal_swing * 0.05) # 5% threshold 

390 

391 return bool(has_overshoot), max(0.0, overshoot) 

392 

393 def _calculate_duty_cycle(self, trace: NDArray[np.float64], threshold: float) -> float: 

394 """Calculate signal duty cycle. 

395 

396 Args: 

397 trace: Input signal trace. 

398 threshold: Detection threshold. 

399 

400 Returns: 

401 Duty cycle as ratio (0.0 to 1.0). 

402 """ 

403 if len(trace) == 0: 403 ↛ 404line 403 didn't jump to line 404 because the condition on line 403 was never true

404 return 0.0 

405 

406 # Handle boolean trace 

407 if trace.dtype == np.bool_: 

408 high_count = np.sum(trace) 

409 else: 

410 high_count = np.sum(trace > threshold) 

411 

412 return float(high_count) / float(len(trace)) 

413 

414 def _analyze_full( 

415 self, trace: NDArray[np.float64], clock_trace: NDArray[np.float64] | None = None 

416 ) -> SignalIntegrityReport: 

417 """Full analysis mode returning comprehensive report. 

418 

419 Args: 

420 trace: Input signal trace. 

421 clock_trace: Optional clock signal. 

422 

423 Returns: 

424 SignalIntegrityReport with complete analysis. 

425 """ 

426 # Measure noise margins 

427 logic_fam: Literal["ttl", "cmos", "lvttl", "lvcmos", "auto"] 

428 if self.logic_family in ("ttl", "cmos", "lvttl", "lvcmos", "auto"): 428 ↛ 431line 428 didn't jump to line 431 because the condition on line 428 was always true

429 logic_fam = self.logic_family # type: ignore[assignment] 

430 else: 

431 logic_fam = "auto" 

432 noise_margins = self.measure_noise_margins(trace, logic_fam) 

433 

434 # Measure transitions 

435 transitions = self.measure_transitions(trace) 

436 

437 # Calculate SNR 

438 snr_db = self.calculate_snr(trace) 

439 

440 # Assess overall quality and identify issues 

441 issues = [] 

442 recommendations = [] 

443 

444 # Check noise margins 

445 if noise_margins.high_margin < 0.4: 

446 issues.append("Insufficient high-level noise margin") 

447 recommendations.append("Increase signal high level or reduce noise") 

448 

449 if noise_margins.low_margin < 0.4: 

450 issues.append("Insufficient low-level noise margin") 

451 recommendations.append("Decrease signal low level or reduce noise") 

452 

453 # Check transitions 

454 if transitions.overshoot > 20: 

455 issues.append(f"Excessive overshoot: {transitions.overshoot:.1f}%") 

456 recommendations.append("Add series termination or reduce capacitance") 

457 

458 if transitions.undershoot > 20: 

459 issues.append(f"Excessive undershoot: {transitions.undershoot:.1f}%") 

460 recommendations.append("Check ground connections and reduce inductance") 

461 

462 if transitions.ringing_amplitude and transitions.ringing_amplitude > 0.2: 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true

463 issues.append("Significant ringing detected") 

464 recommendations.append("Add damping resistor or improve impedance matching") 

465 

466 # Check SNR 

467 if snr_db < 20: 

468 issues.append(f"Low SNR: {snr_db:.1f} dB") 

469 recommendations.append("Reduce noise sources or improve shielding") 

470 

471 # Determine overall quality 

472 quality: Literal["excellent", "good", "fair", "poor"] 

473 if len(issues) == 0 and snr_db > 40: 

474 quality = "excellent" 

475 elif len(issues) <= 1 and snr_db > 30: 

476 quality = "good" 

477 elif len(issues) <= 2 and snr_db > 20: 

478 quality = "fair" 

479 else: 

480 quality = "poor" 

481 

482 return SignalIntegrityReport( 

483 noise_margins=noise_margins, 

484 transitions=transitions, 

485 snr_db=snr_db, 

486 signal_quality=quality, 

487 issues=issues, 

488 recommendations=recommendations, 

489 ) 

490 

491 def measure_noise_margins( 

492 self, 

493 trace: NDArray[np.float64], 

494 logic_family: Literal["ttl", "cmos", "lvttl", "lvcmos", "auto"] = "auto", 

495 ) -> NoiseMargins: 

496 """Measure noise margins for high and low states. 

497 

498 Args: 

499 trace: Input signal trace (analog voltage values). 

500 logic_family: Logic family for threshold determination. 

501 

502 Returns: 

503 NoiseMargins object with measured margins. 

504 

505 Example: 

506 >>> margins = analyzer.measure_noise_margins(trace, logic_family='TTL') 

507 """ 

508 trace = np.asarray(trace) 

509 

510 # Determine threshold 

511 if logic_family == "auto": 

512 # Auto-detect based on signal range 

513 signal_range = np.max(trace) - np.min(trace) 

514 if signal_range > 4.0: 

515 logic_family = "ttl" # 5V logic 

516 elif signal_range > 2.5: 

517 logic_family = "lvttl" # 3.3V logic 

518 else: 

519 logic_family = "lvcmos" # Low voltage 

520 

521 # Get thresholds for logic family 

522 thresholds = LOGIC_THRESHOLDS.get(logic_family, LOGIC_THRESHOLDS["ttl"]) 

523 threshold = (thresholds["VIL"] + thresholds["VIH"]) / 2.0 

524 

525 # Separate high and low samples 

526 high_samples = trace[trace > threshold] 

527 low_samples = trace[trace <= threshold] 

528 

529 # Calculate statistics 

530 if len(high_samples) > 0: 

531 high_mean = np.mean(high_samples) 

532 high_std = np.std(high_samples) 

533 high_margin = high_mean - threshold 

534 else: 

535 high_mean = 0.0 

536 high_std = 0.0 

537 high_margin = 0.0 

538 

539 if len(low_samples) > 0: 

540 low_mean = np.mean(low_samples) 

541 low_std = np.std(low_samples) 

542 low_margin = threshold - low_mean 

543 else: 

544 low_mean = 0.0 

545 low_std = 0.0 

546 low_margin = 0.0 

547 

548 return NoiseMargins( 

549 high_margin=float(high_margin), 

550 low_margin=float(low_margin), 

551 high_mean=float(high_mean), 

552 low_mean=float(low_mean), 

553 high_std=float(high_std), 

554 low_std=float(low_std), 

555 threshold=float(threshold), 

556 ) 

557 

558 def measure_transitions(self, trace: NDArray[np.float64]) -> TransitionMetrics: 

559 """Measure transition characteristics. 

560 

561 Analyzes rising and falling edges to measure rise/fall times, 

562 slew rates, overshoot, undershoot, and ringing. 

563 

564 Args: 

565 trace: Input signal trace (analog voltage values). 

566 

567 Returns: 

568 TransitionMetrics object with transition measurements. 

569 

570 Example: 

571 >>> metrics = analyzer.measure_transitions(trace) 

572 """ 

573 trace = np.asarray(trace) 

574 

575 # Find threshold crossings 

576 threshold = (np.max(trace) + np.min(trace)) / 2.0 

577 signal_range = np.max(trace) - np.min(trace) 

578 

579 # Detect edges (simple threshold crossing) 

580 crossings = np.diff((trace > threshold).astype(int)) 

581 rising_edges = np.where(crossings > 0)[0] 

582 falling_edges = np.where(crossings < 0)[0] 

583 

584 # Measure rise time (10-90%) 

585 rise_times = [] 

586 for edge_idx in rising_edges: 

587 if edge_idx > 10 and edge_idx < len(trace) - 10: 

588 # Get window around edge 

589 window = trace[edge_idx - 10 : edge_idx + 10] 

590 v_min = np.min(window) 

591 v_max = np.max(window) 

592 

593 # Find 10% and 90% points 

594 v_10 = v_min + 0.1 * (v_max - v_min) 

595 v_90 = v_min + 0.9 * (v_max - v_min) 

596 

597 # Find sample indices 

598 idx_10 = np.where(window >= v_10)[0] 

599 idx_90 = np.where(window >= v_90)[0] 

600 

601 if len(idx_10) > 0 and len(idx_90) > 0: 601 ↛ 586line 601 didn't jump to line 586 because the condition on line 601 was always true

602 rise_time = (idx_90[0] - idx_10[0]) * self._time_base 

603 rise_times.append(rise_time) 

604 

605 # Measure fall time (90-10%) 

606 fall_times = [] 

607 for edge_idx in falling_edges: 

608 if edge_idx > 10 and edge_idx < len(trace) - 10: 608 ↛ 607line 608 didn't jump to line 607 because the condition on line 608 was always true

609 window = trace[edge_idx - 10 : edge_idx + 10] 

610 v_min = np.min(window) 

611 v_max = np.max(window) 

612 

613 v_90 = v_min + 0.9 * (v_max - v_min) 

614 v_10 = v_min + 0.1 * (v_max - v_min) 

615 

616 idx_90 = np.where(window <= v_90)[0] 

617 idx_10 = np.where(window <= v_10)[0] 

618 

619 if len(idx_90) > 0 and len(idx_10) > 0: 619 ↛ 607line 619 didn't jump to line 607 because the condition on line 619 was always true

620 fall_time = (idx_10[-1] - idx_90[0]) * self._time_base 

621 fall_times.append(fall_time) 

622 

623 # Calculate average times 

624 rise_time = np.mean(rise_times) if rise_times else 0.0 

625 fall_time = np.mean(fall_times) if fall_times else 0.0 

626 

627 # Calculate slew rates 

628 slew_rate_rising = (0.8 * signal_range / rise_time) if rise_time > 0 else 0.0 

629 slew_rate_falling = (0.8 * signal_range / fall_time) if fall_time > 0 else 0.0 

630 

631 # Detect overshoot and undershoot 

632 overshoot_pct, undershoot_pct = self.detect_overshoot(trace) 

633 

634 # Detect ringing 

635 ringing = self.detect_ringing(trace) 

636 if ringing: 

637 ringing_freq, ringing_amp = ringing 

638 else: 

639 ringing_freq, ringing_amp = None, None 

640 

641 return TransitionMetrics( 

642 rise_time=float(rise_time), 

643 fall_time=float(fall_time), 

644 slew_rate_rising=float(slew_rate_rising), 

645 slew_rate_falling=float(slew_rate_falling), 

646 overshoot=float(overshoot_pct), 

647 undershoot=float(undershoot_pct), 

648 ringing_frequency=ringing_freq, 

649 ringing_amplitude=ringing_amp, 

650 ) 

651 

652 def detect_overshoot( 

653 self, trace: NDArray[np.float64], edges: list[Any] | None = None 

654 ) -> tuple[float, float]: 

655 """Detect and measure overshoot and undershoot. 

656 

657 Args: 

658 trace: Input signal trace. 

659 edges: Optional list of edge objects (not used in this implementation). 

660 

661 Returns: 

662 Tuple of (overshoot_percent, undershoot_percent). 

663 

664 Example: 

665 >>> overshoot, undershoot = analyzer.detect_overshoot(trace) 

666 """ 

667 trace = np.asarray(trace) 

668 

669 # Determine signal levels 

670 threshold = (np.max(trace) + np.min(trace)) / 2.0 

671 high_samples = trace[trace > threshold] 

672 low_samples = trace[trace <= threshold] 

673 

674 if len(high_samples) == 0 or len(low_samples) == 0: 

675 return 0.0, 0.0 

676 

677 # Expected levels (mean of stable regions) 

678 high_level = np.median(high_samples) 

679 low_level = np.median(low_samples) 

680 signal_swing = high_level - low_level 

681 

682 if signal_swing < 1e-6: 682 ↛ 683line 682 didn't jump to line 683 because the condition on line 682 was never true

683 return 0.0, 0.0 

684 

685 # Overshoot: how much signal exceeds high level 

686 max_val = np.max(trace) 

687 overshoot = max_val - high_level 

688 overshoot_pct = (overshoot / signal_swing) * 100.0 

689 

690 # Undershoot: how much signal goes below low level 

691 min_val = np.min(trace) 

692 undershoot = low_level - min_val 

693 undershoot_pct = (undershoot / signal_swing) * 100.0 

694 

695 return max(0.0, overshoot_pct), max(0.0, undershoot_pct) 

696 

697 def detect_ringing(self, trace: NDArray[np.float64]) -> tuple[float, float] | None: 

698 """Detect and characterize ringing (frequency, amplitude). 

699 

700 Uses FFT analysis to detect oscillations after edges that indicate ringing. 

701 

702 Args: 

703 trace: Input signal trace. 

704 

705 Returns: 

706 Tuple of (frequency_hz, amplitude_volts) if ringing detected, None otherwise. 

707 

708 Example: 

709 >>> ringing = analyzer.detect_ringing(trace) 

710 >>> if ringing: 

711 ... freq, amp = ringing 

712 """ 

713 trace = np.asarray(trace) 

714 

715 if len(trace) < 32: 

716 return None 

717 

718 # Detrend to remove DC offset 

719 detrended = trace - np.mean(trace) 

720 

721 # Apply FFT to detect high-frequency oscillations 

722 fft = np.fft.rfft(detrended) 

723 freqs = np.fft.rfftfreq(len(trace), self._time_base) 

724 power = np.abs(fft) ** 2 

725 

726 # Look for peaks in high-frequency range (above 1 MHz or 1% of sample rate) 

727 min_freq = max(1e6, self.sample_rate * 0.01) 

728 max_freq = self.sample_rate / 4.0 # Below Nyquist/2 for safety 

729 

730 freq_mask = (freqs > min_freq) & (freqs < max_freq) 

731 

732 if not np.any(freq_mask): 

733 return None 

734 

735 # Find dominant frequency in ringing range 

736 masked_power = power.copy() 

737 masked_power[~freq_mask] = 0 

738 

739 if np.max(masked_power) < np.max(power) * 0.1: 

740 # No significant high-frequency content 

741 return None 

742 

743 peak_idx = np.argmax(masked_power) 

744 ringing_freq = freqs[peak_idx] 

745 

746 # Estimate amplitude of ringing (very simplified) 

747 # Band-pass filter around detected frequency 

748 try: 

749 # Design bandpass filter 

750 bandwidth = ringing_freq * 0.2 # 20% bandwidth 

751 low = max(ringing_freq - bandwidth, 1.0) 

752 high = min(ringing_freq + bandwidth, self.sample_rate / 2.0 - 1.0) 

753 

754 if high > low: 

755 sos = scipy_signal.butter(4, [low, high], "band", fs=self.sample_rate, output="sos") 

756 filtered = scipy_signal.sosfilt(sos, detrended) 

757 ringing_amp = np.std(filtered) * 2.0 # Peak-to-peak estimate 

758 else: 

759 ringing_amp = 0.0 

760 except Exception: 

761 # If filtering fails, use simple estimate 

762 ringing_amp = np.std(detrended) * 0.5 

763 

764 # Only report if amplitude is significant 

765 if ringing_amp < np.std(trace) * 0.1: 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true

766 return None 

767 

768 return float(ringing_freq), float(ringing_amp) 

769 

770 def calculate_snr(self, trace: NDArray[np.float64]) -> float: 

771 """Calculate signal-to-noise ratio. 

772 

773 Computes SNR by separating signal from noise in stable regions. 

774 

775 Args: 

776 trace: Input signal trace. 

777 

778 Returns: 

779 SNR in decibels. 

780 

781 Example: 

782 >>> snr = analyzer.calculate_snr(trace) 

783 """ 

784 trace = np.asarray(trace) 

785 

786 # Separate into high and low regions 

787 threshold = (np.max(trace) + np.min(trace)) / 2.0 

788 high_samples = trace[trace > threshold] 

789 low_samples = trace[trace <= threshold] 

790 

791 if len(high_samples) == 0 or len(low_samples) == 0: 

792 return 0.0 

793 

794 # Signal power: difference between high and low levels 

795 signal_level = abs(np.mean(high_samples) - np.mean(low_samples)) 

796 

797 # Noise power: standard deviation in stable regions 

798 noise_high = np.std(high_samples) 

799 noise_low = np.std(low_samples) 

800 noise_level = (noise_high + noise_low) / 2.0 

801 

802 if noise_level < 1e-10: 

803 return 100.0 # Very high SNR 

804 

805 # SNR in dB 

806 snr = 20 * np.log10(signal_level / noise_level) 

807 

808 return float(snr) 

809 

810 

811# Convenience functions 

812 

813 

814def measure_noise_margins(trace: NDArray[np.float64], logic_family: str = "auto") -> NoiseMargins: 

815 """Measure noise margins. 

816 

817 Convenience function for quick noise margin measurement. 

818 

819 Args: 

820 trace: Input signal trace. 

821 logic_family: Logic family ('TTL', 'CMOS', 'LVTTL', 'LVCMOS', 'auto'). 

822 

823 Returns: 

824 NoiseMargins object. 

825 

826 Example: 

827 >>> margins = measure_noise_margins(trace, 'TTL') 

828 """ 

829 # Use a default sample rate for convenience 

830 sample_rate = 1e9 # 1 GHz default 

831 analyzer = SignalQualityAnalyzer(sample_rate, logic_family) 

832 logic_fam: Literal["ttl", "cmos", "lvttl", "lvcmos", "auto"] 

833 logic_family_lower = logic_family.lower() 

834 if logic_family_lower in ("ttl", "cmos", "lvttl", "lvcmos", "auto"): 834 ↛ 837line 834 didn't jump to line 837 because the condition on line 834 was always true

835 logic_fam = logic_family_lower # type: ignore[assignment] 

836 else: 

837 logic_fam = "auto" 

838 return analyzer.measure_noise_margins(trace, logic_fam) 

839 

840 

841def analyze_signal_integrity( 

842 trace: NDArray[np.float64], 

843 sample_rate: float, 

844 clock_trace: NDArray[np.float64] | None = None, 

845) -> SignalIntegrityReport: 

846 """Complete signal integrity analysis. 

847 

848 Convenience function for complete signal integrity analysis. 

849 

850 Args: 

851 trace: Input signal trace. 

852 sample_rate: Sample rate in Hz. 

853 clock_trace: Optional clock signal. 

854 

855 Returns: 

856 SignalIntegrityReport with complete analysis. 

857 

858 Example: 

859 >>> report = analyze_signal_integrity(trace, 100e6) 

860 """ 

861 analyzer = SignalQualityAnalyzer(sample_rate, logic_family="auto") 

862 result = analyzer.analyze(trace, clock_trace) 

863 # In full mode (no v_il/v_ih/vdd), this always returns SignalIntegrityReport 

864 assert isinstance(result, SignalIntegrityReport) 

865 return result 

866 

867 

868__all__ = [ 

869 "LOGIC_THRESHOLDS", 

870 "NoiseMargins", 

871 "SignalIntegrityReport", 

872 "SignalQualityAnalyzer", 

873 "SimpleQualityMetrics", 

874 "TransitionMetrics", 

875 "analyze_signal_integrity", 

876 "measure_noise_margins", 

877]