Coverage for src / tracekit / discovery / signal_detector.py: 86%

287 statements  

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

1"""Automatic signal characterization and type detection. 

2 

3This module provides intelligent signal type detection, extracting 

4characteristics without requiring user expertise. 

5 

6 

7Example: 

8 >>> from tracekit.discovery import characterize_signal 

9 >>> result = characterize_signal(trace) 

10 >>> print(f"{result.signal_type}: {result.confidence:.2f}") 

11 UART: 0.94 

12 

13References: 

14 IEEE 181-2011: Transitional Waveform Definitions 

15""" 

16 

17from __future__ import annotations 

18 

19from dataclasses import dataclass, field 

20from typing import TYPE_CHECKING, Any, Literal 

21 

22import numpy as np 

23 

24from tracekit.analyzers.statistics.basic import basic_stats 

25from tracekit.core.types import DigitalTrace, WaveformTrace 

26 

27if TYPE_CHECKING: 

28 from numpy.typing import NDArray 

29 

30SignalType = Literal["digital", "analog", "pwm", "uart", "spi", "i2c", "unknown"] 

31 

32 

33@dataclass 

34class SignalCharacterization: 

35 """Result of automatic signal characterization. 

36 

37 Contains detected signal type, confidence score, and extracted parameters. 

38 

39 Attributes: 

40 signal_type: Detected signal type. 

41 confidence: Confidence score (0.0-1.0). 

42 voltage_low: Low voltage level in volts. 

43 voltage_high: High voltage level in volts. 

44 frequency_hz: Dominant frequency in Hz. 

45 parameters: Additional signal-specific parameters. 

46 quality_metrics: Signal quality measurements. 

47 alternatives: Alternative signal type suggestions. 

48 

49 Example: 

50 >>> result = characterize_signal(trace) 

51 >>> if result.confidence >= 0.8: 

52 ... print(f"High confidence: {result.signal_type}") 

53 """ 

54 

55 signal_type: SignalType 

56 confidence: float 

57 voltage_low: float 

58 voltage_high: float 

59 frequency_hz: float 

60 parameters: dict[str, Any] = field(default_factory=dict) 

61 quality_metrics: dict[str, float] = field(default_factory=dict) 

62 alternatives: list[tuple[SignalType, float]] = field(default_factory=list) 

63 

64 

65def characterize_signal( 

66 trace: WaveformTrace | DigitalTrace, 

67 *, 

68 confidence_threshold: float = 0.6, 

69 include_alternatives: bool = False, 

70 min_alternatives: int = 3, 

71) -> SignalCharacterization: 

72 """Automatically characterize signal type and properties. 

73 

74 Analyzes waveform to detect signal type (digital, analog, PWM, UART, SPI, I2C) 

75 and extract key parameters without requiring manual configuration. 

76 

77 Args: 

78 trace: Input waveform or digital trace. 

79 confidence_threshold: Minimum confidence for primary detection (0.0-1.0). 

80 include_alternatives: Whether to include alternative suggestions. 

81 min_alternatives: Minimum number of alternatives when confidence is low. 

82 

83 Returns: 

84 SignalCharacterization with detected type and parameters. 

85 

86 Raises: 

87 ValueError: If trace is empty or invalid. 

88 

89 Example: 

90 >>> result = characterize_signal(trace, confidence_threshold=0.8) 

91 >>> print(f"Signal: {result.signal_type}") 

92 >>> print(f"Confidence: {result.confidence:.2f}") 

93 >>> print(f"Voltage: {result.voltage_low:.2f}V to {result.voltage_high:.2f}V") 

94 Signal: UART 

95 Confidence: 0.94 

96 Voltage: 0.02V to 3.28V 

97 

98 References: 

99 DISC-001: Automatic Signal Characterization 

100 """ 

101 # Validate input 

102 if len(trace) == 0: 

103 raise ValueError("Cannot characterize empty trace") 

104 

105 # Get signal data 

106 if isinstance(trace, WaveformTrace): 

107 data = trace.data 

108 sample_rate = trace.metadata.sample_rate 

109 is_analog = True 

110 else: 

111 data = trace.data.astype(np.float64) 

112 sample_rate = trace.metadata.sample_rate 

113 is_analog = False 

114 

115 # Compute basic statistics 

116 stats = basic_stats(data) 

117 

118 # Determine voltage levels using percentiles to be robust to noise 

119 # Use 5th and 95th percentiles to ignore outliers from noise 

120 voltage_low = float(np.percentile(data, 5)) 

121 voltage_high = float(np.percentile(data, 95)) 

122 voltage_swing = voltage_high - voltage_low 

123 

124 # Analyze signal characteristics 

125 candidates: dict[SignalType, float] = {} 

126 

127 # Check for digital signal (bimodal distribution) 

128 digital_confidence = _detect_digital(data, voltage_swing) 

129 candidates["digital"] = digital_confidence 

130 

131 # Check for analog signal (continuous distribution) 

132 analog_confidence = _detect_analog(data, voltage_swing, is_analog) 

133 candidates["analog"] = analog_confidence 

134 

135 # Check for PWM (periodic square wave with varying duty cycle) 

136 pwm_confidence = _detect_pwm(data, sample_rate, voltage_swing) 

137 candidates["pwm"] = pwm_confidence 

138 

139 # Check for UART (asynchronous serial with start/stop bits) 

140 uart_confidence = _detect_uart(data, sample_rate, voltage_swing) 

141 candidates["uart"] = uart_confidence 

142 

143 # Check for SPI (synchronous with clock and data) 

144 spi_confidence = _detect_spi(data, sample_rate, voltage_swing) 

145 candidates["spi"] = spi_confidence 

146 

147 # Check for I2C (two-wire with specific patterns) 

148 i2c_confidence = _detect_i2c(data, sample_rate, voltage_swing) 

149 candidates["i2c"] = i2c_confidence 

150 

151 # Select best match 

152 sorted_candidates = sorted(candidates.items(), key=lambda x: x[1], reverse=True) 

153 best_type, best_confidence = sorted_candidates[0] 

154 

155 # If confidence is too low, mark as unknown 

156 if best_confidence < 0.5: 

157 best_type = "unknown" 

158 

159 # If analog won but digital score is meaningful, prefer digital/unknown 

160 # This handles noisy digital signals that look analog-ish 

161 if best_type == "analog": 161 ↛ 163line 161 didn't jump to line 163 because the condition on line 161 was never true

162 # Check if any protocol detector had reasonable confidence 

163 protocol_confidence = max( 

164 candidates.get("uart", 0), 

165 candidates.get("spi", 0), 

166 candidates.get("pwm", 0), 

167 ) 

168 

169 # If digital or protocol detectors have some confidence, don't call it purely analog 

170 if digital_confidence > 0.3 or protocol_confidence > 0.2: 

171 # Signal has digital characteristics - don't call it analog 

172 if protocol_confidence > 0.3: 

173 best_type = "unknown" # Too noisy/ambiguous to classify as specific protocol 

174 best_confidence = protocol_confidence 

175 elif digital_confidence > 0.4: 

176 best_type = "digital" # Generic digital signal 

177 best_confidence = digital_confidence 

178 else: 

179 best_type = "unknown" # Too ambiguous 

180 best_confidence = max(digital_confidence, protocol_confidence, analog_confidence) 

181 

182 # Estimate dominant frequency 

183 frequency_hz = _estimate_frequency(data, sample_rate) 

184 

185 # Extract type-specific parameters 

186 parameters = _extract_parameters(best_type, data, sample_rate, voltage_low, voltage_high) 

187 

188 # Calculate quality metrics with improved noise estimation 

189 noise_level = _estimate_noise_level(data, voltage_low, voltage_high, digital_confidence) 

190 quality_metrics = { 

191 "snr_db": _estimate_snr(data, stats), 

192 "jitter_ns": _estimate_jitter(data, sample_rate) * 1e9, 

193 "noise_level": noise_level, 

194 } 

195 

196 # Prepare alternatives 

197 alternatives: list[tuple[SignalType, float]] = [] 

198 if include_alternatives or best_confidence < confidence_threshold: 

199 # Include top alternatives (excluding the winner) 

200 for sig_type, conf in sorted_candidates[1:]: 

201 if len(alternatives) >= min_alternatives: 

202 break 

203 if conf >= 0.3: # Only include reasonable alternatives 

204 alternatives.append((sig_type, conf)) 

205 

206 return SignalCharacterization( 

207 signal_type=best_type, 

208 confidence=round(best_confidence, 2), 

209 voltage_low=voltage_low, 

210 voltage_high=voltage_high, 

211 frequency_hz=frequency_hz, 

212 parameters=parameters, 

213 quality_metrics=quality_metrics, 

214 alternatives=alternatives, 

215 ) 

216 

217 

218def _estimate_noise_level( 

219 data: NDArray[np.floating[Any]], 

220 voltage_low: float, 

221 voltage_high: float, 

222 digital_confidence: float, 

223) -> float: 

224 """Estimate noise level in signal. 

225 

226 For digital signals, measures deviation from ideal logic levels. 

227 For analog signals, uses normalized std. 

228 

229 Args: 

230 data: Signal data array. 

231 voltage_low: Low voltage level. 

232 voltage_high: High voltage level. 

233 digital_confidence: Confidence that signal is digital. 

234 

235 Returns: 

236 Noise level as fraction of voltage swing (0.0-1.0). 

237 """ 

238 voltage_swing = voltage_high - voltage_low 

239 if voltage_swing == 0: 

240 return 0.0 

241 

242 # For digital signals, estimate noise from deviation around logic levels 

243 if digital_confidence >= 0.5: 243 ↛ 266line 243 didn't jump to line 266 because the condition on line 243 was always true

244 threshold = (voltage_high + voltage_low) / 2 

245 low_samples = data[data < threshold] 

246 high_samples = data[data >= threshold] 

247 

248 noise_estimates = [] 

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

250 # Deviation from the low level 

251 low_level = np.min(data) 

252 low_noise = np.std(low_samples - low_level) 

253 noise_estimates.append(low_noise) 

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

255 # Deviation from the high level 

256 high_level = np.max(data) 

257 high_noise = np.std(high_samples - high_level) 

258 noise_estimates.append(high_noise) 

259 

260 if noise_estimates: 260 ↛ 266line 260 didn't jump to line 266 because the condition on line 260 was always true

261 avg_noise = np.mean(noise_estimates) 

262 return float(avg_noise / voltage_swing) 

263 

264 # For analog signals, use std as fraction of range 

265 # But cap it at 0.5 to indicate high variability, not noise 

266 std_noise = float(np.std(data) / voltage_swing) 

267 return min(0.5, std_noise) 

268 

269 

270def _detect_digital(data: NDArray[np.floating[Any]], voltage_swing: float) -> float: 

271 """Detect digital signal characteristics. 

272 

273 Args: 

274 data: Signal data array. 

275 voltage_swing: Peak-to-peak voltage swing. 

276 

277 Returns: 

278 Confidence score (0.0-1.0). 

279 """ 

280 if voltage_swing == 0: 

281 return 0.0 

282 

283 # Check for bimodal distribution (two distinct levels) 

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

285 

286 # Normalize histogram 

287 hist = hist / np.sum(hist) 

288 

289 # Find peaks in histogram (should have 2 for digital) 

290 peak_threshold = np.max(hist) * 0.3 

291 peaks = np.where(hist > peak_threshold)[0] 

292 

293 if len(peaks) < 2: 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 return 0.3 # Low confidence 

295 

296 # Check if peaks are well separated 

297 peak_separation = (bin_edges[peaks[-1]] - bin_edges[peaks[0]]) / voltage_swing 

298 

299 # Digital signals spend most time at rails 

300 edge_bins = hist[:5].sum() + hist[-5:].sum() 

301 

302 # Combine factors 

303 bimodal_score = min(1.0, len(peaks) / 2.0) # Closer to 2 peaks is better 

304 separation_score = min(1.0, peak_separation) 

305 rail_score = min(1.0, edge_bins * 2) # More time at rails is better 

306 

307 confidence = bimodal_score * 0.4 + separation_score * 0.3 + rail_score * 0.3 

308 return min(0.95, confidence) # type: ignore[no-any-return] 

309 

310 

311def _detect_analog(data: NDArray[np.floating[Any]], voltage_swing: float, is_analog: bool) -> float: 

312 """Detect analog signal characteristics. 

313 

314 Args: 

315 data: Signal data array. 

316 voltage_swing: Peak-to-peak voltage swing. 

317 is_analog: Whether input is from analog trace. 

318 

319 Returns: 

320 Confidence score (0.0-1.0). 

321 """ 

322 if voltage_swing == 0: 

323 return 0.0 

324 

325 # Check if signal has strong digital characteristics first 

326 # If it does, this is NOT analog - reduce confidence significantly 

327 digital_confidence = _detect_digital(data, voltage_swing) 

328 if digital_confidence >= 0.6: 328 ↛ 333line 328 didn't jump to line 333 because the condition on line 328 was always true

329 # Strong digital signal - very low analog confidence 

330 return max(0.0, 0.4 - digital_confidence * 0.3) 

331 

332 # Analog signals have continuous distribution 

333 hist, _ = np.histogram(data, bins=50) 

334 hist = hist / np.sum(hist) 

335 

336 # Check for uniform or Gaussian-like distribution 

337 uniform_score = 1.0 - np.std(hist) 

338 

339 # Check for smooth transitions (not many abrupt changes) 

340 diff = np.diff(data) 

341 smooth_score = 1.0 - min(1.0, np.mean(np.abs(diff)) / voltage_swing) 

342 

343 # Analog traces get boost 

344 source_score = 1.0 if is_analog else 0.5 # Reduced from 0.7 

345 

346 confidence = uniform_score * 0.4 + smooth_score * 0.3 + source_score * 0.3 

347 

348 # Further reduce if there's any digital characteristics 

349 if digital_confidence > 0.3: 

350 confidence *= 1.0 - digital_confidence * 0.5 

351 

352 return min(0.9, confidence) # type: ignore[no-any-return] 

353 

354 

355def _detect_pwm( 

356 data: NDArray[np.floating[Any]], 

357 sample_rate: float, 

358 voltage_swing: float, 

359) -> float: 

360 """Detect PWM signal characteristics. 

361 

362 Args: 

363 data: Signal data array. 

364 sample_rate: Sample rate in Hz. 

365 voltage_swing: Peak-to-peak voltage swing. 

366 

367 Returns: 

368 Confidence score (0.0-1.0). 

369 """ 

370 if voltage_swing == 0 or len(data) < 100: 

371 return 0.0 

372 

373 # PWM should have digital levels 

374 digital_score = _detect_digital(data, voltage_swing) 

375 

376 if digital_score < 0.5: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 return 0.0 

378 

379 # Threshold signal 

380 threshold = (np.max(data) + np.min(data)) / 2 

381 digital = data > threshold 

382 

383 # Find transitions 

384 transitions = np.diff(digital.astype(int)) 

385 rising = np.where(transitions > 0)[0] 

386 falling = np.where(transitions < 0)[0] 

387 

388 if len(rising) < 3 or len(falling) < 3: 

389 return 0.0 

390 

391 # Check for periodic transitions 

392 rising_periods = np.diff(rising) 

393 period_std = np.std(rising_periods) if len(rising_periods) > 0 else 0 

394 mean_period = np.mean(rising_periods) if len(rising_periods) > 0 else 1 

395 

396 periodicity_score = 1.0 - min(1.0, period_std / (mean_period + 1e-10)) 

397 

398 # PWM should have varying duty cycle 

399 duty_cycles = [] 

400 for i in range(min(len(rising), len(falling))): 

401 if i < len(falling) and falling[i] > rising[i]: 

402 duty = (falling[i] - rising[i]) / (mean_period + 1e-10) 

403 duty_cycles.append(duty) 

404 

405 duty_variation = np.std(duty_cycles) if len(duty_cycles) > 1 else 0 

406 variation_score = min(1.0, duty_variation * 5) # Some variation expected 

407 

408 confidence = digital_score * 0.3 + periodicity_score * 0.5 + variation_score * 0.2 

409 

410 # Boost if strong periodicity with variation (classic PWM signature) 

411 if periodicity_score > 0.7 and variation_score > 0.3: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 confidence = min(0.94, confidence * 1.1) 

413 

414 return min(0.94, confidence) 

415 

416 

417def _detect_uart( 

418 data: NDArray[np.floating[Any]], sample_rate: float, voltage_swing: float 

419) -> float: 

420 """Detect UART signal characteristics. 

421 

422 Args: 

423 data: Signal data array. 

424 sample_rate: Sample rate in Hz. 

425 voltage_swing: Peak-to-peak voltage swing. 

426 

427 Returns: 

428 Confidence score (0.0-1.0). 

429 """ 

430 if voltage_swing == 0 or len(data) < 200: 

431 return 0.0 

432 

433 # UART should be digital 

434 digital_score = _detect_digital(data, voltage_swing) 

435 if digital_score < 0.7: # Strict threshold for UART 

436 return 0.0 

437 

438 # Check for bimodal (two-level) distribution 

439 # UART should have primarily two voltage levels, not continuous values 

440 hist, _ = np.histogram(data, bins=50) 

441 hist = hist / np.sum(hist) 

442 

443 # Count significant histogram bins (>5% of samples) 

444 significant_bins = np.sum(hist > 0.05) 

445 

446 # UART should have at most 2-4 significant bins (low and high with some noise) 

447 # Sine wave will have many bins 

448 if significant_bins > 6: 

449 return 0.0 

450 

451 # Threshold signal 

452 threshold = (np.max(data) + np.min(data)) / 2 

453 digital = data > threshold 

454 

455 # Find edges 

456 transitions = np.diff(digital.astype(int)) 

457 edges = np.where(np.abs(transitions) > 0)[0] 

458 

459 if len(edges) < 10: 

460 return 0.0 

461 

462 # UART has consistent bit timing 

463 edge_intervals = np.diff(edges) 

464 

465 # Look for common baud rates 

466 common_bauds = [9600, 19200, 38400, 57600, 115200] 

467 baud_scores = [] 

468 

469 for baud in common_bauds: 

470 bit_period_samples = sample_rate / baud 

471 # Count edges that align with this baud rate (stricter alignment) 

472 aligned = np.sum(np.abs(edge_intervals % bit_period_samples) < bit_period_samples * 0.15) 

473 baud_scores.append(aligned / len(edge_intervals)) 

474 

475 timing_score = max(baud_scores) if baud_scores else 0.0 

476 

477 # UART requires strong timing alignment 

478 if timing_score < 0.4: 

479 return 0.0 

480 

481 # UART idles high typically 

482 idle_score = np.mean(digital[-100:]) 

483 

484 confidence = digital_score * 0.3 + timing_score * 0.6 + idle_score * 0.1 

485 

486 # Boost confidence if timing alignment is strong 

487 if timing_score > 0.7: 

488 confidence = min(0.96, confidence * 1.1) 

489 

490 return min(0.96, confidence) # type: ignore[no-any-return] 

491 

492 

493def _detect_spi( 

494 data: NDArray[np.floating[Any]], 

495 sample_rate: float, 

496 voltage_swing: float, 

497) -> float: 

498 """Detect SPI signal characteristics. 

499 

500 Args: 

501 data: Signal data array. 

502 sample_rate: Sample rate in Hz. 

503 voltage_swing: Peak-to-peak voltage swing. 

504 

505 Returns: 

506 Confidence score (0.0-1.0). 

507 """ 

508 if voltage_swing == 0 or len(data) < 200: 

509 return 0.0 

510 

511 # SPI should be digital 

512 digital_score = _detect_digital(data, voltage_swing) 

513 if digital_score < 0.6: 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true

514 return 0.0 

515 

516 # Threshold signal 

517 threshold = (np.max(data) + np.min(data)) / 2 

518 digital = data > threshold 

519 

520 # Find edges 

521 transitions = np.diff(digital.astype(int)) 

522 edges = np.where(np.abs(transitions) > 0)[0] 

523 

524 if len(edges) < 20: 

525 return 0.0 

526 

527 # SPI typically has bursts of regular clock transitions 

528 edge_intervals = np.diff(edges) 

529 

530 # Check for consistent clock period 

531 median_interval = np.median(edge_intervals) 

532 interval_std = np.std(edge_intervals) 

533 consistency_score = 1.0 - min(1.0, interval_std / (median_interval + 1e-10)) 

534 

535 # SPI has many transitions (clock toggling) 

536 transition_density = len(edges) / len(data) 

537 density_score = min(1.0, transition_density * 20) 

538 

539 confidence = digital_score * 0.3 + consistency_score * 0.5 + density_score * 0.2 

540 

541 # Boost confidence if consistency is very high (strong clock signal) 

542 if consistency_score > 0.8 and density_score > 0.5: 

543 confidence = min(0.95, confidence * 1.15) 

544 

545 return min(0.95, confidence) # type: ignore[no-any-return] 

546 

547 

548def _detect_i2c( 

549 data: NDArray[np.floating[Any]], 

550 sample_rate: float, 

551 voltage_swing: float, 

552) -> float: 

553 """Detect I2C signal characteristics. 

554 

555 Args: 

556 data: Signal data array. 

557 sample_rate: Sample rate in Hz. 

558 voltage_swing: Peak-to-peak voltage swing. 

559 

560 Returns: 

561 Confidence score (0.0-1.0). 

562 """ 

563 # I2C detection requires both SDA and SCL, single channel is limited 

564 # This is a placeholder that gives low confidence 

565 digital_score = _detect_digital(data, voltage_swing) 

566 return min(0.6, digital_score * 0.5) 

567 

568 

569def _estimate_frequency(data: NDArray[np.floating[Any]], sample_rate: float) -> float: 

570 """Estimate dominant frequency in signal. 

571 

572 Args: 

573 data: Signal data array. 

574 sample_rate: Sample rate in Hz. 

575 

576 Returns: 

577 Dominant frequency in Hz. 

578 """ 

579 if len(data) < 10: 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true

580 return 0.0 

581 

582 # Simple zero-crossing based frequency estimate 

583 mean_val = np.mean(data) 

584 crossings = np.where(np.diff(np.sign(data - mean_val)) != 0)[0] 

585 

586 if len(crossings) < 2: 

587 return 0.0 

588 

589 # Average period between crossings (half periods) 

590 avg_half_period = np.mean(np.diff(crossings)) 

591 period_samples = avg_half_period * 2 

592 

593 frequency = sample_rate / period_samples if period_samples > 0 else 0.0 

594 return frequency 

595 

596 

597def _estimate_snr(data: NDArray[np.floating[Any]], stats: dict[str, float]) -> float: 

598 """Estimate signal-to-noise ratio. 

599 

600 Args: 

601 data: Signal data array. 

602 stats: Basic statistics dictionary. 

603 

604 Returns: 

605 Estimated SNR in dB. 

606 """ 

607 signal_power = stats["mean"] ** 2 

608 noise_power = stats["variance"] 

609 

610 if noise_power == 0: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true

611 return 100.0 # Very high SNR 

612 

613 snr = signal_power / noise_power 

614 snr_db = 10 * np.log10(snr) if snr > 0 else 0.0 

615 

616 return max(0.0, min(100.0, snr_db)) 

617 

618 

619def _estimate_jitter(data: NDArray[np.floating[Any]], sample_rate: float) -> float: 

620 """Estimate timing jitter. 

621 

622 Args: 

623 data: Signal data array. 

624 sample_rate: Sample rate in Hz. 

625 

626 Returns: 

627 Estimated jitter in seconds. 

628 """ 

629 # Simple edge-to-edge jitter estimate 

630 threshold = (np.max(data) + np.min(data)) / 2 

631 digital = data > threshold 

632 edges = np.where(np.diff(digital.astype(int)) != 0)[0] 

633 

634 if len(edges) < 3: 

635 return 0.0 

636 

637 edge_intervals = np.diff(edges) 

638 jitter_samples = np.std(edge_intervals) 

639 jitter_seconds = jitter_samples / sample_rate 

640 

641 return jitter_seconds # type: ignore[no-any-return] 

642 

643 

644def _extract_parameters( 

645 signal_type: SignalType, 

646 data: NDArray[np.floating[Any]], 

647 sample_rate: float, 

648 voltage_low: float, 

649 voltage_high: float, 

650) -> dict[str, Any]: 

651 """Extract signal-specific parameters. 

652 

653 Args: 

654 signal_type: Detected signal type. 

655 data: Signal data array. 

656 sample_rate: Sample rate in Hz. 

657 voltage_low: Low voltage level. 

658 voltage_high: High voltage level. 

659 

660 Returns: 

661 Dictionary of parameters specific to signal type. 

662 """ 

663 params: dict[str, Any] = {} 

664 

665 if signal_type in ("digital", "uart", "spi", "i2c"): 

666 # Add logic level parameters 

667 logic_family = _guess_logic_family(voltage_low, voltage_high) 

668 if logic_family != "Unknown": 

669 params["logic_family"] = logic_family 

670 

671 if signal_type == "pwm": 671 ↛ 673line 671 didn't jump to line 673 because the condition on line 671 was never true

672 # Calculate duty cycle 

673 threshold = (voltage_high + voltage_low) / 2 

674 digital = data > threshold 

675 duty_cycle = np.mean(digital) 

676 params["duty_cycle"] = round(duty_cycle, 3) 

677 

678 if signal_type == "uart": 

679 # Estimate baud rate 

680 params["estimated_baud"] = _estimate_baud_rate(data, sample_rate) 

681 

682 return params 

683 

684 

685def _guess_logic_family(voltage_low: float, voltage_high: float) -> str: 

686 """Guess logic family from voltage levels. 

687 

688 Args: 

689 voltage_low: Low voltage level in volts. 

690 voltage_high: High voltage level in volts. 

691 

692 Returns: 

693 Logic family name. 

694 """ 

695 voltage_swing = voltage_high - voltage_low 

696 

697 # Match to closest standard voltage level 

698 # This handles noise better than fixed ranges 

699 standard_levels = [ 

700 (1.8, "1.8V LVCMOS"), 

701 (3.3, "3.3V LVCMOS"), 

702 (5.0, "5V TTL/CMOS"), 

703 ] 

704 

705 # Find closest match 

706 closest_diff = float("inf") 

707 second_closest_diff = float("inf") 

708 closest_family = "Unknown" 

709 closest_level = 0.0 

710 

711 for level, family in standard_levels: 

712 diff = abs(voltage_swing - level) 

713 if diff < closest_diff: 

714 second_closest_diff = closest_diff 

715 closest_diff = diff 

716 closest_family = family 

717 closest_level = level 

718 elif diff < second_closest_diff: 

719 second_closest_diff = diff 

720 

721 # Only return a match if: 

722 # 1. Closest match is within 50% tolerance 

723 # 2. AND it's significantly closer than second-best (not ambiguous) 

724 if closest_diff == float("inf") or closest_diff > closest_level * 0.5: 

725 return "Unknown" 

726 

727 # Check if ambiguous (second closest is also pretty close) 

728 # If second-best is within 20% more distance, it's too ambiguous 

729 if second_closest_diff < closest_diff * 1.2: 

730 return "Unknown" # Too ambiguous 

731 

732 return closest_family 

733 

734 

735def _estimate_baud_rate(data: NDArray[np.floating[Any]], sample_rate: float) -> int: 

736 """Estimate UART baud rate. 

737 

738 Args: 

739 data: Signal data array. 

740 sample_rate: Sample rate in Hz. 

741 

742 Returns: 

743 Estimated baud rate in bps. 

744 """ 

745 # Find bit period from edge intervals 

746 threshold = (np.max(data) + np.min(data)) / 2 

747 digital = data > threshold 

748 edges = np.where(np.diff(digital.astype(int)) != 0)[0] 

749 

750 if len(edges) < 10: 750 ↛ 751line 750 didn't jump to line 751 because the condition on line 750 was never true

751 return 9600 # Default fallback 

752 

753 edge_intervals = np.diff(edges) 

754 # Use median to be robust to outliers 

755 median_interval = np.median(edge_intervals) 

756 estimated_baud = int(sample_rate / median_interval) 

757 

758 # Snap to common baud rates 

759 common_bauds = [9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600] 

760 closest_baud = min(common_bauds, key=lambda x: abs(x - estimated_baud)) 

761 

762 return closest_baud 

763 

764 

765__all__ = [ 

766 "SignalCharacterization", 

767 "SignalType", 

768 "characterize_signal", 

769]