Skip to main content

sidereon_core/
source_localization.rs

1//! Source localization from arrival times.
2//!
3//! This module solves for an event position from sensors at known Cartesian
4//! coordinates. Inputs are sans-I/O: callers supply sensor positions, arrival
5//! times, and a propagation speed. Coordinates are metres in a caller-chosen 2D
6//! or 3D Cartesian frame, times are seconds, and speeds are metres per second.
7
8use core::fmt;
9
10pub use trust_region_least_squares::loss::Loss;
11use trust_region_least_squares::model::{solve_model, ResidualModel};
12use trust_region_least_squares::trf::{TrfError, TrfOptions, TrfResult, XScale};
13
14use crate::astro::math::least_squares::singular_value_diagnostics;
15use crate::dop::{self, Dop, DopError};
16use crate::geometry_quality::{
17    classify, GeometryQuality, GeometryQualityThresholds, ObservabilityTier,
18};
19use nalgebra::DMatrix;
20
21const ROOT_TOL: f64 = 1.0e-12;
22
23/// A sensor with a known Cartesian position.
24///
25/// `propagation_speed_m_s` overrides the call-level propagation speed for this
26/// sensor when it is present. That is a simple per-path timing approximation;
27/// no refraction or ray tracing is modeled.
28#[derive(Debug, Clone, PartialEq)]
29pub struct Sensor {
30    /// Sensor position in metres. The vector length must be 2 or 3.
31    pub position_m: Vec<f64>,
32    /// Optional per-sensor propagation speed in metres per second.
33    pub propagation_speed_m_s: Option<f64>,
34}
35
36impl Sensor {
37    /// Construct a sensor that uses the call-level propagation speed.
38    pub fn new(position_m: impl Into<Vec<f64>>) -> Self {
39        Self {
40            position_m: position_m.into(),
41            propagation_speed_m_s: None,
42        }
43    }
44
45    /// Construct a sensor with its own propagation speed.
46    pub fn with_speed(position_m: impl Into<Vec<f64>>, propagation_speed_m_s: f64) -> Self {
47        Self {
48            position_m: position_m.into(),
49            propagation_speed_m_s: Some(propagation_speed_m_s),
50        }
51    }
52}
53
54/// Measurement model used by [`locate_source`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum SourceSolveMode {
57    /// Absolute time of arrival. The state is `[position..., origin_time]`.
58    #[default]
59    Toa,
60    /// Time difference of arrival against a reference sensor.
61    ///
62    /// The residual subtracts the reference sensor equation and does not solve
63    /// an origin-time state. The returned origin time is estimated after the
64    /// position solve from the absolute arrivals.
65    Tdoa {
66        /// Reference sensor index.
67        reference_sensor: usize,
68    },
69}
70
71/// Options for [`locate_source`].
72#[derive(Debug, Clone, PartialEq)]
73pub struct SourceLocateOptions {
74    /// ToA or TDOA residual form.
75    pub mode: SourceSolveMode,
76    /// Timing standard deviation used for covariance, CRLB, and normalized
77    /// influence scores.
78    pub timing_sigma_s: f64,
79    /// Loss function passed to the trust-region least-squares solver.
80    pub loss: Loss,
81    /// Residual scale in seconds for non-linear loss functions.
82    pub f_scale_s: f64,
83    /// Optional solver function tolerance.
84    pub ftol: Option<f64>,
85    /// Optional solver step tolerance.
86    pub xtol: Option<f64>,
87    /// Optional solver gradient tolerance.
88    pub gtol: Option<f64>,
89    /// Optional maximum residual evaluations.
90    pub max_nfev: Option<usize>,
91}
92
93impl Default for SourceLocateOptions {
94    fn default() -> Self {
95        Self {
96            mode: SourceSolveMode::Toa,
97            timing_sigma_s: 1.0,
98            loss: Loss::Linear,
99            f_scale_s: 1.0,
100            ftol: None,
101            xtol: None,
102            gtol: None,
103            max_nfev: None,
104        }
105    }
106}
107
108/// Closed-form seed used to start the iterative solve.
109#[derive(Debug, Clone, PartialEq)]
110pub struct SourceInitialGuess {
111    /// Initial position in metres.
112    pub position_m: Vec<f64>,
113    /// Initial origin time in seconds when it can be inferred.
114    pub origin_time_s: Option<f64>,
115    /// Root-mean-square residual of the seed in seconds.
116    pub residual_rms_s: f64,
117}
118
119/// One residual associated with a sensor row.
120#[derive(Debug, Clone, PartialEq)]
121pub struct SourceResidual {
122    /// Sensor index in the caller's input slice.
123    pub sensor_index: usize,
124    /// Reference sensor for a TDOA residual, or `None` for ToA.
125    pub reference_sensor_index: Option<usize>,
126    /// Residual in seconds.
127    pub residual_s: f64,
128}
129
130/// Per-sensor leave-one-out diagnostic.
131#[derive(Debug, Clone, PartialEq)]
132pub struct SourceSensorInfluence {
133    /// Sensor index in the caller's input slice.
134    pub sensor_index: usize,
135    /// ToA residual at the full solution in seconds.
136    pub residual_s: f64,
137    /// Held-out ToA residual after solving without this sensor, in seconds.
138    pub leave_one_out_residual_s: Option<f64>,
139    /// Position change between the full and leave-one-out solutions, in metres.
140    pub position_delta_m: Option<f64>,
141    /// Origin-time change between the full and leave-one-out solutions, in seconds.
142    pub origin_time_delta_s: Option<f64>,
143    /// First-derivative loss weight for the full-solution residual.
144    pub loss_weight: f64,
145    /// Normalized diagnostic score. Larger values indicate a poorer fit.
146    pub score: f64,
147}
148
149/// State covariance or Cramer-Rao lower bound for a source solve.
150#[derive(Debug, Clone, PartialEq)]
151pub struct SourceCovariance {
152    /// Full state covariance in solver state order.
153    pub state: Vec<Vec<f64>>,
154    /// Position covariance block in square metres.
155    pub position_m2: Vec<Vec<f64>>,
156    /// Origin-time variance in square seconds when origin time is in the state.
157    pub origin_time_s2: Option<f64>,
158    /// Timing sigma used to scale the cofactor.
159    pub timing_sigma_s: f64,
160}
161
162/// Source solution from [`locate_source`].
163#[derive(Debug, Clone, PartialEq)]
164pub struct SourceSolution {
165    /// Estimated source position in metres.
166    pub position_m: Vec<f64>,
167    /// Estimated origin time in seconds.
168    pub origin_time_s: Option<f64>,
169    /// State covariance scaled by [`SourceLocateOptions::timing_sigma_s`].
170    pub covariance: Option<SourceCovariance>,
171    /// Solver residuals in seconds.
172    pub residuals: Vec<SourceResidual>,
173    /// Per-sensor influence diagnostics.
174    pub per_sensor_influence: Vec<SourceSensorInfluence>,
175    /// Geometry observability and covariance-validation diagnostics for the
176    /// final timing design. Snapshot source solves use no propagated prior, so
177    /// `ZeroRedundancy` covariance bounds are unvalidated, `Weak` bounds are
178    /// reported without clamping, and `RankDeficient` is routed through a typed
179    /// geometry error instead of returning a solution.
180    pub geometry_quality: GeometryQuality,
181    /// Closed-form seed used to start the iterative solve.
182    pub initial_guess: SourceInitialGuess,
183    /// Trust-region termination status.
184    pub status: i32,
185    /// Residual evaluations used by the solver.
186    pub nfev: usize,
187    /// Jacobian evaluations used by the solver.
188    pub njev: usize,
189    /// Final least-squares cost.
190    pub cost: f64,
191    /// Infinity norm of the final gradient.
192    pub optimality: f64,
193}
194
195impl SourceSolution {
196    /// Return the covariance as the CRLB for the timing sigma used by the solve.
197    pub fn crlb(&self) -> Option<&SourceCovariance> {
198        self.covariance.as_ref()
199    }
200}
201
202/// CRLB and DOP for a proposed sensor/source geometry.
203#[derive(Debug, Clone, PartialEq)]
204pub struct SourceCrlb {
205    /// DOP scalars formed from the timing design matrix.
206    pub dop: Dop,
207    /// State covariance scaled by the requested timing sigma.
208    pub covariance: SourceCovariance,
209}
210
211/// Source-localization failure.
212#[derive(Debug, Clone, PartialEq)]
213pub enum SourceLocalizationError {
214    /// A boundary input is malformed.
215    InvalidInput {
216        /// Name of the malformed field.
217        field: &'static str,
218        /// Stable validation reason.
219        reason: &'static str,
220    },
221    /// There are fewer sensors than the selected solve needs.
222    TooFewSensors {
223        /// Number of sensors supplied.
224        sensors: usize,
225        /// Minimum number of sensors required.
226        needed: usize,
227    },
228    /// The closed-form initializer could not solve the geometry.
229    InitializerSingular,
230    /// Geometry DOP or CRLB failed.
231    Geometry(DopError),
232    /// The trust-region solver failed.
233    Solver(TrfError),
234    /// The trust-region solver exhausted its evaluation budget.
235    DidNotConverge {
236        /// Solver status code.
237        status: i32,
238    },
239}
240
241impl fmt::Display for SourceLocalizationError {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        match self {
244            Self::InvalidInput { field, reason } => {
245                write!(f, "invalid source localization input {field}: {reason}")
246            }
247            Self::TooFewSensors { sensors, needed } => {
248                write!(
249                    f,
250                    "source localization has {sensors} sensors; need at least {needed}"
251                )
252            }
253            Self::InitializerSingular => write!(f, "closed-form source initializer is singular"),
254            Self::Geometry(err) => write!(f, "source geometry failed: {err}"),
255            Self::Solver(err) => write!(f, "source solver failed: {err}"),
256            Self::DidNotConverge { status } => {
257                write!(f, "source solver did not converge, status {status}")
258            }
259        }
260    }
261}
262
263impl std::error::Error for SourceLocalizationError {
264    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
265        match self {
266            Self::Geometry(err) => Some(err),
267            Self::Solver(err) => Some(err),
268            _ => None,
269        }
270    }
271}
272
273impl From<DopError> for SourceLocalizationError {
274    fn from(value: DopError) -> Self {
275        Self::Geometry(value)
276    }
277}
278
279impl From<TrfError> for SourceLocalizationError {
280    fn from(value: TrfError) -> Self {
281        Self::Solver(value)
282    }
283}
284
285/// Locate a source from sensor arrival times.
286///
287/// `sensors` and `arrival_times_s` must have matching length. Positions must
288/// all be 2D or all be 3D. The call-level propagation speed is used for every
289/// sensor without a per-sensor override.
290pub fn locate_source(
291    sensors: &[Sensor],
292    arrival_times_s: &[f64],
293    propagation_speed_m_s: f64,
294    options: &SourceLocateOptions,
295) -> Result<SourceSolution, SourceLocalizationError> {
296    locate_source_inner(
297        sensors,
298        arrival_times_s,
299        propagation_speed_m_s,
300        options,
301        true,
302    )
303}
304
305/// Compute the closed-form Chan-Ho style seed used by [`locate_source`].
306///
307/// The seed uses the call-level propagation speed in the closed-form equations.
308/// Per-sensor speed overrides are applied by the iterative residual model.
309pub fn chan_ho_initial_guess(
310    sensors: &[Sensor],
311    arrival_times_s: &[f64],
312    propagation_speed_m_s: f64,
313    mode: SourceSolveMode,
314) -> Result<SourceInitialGuess, SourceLocalizationError> {
315    let options = SourceLocateOptions {
316        mode,
317        ..SourceLocateOptions::default()
318    };
319    let resolved =
320        resolve_locate_inputs(sensors, arrival_times_s, propagation_speed_m_s, &options)?;
321    chan_ho_initial_guess_resolved(sensors, arrival_times_s, propagation_speed_m_s, &resolved)
322}
323
324/// Compute timing DOP for a proposed source location.
325///
326/// The returned position DOP values multiply timing sigma in seconds to produce
327/// metres. The local Cartesian axes are used for the horizontal and vertical
328/// split.
329pub fn source_dop(
330    sensors: &[Sensor],
331    source_position_m: &[f64],
332    propagation_speed_m_s: f64,
333) -> Result<Dop, SourceLocalizationError> {
334    let resolved = resolve_geometry_inputs(sensors, source_position_m, propagation_speed_m_s)?;
335    let rows = source_toa_design_rows(sensors, source_position_m, &resolved)?;
336    let weights = vec![1.0; sensors.len()];
337    dop::dop_from_design_rows(&rows, &weights, resolved.dimension, identity_rotation())
338        .map_err(SourceLocalizationError::Geometry)
339}
340
341/// Compute a timing CRLB for a proposed source location.
342///
343/// The covariance is `(H^T H)^-1 * timing_sigma_s^2`, where each row is the ToA
344/// timing derivative at `source_position_m`.
345pub fn source_crlb(
346    sensors: &[Sensor],
347    source_position_m: &[f64],
348    propagation_speed_m_s: f64,
349    timing_sigma_s: f64,
350) -> Result<SourceCrlb, SourceLocalizationError> {
351    validate_positive("timing_sigma_s", timing_sigma_s)?;
352    let resolved = resolve_geometry_inputs(sensors, source_position_m, propagation_speed_m_s)?;
353    let rows = source_toa_design_rows(sensors, source_position_m, &resolved)?;
354    let weights = vec![1.0; sensors.len()];
355    let rotation = identity_rotation();
356    let cofactor =
357        dop::geometry_cofactor_from_design_rows(&rows, &weights, resolved.dimension, rotation)?;
358    let dop = dop::dop_from_design_rows(&rows, &weights, resolved.dimension, rotation)?;
359    let covariance =
360        covariance_from_state_cofactor(&cofactor.state, resolved.dimension, timing_sigma_s, true);
361    Ok(SourceCrlb { dop, covariance })
362}
363
364#[derive(Debug, Clone)]
365struct ResolvedInputs {
366    dimension: usize,
367    speeds_m_s: Vec<f64>,
368    mode: SourceSolveMode,
369}
370
371#[derive(Debug, Clone)]
372struct ResolvedGeometry {
373    dimension: usize,
374    speeds_m_s: Vec<f64>,
375}
376
377#[derive(Debug)]
378struct SourceProblem<'a> {
379    sensors: &'a [Sensor],
380    arrival_times_s: &'a [f64],
381    speeds_m_s: &'a [f64],
382    dimension: usize,
383    mode: SourceSolveMode,
384}
385
386impl SourceProblem<'_> {
387    fn residual_records(&self, residuals: &[f64]) -> Vec<SourceResidual> {
388        match self.mode {
389            SourceSolveMode::Toa => residuals
390                .iter()
391                .enumerate()
392                .map(|(sensor_index, &residual_s)| SourceResidual {
393                    sensor_index,
394                    reference_sensor_index: None,
395                    residual_s,
396                })
397                .collect(),
398            SourceSolveMode::Tdoa { reference_sensor } => {
399                let mut out = Vec::with_capacity(residuals.len());
400                let mut row = 0;
401                for sensor_index in 0..self.sensors.len() {
402                    if sensor_index == reference_sensor {
403                        continue;
404                    }
405                    out.push(SourceResidual {
406                        sensor_index,
407                        reference_sensor_index: Some(reference_sensor),
408                        residual_s: residuals[row],
409                    });
410                    row += 1;
411                }
412                out
413            }
414        }
415    }
416}
417
418impl ResidualModel for SourceProblem<'_> {
419    fn residual(&self, x: &[f64], out: &mut Vec<f64>) {
420        out.clear();
421        match self.mode {
422            SourceSolveMode::Toa => {
423                let origin_time_s = x[self.dimension];
424                for (i, sensor) in self.sensors.iter().enumerate() {
425                    let range_m = distance(&x[..self.dimension], &sensor.position_m);
426                    out.push(
427                        origin_time_s + range_m / self.speeds_m_s[i] - self.arrival_times_s[i],
428                    );
429                }
430            }
431            SourceSolveMode::Tdoa { reference_sensor } => {
432                let ref_range_m = distance(
433                    &x[..self.dimension],
434                    &self.sensors[reference_sensor].position_m,
435                );
436                let ref_time_s = ref_range_m / self.speeds_m_s[reference_sensor];
437                for (i, sensor) in self.sensors.iter().enumerate() {
438                    if i == reference_sensor {
439                        continue;
440                    }
441                    let range_m = distance(&x[..self.dimension], &sensor.position_m);
442                    let predicted_s = range_m / self.speeds_m_s[i] - ref_time_s;
443                    let observed_s =
444                        self.arrival_times_s[i] - self.arrival_times_s[reference_sensor];
445                    out.push(predicted_s - observed_s);
446                }
447            }
448        }
449    }
450
451    fn jacobian(&self, x: &[f64], _f0: &[f64], out: &mut Vec<f64>) {
452        out.clear();
453        match self.mode {
454            SourceSolveMode::Toa => {
455                let n = self.dimension + 1;
456                out.resize(self.sensors.len() * n, 0.0);
457                for (row, sensor) in self.sensors.iter().enumerate() {
458                    fill_range_derivative(
459                        &x[..self.dimension],
460                        &sensor.position_m,
461                        self.speeds_m_s[row],
462                        &mut out[row * n..row * n + self.dimension],
463                    );
464                    out[row * n + self.dimension] = 1.0;
465                }
466            }
467            SourceSolveMode::Tdoa { reference_sensor } => {
468                let n = self.dimension;
469                out.resize((self.sensors.len() - 1) * n, 0.0);
470                let mut ref_derivative = vec![0.0; self.dimension];
471                fill_range_derivative(
472                    &x[..self.dimension],
473                    &self.sensors[reference_sensor].position_m,
474                    self.speeds_m_s[reference_sensor],
475                    &mut ref_derivative,
476                );
477                let mut row = 0;
478                for (i, sensor) in self.sensors.iter().enumerate() {
479                    if i == reference_sensor {
480                        continue;
481                    }
482                    let start = row * n;
483                    fill_range_derivative(
484                        &x[..self.dimension],
485                        &sensor.position_m,
486                        self.speeds_m_s[i],
487                        &mut out[start..start + n],
488                    );
489                    for axis in 0..n {
490                        out[start + axis] -= ref_derivative[axis];
491                    }
492                    row += 1;
493                }
494            }
495        }
496    }
497}
498
499fn locate_source_inner(
500    sensors: &[Sensor],
501    arrival_times_s: &[f64],
502    propagation_speed_m_s: f64,
503    options: &SourceLocateOptions,
504    include_influence: bool,
505) -> Result<SourceSolution, SourceLocalizationError> {
506    let resolved = resolve_locate_inputs(sensors, arrival_times_s, propagation_speed_m_s, options)?;
507    let initial_guess =
508        chan_ho_initial_guess_resolved(sensors, arrival_times_s, propagation_speed_m_s, &resolved)?;
509    let mut x0 = initial_guess.position_m.clone();
510    if matches!(resolved.mode, SourceSolveMode::Toa) {
511        x0.push(initial_guess.origin_time_s.expect("ToA seed has time"));
512    }
513
514    let problem = SourceProblem {
515        sensors,
516        arrival_times_s,
517        speeds_m_s: &resolved.speeds_m_s,
518        dimension: resolved.dimension,
519        mode: resolved.mode,
520    };
521    let result = solve_model(&problem, &x0, &solver_options(options))?;
522    if !result.success() {
523        return Err(SourceLocalizationError::DidNotConverge {
524            status: result.status,
525        });
526    }
527
528    let mut solution = build_solution(
529        &problem,
530        &resolved,
531        &initial_guess,
532        result,
533        options.timing_sigma_s,
534    )?;
535    if include_influence {
536        solution.per_sensor_influence = compute_influence(
537            &solution,
538            sensors,
539            arrival_times_s,
540            propagation_speed_m_s,
541            options,
542        );
543    }
544    Ok(solution)
545}
546
547fn build_solution(
548    problem: &SourceProblem<'_>,
549    resolved: &ResolvedInputs,
550    initial_guess: &SourceInitialGuess,
551    result: TrfResult,
552    timing_sigma_s: f64,
553) -> Result<SourceSolution, SourceLocalizationError> {
554    let position_m = result.x[..resolved.dimension].to_vec();
555    let origin_time_s = match resolved.mode {
556        SourceSolveMode::Toa => Some(result.x[resolved.dimension]),
557        SourceSolveMode::Tdoa { .. } => Some(estimate_origin_time_s(
558            problem.sensors,
559            problem.arrival_times_s,
560            problem.speeds_m_s,
561            &position_m,
562        )),
563    };
564    let residuals = problem.residual_records(&result.fun);
565    let parameter_count = result.x.len();
566    let residual_count = result.fun.len();
567    let geometry_quality =
568        source_geometry_quality_from_jacobian(&result.jac, residual_count, parameter_count)?;
569    if geometry_quality.tier == ObservabilityTier::RankDeficient {
570        return Err(SourceLocalizationError::Geometry(DopError::Singular));
571    }
572    let covariance = covariance_from_jacobian(
573        &result.jac,
574        residual_count,
575        parameter_count,
576        resolved.dimension,
577        timing_sigma_s,
578    )
579    .ok_or(SourceLocalizationError::Geometry(DopError::Singular))?;
580    Ok(SourceSolution {
581        position_m,
582        origin_time_s,
583        covariance: Some(covariance),
584        residuals,
585        per_sensor_influence: Vec::new(),
586        geometry_quality,
587        initial_guess: initial_guess.clone(),
588        status: result.status,
589        nfev: result.nfev,
590        njev: result.njev,
591        cost: result.cost,
592        optimality: result.optimality,
593    })
594}
595
596fn source_geometry_quality_from_jacobian(
597    jac: &[f64],
598    m: usize,
599    n: usize,
600) -> Result<GeometryQuality, SourceLocalizationError> {
601    if n == 0 || m == 0 || jac.len() != m.saturating_mul(n) {
602        return Err(SourceLocalizationError::Geometry(DopError::Singular));
603    }
604    let matrix = DMatrix::from_row_slice(m, n, jac);
605    let singular_values = matrix.svd(false, false).singular_values;
606    let diagnostics = singular_value_diagnostics(singular_values.as_slice(), m, n);
607    let gdop = if diagnostics.rank < n {
608        f64::INFINITY
609    } else {
610        cofactor_trace_from_jacobian(jac, m, n)
611            .filter(|trace| *trace >= 0.0 && trace.is_finite())
612            .map(f64::sqrt)
613            .unwrap_or(f64::INFINITY)
614    };
615    Ok(classify(
616        diagnostics.rank,
617        n,
618        m as i32 - n as i32,
619        diagnostics.condition_number,
620        gdop,
621        false,
622        GeometryQualityThresholds::default(),
623    ))
624}
625
626fn chan_ho_initial_guess_resolved(
627    sensors: &[Sensor],
628    arrival_times_s: &[f64],
629    propagation_speed_m_s: f64,
630    resolved: &ResolvedInputs,
631) -> Result<SourceInitialGuess, SourceLocalizationError> {
632    match resolved.mode {
633        SourceSolveMode::Toa => {
634            chan_ho_toa_initial_guess(sensors, arrival_times_s, propagation_speed_m_s, resolved)
635        }
636        SourceSolveMode::Tdoa { reference_sensor } => chan_ho_tdoa_initial_guess(
637            sensors,
638            arrival_times_s,
639            propagation_speed_m_s,
640            resolved,
641            reference_sensor,
642        ),
643    }
644}
645
646fn chan_ho_toa_initial_guess(
647    sensors: &[Sensor],
648    arrival_times_s: &[f64],
649    propagation_speed_m_s: f64,
650    resolved: &ResolvedInputs,
651) -> Result<SourceInitialGuess, SourceLocalizationError> {
652    let d = resolved.dimension;
653    let ref_pos = &sensors[0].position_m;
654    let z0 = propagation_speed_m_s * arrival_times_s[0];
655    let ref_norm2 = dot(ref_pos, ref_pos);
656    let mut a = Vec::with_capacity(sensors.len() - 1);
657    let mut b = Vec::with_capacity(sensors.len() - 1);
658    let mut h = Vec::with_capacity(sensors.len() - 1);
659    for i in 1..sensors.len() {
660        let row: Vec<f64> = sensors[i]
661            .position_m
662            .iter()
663            .zip(ref_pos)
664            .map(|(s, r)| s - r)
665            .collect();
666        let zi = propagation_speed_m_s * arrival_times_s[i];
667        let delta_z = zi - z0;
668        let delta_norm = dot(&sensors[i].position_m, &sensors[i].position_m) - ref_norm2;
669        a.push(row);
670        b.push(0.5 * (delta_norm - (zi * zi - z0 * z0)));
671        h.push(delta_z);
672    }
673    let p0 = least_squares(&a, &b)?;
674    let p1 = least_squares(&a, &h)?;
675    let q: Vec<f64> = p0.iter().zip(ref_pos).map(|(p, r)| p - r).collect();
676    let roots = quadratic_roots(
677        dot(&p1, &p1) - 1.0,
678        2.0 * dot(&q, &p1) + 2.0 * z0,
679        dot(&q, &q) - z0 * z0,
680    )?;
681
682    let mut best: Option<SourceInitialGuess> = None;
683    let mut best_sse = f64::INFINITY;
684    for tau_m in roots {
685        let position_m: Vec<f64> = (0..d).map(|axis| p0[axis] + p1[axis] * tau_m).collect();
686        if sensors.iter().any(|sensor| {
687            distance(&position_m, &sensor.position_m) > propagation_speed_m_s * 1.0e12
688        }) {
689            continue;
690        }
691        let origin_time_s = tau_m / propagation_speed_m_s;
692        let sse = toa_sse(
693            sensors,
694            arrival_times_s,
695            &resolved.speeds_m_s,
696            &position_m,
697            origin_time_s,
698        );
699        if sse < best_sse {
700            best_sse = sse;
701            best = Some(SourceInitialGuess {
702                position_m,
703                origin_time_s: Some(origin_time_s),
704                residual_rms_s: (sse / sensors.len() as f64).sqrt(),
705            });
706        }
707    }
708    best.ok_or(SourceLocalizationError::InitializerSingular)
709}
710
711fn chan_ho_tdoa_initial_guess(
712    sensors: &[Sensor],
713    arrival_times_s: &[f64],
714    propagation_speed_m_s: f64,
715    resolved: &ResolvedInputs,
716    reference_sensor: usize,
717) -> Result<SourceInitialGuess, SourceLocalizationError> {
718    let d = resolved.dimension;
719    let ref_pos = &sensors[reference_sensor].position_m;
720    let ref_norm2 = dot(ref_pos, ref_pos);
721    let mut a = Vec::with_capacity(sensors.len() - 1);
722    let mut b = Vec::with_capacity(sensors.len() - 1);
723    let mut h = Vec::with_capacity(sensors.len() - 1);
724    for (i, sensor) in sensors.iter().enumerate() {
725        if i == reference_sensor {
726            continue;
727        }
728        let row: Vec<f64> = sensor
729            .position_m
730            .iter()
731            .zip(ref_pos)
732            .map(|(s, r)| s - r)
733            .collect();
734        let delta_range_m =
735            propagation_speed_m_s * (arrival_times_s[i] - arrival_times_s[reference_sensor]);
736        let delta_norm = dot(&sensor.position_m, &sensor.position_m) - ref_norm2;
737        a.push(row);
738        b.push(0.5 * (delta_norm - delta_range_m * delta_range_m));
739        h.push(-delta_range_m);
740    }
741    let p0 = least_squares(&a, &b)?;
742    let p1 = least_squares(&a, &h)?;
743    let q: Vec<f64> = p0.iter().zip(ref_pos).map(|(p, r)| p - r).collect();
744    let roots = quadratic_roots(dot(&p1, &p1) - 1.0, 2.0 * dot(&q, &p1), dot(&q, &q))?;
745
746    let mut best: Option<SourceInitialGuess> = None;
747    let mut best_sse = f64::INFINITY;
748    for rho_m in roots {
749        if rho_m < 0.0 {
750            continue;
751        }
752        let position_m: Vec<f64> = (0..d).map(|axis| p0[axis] + p1[axis] * rho_m).collect();
753        let origin_time_s =
754            estimate_origin_time_s(sensors, arrival_times_s, &resolved.speeds_m_s, &position_m);
755        let sse = tdoa_sse(
756            sensors,
757            arrival_times_s,
758            &resolved.speeds_m_s,
759            &position_m,
760            reference_sensor,
761        );
762        if sse < best_sse {
763            best_sse = sse;
764            best = Some(SourceInitialGuess {
765                position_m,
766                origin_time_s: Some(origin_time_s),
767                residual_rms_s: (sse / (sensors.len() - 1) as f64).sqrt(),
768            });
769        }
770    }
771    best.ok_or(SourceLocalizationError::InitializerSingular)
772}
773
774fn compute_influence(
775    solution: &SourceSolution,
776    sensors: &[Sensor],
777    arrival_times_s: &[f64],
778    propagation_speed_m_s: f64,
779    options: &SourceLocateOptions,
780) -> Vec<SourceSensorInfluence> {
781    let speeds = match sensor_speeds(sensors, propagation_speed_m_s) {
782        Ok(speeds) => speeds,
783        Err(_) => return Vec::new(),
784    };
785    let origin_time_s = solution.origin_time_s.unwrap_or_else(|| {
786        estimate_origin_time_s(sensors, arrival_times_s, &speeds, &solution.position_m)
787    });
788    let full_residuals = toa_residuals(
789        sensors,
790        arrival_times_s,
791        &speeds,
792        &solution.position_m,
793        origin_time_s,
794    );
795    let sigma = options.timing_sigma_s.max(f64::MIN_POSITIVE);
796
797    (0..sensors.len())
798        .map(|sensor_index| {
799            let loo = leave_one_out_solution(
800                sensors,
801                arrival_times_s,
802                propagation_speed_m_s,
803                options,
804                sensor_index,
805            );
806            let (leave_one_out_residual_s, position_delta_m, origin_time_delta_s) =
807                if let Some(loo_solution) = loo {
808                    let loo_origin = loo_solution.origin_time_s.unwrap_or_else(|| {
809                        estimate_origin_time_s(
810                            sensors,
811                            arrival_times_s,
812                            &speeds,
813                            &loo_solution.position_m,
814                        )
815                    });
816                    let held_out_residual = single_toa_residual(
817                        &sensors[sensor_index],
818                        arrival_times_s[sensor_index],
819                        speeds[sensor_index],
820                        &loo_solution.position_m,
821                        loo_origin,
822                    );
823                    (
824                        Some(held_out_residual),
825                        Some(distance(&solution.position_m, &loo_solution.position_m)),
826                        Some((origin_time_s - loo_origin).abs()),
827                    )
828                } else {
829                    (None, None, None)
830                };
831            let loss_weight = loss_weight(
832                options.loss,
833                options.f_scale_s,
834                full_residuals[sensor_index],
835            );
836            let score_basis = leave_one_out_residual_s
837                .unwrap_or(full_residuals[sensor_index])
838                .abs()
839                .max(full_residuals[sensor_index].abs());
840            SourceSensorInfluence {
841                sensor_index,
842                residual_s: full_residuals[sensor_index],
843                leave_one_out_residual_s,
844                position_delta_m,
845                origin_time_delta_s,
846                loss_weight,
847                score: score_basis / sigma + (1.0 - loss_weight) * 1.0e6,
848            }
849        })
850        .collect()
851}
852
853fn leave_one_out_solution(
854    sensors: &[Sensor],
855    arrival_times_s: &[f64],
856    propagation_speed_m_s: f64,
857    options: &SourceLocateOptions,
858    excluded: usize,
859) -> Option<SourceSolution> {
860    let mut sub_sensors = Vec::with_capacity(sensors.len() - 1);
861    let mut sub_arrivals = Vec::with_capacity(arrival_times_s.len() - 1);
862    for (i, sensor) in sensors.iter().enumerate() {
863        if i == excluded {
864            continue;
865        }
866        sub_sensors.push(sensor.clone());
867        sub_arrivals.push(arrival_times_s[i]);
868    }
869    let mut sub_options = options.clone();
870    sub_options.mode = match options.mode {
871        SourceSolveMode::Toa => SourceSolveMode::Toa,
872        SourceSolveMode::Tdoa { reference_sensor } => {
873            if excluded == reference_sensor {
874                SourceSolveMode::Tdoa {
875                    reference_sensor: 0,
876                }
877            } else if excluded < reference_sensor {
878                SourceSolveMode::Tdoa {
879                    reference_sensor: reference_sensor - 1,
880                }
881            } else {
882                SourceSolveMode::Tdoa { reference_sensor }
883            }
884        }
885    };
886    locate_source_inner(
887        &sub_sensors,
888        &sub_arrivals,
889        propagation_speed_m_s,
890        &sub_options,
891        false,
892    )
893    .ok()
894}
895
896fn resolve_locate_inputs(
897    sensors: &[Sensor],
898    arrival_times_s: &[f64],
899    propagation_speed_m_s: f64,
900    options: &SourceLocateOptions,
901) -> Result<ResolvedInputs, SourceLocalizationError> {
902    if sensors.len() != arrival_times_s.len() {
903        return Err(invalid_input(
904            "arrival_times_s",
905            "length must match sensors",
906        ));
907    }
908    for &arrival in arrival_times_s {
909        validate_finite("arrival_times_s", arrival)?;
910    }
911    validate_positive("timing_sigma_s", options.timing_sigma_s)?;
912    if options.loss != Loss::Linear {
913        validate_positive("f_scale_s", options.f_scale_s)?;
914    }
915    validate_optional_positive("ftol", options.ftol)?;
916    validate_optional_positive("xtol", options.xtol)?;
917    validate_optional_positive("gtol", options.gtol)?;
918    if options.max_nfev == Some(0) {
919        return Err(invalid_input("max_nfev", "must be positive"));
920    }
921    let geometry = resolve_geometry_inputs(
922        sensors,
923        sensors
924            .first()
925            .map(|sensor| sensor.position_m.as_slice())
926            .unwrap_or(&[]),
927        propagation_speed_m_s,
928    )?;
929    if let SourceSolveMode::Tdoa { reference_sensor } = options.mode {
930        if reference_sensor >= sensors.len() {
931            return Err(invalid_input("reference_sensor", "out of range"));
932        }
933    }
934    let needed = geometry.dimension + 1;
935    if sensors.len() < needed {
936        return Err(SourceLocalizationError::TooFewSensors {
937            sensors: sensors.len(),
938            needed,
939        });
940    }
941    Ok(ResolvedInputs {
942        dimension: geometry.dimension,
943        speeds_m_s: geometry.speeds_m_s,
944        mode: options.mode,
945    })
946}
947
948fn resolve_geometry_inputs(
949    sensors: &[Sensor],
950    source_position_m: &[f64],
951    propagation_speed_m_s: f64,
952) -> Result<ResolvedGeometry, SourceLocalizationError> {
953    if sensors.is_empty() {
954        return Err(SourceLocalizationError::TooFewSensors {
955            sensors: 0,
956            needed: 3,
957        });
958    }
959    validate_positive("propagation_speed_m_s", propagation_speed_m_s)?;
960    let dimension = sensors[0].position_m.len();
961    if !(2..=3).contains(&dimension) {
962        return Err(invalid_input("position_m", "length must be 2 or 3"));
963    }
964    if !source_position_m.is_empty() && source_position_m.len() != dimension {
965        return Err(invalid_input(
966            "source_position_m",
967            "length must match sensors",
968        ));
969    }
970    for sensor in sensors {
971        if sensor.position_m.len() != dimension {
972            return Err(invalid_input("position_m", "length must match sensors"));
973        }
974        for &value in &sensor.position_m {
975            validate_finite("position_m", value)?;
976        }
977        if let Some(speed) = sensor.propagation_speed_m_s {
978            validate_positive("sensor.propagation_speed_m_s", speed)?;
979        }
980    }
981    for &value in source_position_m {
982        validate_finite("source_position_m", value)?;
983    }
984    Ok(ResolvedGeometry {
985        dimension,
986        speeds_m_s: sensor_speeds(sensors, propagation_speed_m_s)?,
987    })
988}
989
990fn sensor_speeds(
991    sensors: &[Sensor],
992    propagation_speed_m_s: f64,
993) -> Result<Vec<f64>, SourceLocalizationError> {
994    validate_positive("propagation_speed_m_s", propagation_speed_m_s)?;
995    sensors
996        .iter()
997        .map(|sensor| {
998            let speed = sensor
999                .propagation_speed_m_s
1000                .unwrap_or(propagation_speed_m_s);
1001            validate_positive("sensor.propagation_speed_m_s", speed)?;
1002            Ok(speed)
1003        })
1004        .collect()
1005}
1006
1007fn source_toa_design_rows(
1008    sensors: &[Sensor],
1009    source_position_m: &[f64],
1010    resolved: &ResolvedGeometry,
1011) -> Result<Vec<Vec<f64>>, SourceLocalizationError> {
1012    sensors
1013        .iter()
1014        .zip(&resolved.speeds_m_s)
1015        .map(|(sensor, &speed)| {
1016            let mut row = vec![0.0; resolved.dimension + 1];
1017            let range_m = distance(source_position_m, &sensor.position_m);
1018            if range_m <= 0.0 {
1019                return Err(invalid_input(
1020                    "source_position_m",
1021                    "coincident with a sensor",
1022                ));
1023            }
1024            for axis in 0..resolved.dimension {
1025                row[axis] = (source_position_m[axis] - sensor.position_m[axis]) / range_m / speed;
1026            }
1027            row[resolved.dimension] = 1.0;
1028            Ok(row)
1029        })
1030        .collect()
1031}
1032
1033fn covariance_from_jacobian(
1034    jac: &[f64],
1035    m: usize,
1036    n: usize,
1037    dimension: usize,
1038    timing_sigma_s: f64,
1039) -> Option<SourceCovariance> {
1040    let cofactor = cofactor_from_jacobian(jac, m, n)?;
1041    Some(covariance_from_state_cofactor(
1042        &cofactor,
1043        dimension,
1044        timing_sigma_s,
1045        n == dimension + 1,
1046    ))
1047}
1048
1049fn cofactor_trace_from_jacobian(jac: &[f64], m: usize, n: usize) -> Option<f64> {
1050    let cofactor = cofactor_from_jacobian(jac, m, n)?;
1051    Some((0..n).map(|idx| cofactor[idx][idx]).sum())
1052}
1053
1054fn cofactor_from_jacobian(jac: &[f64], m: usize, n: usize) -> Option<Vec<Vec<f64>>> {
1055    if jac.len() != m.checked_mul(n)? {
1056        return None;
1057    }
1058    let mut normal = vec![vec![0.0_f64; n]; n];
1059    for row in 0..m {
1060        for i in 0..n {
1061            for j in 0..n {
1062                normal[i][j] += jac[row * n + i] * jac[row * n + j];
1063            }
1064        }
1065    }
1066    crate::astro::math::linear::invert_symmetric_pd(&normal)
1067}
1068
1069fn covariance_from_state_cofactor(
1070    cofactor: &[Vec<f64>],
1071    dimension: usize,
1072    timing_sigma_s: f64,
1073    has_origin_time: bool,
1074) -> SourceCovariance {
1075    let scale = timing_sigma_s * timing_sigma_s;
1076    let state: Vec<Vec<f64>> = cofactor
1077        .iter()
1078        .map(|row| row.iter().map(|value| value * scale).collect())
1079        .collect();
1080    let position_m2: Vec<Vec<f64>> = (0..dimension)
1081        .map(|i| (0..dimension).map(|j| state[i][j]).collect())
1082        .collect();
1083    SourceCovariance {
1084        origin_time_s2: if has_origin_time {
1085            Some(state[dimension][dimension])
1086        } else {
1087            None
1088        },
1089        state,
1090        position_m2,
1091        timing_sigma_s,
1092    }
1093}
1094
1095fn solver_options(config: &SourceLocateOptions) -> TrfOptions {
1096    let mut options = TrfOptions::default();
1097    if let Some(ftol) = config.ftol {
1098        options.ftol = ftol;
1099    }
1100    if let Some(xtol) = config.xtol {
1101        options.xtol = xtol;
1102    }
1103    if let Some(gtol) = config.gtol {
1104        options.gtol = gtol;
1105    }
1106    options.max_nfev = config.max_nfev;
1107    options.x_scale = XScale::Jac;
1108    options.loss = config.loss;
1109    options.f_scale = config.f_scale_s;
1110    options
1111}
1112
1113fn least_squares(a: &[Vec<f64>], y: &[f64]) -> Result<Vec<f64>, SourceLocalizationError> {
1114    let n = a.first().map(Vec::len).unwrap_or(0);
1115    if n == 0 || a.len() != y.len() || a.len() < n {
1116        return Err(SourceLocalizationError::InitializerSingular);
1117    }
1118    let mut normal = vec![vec![0.0_f64; n]; n];
1119    let mut rhs = vec![0.0_f64; n];
1120    for (row, &value) in a.iter().zip(y) {
1121        if row.len() != n {
1122            return Err(SourceLocalizationError::InitializerSingular);
1123        }
1124        for i in 0..n {
1125            rhs[i] += row[i] * value;
1126            for j in 0..n {
1127                normal[i][j] += row[i] * row[j];
1128            }
1129        }
1130    }
1131    let inv = crate::astro::math::linear::invert_symmetric_pd(&normal)
1132        .ok_or(SourceLocalizationError::InitializerSingular)?;
1133    Ok((0..n)
1134        .map(|i| (0..n).map(|j| inv[i][j] * rhs[j]).sum())
1135        .collect())
1136}
1137
1138fn quadratic_roots(a: f64, b: f64, c: f64) -> Result<Vec<f64>, SourceLocalizationError> {
1139    if !a.is_finite() || !b.is_finite() || !c.is_finite() {
1140        return Err(SourceLocalizationError::InitializerSingular);
1141    }
1142    if a.abs() <= ROOT_TOL {
1143        if b.abs() <= ROOT_TOL {
1144            return Err(SourceLocalizationError::InitializerSingular);
1145        }
1146        return Ok(vec![-c / b]);
1147    }
1148    let disc = b * b - 4.0 * a * c;
1149    if disc < -ROOT_TOL || !disc.is_finite() {
1150        return Err(SourceLocalizationError::InitializerSingular);
1151    }
1152    let root = disc.max(0.0).sqrt();
1153    Ok(vec![(-b - root) / (2.0 * a), (-b + root) / (2.0 * a)])
1154}
1155
1156fn toa_sse(
1157    sensors: &[Sensor],
1158    arrival_times_s: &[f64],
1159    speeds_m_s: &[f64],
1160    position_m: &[f64],
1161    origin_time_s: f64,
1162) -> f64 {
1163    toa_residuals(
1164        sensors,
1165        arrival_times_s,
1166        speeds_m_s,
1167        position_m,
1168        origin_time_s,
1169    )
1170    .iter()
1171    .map(|value| value * value)
1172    .sum()
1173}
1174
1175fn tdoa_sse(
1176    sensors: &[Sensor],
1177    arrival_times_s: &[f64],
1178    speeds_m_s: &[f64],
1179    position_m: &[f64],
1180    reference_sensor: usize,
1181) -> f64 {
1182    let ref_time =
1183        distance(position_m, &sensors[reference_sensor].position_m) / speeds_m_s[reference_sensor];
1184    let mut sse = 0.0;
1185    for (i, sensor) in sensors.iter().enumerate() {
1186        if i == reference_sensor {
1187            continue;
1188        }
1189        let predicted = distance(position_m, &sensor.position_m) / speeds_m_s[i] - ref_time;
1190        let observed = arrival_times_s[i] - arrival_times_s[reference_sensor];
1191        let residual = predicted - observed;
1192        sse += residual * residual;
1193    }
1194    sse
1195}
1196
1197fn toa_residuals(
1198    sensors: &[Sensor],
1199    arrival_times_s: &[f64],
1200    speeds_m_s: &[f64],
1201    position_m: &[f64],
1202    origin_time_s: f64,
1203) -> Vec<f64> {
1204    sensors
1205        .iter()
1206        .enumerate()
1207        .map(|(i, sensor)| {
1208            single_toa_residual(
1209                sensor,
1210                arrival_times_s[i],
1211                speeds_m_s[i],
1212                position_m,
1213                origin_time_s,
1214            )
1215        })
1216        .collect()
1217}
1218
1219fn single_toa_residual(
1220    sensor: &Sensor,
1221    arrival_time_s: f64,
1222    speed_m_s: f64,
1223    position_m: &[f64],
1224    origin_time_s: f64,
1225) -> f64 {
1226    origin_time_s + distance(position_m, &sensor.position_m) / speed_m_s - arrival_time_s
1227}
1228
1229fn estimate_origin_time_s(
1230    sensors: &[Sensor],
1231    arrival_times_s: &[f64],
1232    speeds_m_s: &[f64],
1233    position_m: &[f64],
1234) -> f64 {
1235    let sum: f64 = sensors
1236        .iter()
1237        .enumerate()
1238        .map(|(i, sensor)| {
1239            arrival_times_s[i] - distance(position_m, &sensor.position_m) / speeds_m_s[i]
1240        })
1241        .sum();
1242    sum / sensors.len() as f64
1243}
1244
1245fn fill_range_derivative(position_m: &[f64], sensor_m: &[f64], speed_m_s: f64, out: &mut [f64]) {
1246    let range_m = distance(position_m, sensor_m);
1247    if range_m <= 0.0 || !range_m.is_finite() {
1248        out.fill(0.0);
1249        return;
1250    }
1251    for axis in 0..out.len() {
1252        out[axis] = (position_m[axis] - sensor_m[axis]) / range_m / speed_m_s;
1253    }
1254}
1255
1256fn loss_weight(loss: Loss, f_scale_s: f64, residual_s: f64) -> f64 {
1257    if loss == Loss::Linear {
1258        return 1.0;
1259    }
1260    let z = (residual_s / f_scale_s) * (residual_s / f_scale_s);
1261    match loss {
1262        Loss::Linear => 1.0,
1263        Loss::Huber => {
1264            if z <= 1.0 {
1265                1.0
1266            } else {
1267                z.sqrt().recip()
1268            }
1269        }
1270        Loss::SoftL1 => (1.0 + z).sqrt().recip(),
1271        Loss::Cauchy => (1.0 + z).recip(),
1272        Loss::Arctan => (1.0 + z * z).recip(),
1273    }
1274}
1275
1276fn distance(a: &[f64], b: &[f64]) -> f64 {
1277    a.iter()
1278        .zip(b)
1279        .map(|(x, y)| {
1280            let d = x - y;
1281            d * d
1282        })
1283        .sum::<f64>()
1284        .sqrt()
1285}
1286
1287fn dot(a: &[f64], b: &[f64]) -> f64 {
1288    a.iter().zip(b).map(|(x, y)| x * y).sum()
1289}
1290
1291fn identity_rotation() -> [[f64; 3]; 3] {
1292    [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
1293}
1294
1295fn invalid_input(field: &'static str, reason: &'static str) -> SourceLocalizationError {
1296    SourceLocalizationError::InvalidInput { field, reason }
1297}
1298
1299fn validate_optional_positive(
1300    field: &'static str,
1301    value: Option<f64>,
1302) -> Result<(), SourceLocalizationError> {
1303    if let Some(value) = value {
1304        validate_positive(field, value)?;
1305    }
1306    Ok(())
1307}
1308
1309fn validate_positive(field: &'static str, value: f64) -> Result<(), SourceLocalizationError> {
1310    validate_finite(field, value)?;
1311    if value <= 0.0 {
1312        return Err(invalid_input(field, "must be > 0"));
1313    }
1314    Ok(())
1315}
1316
1317fn validate_finite(field: &'static str, value: f64) -> Result<(), SourceLocalizationError> {
1318    if value.is_finite() {
1319        Ok(())
1320    } else {
1321        Err(invalid_input(field, "must be finite"))
1322    }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327    //! Analytic source-localization fixtures.
1328    //!
1329    //! The tests below use Euclidean ranges, closed-form normal equations, and
1330    //! synthetic corrupted arrivals. They do not compare against another
1331    //! implementation.
1332
1333    use super::*;
1334    use crate::dop::{dop, dop_from_design_rows, ecef_to_enu_rotation, LineOfSight};
1335    use crate::frame::Wgs84Geodetic;
1336
1337    fn arrivals(sensors: &[Sensor], source: &[f64], origin: f64, speed: f64) -> Vec<f64> {
1338        sensors
1339            .iter()
1340            .map(|sensor| {
1341                let s = sensor.propagation_speed_m_s.unwrap_or(speed);
1342                origin + distance(source, &sensor.position_m) / s
1343            })
1344            .collect()
1345    }
1346
1347    fn assert_vec_close(actual: &[f64], expected: &[f64], tol: f64) {
1348        for (axis, (a, e)) in actual.iter().zip(expected).enumerate() {
1349            assert!(
1350                (a - e).abs() < tol,
1351                "axis {axis}: actual {a}, expected {e}, tol {tol}"
1352            );
1353        }
1354    }
1355
1356    #[test]
1357    fn chan_ho_toa_initializer_recovers_clean_3d() {
1358        let sensors = vec![
1359            Sensor::new(vec![0.0, 0.0, 0.0]),
1360            Sensor::new(vec![1200.0, 0.0, 0.0]),
1361            Sensor::new(vec![0.0, 900.0, 0.0]),
1362            Sensor::new(vec![0.0, 0.0, 700.0]),
1363            Sensor::new(vec![1100.0, 800.0, 600.0]),
1364        ];
1365        let source = vec![320.0, 260.0, 180.0];
1366        let origin = 12.5;
1367        let speed = 343.0;
1368        let times = arrivals(&sensors, &source, origin, speed);
1369
1370        let seed =
1371            chan_ho_initial_guess(&sensors, &times, speed, SourceSolveMode::Toa).expect("seed");
1372        assert_vec_close(&seed.position_m, &source, 1.0e-8);
1373        assert!((seed.origin_time_s.unwrap() - origin).abs() < 1.0e-10);
1374        assert!(seed.residual_rms_s < 1.0e-11);
1375    }
1376
1377    #[test]
1378    fn locate_source_toa_recovers_clean_3d() {
1379        let sensors = vec![
1380            Sensor::new(vec![0.0, 0.0, 0.0]),
1381            Sensor::new(vec![1200.0, 0.0, 0.0]),
1382            Sensor::new(vec![0.0, 900.0, 0.0]),
1383            Sensor::new(vec![0.0, 0.0, 700.0]),
1384            Sensor::new(vec![1100.0, 800.0, 600.0]),
1385        ];
1386        let source = vec![320.0, 260.0, 180.0];
1387        let origin = 12.5;
1388        let speed = 343.0;
1389        let times = arrivals(&sensors, &source, origin, speed);
1390        let options = SourceLocateOptions {
1391            timing_sigma_s: 0.001,
1392            ..SourceLocateOptions::default()
1393        };
1394
1395        let solution = locate_source(&sensors, &times, speed, &options).expect("solution");
1396        assert_vec_close(&solution.position_m, &source, 1.0e-7);
1397        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-10);
1398        assert!(solution.covariance.is_some());
1399        assert!(solution
1400            .residuals
1401            .iter()
1402            .all(|row| row.residual_s.abs() < 1.0e-10));
1403    }
1404
1405    #[test]
1406    fn locate_source_toa_geometry_quality_is_nominal() {
1407        let sensors = vec![
1408            Sensor::new(vec![0.0, 0.0, 0.0]),
1409            Sensor::new(vec![2.0, 0.0, 0.0]),
1410            Sensor::new(vec![0.0, 2.0, 0.0]),
1411            Sensor::new(vec![0.0, 0.0, 2.0]),
1412            Sensor::new(vec![2.0, 2.0, 2.0]),
1413        ];
1414        let source = vec![0.4, 0.6, 0.5];
1415        let origin = 1.25;
1416        let speed = 1.0;
1417        let times = arrivals(&sensors, &source, origin, speed);
1418
1419        let solution = locate_source(&sensors, &times, speed, &SourceLocateOptions::default())
1420            .expect("well-posed source solve");
1421
1422        assert_eq!(
1423            solution.geometry_quality.tier,
1424            crate::geometry_quality::ObservabilityTier::Nominal
1425        );
1426        assert_eq!(solution.geometry_quality.rank, 4);
1427        assert_eq!(solution.geometry_quality.redundancy, 1);
1428        assert!(solution.geometry_quality.raim_checkable);
1429        assert!(solution.geometry_quality.covariance_validated);
1430    }
1431
1432    #[test]
1433    fn locate_source_tdoa_recovers_clean_2d() {
1434        let sensors = vec![
1435            Sensor::new(vec![0.0, 0.0]),
1436            Sensor::new(vec![1000.0, 0.0]),
1437            Sensor::new(vec![0.0, 800.0]),
1438            Sensor::new(vec![900.0, 900.0]),
1439        ];
1440        let source = vec![300.0, 260.0];
1441        let origin = 4.0;
1442        let speed = 340.0;
1443        let times = arrivals(&sensors, &source, origin, speed);
1444        let options = SourceLocateOptions {
1445            mode: SourceSolveMode::Tdoa {
1446                reference_sensor: 0,
1447            },
1448            timing_sigma_s: 0.001,
1449            ..SourceLocateOptions::default()
1450        };
1451
1452        let solution = locate_source(&sensors, &times, speed, &options).expect("solution");
1453        assert_vec_close(&solution.position_m, &source, 1.0e-7);
1454        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-9);
1455        assert_eq!(solution.residuals.len(), sensors.len() - 1);
1456    }
1457
1458    #[test]
1459    fn per_sensor_speed_override_refines_from_uniform_seed() {
1460        let sensors = vec![
1461            Sensor::new(vec![0.0, 0.0, 0.0]),
1462            Sensor::with_speed(vec![1200.0, 0.0, 0.0], 330.0),
1463            Sensor::new(vec![0.0, 900.0, 0.0]),
1464            Sensor::new(vec![0.0, 0.0, 700.0]),
1465            Sensor::new(vec![1100.0, 800.0, 600.0]),
1466        ];
1467        let source = vec![320.0, 260.0, 180.0];
1468        let origin = 12.5;
1469        let speed = 343.0;
1470        let times = arrivals(&sensors, &source, origin, speed);
1471
1472        let solution =
1473            locate_source(&sensors, &times, speed, &SourceLocateOptions::default()).expect("solve");
1474        assert_vec_close(&solution.position_m, &source, 1.0e-6);
1475        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-9);
1476    }
1477
1478    #[test]
1479    fn source_dop_matches_hand_computed_square_layout() {
1480        let sensors = vec![
1481            Sensor::new(vec![100.0, 0.0]),
1482            Sensor::new(vec![-100.0, 0.0]),
1483            Sensor::new(vec![0.0, 100.0]),
1484            Sensor::new(vec![0.0, -100.0]),
1485        ];
1486        let source = vec![0.0, 0.0];
1487        let speed = 10.0;
1488
1489        let d = source_dop(&sensors, &source, speed).expect("dop");
1490        assert!((d.pdop - 10.0).abs() < 1.0e-12);
1491        assert!((d.hdop - 10.0).abs() < 1.0e-12);
1492        assert_eq!(d.vdop.to_bits(), 0.0_f64.to_bits());
1493        assert!((d.tdop - 0.5).abs() < 1.0e-12);
1494        assert!((d.gdop - 100.25_f64.sqrt()).abs() < 1.0e-12);
1495
1496        let crlb = source_crlb(&sensors, &source, speed, 0.01).expect("crlb");
1497        assert!((crlb.covariance.position_m2[0][0] - 0.005).abs() < 1.0e-15);
1498        assert!((crlb.covariance.position_m2[1][1] - 0.005).abs() < 1.0e-15);
1499        assert!((crlb.covariance.origin_time_s2.unwrap() - 0.000025).abs() < 1.0e-18);
1500    }
1501
1502    #[test]
1503    fn generalized_dop_matches_gnss_rows() {
1504        let receiver = Wgs84Geodetic::new(45.0_f64.to_radians(), -75.0_f64.to_radians(), 100.0)
1505            .expect("receiver");
1506        let los = vec![
1507            LineOfSight::new(0.6509445549041194, -0.3229151081253906, 0.6870132099084238),
1508            LineOfSight::new(-0.1936430033175727, 0.7473746634879952, 0.6356771337896102),
1509            LineOfSight::new(
1510                -0.730_360_483_841_695,
1511                -0.506583142388898,
1512                0.4579016226872558,
1513            ),
1514            LineOfSight::new(0.189511839684945, -0.9347210311772362, 0.300573550871319),
1515        ];
1516        let weights = vec![1.0, 0.9, 1.2, 0.8];
1517        let gnss = dop(&los, &weights, receiver).expect("gnss dop");
1518        let rows = los
1519            .iter()
1520            .map(|line| vec![-line.e_x, -line.e_y, -line.e_z, 1.0])
1521            .collect::<Vec<_>>();
1522        let rotation = ecef_to_enu_rotation(receiver.lat_rad, receiver.lon_rad);
1523        let general = dop_from_design_rows(&rows, &weights, 3, rotation).expect("general dop");
1524
1525        assert_eq!(gnss.gdop.to_bits(), general.gdop.to_bits());
1526        assert_eq!(gnss.pdop.to_bits(), general.pdop.to_bits());
1527        assert_eq!(gnss.hdop.to_bits(), general.hdop.to_bits());
1528        assert_eq!(gnss.vdop.to_bits(), general.vdop.to_bits());
1529        assert_eq!(gnss.tdop.to_bits(), general.tdop.to_bits());
1530    }
1531
1532    #[test]
1533    fn corrupted_arrival_is_downweighted_and_flagged() {
1534        let sensors = vec![
1535            Sensor::new(vec![100.0, 0.0]),
1536            Sensor::new(vec![-100.0, 0.0]),
1537            Sensor::new(vec![0.0, 100.0]),
1538            Sensor::new(vec![0.0, -100.0]),
1539            Sensor::new(vec![120.0, 120.0]),
1540            Sensor::new(vec![-120.0, 80.0]),
1541        ];
1542        let source = vec![15.0, -20.0];
1543        let origin = 1.25;
1544        let speed = 50.0;
1545        let mut times = arrivals(&sensors, &source, origin, speed);
1546        times[4] += 0.5;
1547        let options = SourceLocateOptions {
1548            loss: Loss::Huber,
1549            f_scale_s: 0.01,
1550            timing_sigma_s: 0.01,
1551            ..SourceLocateOptions::default()
1552        };
1553
1554        let solution = locate_source(&sensors, &times, speed, &options).expect("solution");
1555        let worst = solution
1556            .per_sensor_influence
1557            .iter()
1558            .max_by(|a, b| a.score.total_cmp(&b.score))
1559            .expect("influence");
1560        assert_eq!(worst.sensor_index, 4);
1561        assert!(worst.loss_weight < 0.05);
1562    }
1563
1564    #[test]
1565    fn degenerate_collinear_geometry_reports_singular_dop() {
1566        let sensors = vec![
1567            Sensor::new(vec![0.0, 0.0]),
1568            Sensor::new(vec![100.0, 0.0]),
1569            Sensor::new(vec![200.0, 0.0]),
1570            Sensor::new(vec![300.0, 0.0]),
1571        ];
1572        let err = source_dop(&sensors, &[50.0, 0.0], 300.0).expect_err("singular");
1573        assert!(matches!(
1574            err,
1575            SourceLocalizationError::Geometry(DopError::Singular)
1576        ));
1577    }
1578
1579    #[test]
1580    fn source_collinear_timing_design_classifies_rank_deficient() {
1581        let jac = [
1582            1.0 / 300.0,
1583            0.0,
1584            1.0,
1585            -1.0 / 300.0,
1586            0.0,
1587            1.0,
1588            -1.0 / 300.0,
1589            0.0,
1590            1.0,
1591            -1.0 / 300.0,
1592            0.0,
1593            1.0,
1594        ];
1595        let quality = source_geometry_quality_from_jacobian(&jac, 4, 3).expect("quality");
1596
1597        assert_eq!(
1598            quality.tier,
1599            crate::geometry_quality::ObservabilityTier::RankDeficient
1600        );
1601        assert!(!quality.raim_checkable);
1602        assert!(!quality.covariance_validated);
1603    }
1604}