Skip to main content

native/srp_phat/src/tests.rs

//! Acceptance tests against synthesized geometry.
//!
//! These reproduce the Augur oracle's synthetic input — a Morlet wavelet at
//! each emplacement, delayed by the *true* geometric TDOA — and assert the
//! recovered ECEF position lands within tolerance. This is the parity target
//! the standalone library must clear.

use crate::codec::{Frames, Geometry, Opts};
use crate::solve::solve;
use crate::wgs84::{latlon_alt_to_ecef, slant_range};
use std::f64::consts::PI;

const FS: f64 = 4_000.0;
const C: f64 = 343.0;
const BASE: f64 = 1_200.0;
const SIGMA: f64 = 50.0;
const LEN: usize = 2_600;

// (lat, lon, alt) emplacements from the Augur pipeline fixture.
fn emplacements() -> Vec<[f64; 3]> {
    vec![
        latlon_alt_to_ecef(35.0000, -106.0000, 1_600.0),
        latlon_alt_to_ecef(35.0006, -106.0000, 1_600.0),
        latlon_alt_to_ecef(35.0000, -106.0008, 1_600.0),
        latlon_alt_to_ecef(35.0004, -106.0004, 1_670.0),
    ]
}

fn wavelet(n: usize, center: f64, freq: f64) -> f64 {
    let t = n as f64 - center;
    let env = (-0.5 * (t / SIGMA).powi(2)).exp();
    env * (2.0 * PI * freq * t / FS).cos()
}

/// Build a frame-set: one Morlet per (source, freq), delayed by the geometric
/// TDOA relative to emplacement 0.
fn synth(emps: &[[f64; 3]], sources: &[([f64; 3], f64)]) -> Frames {
    let d0: Vec<f64> = sources
        .iter()
        .map(|(src, _)| slant_range(emps[0], *src))
        .collect();

    let mut data = vec![0.0f64; emps.len() * LEN];
    for (ei, emp) in emps.iter().enumerate() {
        for n in 0..LEN {
            let mut acc = 0.0;
            for (si, (src, freq)) in sources.iter().enumerate() {
                let center = BASE + (slant_range(*emp, *src) - d0[si]) / C * FS;
                acc += wavelet(n, center, *freq);
            }
            data[ei * LEN + n] = acc;
        }
    }
    Frames {
        n_emp: emps.len(),
        n_samples: LEN,
        data,
    }
}

fn geometry(emps: &[[f64; 3]]) -> Geometry {
    Geometry {
        sample_rate_hz: FS,
        ecef: emps.to_vec(),
    }
}

fn default_opts() -> Opts {
    Opts {
        grid_extent_m: 300.0,
        coarse_res_m: 20.0,
        fine_res_m: 2.0,
        min_peak_ratio: 0.5,
        max_sources: 4,
    }
}

#[test]
fn single_source_recovered_within_tolerance() {
    let emps = emplacements();
    let source = latlon_alt_to_ecef(35.0003, -106.0003, 1_720.0);
    let frames = synth(&emps, &[(source, 600.0)]);

    let sources = solve(&frames, &geometry(&emps), &default_opts()).unwrap();
    assert!(!sources.is_empty(), "no source recovered");

    // Strongest by confidence.
    let best = sources
        .iter()
        .max_by(|a, b| a.confidence.partial_cmp(&b.confidence).unwrap())
        .unwrap();

    let err = slant_range(best.ecef, source);
    assert!(err < 50.0, "position error {err} m exceeds 50 m");
    assert!(best.confidence > 0.0, "confidence not positive");
    assert_eq!(
        best.dominant_hz.round() as i64,
        600,
        "dominant_hz = {}",
        best.dominant_hz
    );
}

#[test]
fn two_distinct_band_sources_separated() {
    let emps = emplacements();
    let src_a = latlon_alt_to_ecef(35.0003, -106.0003, 1_720.0);
    let src_b = latlon_alt_to_ecef(34.9997, -105.9995, 1_650.0);
    let frames = synth(&emps, &[(src_a, 600.0), (src_b, 300.0)]);

    let opts = Opts {
        min_peak_ratio: 0.35,
        max_sources: 4,
        ..default_opts()
    };
    let sources = solve(&frames, &geometry(&emps), &opts).unwrap();
    assert!(
        sources.len() >= 2,
        "expected >=2 sources, got {}",
        sources.len()
    );

    // Each true source has a recovered peak within tolerance. The stretch bar is
    // looser than the 50 m single-source bar: in a two-source field each source's
    // TDOAs are associated from per-pair peaks that the *other* source perturbs,
    // so a few extra meters of spread is expected.
    for src in [src_a, src_b] {
        let nearest = sources
            .iter()
            .map(|s| slant_range(s.ecef, src))
            .fold(f64::INFINITY, f64::min);
        assert!(
            nearest < 60.0,
            "no recovered source within 60 m of {src:?} (nearest {nearest} m)"
        );
    }
}

#[test]
fn combination_cost_stays_bounded() {
    // The localizer enumerates TDOA combinations across reference pairs:
    // max_sources^(n_emp - 1). Guard that the worst realistic case stays within
    // the MAX_COMBOS cap that solve() actually enforces.
    let max_sources = crate::codec::MAX_SOURCES_CAP.min(4);
    let n_emp = 4usize;
    let worst = max_sources.pow((n_emp - 1) as u32);
    assert!(
        worst <= crate::solve::MAX_COMBOS,
        "combination count {worst} exceeds cap {}",
        crate::solve::MAX_COMBOS
    );
}