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