Skip to main content

scirs2_signal/beamforming/
array.rs

1//! Array geometry definitions and steering vector computation
2//!
3//! Provides:
4//! - [`UniformLinearArray`]: ULA with elements at d * [0, 1, ..., N-1]
5//! - [`UniformCircularArray`]: UCA with elements on a circle of radius R
6//! - [`ArbitraryArray`]: user-specified element positions
7//! - [`ArrayGeometry`]: trait for steering vector computation
8//! - `ArrayManifold`: set of steering vectors for a range of angles
9//!
10//! Pure Rust, no unwrap(), snake_case naming.
11
12use crate::error::{SignalError, SignalResult};
13use scirs2_core::numeric::Complex64;
14use std::f64::consts::PI;
15
16// ---------------------------------------------------------------------------
17// ArrayGeometry trait
18// ---------------------------------------------------------------------------
19
20/// Trait for array geometry — provides steering vector computation
21pub trait ArrayGeometry: Send + Sync {
22    /// Compute the steering vector for a given angle (radians, measured from broadside)
23    /// at a given frequency.
24    ///
25    /// # Arguments
26    ///
27    /// * `angle_rad` - Direction angle in radians (0 = broadside)
28    /// * `wavelength` - Signal wavelength (same units as element positions)
29    fn steering_vector(&self, angle_rad: f64, wavelength: f64) -> SignalResult<Vec<Complex64>>;
30
31    /// Number of array elements
32    fn n_elements(&self) -> usize;
33
34    /// Get element positions as (x, y) coordinates
35    fn element_positions(&self) -> Vec<(f64, f64)>;
36
37    /// Compute steering vector using element spacing in wavelengths (convenience)
38    ///
39    /// Uses wavelength = 1.0 so that positions are interpreted directly in wavelengths.
40    fn steering_vector_normalized(&self, angle_rad: f64) -> SignalResult<Vec<Complex64>> {
41        self.steering_vector(angle_rad, 1.0)
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Uniform Linear Array (ULA)
47// ---------------------------------------------------------------------------
48
49/// Uniform Linear Array (ULA)
50///
51/// Elements are placed at positions d * [0, 1, ..., N-1] along the x-axis.
52/// The steering vector is:
53///
54/// `a(theta) = [1, exp(-j*2*pi*d*sin(theta)/lambda), ..., exp(-j*2*pi*(N-1)*d*sin(theta)/lambda)]`
55#[derive(Debug, Clone)]
56pub struct UniformLinearArray {
57    /// Number of elements
58    n_elements: usize,
59    /// Inter-element spacing (in metres or wavelengths)
60    element_spacing: f64,
61}
62
63impl UniformLinearArray {
64    /// Create a new ULA
65    ///
66    /// # Arguments
67    ///
68    /// * `n_elements` - Number of sensors (must be >= 2)
69    /// * `element_spacing` - Spacing between adjacent elements
70    pub fn new(n_elements: usize, element_spacing: f64) -> SignalResult<Self> {
71        if n_elements < 2 {
72            return Err(SignalError::ValueError(
73                "ULA requires at least 2 elements".to_string(),
74            ));
75        }
76        if element_spacing <= 0.0 {
77            return Err(SignalError::ValueError(
78                "Element spacing must be positive".to_string(),
79            ));
80        }
81        Ok(Self {
82            n_elements,
83            element_spacing,
84        })
85    }
86
87    /// Get element spacing
88    pub fn element_spacing(&self) -> f64 {
89        self.element_spacing
90    }
91
92    /// Compute steering vectors for multiple angles simultaneously
93    pub fn steering_matrix(
94        &self,
95        angles_rad: &[f64],
96        wavelength: f64,
97    ) -> SignalResult<Vec<Vec<Complex64>>> {
98        angles_rad
99            .iter()
100            .map(|&theta| self.steering_vector(theta, wavelength))
101            .collect()
102    }
103}
104
105impl ArrayGeometry for UniformLinearArray {
106    fn steering_vector(&self, angle_rad: f64, wavelength: f64) -> SignalResult<Vec<Complex64>> {
107        if wavelength <= 0.0 {
108            return Err(SignalError::ValueError(
109                "Wavelength must be positive".to_string(),
110            ));
111        }
112        let phase_increment = -2.0 * PI * self.element_spacing * angle_rad.sin() / wavelength;
113        let sv = (0..self.n_elements)
114            .map(|m| {
115                let phase = phase_increment * m as f64;
116                Complex64::new(phase.cos(), phase.sin())
117            })
118            .collect();
119        Ok(sv)
120    }
121
122    fn n_elements(&self) -> usize {
123        self.n_elements
124    }
125
126    fn element_positions(&self) -> Vec<(f64, f64)> {
127        (0..self.n_elements)
128            .map(|m| (self.element_spacing * m as f64, 0.0))
129            .collect()
130    }
131}
132
133// ---------------------------------------------------------------------------
134// Uniform Circular Array (UCA)
135// ---------------------------------------------------------------------------
136
137/// Uniform Circular Array (UCA)
138///
139/// N elements are placed uniformly on a circle of radius R.
140/// Element m is at angle `2*pi*m/N` from the x-axis.
141/// For a source at azimuth `phi`, the m-th element phase is:
142///
143/// `psi_m = 2*pi*(R/lambda)*cos(phi - 2*pi*m/N)`
144#[derive(Debug, Clone)]
145pub struct UniformCircularArray {
146    /// Number of elements
147    n_elements: usize,
148    /// Array radius
149    radius: f64,
150}
151
152impl UniformCircularArray {
153    /// Create a new UCA
154    ///
155    /// # Arguments
156    ///
157    /// * `n_elements` - Number of sensors (must be >= 3)
158    /// * `radius` - Array radius (same units as wavelength)
159    pub fn new(n_elements: usize, radius: f64) -> SignalResult<Self> {
160        if n_elements < 3 {
161            return Err(SignalError::ValueError(
162                "UCA requires at least 3 elements".to_string(),
163            ));
164        }
165        if radius <= 0.0 {
166            return Err(SignalError::ValueError(
167                "Radius must be positive".to_string(),
168            ));
169        }
170        Ok(Self { n_elements, radius })
171    }
172
173    /// Get array radius
174    pub fn radius(&self) -> f64 {
175        self.radius
176    }
177}
178
179impl ArrayGeometry for UniformCircularArray {
180    fn steering_vector(&self, angle_rad: f64, wavelength: f64) -> SignalResult<Vec<Complex64>> {
181        if wavelength <= 0.0 {
182            return Err(SignalError::ValueError(
183                "Wavelength must be positive".to_string(),
184            ));
185        }
186        let n = self.n_elements;
187        let sv = (0..n)
188            .map(|m| {
189                let element_angle = 2.0 * PI * m as f64 / n as f64;
190                let phase = 2.0 * PI * self.radius * (angle_rad - element_angle).cos() / wavelength;
191                Complex64::new(phase.cos(), phase.sin())
192            })
193            .collect();
194        Ok(sv)
195    }
196
197    fn n_elements(&self) -> usize {
198        self.n_elements
199    }
200
201    fn element_positions(&self) -> Vec<(f64, f64)> {
202        let n = self.n_elements;
203        (0..n)
204            .map(|m| {
205                let angle = 2.0 * PI * m as f64 / n as f64;
206                (self.radius * angle.cos(), self.radius * angle.sin())
207            })
208            .collect()
209    }
210}
211
212// ---------------------------------------------------------------------------
213// Arbitrary Array
214// ---------------------------------------------------------------------------
215
216/// Arbitrary sensor array with user-specified element positions
217///
218/// Supports any planar geometry. Positions are given as (x, y) coordinates.
219/// The steering vector for a far-field source at angle `theta` from broadside
220/// (y-axis) is:
221///
222/// `a_m(theta) = exp(-j*2*pi*(x_m*sin(theta))/lambda)`
223///
224/// For a full 2D model use the `steering_vector_2d` method.
225#[derive(Debug, Clone)]
226pub struct ArbitraryArray {
227    /// Element positions as (x, y)
228    positions: Vec<(f64, f64)>,
229}
230
231impl ArbitraryArray {
232    /// Create a new arbitrary array
233    ///
234    /// # Arguments
235    ///
236    /// * `positions` - Element positions as (x, y) coordinates (must have >= 2 elements)
237    pub fn new(positions: Vec<(f64, f64)>) -> SignalResult<Self> {
238        if positions.len() < 2 {
239            return Err(SignalError::ValueError(
240                "Arbitrary array requires at least 2 elements".to_string(),
241            ));
242        }
243        Ok(Self { positions })
244    }
245
246    /// Compute the 2D steering vector for azimuth `phi` and elevation `theta`
247    ///
248    /// Phase: `psi_m = 2*pi*(x_m*cos(phi)*cos(theta) + y_m*sin(phi)*cos(theta)) / lambda`
249    pub fn steering_vector_2d(
250        &self,
251        azimuth_rad: f64,
252        elevation_rad: f64,
253        wavelength: f64,
254    ) -> SignalResult<Vec<Complex64>> {
255        if wavelength <= 0.0 {
256            return Err(SignalError::ValueError(
257                "Wavelength must be positive".to_string(),
258            ));
259        }
260        let cos_el = elevation_rad.cos();
261        let kx = 2.0 * PI * azimuth_rad.cos() * cos_el / wavelength;
262        let ky = 2.0 * PI * azimuth_rad.sin() * cos_el / wavelength;
263
264        let sv = self
265            .positions
266            .iter()
267            .map(|&(x, y)| {
268                let phase = -(kx * x + ky * y);
269                Complex64::new(phase.cos(), phase.sin())
270            })
271            .collect();
272        Ok(sv)
273    }
274}
275
276impl ArrayGeometry for ArbitraryArray {
277    fn steering_vector(&self, angle_rad: f64, wavelength: f64) -> SignalResult<Vec<Complex64>> {
278        if wavelength <= 0.0 {
279            return Err(SignalError::ValueError(
280                "Wavelength must be positive".to_string(),
281            ));
282        }
283        // 1D model: project positions onto x-axis
284        let sv = self
285            .positions
286            .iter()
287            .map(|&(x, _y)| {
288                let phase = -2.0 * PI * x * angle_rad.sin() / wavelength;
289                Complex64::new(phase.cos(), phase.sin())
290            })
291            .collect();
292        Ok(sv)
293    }
294
295    fn n_elements(&self) -> usize {
296        self.positions.len()
297    }
298
299    fn element_positions(&self) -> Vec<(f64, f64)> {
300        self.positions.clone()
301    }
302}
303
304// ---------------------------------------------------------------------------
305// Array Manifold
306// ---------------------------------------------------------------------------
307
308/// Array manifold: set of steering vectors for a range of angles
309#[derive(Debug, Clone)]
310pub struct ArrayManifoldData {
311    /// Steering vectors, one per scan angle
312    pub steering_vectors: Vec<Vec<Complex64>>,
313    /// Corresponding scan angles in radians
314    pub scan_angles: Vec<f64>,
315    /// Number of elements
316    pub n_elements: usize,
317}
318
319impl ArrayManifoldData {
320    /// Compute the array manifold for a given geometry and scan range
321    ///
322    /// # Arguments
323    ///
324    /// * `array` - Array geometry
325    /// * `scan_angles_rad` - Angles to compute steering vectors for
326    /// * `wavelength` - Signal wavelength
327    pub fn compute(
328        array: &dyn ArrayGeometry,
329        scan_angles_rad: &[f64],
330        wavelength: f64,
331    ) -> SignalResult<Self> {
332        if scan_angles_rad.is_empty() {
333            return Err(SignalError::ValueError(
334                "Scan angles must not be empty".to_string(),
335            ));
336        }
337        let mut steering_vectors = Vec::with_capacity(scan_angles_rad.len());
338        for &angle in scan_angles_rad {
339            steering_vectors.push(array.steering_vector(angle, wavelength)?);
340        }
341        Ok(Self {
342            steering_vectors,
343            scan_angles: scan_angles_rad.to_vec(),
344            n_elements: array.n_elements(),
345        })
346    }
347}
348
349// ---------------------------------------------------------------------------
350// Convenience functions
351// ---------------------------------------------------------------------------
352
353/// Create uniformly spaced scan angles in radians
354///
355/// # Arguments
356///
357/// * `start_deg` - Start angle in degrees
358/// * `end_deg` - End angle in degrees
359/// * `n_points` - Number of scan points
360pub fn scan_angles_degrees(
361    start_deg: f64,
362    end_deg: f64,
363    n_points: usize,
364) -> SignalResult<Vec<f64>> {
365    if n_points == 0 {
366        return Err(SignalError::ValueError(
367            "Number of scan points must be positive".to_string(),
368        ));
369    }
370    if n_points == 1 {
371        return Ok(vec![start_deg.to_radians()]);
372    }
373    let step = (end_deg - start_deg) / (n_points - 1) as f64;
374    Ok((0..n_points)
375        .map(|i| (start_deg + step * i as f64).to_radians())
376        .collect())
377}
378
379/// Compute ULA steering vector (standalone function for convenience)
380///
381/// # Arguments
382///
383/// * `n_elements` - Number of array elements
384/// * `angle_rad` - Steering angle in radians
385/// * `element_spacing` - Element spacing in wavelengths
386pub fn steering_vector_ula(
387    n_elements: usize,
388    angle_rad: f64,
389    element_spacing: f64,
390) -> SignalResult<Vec<Complex64>> {
391    if n_elements == 0 {
392        return Err(SignalError::ValueError(
393            "Number of elements must be positive".to_string(),
394        ));
395    }
396    if element_spacing <= 0.0 {
397        return Err(SignalError::ValueError(
398            "Element spacing must be positive".to_string(),
399        ));
400    }
401    let phase_increment = -2.0 * PI * element_spacing * angle_rad.sin();
402    let sv = (0..n_elements)
403        .map(|m| {
404            let phase = phase_increment * m as f64;
405            Complex64::new(phase.cos(), phase.sin())
406        })
407        .collect();
408    Ok(sv)
409}
410
411/// Compute steering vectors for a set of angles
412pub fn steering_vectors_ula(
413    n_elements: usize,
414    angles_rad: &[f64],
415    element_spacing: f64,
416) -> SignalResult<Vec<Vec<Complex64>>> {
417    if angles_rad.is_empty() {
418        return Err(SignalError::ValueError(
419            "Angle list must not be empty".to_string(),
420        ));
421    }
422    angles_rad
423        .iter()
424        .map(|&angle| steering_vector_ula(n_elements, angle, element_spacing))
425        .collect()
426}
427
428// ---------------------------------------------------------------------------
429// Covariance matrix estimation
430// ---------------------------------------------------------------------------
431
432/// Estimate the spatial covariance matrix from multi-channel complex data
433///
434/// R = (1/N) * X * X^H
435pub fn estimate_covariance(signals: &[Vec<Complex64>]) -> SignalResult<Vec<Vec<Complex64>>> {
436    if signals.is_empty() {
437        return Err(SignalError::ValueError(
438            "Signal matrix must not be empty".to_string(),
439        ));
440    }
441    let n_elements = signals.len();
442    let n_snapshots = signals[0].len();
443    if n_snapshots == 0 {
444        return Err(SignalError::ValueError(
445            "Number of snapshots must be positive".to_string(),
446        ));
447    }
448    for (idx, sig) in signals.iter().enumerate() {
449        if sig.len() != n_snapshots {
450            return Err(SignalError::DimensionMismatch(format!(
451                "Element {} has {} snapshots, expected {}",
452                idx,
453                sig.len(),
454                n_snapshots
455            )));
456        }
457    }
458
459    let mut cov = vec![vec![Complex64::new(0.0, 0.0); n_elements]; n_elements];
460    for i in 0..n_elements {
461        for j in 0..n_elements {
462            let mut sum = Complex64::new(0.0, 0.0);
463            for k in 0..n_snapshots {
464                sum += signals[i][k] * signals[j][k].conj();
465            }
466            cov[i][j] = sum / n_snapshots as f64;
467        }
468    }
469    Ok(cov)
470}
471
472/// Estimate covariance matrix from real-valued multi-channel signals
473pub fn estimate_covariance_real(signals: &[Vec<f64>]) -> SignalResult<Vec<Vec<Complex64>>> {
474    let complex_signals: Vec<Vec<Complex64>> = signals
475        .iter()
476        .map(|ch| ch.iter().map(|&x| Complex64::new(x, 0.0)).collect())
477        .collect();
478    estimate_covariance(&complex_signals)
479}
480
481// ---------------------------------------------------------------------------
482// Matrix utilities
483// ---------------------------------------------------------------------------
484
485/// Invert a Hermitian positive-definite matrix using Gauss-Jordan elimination
486pub(crate) fn invert_hermitian_matrix(
487    matrix: &[Vec<Complex64>],
488) -> SignalResult<Vec<Vec<Complex64>>> {
489    let n = matrix.len();
490    if n == 0 {
491        return Err(SignalError::ValueError(
492            "Matrix must not be empty".to_string(),
493        ));
494    }
495
496    let mut aug: Vec<Vec<Complex64>> = Vec::with_capacity(n);
497    for i in 0..n {
498        if matrix[i].len() != n {
499            return Err(SignalError::DimensionMismatch(format!(
500                "Matrix row {} has length {}, expected {}",
501                i,
502                matrix[i].len(),
503                n
504            )));
505        }
506        let mut row = Vec::with_capacity(2 * n);
507        row.extend_from_slice(&matrix[i]);
508        for j in 0..n {
509            if i == j {
510                row.push(Complex64::new(1.0, 0.0));
511            } else {
512                row.push(Complex64::new(0.0, 0.0));
513            }
514        }
515        aug.push(row);
516    }
517
518    for col in 0..n {
519        let mut max_row = col;
520        let mut max_val = aug[col][col].norm();
521        for row in (col + 1)..n {
522            let val = aug[row][col].norm();
523            if val > max_val {
524                max_val = val;
525                max_row = row;
526            }
527        }
528        if max_val < 1e-14 {
529            return Err(SignalError::ComputationError(
530                "Matrix is singular or near-singular".to_string(),
531            ));
532        }
533        aug.swap(col, max_row);
534
535        let pivot = aug[col][col];
536        let pivot_inv = Complex64::new(1.0, 0.0) / pivot;
537        for j in 0..(2 * n) {
538            aug[col][j] = aug[col][j] * pivot_inv;
539        }
540
541        for row in 0..n {
542            if row == col {
543                continue;
544            }
545            let factor = aug[row][col];
546            for j in 0..(2 * n) {
547                aug[row][j] = aug[row][j] - factor * aug[col][j];
548            }
549        }
550    }
551
552    let mut inverse = Vec::with_capacity(n);
553    for i in 0..n {
554        inverse.push(aug[i][n..(2 * n)].to_vec());
555    }
556    Ok(inverse)
557}
558
559/// Matrix-vector product: R * a
560pub(crate) fn mat_vec_mul(matrix: &[Vec<Complex64>], vec: &[Complex64]) -> Vec<Complex64> {
561    let m = matrix.len();
562    let mut result = vec![Complex64::new(0.0, 0.0); m];
563    for i in 0..m {
564        for (j, &v) in vec.iter().enumerate() {
565            result[i] += matrix[i][j] * v;
566        }
567    }
568    result
569}
570
571/// Inner product: a^H * b
572pub(crate) fn inner_product_conj(a: &[Complex64], b: &[Complex64]) -> Complex64 {
573    a.iter()
574        .zip(b.iter())
575        .fold(Complex64::new(0.0, 0.0), |acc, (&ai, &bi)| {
576            acc + ai.conj() * bi
577        })
578}
579
580// ---------------------------------------------------------------------------
581// Tests
582// ---------------------------------------------------------------------------
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use approx::assert_relative_eq;
588
589    #[test]
590    fn test_ula_steering_vector_broadside() {
591        let ula = UniformLinearArray::new(4, 0.5).expect("should create ULA");
592        let sv = ula
593            .steering_vector(0.0, 1.0)
594            .expect("should compute steering vector");
595        assert_eq!(sv.len(), 4);
596        for s in &sv {
597            assert_relative_eq!(s.re, 1.0, epsilon = 1e-12);
598            assert_relative_eq!(s.im, 0.0, epsilon = 1e-12);
599        }
600    }
601
602    #[test]
603    fn test_ula_steering_vector_unit_norm() {
604        let ula = UniformLinearArray::new(8, 0.5).expect("should create ULA");
605        let sv = ula
606            .steering_vector(0.3, 1.0)
607            .expect("should compute steering vector");
608        for s in &sv {
609            assert_relative_eq!(s.norm(), 1.0, epsilon = 1e-12);
610        }
611    }
612
613    #[test]
614    fn test_ula_element_positions() {
615        let ula = UniformLinearArray::new(4, 0.5).expect("should create ULA");
616        let pos = ula.element_positions();
617        assert_eq!(pos.len(), 4);
618        assert_relative_eq!(pos[0].0, 0.0, epsilon = 1e-12);
619        assert_relative_eq!(pos[1].0, 0.5, epsilon = 1e-12);
620        assert_relative_eq!(pos[3].0, 1.5, epsilon = 1e-12);
621    }
622
623    #[test]
624    fn test_ula_validation() {
625        assert!(UniformLinearArray::new(0, 0.5).is_err());
626        assert!(UniformLinearArray::new(1, 0.5).is_err());
627        assert!(UniformLinearArray::new(4, 0.0).is_err());
628        assert!(UniformLinearArray::new(4, -0.5).is_err());
629    }
630
631    #[test]
632    fn test_uca_steering_vector() {
633        let uca = UniformCircularArray::new(8, 1.0).expect("should create UCA");
634        let sv = uca
635            .steering_vector(0.0, 1.0)
636            .expect("should compute steering vector");
637        assert_eq!(sv.len(), 8);
638        for s in &sv {
639            assert_relative_eq!(s.norm(), 1.0, epsilon = 1e-12);
640        }
641    }
642
643    #[test]
644    fn test_uca_symmetry() {
645        let uca = UniformCircularArray::new(8, 1.0).expect("should create UCA");
646        // At angle=0, elements symmetric about x-axis should have same magnitude
647        let sv = uca
648            .steering_vector(0.0, 1.0)
649            .expect("should compute steering vector");
650        // Element 1 and element 7 are symmetric: same magnitude
651        assert_relative_eq!(sv[1].norm(), sv[7].norm(), epsilon = 1e-10);
652        // The phases should be conjugate-related
653        assert_relative_eq!((sv[1] * sv[7].conj()).im.abs(), 0.0, epsilon = 0.1);
654    }
655
656    #[test]
657    fn test_uca_validation() {
658        assert!(UniformCircularArray::new(2, 1.0).is_err());
659        assert!(UniformCircularArray::new(4, 0.0).is_err());
660    }
661
662    #[test]
663    fn test_arbitrary_array() {
664        // Create a ULA-equivalent using arbitrary array
665        let positions = vec![(0.0, 0.0), (0.5, 0.0), (1.0, 0.0), (1.5, 0.0)];
666        let arr = ArbitraryArray::new(positions).expect("should create arbitrary array");
667        assert_eq!(arr.n_elements(), 4);
668
669        let sv = arr
670            .steering_vector(0.0, 1.0)
671            .expect("should compute steering vector");
672        // Broadside: all phases zero
673        for s in &sv {
674            assert_relative_eq!(s.re, 1.0, epsilon = 1e-12);
675            assert_relative_eq!(s.im, 0.0, epsilon = 1e-12);
676        }
677    }
678
679    #[test]
680    fn test_arbitrary_array_validation() {
681        assert!(ArbitraryArray::new(vec![(0.0, 0.0)]).is_err());
682    }
683
684    #[test]
685    fn test_array_manifold() {
686        let ula = UniformLinearArray::new(4, 0.5).expect("should create ULA");
687        let angles = scan_angles_degrees(-90.0, 90.0, 181).expect("should create angles");
688        let manifold =
689            ArrayManifoldData::compute(&ula, &angles, 1.0).expect("should compute manifold");
690        assert_eq!(manifold.steering_vectors.len(), 181);
691        assert_eq!(manifold.n_elements, 4);
692    }
693
694    #[test]
695    fn test_scan_angles_degrees() {
696        let angles = scan_angles_degrees(-90.0, 90.0, 181).expect("should create angles");
697        assert_eq!(angles.len(), 181);
698        assert_relative_eq!(angles[0], -PI / 2.0, epsilon = 1e-10);
699        assert_relative_eq!(angles[180], PI / 2.0, epsilon = 1e-10);
700    }
701
702    #[test]
703    fn test_scan_angles_single() {
704        let angles = scan_angles_degrees(30.0, 30.0, 1).expect("should create single angle");
705        assert_eq!(angles.len(), 1);
706        assert_relative_eq!(angles[0], 30.0_f64.to_radians(), epsilon = 1e-10);
707    }
708
709    #[test]
710    fn test_scan_angles_validation() {
711        assert!(scan_angles_degrees(-90.0, 90.0, 0).is_err());
712    }
713
714    #[test]
715    fn test_covariance_hermitian() {
716        let signals = vec![
717            vec![Complex64::new(1.0, 0.5), Complex64::new(0.3, -0.2)],
718            vec![Complex64::new(-0.5, 0.1), Complex64::new(0.8, 0.4)],
719        ];
720        let cov = estimate_covariance(&signals).expect("should compute covariance");
721        for i in 0..cov.len() {
722            for j in 0..cov.len() {
723                assert_relative_eq!(cov[i][j].re, cov[j][i].re, epsilon = 1e-12);
724                assert_relative_eq!(cov[i][j].im, -cov[j][i].im, epsilon = 1e-12);
725            }
726        }
727    }
728
729    #[test]
730    fn test_covariance_real() {
731        let signals = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
732        let cov = estimate_covariance_real(&signals).expect("should compute covariance");
733        assert_eq!(cov.len(), 2);
734        for row in &cov {
735            for &val in row {
736                assert!(val.im.abs() < 1e-12);
737            }
738        }
739    }
740
741    #[test]
742    fn test_covariance_validation() {
743        assert!(estimate_covariance(&[]).is_err());
744        assert!(estimate_covariance(&[vec![]]).is_err());
745    }
746
747    #[test]
748    fn test_invert_identity() {
749        let n = 3;
750        let mut identity = vec![vec![Complex64::new(0.0, 0.0); n]; n];
751        for i in 0..n {
752            identity[i][i] = Complex64::new(1.0, 0.0);
753        }
754        let inv = invert_hermitian_matrix(&identity).expect("should invert");
755        for i in 0..n {
756            for j in 0..n {
757                if i == j {
758                    assert_relative_eq!(inv[i][j].re, 1.0, epsilon = 1e-10);
759                } else {
760                    assert!(inv[i][j].norm() < 1e-10);
761                }
762            }
763        }
764    }
765}