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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Signal quality and integrity analysis.
3This module provides comprehensive signal integrity analysis for digital signals,
4including noise margin measurements, transition characterization, overshoot/
5undershoot detection, and ringing analysis.
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"""
17from __future__ import annotations
19from dataclasses import dataclass
20from typing import TYPE_CHECKING, Any, Literal
22import numpy as np
23from scipy import signal as scipy_signal
25if TYPE_CHECKING:
26 from numpy.typing import NDArray
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}
38@dataclass
39class NoiseMargins:
40 """Noise margins for digital signal.
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 """
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
61@dataclass
62class TransitionMetrics:
63 """Metrics for signal transitions.
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 """
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
88@dataclass
89class SignalIntegrityReport:
90 """Complete signal integrity report.
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 """
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]
109@dataclass
110class SimpleQualityMetrics:
111 """Simplified quality metrics for test compatibility.
113 Provides a flat interface with direct attribute access for common metrics.
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 """
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
134class SignalQualityAnalyzer:
135 """Analyze digital signal quality and integrity.
137 Provides comprehensive signal integrity analysis including noise margins,
138 transition metrics, overshoot/undershoot, and ringing detection.
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
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.
151 Example:
152 >>> analyzer = SignalQualityAnalyzer(sample_rate=1e9, logic_family='TTL')
153 >>> report = analyzer.analyze(signal_trace)
154 """
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.
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).
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
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
192 self.logic_family = logic_family.lower() if logic_family else "auto"
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
201 def analyze(
202 self, trace: NDArray[np.float64], clock_trace: NDArray[np.float64] | None = None
203 ) -> Any:
204 """Perform complete signal integrity analysis.
206 Returns SimpleQualityMetrics in simple mode (when v_il/v_ih provided),
207 or SignalIntegrityReport in full mode.
209 Args:
210 trace: Input signal trace (analog voltage values).
211 clock_trace: Optional clock signal for synchronized analysis.
213 Returns:
214 SimpleQualityMetrics or SignalIntegrityReport with analysis results.
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)
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)
226 # Full mode: return SignalIntegrityReport
227 return self._analyze_full(trace, clock_trace)
229 def _analyze_simple(self, trace: NDArray[np.float64]) -> SimpleQualityMetrics:
230 """Simple analysis mode returning flat metrics.
232 Args:
233 trace: Input signal trace.
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)
245 # Separate high and low samples
246 high_samples = trace[trace > threshold]
247 low_samples = trace[trace <= threshold]
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
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
268 # Measure rise/fall times in samples
269 rise_time, fall_time = self._measure_rise_fall_samples(trace, threshold)
271 # Detect overshoot
272 has_overshoot, max_overshoot = self._detect_overshoot_simple(trace)
274 # Calculate duty cycle
275 duty_cycle = self._calculate_duty_cycle(trace, threshold)
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 )
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.
292 Args:
293 trace: Input signal trace.
294 threshold: Detection threshold.
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]
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
311 window = trace[edge_idx - window_size : edge_idx + window_size + 1]
312 v_min = np.min(window)
313 v_max = np.max(window)
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
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)
322 idx_10 = np.where(window >= v_10)[0]
323 idx_90 = np.where(window >= v_90)[0]
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)
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
337 window = trace[edge_idx - window_size : edge_idx + window_size + 1]
338 v_min = np.min(window)
339 v_max = np.max(window)
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
344 v_90 = v_min + 0.9 * (v_max - v_min)
345 v_10 = v_min + 0.1 * (v_max - v_min)
347 idx_90 = np.where(window <= v_90)[0]
348 idx_10 = np.where(window <= v_10)[0]
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)
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
358 return rise_time, fall_time
360 def _detect_overshoot_simple(self, trace: NDArray[np.float64]) -> tuple[bool, float]:
361 """Detect overshoot in simple mode.
363 Args:
364 trace: Input signal trace.
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]
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
375 high_median = np.median(high_samples)
376 max_val = np.max(trace)
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
391 return bool(has_overshoot), max(0.0, overshoot)
393 def _calculate_duty_cycle(self, trace: NDArray[np.float64], threshold: float) -> float:
394 """Calculate signal duty cycle.
396 Args:
397 trace: Input signal trace.
398 threshold: Detection threshold.
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
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)
412 return float(high_count) / float(len(trace))
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.
419 Args:
420 trace: Input signal trace.
421 clock_trace: Optional clock signal.
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)
434 # Measure transitions
435 transitions = self.measure_transitions(trace)
437 # Calculate SNR
438 snr_db = self.calculate_snr(trace)
440 # Assess overall quality and identify issues
441 issues = []
442 recommendations = []
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")
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")
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")
458 if transitions.undershoot > 20:
459 issues.append(f"Excessive undershoot: {transitions.undershoot:.1f}%")
460 recommendations.append("Check ground connections and reduce inductance")
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")
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")
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"
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 )
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.
498 Args:
499 trace: Input signal trace (analog voltage values).
500 logic_family: Logic family for threshold determination.
502 Returns:
503 NoiseMargins object with measured margins.
505 Example:
506 >>> margins = analyzer.measure_noise_margins(trace, logic_family='TTL')
507 """
508 trace = np.asarray(trace)
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
521 # Get thresholds for logic family
522 thresholds = LOGIC_THRESHOLDS.get(logic_family, LOGIC_THRESHOLDS["ttl"])
523 threshold = (thresholds["VIL"] + thresholds["VIH"]) / 2.0
525 # Separate high and low samples
526 high_samples = trace[trace > threshold]
527 low_samples = trace[trace <= threshold]
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
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
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 )
558 def measure_transitions(self, trace: NDArray[np.float64]) -> TransitionMetrics:
559 """Measure transition characteristics.
561 Analyzes rising and falling edges to measure rise/fall times,
562 slew rates, overshoot, undershoot, and ringing.
564 Args:
565 trace: Input signal trace (analog voltage values).
567 Returns:
568 TransitionMetrics object with transition measurements.
570 Example:
571 >>> metrics = analyzer.measure_transitions(trace)
572 """
573 trace = np.asarray(trace)
575 # Find threshold crossings
576 threshold = (np.max(trace) + np.min(trace)) / 2.0
577 signal_range = np.max(trace) - np.min(trace)
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]
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)
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)
597 # Find sample indices
598 idx_10 = np.where(window >= v_10)[0]
599 idx_90 = np.where(window >= v_90)[0]
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)
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)
613 v_90 = v_min + 0.9 * (v_max - v_min)
614 v_10 = v_min + 0.1 * (v_max - v_min)
616 idx_90 = np.where(window <= v_90)[0]
617 idx_10 = np.where(window <= v_10)[0]
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)
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
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
631 # Detect overshoot and undershoot
632 overshoot_pct, undershoot_pct = self.detect_overshoot(trace)
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
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 )
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.
657 Args:
658 trace: Input signal trace.
659 edges: Optional list of edge objects (not used in this implementation).
661 Returns:
662 Tuple of (overshoot_percent, undershoot_percent).
664 Example:
665 >>> overshoot, undershoot = analyzer.detect_overshoot(trace)
666 """
667 trace = np.asarray(trace)
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]
674 if len(high_samples) == 0 or len(low_samples) == 0:
675 return 0.0, 0.0
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
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
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
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
695 return max(0.0, overshoot_pct), max(0.0, undershoot_pct)
697 def detect_ringing(self, trace: NDArray[np.float64]) -> tuple[float, float] | None:
698 """Detect and characterize ringing (frequency, amplitude).
700 Uses FFT analysis to detect oscillations after edges that indicate ringing.
702 Args:
703 trace: Input signal trace.
705 Returns:
706 Tuple of (frequency_hz, amplitude_volts) if ringing detected, None otherwise.
708 Example:
709 >>> ringing = analyzer.detect_ringing(trace)
710 >>> if ringing:
711 ... freq, amp = ringing
712 """
713 trace = np.asarray(trace)
715 if len(trace) < 32:
716 return None
718 # Detrend to remove DC offset
719 detrended = trace - np.mean(trace)
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
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
730 freq_mask = (freqs > min_freq) & (freqs < max_freq)
732 if not np.any(freq_mask):
733 return None
735 # Find dominant frequency in ringing range
736 masked_power = power.copy()
737 masked_power[~freq_mask] = 0
739 if np.max(masked_power) < np.max(power) * 0.1:
740 # No significant high-frequency content
741 return None
743 peak_idx = np.argmax(masked_power)
744 ringing_freq = freqs[peak_idx]
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)
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
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
768 return float(ringing_freq), float(ringing_amp)
770 def calculate_snr(self, trace: NDArray[np.float64]) -> float:
771 """Calculate signal-to-noise ratio.
773 Computes SNR by separating signal from noise in stable regions.
775 Args:
776 trace: Input signal trace.
778 Returns:
779 SNR in decibels.
781 Example:
782 >>> snr = analyzer.calculate_snr(trace)
783 """
784 trace = np.asarray(trace)
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]
791 if len(high_samples) == 0 or len(low_samples) == 0:
792 return 0.0
794 # Signal power: difference between high and low levels
795 signal_level = abs(np.mean(high_samples) - np.mean(low_samples))
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
802 if noise_level < 1e-10:
803 return 100.0 # Very high SNR
805 # SNR in dB
806 snr = 20 * np.log10(signal_level / noise_level)
808 return float(snr)
811# Convenience functions
814def measure_noise_margins(trace: NDArray[np.float64], logic_family: str = "auto") -> NoiseMargins:
815 """Measure noise margins.
817 Convenience function for quick noise margin measurement.
819 Args:
820 trace: Input signal trace.
821 logic_family: Logic family ('TTL', 'CMOS', 'LVTTL', 'LVCMOS', 'auto').
823 Returns:
824 NoiseMargins object.
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)
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.
848 Convenience function for complete signal integrity analysis.
850 Args:
851 trace: Input signal trace.
852 sample_rate: Sample rate in Hz.
853 clock_trace: Optional clock signal.
855 Returns:
856 SignalIntegrityReport with complete analysis.
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
868__all__ = [
869 "LOGIC_THRESHOLDS",
870 "NoiseMargins",
871 "SignalIntegrityReport",
872 "SignalQualityAnalyzer",
873 "SimpleQualityMetrics",
874 "TransitionMetrics",
875 "analyze_signal_integrity",
876 "measure_noise_margins",
877]