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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Automatic signal characterization and type detection.
3This module provides intelligent signal type detection, extracting
4characteristics without requiring user expertise.
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
13References:
14 IEEE 181-2011: Transitional Waveform Definitions
15"""
17from __future__ import annotations
19from dataclasses import dataclass, field
20from typing import TYPE_CHECKING, Any, Literal
22import numpy as np
24from tracekit.analyzers.statistics.basic import basic_stats
25from tracekit.core.types import DigitalTrace, WaveformTrace
27if TYPE_CHECKING:
28 from numpy.typing import NDArray
30SignalType = Literal["digital", "analog", "pwm", "uart", "spi", "i2c", "unknown"]
33@dataclass
34class SignalCharacterization:
35 """Result of automatic signal characterization.
37 Contains detected signal type, confidence score, and extracted parameters.
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.
49 Example:
50 >>> result = characterize_signal(trace)
51 >>> if result.confidence >= 0.8:
52 ... print(f"High confidence: {result.signal_type}")
53 """
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)
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.
74 Analyzes waveform to detect signal type (digital, analog, PWM, UART, SPI, I2C)
75 and extract key parameters without requiring manual configuration.
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.
83 Returns:
84 SignalCharacterization with detected type and parameters.
86 Raises:
87 ValueError: If trace is empty or invalid.
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
98 References:
99 DISC-001: Automatic Signal Characterization
100 """
101 # Validate input
102 if len(trace) == 0:
103 raise ValueError("Cannot characterize empty trace")
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
115 # Compute basic statistics
116 stats = basic_stats(data)
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
124 # Analyze signal characteristics
125 candidates: dict[SignalType, float] = {}
127 # Check for digital signal (bimodal distribution)
128 digital_confidence = _detect_digital(data, voltage_swing)
129 candidates["digital"] = digital_confidence
131 # Check for analog signal (continuous distribution)
132 analog_confidence = _detect_analog(data, voltage_swing, is_analog)
133 candidates["analog"] = analog_confidence
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
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
143 # Check for SPI (synchronous with clock and data)
144 spi_confidence = _detect_spi(data, sample_rate, voltage_swing)
145 candidates["spi"] = spi_confidence
147 # Check for I2C (two-wire with specific patterns)
148 i2c_confidence = _detect_i2c(data, sample_rate, voltage_swing)
149 candidates["i2c"] = i2c_confidence
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]
155 # If confidence is too low, mark as unknown
156 if best_confidence < 0.5:
157 best_type = "unknown"
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 )
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)
182 # Estimate dominant frequency
183 frequency_hz = _estimate_frequency(data, sample_rate)
185 # Extract type-specific parameters
186 parameters = _extract_parameters(best_type, data, sample_rate, voltage_low, voltage_high)
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 }
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))
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 )
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.
226 For digital signals, measures deviation from ideal logic levels.
227 For analog signals, uses normalized std.
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.
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
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]
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)
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)
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)
270def _detect_digital(data: NDArray[np.floating[Any]], voltage_swing: float) -> float:
271 """Detect digital signal characteristics.
273 Args:
274 data: Signal data array.
275 voltage_swing: Peak-to-peak voltage swing.
277 Returns:
278 Confidence score (0.0-1.0).
279 """
280 if voltage_swing == 0:
281 return 0.0
283 # Check for bimodal distribution (two distinct levels)
284 hist, bin_edges = np.histogram(data, bins=50)
286 # Normalize histogram
287 hist = hist / np.sum(hist)
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]
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
296 # Check if peaks are well separated
297 peak_separation = (bin_edges[peaks[-1]] - bin_edges[peaks[0]]) / voltage_swing
299 # Digital signals spend most time at rails
300 edge_bins = hist[:5].sum() + hist[-5:].sum()
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
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]
311def _detect_analog(data: NDArray[np.floating[Any]], voltage_swing: float, is_analog: bool) -> float:
312 """Detect analog signal characteristics.
314 Args:
315 data: Signal data array.
316 voltage_swing: Peak-to-peak voltage swing.
317 is_analog: Whether input is from analog trace.
319 Returns:
320 Confidence score (0.0-1.0).
321 """
322 if voltage_swing == 0:
323 return 0.0
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)
332 # Analog signals have continuous distribution
333 hist, _ = np.histogram(data, bins=50)
334 hist = hist / np.sum(hist)
336 # Check for uniform or Gaussian-like distribution
337 uniform_score = 1.0 - np.std(hist)
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)
343 # Analog traces get boost
344 source_score = 1.0 if is_analog else 0.5 # Reduced from 0.7
346 confidence = uniform_score * 0.4 + smooth_score * 0.3 + source_score * 0.3
348 # Further reduce if there's any digital characteristics
349 if digital_confidence > 0.3:
350 confidence *= 1.0 - digital_confidence * 0.5
352 return min(0.9, confidence) # type: ignore[no-any-return]
355def _detect_pwm(
356 data: NDArray[np.floating[Any]],
357 sample_rate: float,
358 voltage_swing: float,
359) -> float:
360 """Detect PWM signal characteristics.
362 Args:
363 data: Signal data array.
364 sample_rate: Sample rate in Hz.
365 voltage_swing: Peak-to-peak voltage swing.
367 Returns:
368 Confidence score (0.0-1.0).
369 """
370 if voltage_swing == 0 or len(data) < 100:
371 return 0.0
373 # PWM should have digital levels
374 digital_score = _detect_digital(data, voltage_swing)
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
379 # Threshold signal
380 threshold = (np.max(data) + np.min(data)) / 2
381 digital = data > threshold
383 # Find transitions
384 transitions = np.diff(digital.astype(int))
385 rising = np.where(transitions > 0)[0]
386 falling = np.where(transitions < 0)[0]
388 if len(rising) < 3 or len(falling) < 3:
389 return 0.0
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
396 periodicity_score = 1.0 - min(1.0, period_std / (mean_period + 1e-10))
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)
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
408 confidence = digital_score * 0.3 + periodicity_score * 0.5 + variation_score * 0.2
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)
414 return min(0.94, confidence)
417def _detect_uart(
418 data: NDArray[np.floating[Any]], sample_rate: float, voltage_swing: float
419) -> float:
420 """Detect UART signal characteristics.
422 Args:
423 data: Signal data array.
424 sample_rate: Sample rate in Hz.
425 voltage_swing: Peak-to-peak voltage swing.
427 Returns:
428 Confidence score (0.0-1.0).
429 """
430 if voltage_swing == 0 or len(data) < 200:
431 return 0.0
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
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)
443 # Count significant histogram bins (>5% of samples)
444 significant_bins = np.sum(hist > 0.05)
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
451 # Threshold signal
452 threshold = (np.max(data) + np.min(data)) / 2
453 digital = data > threshold
455 # Find edges
456 transitions = np.diff(digital.astype(int))
457 edges = np.where(np.abs(transitions) > 0)[0]
459 if len(edges) < 10:
460 return 0.0
462 # UART has consistent bit timing
463 edge_intervals = np.diff(edges)
465 # Look for common baud rates
466 common_bauds = [9600, 19200, 38400, 57600, 115200]
467 baud_scores = []
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))
475 timing_score = max(baud_scores) if baud_scores else 0.0
477 # UART requires strong timing alignment
478 if timing_score < 0.4:
479 return 0.0
481 # UART idles high typically
482 idle_score = np.mean(digital[-100:])
484 confidence = digital_score * 0.3 + timing_score * 0.6 + idle_score * 0.1
486 # Boost confidence if timing alignment is strong
487 if timing_score > 0.7:
488 confidence = min(0.96, confidence * 1.1)
490 return min(0.96, confidence) # type: ignore[no-any-return]
493def _detect_spi(
494 data: NDArray[np.floating[Any]],
495 sample_rate: float,
496 voltage_swing: float,
497) -> float:
498 """Detect SPI signal characteristics.
500 Args:
501 data: Signal data array.
502 sample_rate: Sample rate in Hz.
503 voltage_swing: Peak-to-peak voltage swing.
505 Returns:
506 Confidence score (0.0-1.0).
507 """
508 if voltage_swing == 0 or len(data) < 200:
509 return 0.0
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
516 # Threshold signal
517 threshold = (np.max(data) + np.min(data)) / 2
518 digital = data > threshold
520 # Find edges
521 transitions = np.diff(digital.astype(int))
522 edges = np.where(np.abs(transitions) > 0)[0]
524 if len(edges) < 20:
525 return 0.0
527 # SPI typically has bursts of regular clock transitions
528 edge_intervals = np.diff(edges)
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))
535 # SPI has many transitions (clock toggling)
536 transition_density = len(edges) / len(data)
537 density_score = min(1.0, transition_density * 20)
539 confidence = digital_score * 0.3 + consistency_score * 0.5 + density_score * 0.2
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)
545 return min(0.95, confidence) # type: ignore[no-any-return]
548def _detect_i2c(
549 data: NDArray[np.floating[Any]],
550 sample_rate: float,
551 voltage_swing: float,
552) -> float:
553 """Detect I2C signal characteristics.
555 Args:
556 data: Signal data array.
557 sample_rate: Sample rate in Hz.
558 voltage_swing: Peak-to-peak voltage swing.
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)
569def _estimate_frequency(data: NDArray[np.floating[Any]], sample_rate: float) -> float:
570 """Estimate dominant frequency in signal.
572 Args:
573 data: Signal data array.
574 sample_rate: Sample rate in Hz.
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
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]
586 if len(crossings) < 2:
587 return 0.0
589 # Average period between crossings (half periods)
590 avg_half_period = np.mean(np.diff(crossings))
591 period_samples = avg_half_period * 2
593 frequency = sample_rate / period_samples if period_samples > 0 else 0.0
594 return frequency
597def _estimate_snr(data: NDArray[np.floating[Any]], stats: dict[str, float]) -> float:
598 """Estimate signal-to-noise ratio.
600 Args:
601 data: Signal data array.
602 stats: Basic statistics dictionary.
604 Returns:
605 Estimated SNR in dB.
606 """
607 signal_power = stats["mean"] ** 2
608 noise_power = stats["variance"]
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
613 snr = signal_power / noise_power
614 snr_db = 10 * np.log10(snr) if snr > 0 else 0.0
616 return max(0.0, min(100.0, snr_db))
619def _estimate_jitter(data: NDArray[np.floating[Any]], sample_rate: float) -> float:
620 """Estimate timing jitter.
622 Args:
623 data: Signal data array.
624 sample_rate: Sample rate in Hz.
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]
634 if len(edges) < 3:
635 return 0.0
637 edge_intervals = np.diff(edges)
638 jitter_samples = np.std(edge_intervals)
639 jitter_seconds = jitter_samples / sample_rate
641 return jitter_seconds # type: ignore[no-any-return]
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.
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.
660 Returns:
661 Dictionary of parameters specific to signal type.
662 """
663 params: dict[str, Any] = {}
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
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)
678 if signal_type == "uart":
679 # Estimate baud rate
680 params["estimated_baud"] = _estimate_baud_rate(data, sample_rate)
682 return params
685def _guess_logic_family(voltage_low: float, voltage_high: float) -> str:
686 """Guess logic family from voltage levels.
688 Args:
689 voltage_low: Low voltage level in volts.
690 voltage_high: High voltage level in volts.
692 Returns:
693 Logic family name.
694 """
695 voltage_swing = voltage_high - voltage_low
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 ]
705 # Find closest match
706 closest_diff = float("inf")
707 second_closest_diff = float("inf")
708 closest_family = "Unknown"
709 closest_level = 0.0
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
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"
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
732 return closest_family
735def _estimate_baud_rate(data: NDArray[np.floating[Any]], sample_rate: float) -> int:
736 """Estimate UART baud rate.
738 Args:
739 data: Signal data array.
740 sample_rate: Sample rate in Hz.
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]
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
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)
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))
762 return closest_baud
765__all__ = [
766 "SignalCharacterization",
767 "SignalType",
768 "characterize_signal",
769]