Skip to main content

sonar_vision/
lib.rs

1//! Sonar/ultrasonic perception pipeline — beam forming, echo detection, spatial mapping.
2
3use std::f64::consts::PI;
4
5/// Speed of sound in air (m/s).
6pub const SPEED_OF_SOUND: f64 = 343.0;
7
8/// A detected echo with timing and amplitude.
9#[derive(Debug, Clone, Copy)]
10pub struct Echo {
11    /// Sample index where echo was detected.
12    pub sample_idx: usize,
13    /// Round-trip time in seconds.
14    pub rtt: f64,
15    /// Estimated distance in meters.
16    pub distance: f64,
17    /// Signal amplitude (0.0–1.0).
18    pub amplitude: f64,
19    /// Confidence of detection.
20    pub confidence: f64,
21}
22
23impl Echo {
24    pub fn from_rtt(rtt: f64, amplitude: f64) -> Self {
25        Self {
26            sample_idx: 0,
27            rtt,
28            distance: rtt * SPEED_OF_SOUND / 2.0,
29            amplitude,
30            confidence: amplitude,
31        }
32    }
33}
34
35/// A point in a sonar spatial map.
36#[derive(Debug, Clone, Copy)]
37pub struct SpatialPoint {
38    pub x: f64,
39    pub y: f64,
40    pub z: f64,
41    pub intensity: f64,
42}
43
44/// Sonar pulse parameters.
45#[derive(Debug, Clone, Copy)]
46pub struct PulseConfig {
47    pub frequency: f64,   // Hz
48    pub duration: f64,    // seconds
49    pub sample_rate: f64, // Hz
50    pub amplitude: f64,   // 0.0–1.0
51}
52
53impl Default for PulseConfig {
54    fn default() -> Self {
55        Self {
56            frequency: 40_000.0,
57            duration: 0.001,
58            sample_rate: 1_000_000.0,
59            amplitude: 1.0,
60        }
61    }
62}
63
64/// Generate a sonar pulse signal.
65pub fn generate_pulse(config: &PulseConfig) -> Vec<f64> {
66    let n_samples = (config.duration * config.sample_rate) as usize;
67    (0..n_samples)
68        .map(|i| {
69            let t = i as f64 / config.sample_rate;
70            config.amplitude * (2.0 * PI * config.frequency * t).sin()
71        })
72        .collect()
73}
74
75/// Detect echoes in a return signal using threshold-based peak detection.
76pub fn detect_echoes(
77    signal: &[f64],
78    sample_rate: f64,
79    threshold: f64,
80    min_separation: f64,
81) -> Vec<Echo> {
82    let min_sep_samples = (min_separation * sample_rate) as usize;
83    let mut echoes = Vec::new();
84    let mut last_peak = 0isize;
85
86    for i in 1..signal.len().saturating_sub(1) {
87        if signal[i] > threshold
88            && signal[i] > signal[i - 1]
89            && signal[i] > signal[i + 1]
90            && (last_peak < 0 || (i as isize - last_peak) as usize >= min_sep_samples)
91        {
92            let rtt = i as f64 / sample_rate;
93            echoes.push(Echo {
94                sample_idx: i,
95                rtt,
96                distance: rtt * SPEED_OF_SOUND / 2.0,
97                amplitude: signal[i],
98                confidence: signal[i],
99            });
100            last_peak = i as isize;
101        }
102    }
103    echoes
104}
105
106/// Simple delay-and-sum beamforming.
107pub fn beamform(signals: &[Vec<f64>], delays: &[f64], sample_rate: f64) -> Vec<f64> {
108    if signals.is_empty() || delays.len() != signals.len() {
109        return vec![];
110    }
111    let max_len = signals.iter().map(|s| s.len()).max().unwrap_or(0);
112    if max_len == 0 {
113        return vec![];
114    }
115
116    let mut output = vec![0.0f64; max_len];
117    let mut counts = vec![0usize; max_len];
118
119    for (signal, &delay) in signals.iter().zip(delays.iter()) {
120        let delay_samples = (delay * sample_rate).round() as isize;
121        for (i, &val) in signal.iter().enumerate() {
122            let out_idx = i as isize + delay_samples;
123            if out_idx >= 0 && (out_idx as usize) < max_len {
124                output[out_idx as usize] += val;
125                counts[out_idx as usize] += 1;
126            }
127        }
128    }
129
130    for i in 0..max_len {
131        if counts[i] > 0 {
132            output[i] /= counts[i] as f64;
133        }
134    }
135    output
136}
137
138/// Convert an echo to a spatial point given sensor angle (radians from forward).
139pub fn echo_to_spatial(echo: &Echo, angle: f64) -> SpatialPoint {
140    SpatialPoint {
141        x: echo.distance * angle.cos(),
142        y: echo.distance * angle.sin(),
143        z: 0.0,
144        intensity: echo.amplitude,
145    }
146}
147
148/// Build a spatial map from multiple sonar sweeps at different angles.
149pub fn build_spatial_map(sweeps: &[(f64, Vec<Echo>)]) -> Vec<SpatialPoint> {
150    sweeps
151        .iter()
152        .flat_map(|(angle, echoes)| echoes.iter().map(|e| echo_to_spatial(e, *angle)))
153        .collect()
154}
155
156/// Compute signal-to-noise ratio of a signal.
157pub fn compute_snr(signal: &[f64]) -> f64 {
158    if signal.len() < 2 {
159        return 0.0;
160    }
161    let mean: f64 = signal.iter().sum::<f64>() / signal.len() as f64;
162    let signal_power: f64 = signal.iter().map(|x| x.powi(2)).sum::<f64>() / signal.len() as f64;
163    let noise_power = (signal_power - mean * mean).max(0.0);
164    if noise_power == 0.0 {
165        return f64::INFINITY;
166    }
167    10.0 * (signal_power / noise_power).log10()
168}
169
170/// Simple matched filter (cross-correlation with template).
171pub fn matched_filter(signal: &[f64], template: &[f64]) -> Vec<f64> {
172    if signal.len() < template.len() {
173        return vec![];
174    }
175    let out_len = signal.len() - template.len() + 1;
176    (0..out_len)
177        .map(|i| {
178            signal[i..i + template.len()]
179                .iter()
180                .zip(template.iter())
181                .map(|(s, t)| s * t)
182                .sum()
183        })
184        .collect()
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_generate_pulse() {
193        let config = PulseConfig::default();
194        let pulse = generate_pulse(&config);
195        assert!(!pulse.is_empty());
196        assert!(pulse.iter().all(|&s| s.abs() <= 1.0));
197    }
198
199    #[test]
200    fn test_detect_echoes() {
201        // Create signal with two echoes
202        let mut signal = vec![0.0; 1000];
203        signal[100] = 0.8;
204        signal[500] = 0.6;
205        let echoes = detect_echoes(&signal, 1_000_000.0, 0.5, 0.0001);
206        assert_eq!(echoes.len(), 2);
207        assert!((echoes[0].distance - 0.01715).abs() < 0.001);
208    }
209
210    #[test]
211    fn test_echo_from_rtt() {
212        let echo = Echo::from_rtt(0.01, 0.9);
213        assert!((echo.distance - 1.715).abs() < 0.01);
214    }
215
216    #[test]
217    fn test_beamform() {
218        let signals = vec![vec![1.0, 2.0, 3.0, 0.0], vec![0.0, 1.0, 2.0, 3.0]];
219        let delays = vec![0.0, 0.000001];
220        let output = beamform(&signals, &delays, 1_000_000.0);
221        assert!(!output.is_empty());
222    }
223
224    #[test]
225    fn test_echo_to_spatial() {
226        let echo = Echo::from_rtt(0.01, 0.9);
227        let point = echo_to_spatial(&echo, 0.0); // forward
228        assert!((point.x - 1.715).abs() < 0.01);
229        assert!(point.y.abs() < 0.01);
230    }
231
232    #[test]
233    fn test_spatial_map() {
234        let sweeps = vec![
235            (0.0, vec![Echo::from_rtt(0.01, 0.9)]),
236            (PI / 4.0, vec![Echo::from_rtt(0.02, 0.7)]),
237        ];
238        let map = build_spatial_map(&sweeps);
239        assert_eq!(map.len(), 2);
240    }
241
242    #[test]
243    fn test_matched_filter() {
244        let template = vec![1.0, 1.0, 1.0];
245        let signal = vec![0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0];
246        let result = matched_filter(&signal, &template);
247        assert_eq!(result.len(), 5);
248        assert!((result[2] - 3.0).abs() < 0.001);
249    }
250
251    #[test]
252    fn test_snr() {
253        let signal = vec![1.0, 1.0, 1.0, 1.0];
254        let snr = compute_snr(&signal);
255        assert!(snr.is_infinite() || snr > 30.0); // pure signal = high SNR
256    }
257}