Skip to main content

sidereon_core/
source_localization.rs

1//! Source localization from arrival times.
2//!
3//! This sans-I/O module solves for an event position from sensors at known
4//! Cartesian coordinates. Coordinates are metres in a caller-chosen 2D or 3D
5//! frame, times are seconds, and propagation speeds are metres per second.
6//!
7//! # Measurement models
8//!
9//! Absolute time of arrival (ToA) uses
10//!
11//! ```text
12//! t_i = t0 + ||x - s_i|| / c_i,
13//! ```
14//!
15//! with state `[x, t0]`: two or three position coordinates followed by the
16//! origin time. Time difference of arrival (TDOA) uses
17//!
18//! ```text
19//! t_i - t_ref = ||x - s_i|| / c_i - ||x - s_ref|| / c_ref,
20//! ```
21//!
22//! with position-only state `[x]`. After a TDOA position solve, the origin time
23//! is recovered from the absolute arrivals. Linear loss uses their arithmetic
24//! mean exactly; a non-linear loss applies one iteratively reweighted refinement
25//! so an arrival downweighted by the position solve is also downweighted in the
26//! reported time.
27//!
28//! Both modes require at least `dimension + 1` sensors: three in 2D or four in
29//! 3D. The closed-form seed is the spherical-intersection linearization of
30//! H. C. Schau and A. Z. Robinson, *IEEE Transactions on Acoustics, Speech, and
31//! Signal Processing* 35(8), 1987, followed by a quadratic in the reference
32//! range (TDOA) or emission-distance unknown `c * t0` (ToA). See
33//! [`closed_form_initial_guess`].
34//!
35//! The solution covariance is `(J^T J)^-1 * timing_sigma_s^2`, formed from the
36//! retained singular vectors and values of the final Jacobian. At the fitted
37//! state this is the local estimate covariance. [`source_crlb`] evaluates the
38//! same timing-information interpretation at a proposed source point through
39//! the shared DOP machinery, where it is a Cramer-Rao lower bound (CRLB).
40//!
41//! By default [`locate_source`] also measures each sensor's influence by running
42//! one complete nonlinear leave-one-out solve per sensor. Each record reports
43//! the full-solution ToA residual, held-out ToA residual, state displacement,
44//! robust-loss weight, and normalized residual magnitude. Call
45//! [`locate_source_with`] with a [`SourceLocateConfig`] whose
46//! `include_influence` is `false` to skip those re-solves.
47//!
48//! # Example
49//!
50//! ```
51//! use sidereon_core::source_localization::{
52//!     locate_source_with, Sensor, SourceLocateConfig, SourceLocateOptions, SourceSolveMode,
53//! };
54//!
55//! let sensors = vec![
56//!     Sensor::new(vec![0.0, 0.0]),
57//!     Sensor::new(vec![100.0, 0.0]),
58//!     Sensor::new(vec![0.0, 100.0]),
59//!     Sensor::new(vec![100.0, 100.0]),
60//! ];
61//! let source_m = [30.0, 40.0];
62//! let origin_time_s = 2.0;
63//! let propagation_speed_m_s = 50.0;
64//! let arrival_times_s = sensors
65//!     .iter()
66//!     .map(|sensor| {
67//!         let dx = source_m[0] - sensor.position_m[0];
68//!         let dy = source_m[1] - sensor.position_m[1];
69//!         origin_time_s + (dx * dx + dy * dy).sqrt() / propagation_speed_m_s
70//!     })
71//!     .collect::<Vec<_>>();
72//!
73//! let options = SourceLocateOptions {
74//!     mode: SourceSolveMode::Toa,
75//!     ..SourceLocateOptions::default()
76//! };
77//! let mut config = SourceLocateConfig::from(options);
78//! config.include_influence = false;
79//! let solution = locate_source_with(
80//!     &sensors,
81//!     &arrival_times_s,
82//!     propagation_speed_m_s,
83//!     &config,
84//! )?;
85//!
86//! assert!((solution.position_m[0] - source_m[0]).abs() < 1.0e-8);
87//! assert!((solution.position_m[1] - source_m[1]).abs() < 1.0e-8);
88//! assert!((solution.origin_time_s.unwrap() - origin_time_s).abs() < 1.0e-10);
89//! # Ok::<(), sidereon_core::source_localization::SourceLocalizationError>(())
90//! ```
91
92use core::fmt;
93
94pub use trust_region_least_squares::loss::Loss;
95use trust_region_least_squares::model::{solve_model_with, ResidualModel};
96use trust_region_least_squares::trf::{TrfError, TrfOptions, TrfResult, XScale};
97
98use crate::astro::math::least_squares::singular_value_diagnostics;
99use crate::astro::math::portable::{self, PortableNumerics};
100use crate::dop::{self, Dop, DopError};
101use crate::geometry_quality::{
102    classify, GeometryQuality, GeometryQualityThresholds, ObservabilityTier,
103};
104use nalgebra::DMatrix;
105
106/// Relative tolerance for classifying a quadratic coefficient or discriminant
107/// as numerically zero. The closed-form coefficients contain several dot
108/// products, so 32 machine epsilons covers their rounding accumulation while
109/// remaining far below a meaningful root separation.
110const QUADRATIC_REL_EPS: f64 = 32.0 * f64::EPSILON;
111
112/// A sensor with a known Cartesian position.
113///
114/// `propagation_speed_m_s` overrides the call-level propagation speed for this
115/// sensor when it is present. That is a simple per-path timing approximation;
116/// no refraction or ray tracing is modeled.
117#[derive(Debug, Clone, PartialEq)]
118pub struct Sensor {
119    /// Sensor position in metres. The vector length must be 2 or 3.
120    pub position_m: Vec<f64>,
121    /// Optional per-sensor propagation speed in metres per second.
122    pub propagation_speed_m_s: Option<f64>,
123}
124
125impl Sensor {
126    /// Construct a sensor that uses the call-level propagation speed.
127    pub fn new(position_m: impl Into<Vec<f64>>) -> Self {
128        Self {
129            position_m: position_m.into(),
130            propagation_speed_m_s: None,
131        }
132    }
133
134    /// Construct a sensor with its own propagation speed.
135    pub fn with_speed(position_m: impl Into<Vec<f64>>, propagation_speed_m_s: f64) -> Self {
136        Self {
137            position_m: position_m.into(),
138            propagation_speed_m_s: Some(propagation_speed_m_s),
139        }
140    }
141}
142
143/// Measurement model used by [`locate_source`].
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
145pub enum SourceSolveMode {
146    /// Absolute time of arrival. The state is `[position..., origin_time]`.
147    #[default]
148    Toa,
149    /// Time difference of arrival against a reference sensor.
150    ///
151    /// The residual subtracts the reference sensor equation and does not solve
152    /// an origin-time state. The returned origin time is estimated after the
153    /// position solve from the absolute arrivals.
154    Tdoa {
155        /// Reference sensor index.
156        reference_sensor: usize,
157    },
158}
159
160/// Options for [`locate_source`].
161///
162/// This type keeps its 1.0 shape so struct literals stay valid. Settings added
163/// after 1.0 live on [`SourceLocateConfig`] and are passed through
164/// [`locate_source_with`].
165#[derive(Debug, Clone, PartialEq)]
166pub struct SourceLocateOptions {
167    /// ToA or TDOA residual form.
168    pub mode: SourceSolveMode,
169    /// Timing standard deviation used for covariance, CRLB, and normalized
170    /// influence scores.
171    pub timing_sigma_s: f64,
172    /// Loss function passed to the trust-region least-squares solver.
173    pub loss: Loss,
174    /// Residual scale in seconds for non-linear loss functions.
175    pub f_scale_s: f64,
176    /// Optional solver function tolerance.
177    pub ftol: Option<f64>,
178    /// Optional solver step tolerance.
179    pub xtol: Option<f64>,
180    /// Optional solver gradient tolerance.
181    pub gtol: Option<f64>,
182    /// Optional maximum residual evaluations.
183    pub max_nfev: Option<usize>,
184}
185
186impl Default for SourceLocateOptions {
187    fn default() -> Self {
188        Self {
189            mode: SourceSolveMode::Toa,
190            timing_sigma_s: 1.0,
191            loss: Loss::Linear,
192            f_scale_s: 1.0,
193            ftol: None,
194            xtol: None,
195            gtol: None,
196            max_nfev: None,
197        }
198    }
199}
200
201/// Full configuration for [`locate_source_with`].
202///
203/// This type is `#[non_exhaustive]` so later settings can be added without
204/// breaking callers. Construct it from a [`SourceLocateOptions`] with
205/// [`SourceLocateConfig::from`] or start from [`SourceLocateConfig::default`],
206/// then set fields:
207///
208/// ```
209/// use sidereon_core::source_localization::{SourceLocateConfig, SourceLocateOptions};
210///
211/// let mut config = SourceLocateConfig::from(SourceLocateOptions::default());
212/// config.include_influence = false;
213/// ```
214#[non_exhaustive]
215#[derive(Debug, Clone, PartialEq)]
216pub struct SourceLocateConfig {
217    /// Solver and measurement-model options, unchanged from [`locate_source`].
218    pub options: SourceLocateOptions,
219    /// Whether to compute per-sensor leave-one-out influence diagnostics.
220    ///
221    /// Influence runs one full nonlinear re-solve per sensor. The default is
222    /// `true`; set this to `false` to skip all leave-one-out solves and return
223    /// an empty [`SourceSolution::per_sensor_influence`] vector. Every other
224    /// output is bit-identical either way.
225    pub include_influence: bool,
226}
227
228impl Default for SourceLocateConfig {
229    fn default() -> Self {
230        Self {
231            options: SourceLocateOptions::default(),
232            include_influence: true,
233        }
234    }
235}
236
237impl From<SourceLocateOptions> for SourceLocateConfig {
238    fn from(options: SourceLocateOptions) -> Self {
239        Self {
240            options,
241            include_influence: true,
242        }
243    }
244}
245
246/// Closed-form seed used to start the iterative solve.
247#[derive(Debug, Clone, PartialEq)]
248pub struct SourceInitialGuess {
249    /// Initial position in metres.
250    pub position_m: Vec<f64>,
251    /// Initial origin time in seconds when it can be inferred.
252    pub origin_time_s: Option<f64>,
253    /// Root-mean-square residual of the seed in seconds.
254    pub residual_rms_s: f64,
255}
256
257/// One residual associated with a sensor row.
258#[derive(Debug, Clone, PartialEq)]
259pub struct SourceResidual {
260    /// Sensor index in the caller's input slice.
261    pub sensor_index: usize,
262    /// Reference sensor for a TDOA residual, or `None` for ToA.
263    pub reference_sensor_index: Option<usize>,
264    /// Residual in seconds.
265    pub residual_s: f64,
266}
267
268/// Per-sensor leave-one-out diagnostic.
269#[derive(Debug, Clone, PartialEq)]
270pub struct SourceSensorInfluence {
271    /// Sensor index in the caller's input slice.
272    pub sensor_index: usize,
273    /// ToA residual at the full solution and estimated origin time, in seconds.
274    ///
275    /// This is a ToA residual in both solve modes, including TDOA mode.
276    pub residual_s: f64,
277    /// Held-out ToA residual after solving without this sensor, in seconds.
278    ///
279    /// This is evaluated at the leave-one-out estimated origin time in both
280    /// solve modes, including TDOA mode.
281    pub leave_one_out_residual_s: Option<f64>,
282    /// Position change between the full and leave-one-out solutions, in metres.
283    pub position_delta_m: Option<f64>,
284    /// Origin-time change between the full and leave-one-out solutions, in seconds.
285    pub origin_time_delta_s: Option<f64>,
286    /// First-derivative loss weight for the full-solution residual.
287    ///
288    /// This field carries robust-loss downweighting separately from [`score`](Self::score).
289    pub loss_weight: f64,
290    /// Normalized residual magnitude in timing-sigma units.
291    ///
292    /// This is `max(|residual_s|, |leave_one_out_residual_s|) /
293    /// timing_sigma_s`, or `|residual_s| / timing_sigma_s` when the
294    /// leave-one-out solve is unavailable. Robust-loss downweighting is
295    /// reported separately by [`loss_weight`](Self::loss_weight).
296    pub score: f64,
297}
298
299/// Timing-information covariance for a source state.
300///
301/// This is `(J^T J)^-1 * timing_sigma_s^2` at the evaluation point. At a fitted
302/// solution it is the local estimate covariance; at a proposed point it is the
303/// Cramer-Rao lower bound (CRLB).
304#[derive(Debug, Clone, PartialEq)]
305pub struct SourceCovariance {
306    /// Full state covariance in solver state order.
307    pub state: Vec<Vec<f64>>,
308    /// Position covariance block in square metres.
309    pub position_m2: Vec<Vec<f64>>,
310    /// Origin-time variance in square seconds when origin time is in the state.
311    pub origin_time_s2: Option<f64>,
312    /// Timing sigma used to scale the cofactor.
313    pub timing_sigma_s: f64,
314}
315
316/// Source solution from [`locate_source`].
317#[derive(Debug, Clone, PartialEq)]
318pub struct SourceSolution {
319    /// Estimated source position in metres.
320    pub position_m: Vec<f64>,
321    /// Estimated origin time in seconds.
322    pub origin_time_s: Option<f64>,
323    /// State covariance scaled by [`SourceLocateOptions::timing_sigma_s`].
324    pub covariance: Option<SourceCovariance>,
325    /// Solver residuals in seconds.
326    pub residuals: Vec<SourceResidual>,
327    /// Per-sensor influence diagnostics.
328    pub per_sensor_influence: Vec<SourceSensorInfluence>,
329    /// Geometry observability and covariance-validation diagnostics for the
330    /// final timing design. Snapshot source solves use no propagated prior, so
331    /// `ZeroRedundancy` covariance bounds are unvalidated, `Weak` bounds are
332    /// reported without clamping, and `RankDeficient` is routed through a typed
333    /// geometry error instead of returning a solution.
334    pub geometry_quality: GeometryQuality,
335    /// Closed-form seed used to start the iterative solve.
336    pub initial_guess: SourceInitialGuess,
337    /// Trust-region termination code: `0` maximum evaluations, `1` gradient
338    /// tolerance, `2` function tolerance, `3` step tolerance, or `4` both
339    /// function and step tolerances.
340    pub status: i32,
341    /// Residual evaluations used by the solver.
342    pub nfev: usize,
343    /// Jacobian evaluations used by the solver.
344    pub njev: usize,
345    /// Final least-squares cost.
346    pub cost: f64,
347    /// Infinity norm of the final gradient.
348    pub optimality: f64,
349}
350
351impl SourceSolution {
352    /// Return the covariance as the CRLB for the timing sigma used by the solve.
353    pub fn crlb(&self) -> Option<&SourceCovariance> {
354        self.covariance.as_ref()
355    }
356}
357
358/// CRLB and DOP for a proposed sensor/source geometry.
359#[derive(Debug, Clone, PartialEq)]
360pub struct SourceCrlb {
361    /// DOP scalars formed from the timing design matrix.
362    pub dop: Dop,
363    /// State covariance scaled by the requested timing sigma.
364    pub covariance: SourceCovariance,
365}
366
367/// Source-localization failure.
368#[derive(Debug, Clone, PartialEq)]
369pub enum SourceLocalizationError {
370    /// A boundary input is malformed.
371    InvalidInput {
372        /// Name of the malformed field.
373        field: &'static str,
374        /// Stable validation reason.
375        reason: &'static str,
376    },
377    /// There are fewer sensors than the selected solve needs.
378    TooFewSensors {
379        /// Number of sensors supplied.
380        sensors: usize,
381        /// Minimum number of sensors required.
382        needed: usize,
383    },
384    /// The closed-form initializer could not solve the geometry.
385    InitializerSingular,
386    /// Geometry DOP or CRLB failed.
387    Geometry(DopError),
388    /// The trust-region solver failed.
389    Solver(TrfError),
390    /// The trust-region solver exhausted its evaluation budget.
391    DidNotConverge {
392        /// Solver status code.
393        status: i32,
394    },
395}
396
397impl fmt::Display for SourceLocalizationError {
398    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399        match self {
400            Self::InvalidInput { field, reason } => {
401                write!(f, "invalid source localization input {field}: {reason}")
402            }
403            Self::TooFewSensors { sensors, needed } => {
404                write!(
405                    f,
406                    "source localization has {sensors} sensors; need at least {needed}"
407                )
408            }
409            Self::InitializerSingular => write!(f, "closed-form source initializer is singular"),
410            Self::Geometry(err) => write!(f, "source geometry failed: {err}"),
411            Self::Solver(err) => write!(f, "source solver failed: {err}"),
412            Self::DidNotConverge { status } => {
413                write!(f, "source solver did not converge, status {status}")
414            }
415        }
416    }
417}
418
419impl std::error::Error for SourceLocalizationError {
420    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
421        match self {
422            Self::Geometry(err) => Some(err),
423            Self::Solver(err) => Some(err),
424            _ => None,
425        }
426    }
427}
428
429impl From<DopError> for SourceLocalizationError {
430    fn from(value: DopError) -> Self {
431        Self::Geometry(value)
432    }
433}
434
435impl From<TrfError> for SourceLocalizationError {
436    fn from(value: TrfError) -> Self {
437        Self::Solver(value)
438    }
439}
440
441/// Locate a source from sensor arrival times.
442///
443/// `sensors` and `arrival_times_s` must have matching length. Positions must
444/// all be 2D or all be 3D. The call-level propagation speed is used for every
445/// sensor without a per-sensor override.
446///
447/// # Errors
448///
449/// Returns [`SourceLocalizationError::InvalidInput`] for malformed, non-finite,
450/// or inconsistent inputs; [`SourceLocalizationError::TooFewSensors`] when the
451/// selected dimension has fewer than `dimension + 1` sensors;
452/// [`SourceLocalizationError::InitializerSingular`] when the closed-form seed
453/// geometry is degenerate; [`SourceLocalizationError::Solver`] when the
454/// trust-region solver rejects the problem; [`SourceLocalizationError::DidNotConverge`]
455/// when its evaluation budget is exhausted; or
456/// [`SourceLocalizationError::Geometry`] when the final Jacobian is singular.
457pub fn locate_source(
458    sensors: &[Sensor],
459    arrival_times_s: &[f64],
460    propagation_speed_m_s: f64,
461    options: &SourceLocateOptions,
462) -> Result<SourceSolution, SourceLocalizationError> {
463    locate_source_inner(
464        sensors,
465        arrival_times_s,
466        propagation_speed_m_s,
467        options,
468        true,
469    )
470}
471
472/// Locate a source from sensor arrival times with a full [`SourceLocateConfig`].
473///
474/// This is [`locate_source`] plus the settings that live on the config, such
475/// as [`SourceLocateConfig::include_influence`]. With a default config the two
476/// entry points are equivalent.
477///
478/// # Errors
479///
480/// The same [`SourceLocalizationError`] variants as [`locate_source`].
481pub fn locate_source_with(
482    sensors: &[Sensor],
483    arrival_times_s: &[f64],
484    propagation_speed_m_s: f64,
485    config: &SourceLocateConfig,
486) -> Result<SourceSolution, SourceLocalizationError> {
487    locate_source_inner(
488        sensors,
489        arrival_times_s,
490        propagation_speed_m_s,
491        &config.options,
492        config.include_influence,
493    )
494}
495
496/// Compute the closed-form spherical-intersection seed used by [`locate_source`].
497///
498/// The seed uses the call-level propagation speed in the closed-form equations.
499/// Per-sensor speed overrides are applied by the iterative residual model. The
500/// TDOA branch is the spherical-intersection method of H. C. Schau and A. Z.
501/// Robinson, *IEEE Transactions on Acoustics, Speech, and Signal Processing*
502/// 35(8), 1987: position is affine in the unknown reference range, followed by
503/// a quadratic in that range. The ToA branch uses the same linearization in the
504/// emission-distance unknown `propagation_speed_m_s * origin_time_s` and always
505/// uses sensor `0` as its algebraic reference; any sensor is mathematically
506/// valid for that linearization.
507///
508/// # Errors
509///
510/// Returns [`SourceLocalizationError::InvalidInput`] for malformed, non-finite,
511/// or inconsistent inputs; [`SourceLocalizationError::TooFewSensors`] when the
512/// selected dimension has fewer than `dimension + 1` sensors; or
513/// [`SourceLocalizationError::InitializerSingular`] when the linear system or
514/// quadratic is degenerate or has no admissible root.
515pub fn closed_form_initial_guess(
516    sensors: &[Sensor],
517    arrival_times_s: &[f64],
518    propagation_speed_m_s: f64,
519    mode: SourceSolveMode,
520) -> Result<SourceInitialGuess, SourceLocalizationError> {
521    let options = SourceLocateOptions {
522        mode,
523        ..SourceLocateOptions::default()
524    };
525    let resolved =
526        resolve_locate_inputs(sensors, arrival_times_s, propagation_speed_m_s, &options)?;
527    closed_form_initial_guess_resolved(sensors, arrival_times_s, propagation_speed_m_s, &resolved)
528}
529
530/// Deprecated name for [`closed_form_initial_guess`].
531///
532/// The implemented initializer is the Schau-Robinson spherical-intersection
533/// linearization, not the two-stage Chan-Ho weighted least-squares method.
534///
535/// # Errors
536///
537/// Returns the same [`SourceLocalizationError`] variants as
538/// [`closed_form_initial_guess`].
539#[deprecated(
540    since = "1.1.0",
541    note = "use closed_form_initial_guess; this is Schau-Robinson spherical intersection, not Chan-Ho"
542)]
543pub fn chan_ho_initial_guess(
544    sensors: &[Sensor],
545    arrival_times_s: &[f64],
546    propagation_speed_m_s: f64,
547    mode: SourceSolveMode,
548) -> Result<SourceInitialGuess, SourceLocalizationError> {
549    closed_form_initial_guess(sensors, arrival_times_s, propagation_speed_m_s, mode)
550}
551
552/// Compute timing DOP for a proposed source location.
553///
554/// The returned position DOP values multiply timing sigma in seconds to produce
555/// metres. The local Cartesian axes are used for the horizontal and vertical
556/// split.
557///
558/// # Errors
559///
560/// Returns [`SourceLocalizationError::InvalidInput`] for malformed, non-finite,
561/// inconsistent inputs, including a source coincident with a sensor. Returns
562/// [`SourceLocalizationError::Geometry`] when the shared DOP machinery finds
563/// too few sensors or a singular timing design.
564pub fn source_dop(
565    sensors: &[Sensor],
566    source_position_m: &[f64],
567    propagation_speed_m_s: f64,
568) -> Result<Dop, SourceLocalizationError> {
569    let resolved = resolve_geometry_inputs(sensors, source_position_m, propagation_speed_m_s)?;
570    let rows = source_toa_design_rows(sensors, source_position_m, &resolved)?;
571    let weights = vec![1.0; sensors.len()];
572    dop::dop_from_design_rows(&rows, &weights, resolved.dimension, identity_rotation())
573        .map_err(SourceLocalizationError::Geometry)
574}
575
576/// Compute a timing CRLB for a proposed source location.
577///
578/// The covariance is `(H^T H)^-1 * timing_sigma_s^2`, where each row is the ToA
579/// timing derivative at `source_position_m`.
580///
581/// # Errors
582///
583/// Returns [`SourceLocalizationError::InvalidInput`] for malformed, non-finite,
584/// non-positive, or inconsistent inputs. Returns
585/// [`SourceLocalizationError::Geometry`] when the shared DOP machinery finds
586/// too few sensors or a singular timing design. A source coincident with a
587/// sensor is [`SourceLocalizationError::InvalidInput`].
588pub fn source_crlb(
589    sensors: &[Sensor],
590    source_position_m: &[f64],
591    propagation_speed_m_s: f64,
592    timing_sigma_s: f64,
593) -> Result<SourceCrlb, SourceLocalizationError> {
594    validate_positive("timing_sigma_s", timing_sigma_s)?;
595    let resolved = resolve_geometry_inputs(sensors, source_position_m, propagation_speed_m_s)?;
596    let rows = source_toa_design_rows(sensors, source_position_m, &resolved)?;
597    let weights = vec![1.0; sensors.len()];
598    let rotation = identity_rotation();
599    let cofactor =
600        dop::geometry_cofactor_from_design_rows(&rows, &weights, resolved.dimension, rotation)?;
601    let dop = dop::dop_from_design_rows(&rows, &weights, resolved.dimension, rotation)?;
602    let covariance =
603        covariance_from_state_cofactor(&cofactor.state, resolved.dimension, timing_sigma_s, true);
604    Ok(SourceCrlb { dop, covariance })
605}
606
607#[derive(Debug, Clone)]
608struct ResolvedInputs {
609    dimension: usize,
610    speeds_m_s: Vec<f64>,
611    mode: SourceSolveMode,
612}
613
614#[derive(Debug, Clone)]
615struct ResolvedGeometry {
616    dimension: usize,
617    speeds_m_s: Vec<f64>,
618}
619
620#[derive(Debug)]
621struct SourceProblem<'a> {
622    sensors: &'a [Sensor],
623    arrival_times_s: &'a [f64],
624    speeds_m_s: &'a [f64],
625    dimension: usize,
626    mode: SourceSolveMode,
627}
628
629impl SourceProblem<'_> {
630    fn residual_records(&self, residuals: &[f64]) -> Vec<SourceResidual> {
631        match self.mode {
632            SourceSolveMode::Toa => residuals
633                .iter()
634                .enumerate()
635                .map(|(sensor_index, &residual_s)| SourceResidual {
636                    sensor_index,
637                    reference_sensor_index: None,
638                    residual_s,
639                })
640                .collect(),
641            SourceSolveMode::Tdoa { reference_sensor } => {
642                let mut out = Vec::with_capacity(residuals.len());
643                let mut row = 0;
644                for sensor_index in 0..self.sensors.len() {
645                    if sensor_index == reference_sensor {
646                        continue;
647                    }
648                    out.push(SourceResidual {
649                        sensor_index,
650                        reference_sensor_index: Some(reference_sensor),
651                        residual_s: residuals[row],
652                    });
653                    row += 1;
654                }
655                out
656            }
657        }
658    }
659}
660
661impl ResidualModel for SourceProblem<'_> {
662    fn residual(&self, x: &[f64], out: &mut Vec<f64>) {
663        out.clear();
664        match self.mode {
665            SourceSolveMode::Toa => {
666                let origin_time_s = x[self.dimension];
667                for (i, sensor) in self.sensors.iter().enumerate() {
668                    let range_m = distance(&x[..self.dimension], &sensor.position_m);
669                    out.push(
670                        origin_time_s + range_m / self.speeds_m_s[i] - self.arrival_times_s[i],
671                    );
672                }
673            }
674            SourceSolveMode::Tdoa { reference_sensor } => {
675                let ref_range_m = distance(
676                    &x[..self.dimension],
677                    &self.sensors[reference_sensor].position_m,
678                );
679                let ref_time_s = ref_range_m / self.speeds_m_s[reference_sensor];
680                for (i, sensor) in self.sensors.iter().enumerate() {
681                    if i == reference_sensor {
682                        continue;
683                    }
684                    let range_m = distance(&x[..self.dimension], &sensor.position_m);
685                    let predicted_s = range_m / self.speeds_m_s[i] - ref_time_s;
686                    let observed_s =
687                        self.arrival_times_s[i] - self.arrival_times_s[reference_sensor];
688                    out.push(predicted_s - observed_s);
689                }
690            }
691        }
692    }
693
694    fn jacobian(&self, x: &[f64], _f0: &[f64], out: &mut Vec<f64>) {
695        out.clear();
696        match self.mode {
697            SourceSolveMode::Toa => {
698                let n = self.dimension + 1;
699                out.resize(self.sensors.len() * n, 0.0);
700                for (row, sensor) in self.sensors.iter().enumerate() {
701                    fill_range_derivative(
702                        &x[..self.dimension],
703                        &sensor.position_m,
704                        self.speeds_m_s[row],
705                        &mut out[row * n..row * n + self.dimension],
706                    );
707                    out[row * n + self.dimension] = 1.0;
708                }
709            }
710            SourceSolveMode::Tdoa { reference_sensor } => {
711                let n = self.dimension;
712                out.resize((self.sensors.len() - 1) * n, 0.0);
713                let mut ref_derivative = vec![0.0; self.dimension];
714                fill_range_derivative(
715                    &x[..self.dimension],
716                    &self.sensors[reference_sensor].position_m,
717                    self.speeds_m_s[reference_sensor],
718                    &mut ref_derivative,
719                );
720                let mut row = 0;
721                for (i, sensor) in self.sensors.iter().enumerate() {
722                    if i == reference_sensor {
723                        continue;
724                    }
725                    let start = row * n;
726                    fill_range_derivative(
727                        &x[..self.dimension],
728                        &sensor.position_m,
729                        self.speeds_m_s[i],
730                        &mut out[start..start + n],
731                    );
732                    for axis in 0..n {
733                        out[start + axis] -= ref_derivative[axis];
734                    }
735                    row += 1;
736                }
737            }
738        }
739    }
740}
741
742fn locate_source_inner(
743    sensors: &[Sensor],
744    arrival_times_s: &[f64],
745    propagation_speed_m_s: f64,
746    options: &SourceLocateOptions,
747    include_influence: bool,
748) -> Result<SourceSolution, SourceLocalizationError> {
749    let resolved = resolve_locate_inputs(sensors, arrival_times_s, propagation_speed_m_s, options)?;
750    let initial_guess = closed_form_initial_guess_resolved(
751        sensors,
752        arrival_times_s,
753        propagation_speed_m_s,
754        &resolved,
755    )?;
756    let mut x0 = initial_guess.position_m.clone();
757    if matches!(resolved.mode, SourceSolveMode::Toa) {
758        let origin_time_s = initial_guess
759            .origin_time_s
760            .ok_or(SourceLocalizationError::InitializerSingular)?;
761        x0.push(origin_time_s);
762    }
763
764    let problem = SourceProblem {
765        sensors,
766        arrival_times_s,
767        speeds_m_s: &resolved.speeds_m_s,
768        dimension: resolved.dimension,
769        mode: resolved.mode,
770    };
771    let result = solve_model_with(&problem, &x0, &PortableNumerics, &solver_options(options))?;
772    if !result.success() {
773        return Err(SourceLocalizationError::DidNotConverge {
774            status: result.status,
775        });
776    }
777
778    let mut solution = build_solution(
779        &problem,
780        &resolved,
781        &initial_guess,
782        result,
783        options.timing_sigma_s,
784        options.loss,
785        options.f_scale_s,
786    )?;
787    if include_influence {
788        solution.per_sensor_influence = compute_influence(
789            &solution,
790            sensors,
791            arrival_times_s,
792            propagation_speed_m_s,
793            options,
794        );
795    }
796    Ok(solution)
797}
798
799fn build_solution(
800    problem: &SourceProblem<'_>,
801    resolved: &ResolvedInputs,
802    initial_guess: &SourceInitialGuess,
803    result: TrfResult,
804    timing_sigma_s: f64,
805    loss: Loss,
806    f_scale_s: f64,
807) -> Result<SourceSolution, SourceLocalizationError> {
808    let position_m = result.x[..resolved.dimension].to_vec();
809    let origin_time_s = match resolved.mode {
810        SourceSolveMode::Toa => Some(result.x[resolved.dimension]),
811        SourceSolveMode::Tdoa { .. } => Some(estimate_origin_time_for_loss_s(
812            problem.sensors,
813            problem.arrival_times_s,
814            problem.speeds_m_s,
815            &position_m,
816            loss,
817            f_scale_s,
818        )),
819    };
820    let residuals = problem.residual_records(&result.fun);
821    let parameter_count = result.x.len();
822    let residual_count = result.fun.len();
823    let jacobian = jacobian_svd_diagnostics(&result.jac, residual_count, parameter_count)
824        .ok_or(SourceLocalizationError::Geometry(DopError::Singular))?;
825    let geometry_quality =
826        source_geometry_quality_from_svd(&jacobian, residual_count, parameter_count);
827    if geometry_quality.tier == ObservabilityTier::RankDeficient {
828        return Err(SourceLocalizationError::Geometry(DopError::Singular));
829    }
830    let covariance = covariance_from_state_cofactor(
831        &jacobian.cofactor,
832        resolved.dimension,
833        timing_sigma_s,
834        parameter_count == resolved.dimension + 1,
835    );
836    Ok(SourceSolution {
837        position_m,
838        origin_time_s,
839        covariance: Some(covariance),
840        residuals,
841        per_sensor_influence: Vec::new(),
842        geometry_quality,
843        initial_guess: initial_guess.clone(),
844        status: result.status,
845        nfev: result.nfev,
846        njev: result.njev,
847        cost: result.cost,
848        optimality: result.optimality,
849    })
850}
851
852#[cfg(test)]
853fn source_geometry_quality_from_jacobian(
854    jac: &[f64],
855    m: usize,
856    n: usize,
857) -> Result<GeometryQuality, SourceLocalizationError> {
858    let diagnostics = jacobian_svd_diagnostics(jac, m, n)
859        .ok_or(SourceLocalizationError::Geometry(DopError::Singular))?;
860    Ok(source_geometry_quality_from_svd(&diagnostics, m, n))
861}
862
863fn source_geometry_quality_from_svd(
864    diagnostics: &JacobianSvdDiagnostics,
865    m: usize,
866    n: usize,
867) -> GeometryQuality {
868    let gdop = if diagnostics.rank < n {
869        f64::INFINITY
870    } else {
871        let trace = cofactor_trace(&diagnostics.cofactor);
872        if trace >= 0.0 && trace.is_finite() {
873            trace.sqrt()
874        } else {
875            f64::INFINITY
876        }
877    };
878    classify(
879        diagnostics.rank,
880        n,
881        m as i32 - n as i32,
882        diagnostics.condition_number,
883        gdop,
884        false,
885        GeometryQualityThresholds::default(),
886    )
887}
888
889fn closed_form_initial_guess_resolved(
890    sensors: &[Sensor],
891    arrival_times_s: &[f64],
892    propagation_speed_m_s: f64,
893    resolved: &ResolvedInputs,
894) -> Result<SourceInitialGuess, SourceLocalizationError> {
895    match resolved.mode {
896        SourceSolveMode::Toa => {
897            closed_form_toa_initial_guess(sensors, arrival_times_s, propagation_speed_m_s, resolved)
898        }
899        SourceSolveMode::Tdoa { reference_sensor } => closed_form_tdoa_initial_guess(
900            sensors,
901            arrival_times_s,
902            propagation_speed_m_s,
903            resolved,
904            reference_sensor,
905        ),
906    }
907}
908
909fn closed_form_toa_initial_guess(
910    sensors: &[Sensor],
911    arrival_times_s: &[f64],
912    propagation_speed_m_s: f64,
913    resolved: &ResolvedInputs,
914) -> Result<SourceInitialGuess, SourceLocalizationError> {
915    let d = resolved.dimension;
916    let ref_pos = &sensors[0].position_m;
917    let z0 = propagation_speed_m_s * arrival_times_s[0];
918    let ref_norm2 = dot(ref_pos, ref_pos);
919    let mut a = Vec::with_capacity(sensors.len() - 1);
920    let mut b = Vec::with_capacity(sensors.len() - 1);
921    let mut h = Vec::with_capacity(sensors.len() - 1);
922    for i in 1..sensors.len() {
923        let row: Vec<f64> = sensors[i]
924            .position_m
925            .iter()
926            .zip(ref_pos)
927            .map(|(s, r)| s - r)
928            .collect();
929        let zi = propagation_speed_m_s * arrival_times_s[i];
930        let delta_z = zi - z0;
931        let delta_norm = dot(&sensors[i].position_m, &sensors[i].position_m) - ref_norm2;
932        a.push(row);
933        b.push(0.5 * (delta_norm - (zi * zi - z0 * z0)));
934        h.push(delta_z);
935    }
936    let p0 = least_squares(&a, &b)?;
937    let p1 = least_squares(&a, &h)?;
938    let q: Vec<f64> = p0.iter().zip(ref_pos).map(|(p, r)| p - r).collect();
939    let roots = quadratic_roots(
940        dot(&p1, &p1) - 1.0,
941        2.0 * dot(&q, &p1) + 2.0 * z0,
942        dot(&q, &q) - z0 * z0,
943    )?;
944
945    let mut best: Option<SourceInitialGuess> = None;
946    let mut best_sse = f64::INFINITY;
947    for tau_m in roots {
948        let position_m: Vec<f64> = (0..d).map(|axis| p0[axis] + p1[axis] * tau_m).collect();
949        // `tau_m = c * t0` is the emission-distance unknown introduced by the
950        // linearization. It carries the sign of `t0` in the caller's time base,
951        // so a negative value is legitimate (an origin before the epoch the
952        // arrivals are measured against), and its scale is problem-defined, so
953        // no absolute cutoff is defensible either. Reject only a non-finite
954        // candidate and let the ToA SSE select the physically consistent root.
955        if !tau_m.is_finite() || position_m.iter().any(|value| !value.is_finite()) {
956            continue;
957        }
958        let origin_time_s = tau_m / propagation_speed_m_s;
959        let sse = toa_sse(
960            sensors,
961            arrival_times_s,
962            &resolved.speeds_m_s,
963            &position_m,
964            origin_time_s,
965        );
966        if sse < best_sse {
967            best_sse = sse;
968            best = Some(SourceInitialGuess {
969                position_m,
970                origin_time_s: Some(origin_time_s),
971                residual_rms_s: (sse / sensors.len() as f64).sqrt(),
972            });
973        }
974    }
975    best.ok_or(SourceLocalizationError::InitializerSingular)
976}
977
978fn closed_form_tdoa_initial_guess(
979    sensors: &[Sensor],
980    arrival_times_s: &[f64],
981    propagation_speed_m_s: f64,
982    resolved: &ResolvedInputs,
983    reference_sensor: usize,
984) -> Result<SourceInitialGuess, SourceLocalizationError> {
985    let d = resolved.dimension;
986    let ref_pos = &sensors[reference_sensor].position_m;
987    let ref_norm2 = dot(ref_pos, ref_pos);
988    let mut a = Vec::with_capacity(sensors.len() - 1);
989    let mut b = Vec::with_capacity(sensors.len() - 1);
990    let mut h = Vec::with_capacity(sensors.len() - 1);
991    for (i, sensor) in sensors.iter().enumerate() {
992        if i == reference_sensor {
993            continue;
994        }
995        let row: Vec<f64> = sensor
996            .position_m
997            .iter()
998            .zip(ref_pos)
999            .map(|(s, r)| s - r)
1000            .collect();
1001        let delta_range_m =
1002            propagation_speed_m_s * (arrival_times_s[i] - arrival_times_s[reference_sensor]);
1003        let delta_norm = dot(&sensor.position_m, &sensor.position_m) - ref_norm2;
1004        a.push(row);
1005        b.push(0.5 * (delta_norm - delta_range_m * delta_range_m));
1006        h.push(-delta_range_m);
1007    }
1008    let p0 = least_squares(&a, &b)?;
1009    let p1 = least_squares(&a, &h)?;
1010    let q: Vec<f64> = p0.iter().zip(ref_pos).map(|(p, r)| p - r).collect();
1011    let roots = quadratic_roots(dot(&p1, &p1) - 1.0, 2.0 * dot(&q, &p1), dot(&q, &q))?;
1012
1013    let mut best: Option<SourceInitialGuess> = None;
1014    let mut best_sse = f64::INFINITY;
1015    for rho_m in roots {
1016        if rho_m < 0.0 {
1017            continue;
1018        }
1019        let position_m: Vec<f64> = (0..d).map(|axis| p0[axis] + p1[axis] * rho_m).collect();
1020        let origin_time_s =
1021            estimate_origin_time_s(sensors, arrival_times_s, &resolved.speeds_m_s, &position_m);
1022        let sse = tdoa_sse(
1023            sensors,
1024            arrival_times_s,
1025            &resolved.speeds_m_s,
1026            &position_m,
1027            reference_sensor,
1028        );
1029        if sse < best_sse {
1030            best_sse = sse;
1031            best = Some(SourceInitialGuess {
1032                position_m,
1033                origin_time_s: Some(origin_time_s),
1034                residual_rms_s: (sse / (sensors.len() - 1) as f64).sqrt(),
1035            });
1036        }
1037    }
1038    best.ok_or(SourceLocalizationError::InitializerSingular)
1039}
1040
1041fn compute_influence(
1042    solution: &SourceSolution,
1043    sensors: &[Sensor],
1044    arrival_times_s: &[f64],
1045    propagation_speed_m_s: f64,
1046    options: &SourceLocateOptions,
1047) -> Vec<SourceSensorInfluence> {
1048    let speeds = match sensor_speeds(sensors, propagation_speed_m_s) {
1049        Ok(speeds) => speeds,
1050        Err(_) => return Vec::new(),
1051    };
1052    let origin_time_s = solution.origin_time_s.unwrap_or_else(|| {
1053        estimate_origin_time_for_loss_s(
1054            sensors,
1055            arrival_times_s,
1056            &speeds,
1057            &solution.position_m,
1058            options.loss,
1059            options.f_scale_s,
1060        )
1061    });
1062    let full_residuals = toa_residuals(
1063        sensors,
1064        arrival_times_s,
1065        &speeds,
1066        &solution.position_m,
1067        origin_time_s,
1068    );
1069    let sigma = options.timing_sigma_s.max(f64::MIN_POSITIVE);
1070
1071    (0..sensors.len())
1072        .map(|sensor_index| {
1073            let loo = leave_one_out_solution(
1074                sensors,
1075                arrival_times_s,
1076                propagation_speed_m_s,
1077                options,
1078                sensor_index,
1079            );
1080            let (leave_one_out_residual_s, position_delta_m, origin_time_delta_s) =
1081                if let Some((loo_solution, loo_origin)) =
1082                    loo.and_then(|solution| solution.origin_time_s.map(|time| (solution, time)))
1083                {
1084                    let held_out_residual = single_toa_residual(
1085                        &sensors[sensor_index],
1086                        arrival_times_s[sensor_index],
1087                        speeds[sensor_index],
1088                        &loo_solution.position_m,
1089                        loo_origin,
1090                    );
1091                    (
1092                        Some(held_out_residual),
1093                        Some(distance(&solution.position_m, &loo_solution.position_m)),
1094                        Some((origin_time_s - loo_origin).abs()),
1095                    )
1096                } else {
1097                    (None, None, None)
1098                };
1099            let loss_weight = loss_weight(
1100                options.loss,
1101                options.f_scale_s,
1102                full_residuals[sensor_index],
1103            );
1104            SourceSensorInfluence {
1105                sensor_index,
1106                residual_s: full_residuals[sensor_index],
1107                leave_one_out_residual_s,
1108                position_delta_m,
1109                origin_time_delta_s,
1110                loss_weight,
1111                score: influence_score(
1112                    full_residuals[sensor_index],
1113                    leave_one_out_residual_s,
1114                    sigma,
1115                ),
1116            }
1117        })
1118        .collect()
1119}
1120
1121fn leave_one_out_solution(
1122    sensors: &[Sensor],
1123    arrival_times_s: &[f64],
1124    propagation_speed_m_s: f64,
1125    options: &SourceLocateOptions,
1126    excluded: usize,
1127) -> Option<SourceSolution> {
1128    let mut sub_sensors = Vec::with_capacity(sensors.len() - 1);
1129    let mut sub_arrivals = Vec::with_capacity(arrival_times_s.len() - 1);
1130    for (i, sensor) in sensors.iter().enumerate() {
1131        if i == excluded {
1132            continue;
1133        }
1134        sub_sensors.push(sensor.clone());
1135        sub_arrivals.push(arrival_times_s[i]);
1136    }
1137    let mut sub_options = options.clone();
1138    sub_options.mode = match options.mode {
1139        SourceSolveMode::Toa => SourceSolveMode::Toa,
1140        SourceSolveMode::Tdoa { reference_sensor } => {
1141            if excluded == reference_sensor {
1142                SourceSolveMode::Tdoa {
1143                    reference_sensor: 0,
1144                }
1145            } else if excluded < reference_sensor {
1146                SourceSolveMode::Tdoa {
1147                    reference_sensor: reference_sensor - 1,
1148                }
1149            } else {
1150                SourceSolveMode::Tdoa { reference_sensor }
1151            }
1152        }
1153    };
1154    locate_source_inner(
1155        &sub_sensors,
1156        &sub_arrivals,
1157        propagation_speed_m_s,
1158        &sub_options,
1159        false,
1160    )
1161    .ok()
1162}
1163
1164fn resolve_locate_inputs(
1165    sensors: &[Sensor],
1166    arrival_times_s: &[f64],
1167    propagation_speed_m_s: f64,
1168    options: &SourceLocateOptions,
1169) -> Result<ResolvedInputs, SourceLocalizationError> {
1170    if sensors.len() != arrival_times_s.len() {
1171        return Err(invalid_input(
1172            "arrival_times_s",
1173            "length must match sensors",
1174        ));
1175    }
1176    for &arrival in arrival_times_s {
1177        validate_finite("arrival_times_s", arrival)?;
1178    }
1179    validate_positive("timing_sigma_s", options.timing_sigma_s)?;
1180    if options.loss != Loss::Linear {
1181        validate_positive("f_scale_s", options.f_scale_s)?;
1182    }
1183    validate_optional_positive("ftol", options.ftol)?;
1184    validate_optional_positive("xtol", options.xtol)?;
1185    validate_optional_positive("gtol", options.gtol)?;
1186    if options.max_nfev == Some(0) {
1187        return Err(invalid_input("max_nfev", "must be positive"));
1188    }
1189    let geometry = resolve_geometry_inputs(
1190        sensors,
1191        sensors
1192            .first()
1193            .map(|sensor| sensor.position_m.as_slice())
1194            .unwrap_or(&[]),
1195        propagation_speed_m_s,
1196    )?;
1197    if let SourceSolveMode::Tdoa { reference_sensor } = options.mode {
1198        if reference_sensor >= sensors.len() {
1199            return Err(invalid_input("reference_sensor", "out of range"));
1200        }
1201    }
1202    let needed = geometry.dimension + 1;
1203    if sensors.len() < needed {
1204        return Err(SourceLocalizationError::TooFewSensors {
1205            sensors: sensors.len(),
1206            needed,
1207        });
1208    }
1209    Ok(ResolvedInputs {
1210        dimension: geometry.dimension,
1211        speeds_m_s: geometry.speeds_m_s,
1212        mode: options.mode,
1213    })
1214}
1215
1216fn resolve_geometry_inputs(
1217    sensors: &[Sensor],
1218    source_position_m: &[f64],
1219    propagation_speed_m_s: f64,
1220) -> Result<ResolvedGeometry, SourceLocalizationError> {
1221    if sensors.is_empty() {
1222        return Err(invalid_input("sensors", "must not be empty"));
1223    }
1224    validate_positive("propagation_speed_m_s", propagation_speed_m_s)?;
1225    let dimension = sensors[0].position_m.len();
1226    if !(2..=3).contains(&dimension) {
1227        return Err(invalid_input("position_m", "length must be 2 or 3"));
1228    }
1229    if !source_position_m.is_empty() && source_position_m.len() != dimension {
1230        return Err(invalid_input(
1231            "source_position_m",
1232            "length must match sensors",
1233        ));
1234    }
1235    for sensor in sensors {
1236        if sensor.position_m.len() != dimension {
1237            return Err(invalid_input("position_m", "length must match sensors"));
1238        }
1239        for &value in &sensor.position_m {
1240            validate_finite("position_m", value)?;
1241        }
1242        if let Some(speed) = sensor.propagation_speed_m_s {
1243            validate_positive("sensor.propagation_speed_m_s", speed)?;
1244        }
1245    }
1246    for &value in source_position_m {
1247        validate_finite("source_position_m", value)?;
1248    }
1249    Ok(ResolvedGeometry {
1250        dimension,
1251        speeds_m_s: sensor_speeds(sensors, propagation_speed_m_s)?,
1252    })
1253}
1254
1255fn sensor_speeds(
1256    sensors: &[Sensor],
1257    propagation_speed_m_s: f64,
1258) -> Result<Vec<f64>, SourceLocalizationError> {
1259    validate_positive("propagation_speed_m_s", propagation_speed_m_s)?;
1260    sensors
1261        .iter()
1262        .map(|sensor| {
1263            let speed = sensor
1264                .propagation_speed_m_s
1265                .unwrap_or(propagation_speed_m_s);
1266            validate_positive("sensor.propagation_speed_m_s", speed)?;
1267            Ok(speed)
1268        })
1269        .collect()
1270}
1271
1272fn source_toa_design_rows(
1273    sensors: &[Sensor],
1274    source_position_m: &[f64],
1275    resolved: &ResolvedGeometry,
1276) -> Result<Vec<Vec<f64>>, SourceLocalizationError> {
1277    sensors
1278        .iter()
1279        .zip(&resolved.speeds_m_s)
1280        .map(|(sensor, &speed)| {
1281            let mut row = vec![0.0; resolved.dimension + 1];
1282            let range_m = distance(source_position_m, &sensor.position_m);
1283            if range_m <= 0.0 {
1284                return Err(invalid_input(
1285                    "source_position_m",
1286                    "coincident with a sensor",
1287                ));
1288            }
1289            for axis in 0..resolved.dimension {
1290                row[axis] = (source_position_m[axis] - sensor.position_m[axis]) / range_m / speed;
1291            }
1292            row[resolved.dimension] = 1.0;
1293            Ok(row)
1294        })
1295        .collect()
1296}
1297
1298struct JacobianSvdDiagnostics {
1299    rank: usize,
1300    condition_number: f64,
1301    cofactor: Vec<Vec<f64>>,
1302}
1303
1304#[cfg(test)]
1305fn cofactor_trace_from_jacobian(jac: &[f64], m: usize, n: usize) -> Option<f64> {
1306    let cofactor = cofactor_from_jacobian(jac, m, n)?;
1307    Some(cofactor_trace(&cofactor))
1308}
1309
1310#[cfg(test)]
1311fn cofactor_from_jacobian(jac: &[f64], m: usize, n: usize) -> Option<Vec<Vec<f64>>> {
1312    Some(jacobian_svd_diagnostics(jac, m, n)?.cofactor)
1313}
1314
1315fn jacobian_svd_diagnostics(jac: &[f64], m: usize, n: usize) -> Option<JacobianSvdDiagnostics> {
1316    if m == 0 || n == 0 || jac.len() != m.checked_mul(n)? {
1317        return None;
1318    }
1319    let matrix = DMatrix::from_row_slice(m, n, jac);
1320    let svd = portable::svd(&matrix, false, true);
1321    let singular_values: Vec<f64> = svd.singular_values.iter().map(|value| value.0).collect();
1322    let diagnostics = singular_value_diagnostics(&singular_values, m, n);
1323    let v_t = svd.v_t?;
1324    let largest = singular_values.iter().copied().fold(0.0_f64, f64::max);
1325    let threshold = largest * (m.max(n) as f64) * f64::EPSILON;
1326    let mut cofactor = vec![vec![0.0_f64; n]; n];
1327    for i in 0..n {
1328        for j in i..n {
1329            let mut value = 0.0;
1330            for (component, &singular_value) in singular_values.iter().enumerate() {
1331                if singular_value > threshold {
1332                    let inverse_square = (singular_value * singular_value).recip();
1333                    value += v_t[(component, i)].0 * inverse_square * v_t[(component, j)].0;
1334                }
1335            }
1336            cofactor[i][j] = value;
1337            cofactor[j][i] = value;
1338        }
1339    }
1340    if cofactor.iter().flatten().any(|value| !value.is_finite()) {
1341        return None;
1342    }
1343    Some(JacobianSvdDiagnostics {
1344        rank: diagnostics.rank,
1345        condition_number: diagnostics.condition_number,
1346        cofactor,
1347    })
1348}
1349
1350fn cofactor_trace(cofactor: &[Vec<f64>]) -> f64 {
1351    (0..cofactor.len()).map(|idx| cofactor[idx][idx]).sum()
1352}
1353
1354fn covariance_from_state_cofactor(
1355    cofactor: &[Vec<f64>],
1356    dimension: usize,
1357    timing_sigma_s: f64,
1358    has_origin_time: bool,
1359) -> SourceCovariance {
1360    let scale = timing_sigma_s * timing_sigma_s;
1361    let state: Vec<Vec<f64>> = cofactor
1362        .iter()
1363        .map(|row| row.iter().map(|value| value * scale).collect())
1364        .collect();
1365    let position_m2: Vec<Vec<f64>> = (0..dimension)
1366        .map(|i| (0..dimension).map(|j| state[i][j]).collect())
1367        .collect();
1368    SourceCovariance {
1369        origin_time_s2: if has_origin_time {
1370            Some(state[dimension][dimension])
1371        } else {
1372            None
1373        },
1374        state,
1375        position_m2,
1376        timing_sigma_s,
1377    }
1378}
1379
1380fn solver_options(config: &SourceLocateOptions) -> TrfOptions {
1381    let mut options = TrfOptions::default();
1382    if let Some(ftol) = config.ftol {
1383        options.ftol = ftol;
1384    }
1385    if let Some(xtol) = config.xtol {
1386        options.xtol = xtol;
1387    }
1388    if let Some(gtol) = config.gtol {
1389        options.gtol = gtol;
1390    }
1391    options.max_nfev = config.max_nfev;
1392    options.x_scale = XScale::Jac;
1393    options.loss = config.loss;
1394    options.f_scale = config.f_scale_s;
1395    options
1396}
1397
1398fn least_squares(a: &[Vec<f64>], y: &[f64]) -> Result<Vec<f64>, SourceLocalizationError> {
1399    let n = a.first().map(Vec::len).unwrap_or(0);
1400    if n == 0 || a.len() != y.len() || a.len() < n {
1401        return Err(SourceLocalizationError::InitializerSingular);
1402    }
1403    let mut normal = vec![vec![0.0_f64; n]; n];
1404    let mut rhs = vec![0.0_f64; n];
1405    for (row, &value) in a.iter().zip(y) {
1406        if row.len() != n {
1407            return Err(SourceLocalizationError::InitializerSingular);
1408        }
1409        for i in 0..n {
1410            rhs[i] += row[i] * value;
1411            for j in 0..n {
1412                normal[i][j] += row[i] * row[j];
1413            }
1414        }
1415    }
1416    let inv = crate::astro::math::linear::invert_symmetric_pd(&normal)
1417        .ok_or(SourceLocalizationError::InitializerSingular)?;
1418    Ok((0..n)
1419        .map(|i| (0..n).map(|j| inv[i][j] * rhs[j]).sum())
1420        .collect())
1421}
1422
1423fn quadratic_roots(a: f64, b: f64, c: f64) -> Result<Vec<f64>, SourceLocalizationError> {
1424    if !a.is_finite() || !b.is_finite() || !c.is_finite() {
1425        return Err(SourceLocalizationError::InitializerSingular);
1426    }
1427    let coefficient_scale = a.abs().max(b.abs()).max(c.abs()).max(1.0);
1428    let coefficient_tolerance = QUADRATIC_REL_EPS * coefficient_scale;
1429    if a.abs() <= coefficient_tolerance {
1430        if b.abs() <= coefficient_tolerance {
1431            return Err(SourceLocalizationError::InitializerSingular);
1432        }
1433        return Ok(vec![-c / b]);
1434    }
1435    let b_squared = b * b;
1436    let four_ac = 4.0 * a * c;
1437    let disc = b_squared - four_ac;
1438    let discriminant_scale = b_squared.abs().max(four_ac.abs()).max(1.0);
1439    if disc < -QUADRATIC_REL_EPS * discriminant_scale || !disc.is_finite() {
1440        return Err(SourceLocalizationError::InitializerSingular);
1441    }
1442    let root = disc.max(0.0).sqrt();
1443    Ok(vec![(-b - root) / (2.0 * a), (-b + root) / (2.0 * a)])
1444}
1445
1446fn toa_sse(
1447    sensors: &[Sensor],
1448    arrival_times_s: &[f64],
1449    speeds_m_s: &[f64],
1450    position_m: &[f64],
1451    origin_time_s: f64,
1452) -> f64 {
1453    toa_residuals(
1454        sensors,
1455        arrival_times_s,
1456        speeds_m_s,
1457        position_m,
1458        origin_time_s,
1459    )
1460    .iter()
1461    .map(|value| value * value)
1462    .sum()
1463}
1464
1465fn tdoa_sse(
1466    sensors: &[Sensor],
1467    arrival_times_s: &[f64],
1468    speeds_m_s: &[f64],
1469    position_m: &[f64],
1470    reference_sensor: usize,
1471) -> f64 {
1472    let ref_time =
1473        distance(position_m, &sensors[reference_sensor].position_m) / speeds_m_s[reference_sensor];
1474    let mut sse = 0.0;
1475    for (i, sensor) in sensors.iter().enumerate() {
1476        if i == reference_sensor {
1477            continue;
1478        }
1479        let predicted = distance(position_m, &sensor.position_m) / speeds_m_s[i] - ref_time;
1480        let observed = arrival_times_s[i] - arrival_times_s[reference_sensor];
1481        let residual = predicted - observed;
1482        sse += residual * residual;
1483    }
1484    sse
1485}
1486
1487fn toa_residuals(
1488    sensors: &[Sensor],
1489    arrival_times_s: &[f64],
1490    speeds_m_s: &[f64],
1491    position_m: &[f64],
1492    origin_time_s: f64,
1493) -> Vec<f64> {
1494    sensors
1495        .iter()
1496        .enumerate()
1497        .map(|(i, sensor)| {
1498            single_toa_residual(
1499                sensor,
1500                arrival_times_s[i],
1501                speeds_m_s[i],
1502                position_m,
1503                origin_time_s,
1504            )
1505        })
1506        .collect()
1507}
1508
1509fn single_toa_residual(
1510    sensor: &Sensor,
1511    arrival_time_s: f64,
1512    speed_m_s: f64,
1513    position_m: &[f64],
1514    origin_time_s: f64,
1515) -> f64 {
1516    origin_time_s + distance(position_m, &sensor.position_m) / speed_m_s - arrival_time_s
1517}
1518
1519fn estimate_origin_time_s(
1520    sensors: &[Sensor],
1521    arrival_times_s: &[f64],
1522    speeds_m_s: &[f64],
1523    position_m: &[f64],
1524) -> f64 {
1525    let sum: f64 = sensors
1526        .iter()
1527        .enumerate()
1528        .map(|(i, sensor)| {
1529            arrival_times_s[i] - distance(position_m, &sensor.position_m) / speeds_m_s[i]
1530        })
1531        .sum();
1532    sum / sensors.len() as f64
1533}
1534
1535fn estimate_origin_time_for_loss_s(
1536    sensors: &[Sensor],
1537    arrival_times_s: &[f64],
1538    speeds_m_s: &[f64],
1539    position_m: &[f64],
1540    loss: Loss,
1541    f_scale_s: f64,
1542) -> f64 {
1543    let unweighted = estimate_origin_time_s(sensors, arrival_times_s, speeds_m_s, position_m);
1544    if loss == Loss::Linear {
1545        // Preserve the historical expression path, including its rounding, for
1546        // the default loss.
1547        return unweighted;
1548    }
1549
1550    let mut weighted_sum = 0.0;
1551    let mut weight_sum = 0.0;
1552    for (i, sensor) in sensors.iter().enumerate() {
1553        let candidate_s =
1554            arrival_times_s[i] - distance(position_m, &sensor.position_m) / speeds_m_s[i];
1555        let residual_s = unweighted - candidate_s;
1556        let weight = loss_weight(loss, f_scale_s, residual_s);
1557        weighted_sum += weight * candidate_s;
1558        weight_sum += weight;
1559    }
1560    if weight_sum > 0.0 && weight_sum.is_finite() && weighted_sum.is_finite() {
1561        weighted_sum / weight_sum
1562    } else {
1563        unweighted
1564    }
1565}
1566
1567fn fill_range_derivative(position_m: &[f64], sensor_m: &[f64], speed_m_s: f64, out: &mut [f64]) {
1568    let range_m = distance(position_m, sensor_m);
1569    if range_m <= 0.0 || !range_m.is_finite() {
1570        out.fill(0.0);
1571        return;
1572    }
1573    for axis in 0..out.len() {
1574        out[axis] = (position_m[axis] - sensor_m[axis]) / range_m / speed_m_s;
1575    }
1576}
1577
1578fn loss_weight(loss: Loss, f_scale_s: f64, residual_s: f64) -> f64 {
1579    match loss {
1580        Loss::Linear => 1.0,
1581        Loss::Huber => {
1582            let z = (residual_s / f_scale_s) * (residual_s / f_scale_s);
1583            if z <= 1.0 {
1584                1.0
1585            } else {
1586                z.sqrt().recip()
1587            }
1588        }
1589        Loss::SoftL1 => {
1590            let z = (residual_s / f_scale_s) * (residual_s / f_scale_s);
1591            (1.0 + z).sqrt().recip()
1592        }
1593        Loss::Cauchy => {
1594            let z = (residual_s / f_scale_s) * (residual_s / f_scale_s);
1595            (1.0 + z).recip()
1596        }
1597        Loss::Arctan => {
1598            let z = (residual_s / f_scale_s) * (residual_s / f_scale_s);
1599            (1.0 + z * z).recip()
1600        }
1601    }
1602}
1603
1604fn influence_score(residual_s: f64, leave_one_out_residual_s: Option<f64>, sigma_s: f64) -> f64 {
1605    leave_one_out_residual_s
1606        .unwrap_or(residual_s)
1607        .abs()
1608        .max(residual_s.abs())
1609        / sigma_s
1610}
1611
1612fn distance(a: &[f64], b: &[f64]) -> f64 {
1613    a.iter()
1614        .zip(b)
1615        .map(|(x, y)| {
1616            let d = x - y;
1617            d * d
1618        })
1619        .sum::<f64>()
1620        .sqrt()
1621}
1622
1623fn dot(a: &[f64], b: &[f64]) -> f64 {
1624    a.iter().zip(b).map(|(x, y)| x * y).sum()
1625}
1626
1627const fn identity_rotation() -> [[f64; 3]; 3] {
1628    [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
1629}
1630
1631fn invalid_input(field: &'static str, reason: &'static str) -> SourceLocalizationError {
1632    SourceLocalizationError::InvalidInput { field, reason }
1633}
1634
1635fn validate_optional_positive(
1636    field: &'static str,
1637    value: Option<f64>,
1638) -> Result<(), SourceLocalizationError> {
1639    if let Some(value) = value {
1640        validate_positive(field, value)?;
1641    }
1642    Ok(())
1643}
1644
1645fn validate_positive(field: &'static str, value: f64) -> Result<(), SourceLocalizationError> {
1646    validate_finite(field, value)?;
1647    if value <= 0.0 {
1648        return Err(invalid_input(field, "must be > 0"));
1649    }
1650    Ok(())
1651}
1652
1653fn validate_finite(field: &'static str, value: f64) -> Result<(), SourceLocalizationError> {
1654    if value.is_finite() {
1655        Ok(())
1656    } else {
1657        Err(invalid_input(field, "must be finite"))
1658    }
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663    //! Analytic source-localization fixtures.
1664    //!
1665    //! The tests below use Euclidean ranges, closed-form normal equations, and
1666    //! synthetic corrupted arrivals. They do not compare against another
1667    //! implementation.
1668
1669    // Exercise the construction pattern available to callers of the
1670    // non-exhaustive options type.
1671    #![allow(clippy::field_reassign_with_default)]
1672
1673    use super::*;
1674
1675    fn arrivals(sensors: &[Sensor], source: &[f64], origin: f64, speed: f64) -> Vec<f64> {
1676        sensors
1677            .iter()
1678            .map(|sensor| {
1679                let s = sensor.propagation_speed_m_s.unwrap_or(speed);
1680                origin + distance(source, &sensor.position_m) / s
1681            })
1682            .collect()
1683    }
1684
1685    fn no_influence(options: SourceLocateOptions) -> SourceLocateConfig {
1686        SourceLocateConfig {
1687            options,
1688            include_influence: false,
1689        }
1690    }
1691
1692    fn assert_vec_close(actual: &[f64], expected: &[f64], tol: f64) {
1693        for (axis, (a, e)) in actual.iter().zip(expected).enumerate() {
1694            assert!(
1695                (a - e).abs() < tol,
1696                "axis {axis}: actual {a}, expected {e}, tol {tol}"
1697            );
1698        }
1699    }
1700
1701    fn assert_f64_bits(actual: f64, expected: f64, field: &str) {
1702        assert_eq!(
1703            actual.to_bits(),
1704            expected.to_bits(),
1705            "{field}: actual {actual:?}, expected {expected:?}"
1706        );
1707    }
1708
1709    fn assert_covariance_bits(actual: &SourceCovariance, expected: &SourceCovariance, field: &str) {
1710        assert_eq!(actual.state.len(), expected.state.len());
1711        for (row_index, (actual_row, expected_row)) in
1712            actual.state.iter().zip(&expected.state).enumerate()
1713        {
1714            assert_eq!(actual_row.len(), expected_row.len());
1715            for (column_index, (&actual_value, &expected_value)) in
1716                actual_row.iter().zip(expected_row).enumerate()
1717            {
1718                assert_f64_bits(
1719                    actual_value,
1720                    expected_value,
1721                    &format!("{field}.state[{row_index}][{column_index}]"),
1722                );
1723            }
1724        }
1725        assert_eq!(actual.position_m2.len(), expected.position_m2.len());
1726        for (row_index, (actual_row, expected_row)) in actual
1727            .position_m2
1728            .iter()
1729            .zip(&expected.position_m2)
1730            .enumerate()
1731        {
1732            assert_eq!(actual_row.len(), expected_row.len());
1733            for (column_index, (&actual_value, &expected_value)) in
1734                actual_row.iter().zip(expected_row).enumerate()
1735            {
1736                assert_f64_bits(
1737                    actual_value,
1738                    expected_value,
1739                    &format!("{field}.position_m2[{row_index}][{column_index}]"),
1740                );
1741            }
1742        }
1743        match (actual.origin_time_s2, expected.origin_time_s2) {
1744            (Some(actual), Some(expected)) => {
1745                assert_f64_bits(actual, expected, &format!("{field}.origin_time_s2"));
1746            }
1747            (None, None) => {}
1748            pair => panic!("{field}.origin_time_s2 differs: {pair:?}"),
1749        }
1750        assert_f64_bits(
1751            actual.timing_sigma_s,
1752            expected.timing_sigma_s,
1753            &format!("{field}.timing_sigma_s"),
1754        );
1755    }
1756
1757    fn assert_solution_bits_except_influence(actual: &SourceSolution, expected: &SourceSolution) {
1758        assert_eq!(actual.position_m.len(), expected.position_m.len());
1759        for (axis, (&actual, &expected)) in actual
1760            .position_m
1761            .iter()
1762            .zip(&expected.position_m)
1763            .enumerate()
1764        {
1765            assert_f64_bits(actual, expected, &format!("position_m[{axis}]"));
1766        }
1767        match (actual.origin_time_s, expected.origin_time_s) {
1768            (Some(actual), Some(expected)) => {
1769                assert_f64_bits(actual, expected, "origin_time_s");
1770            }
1771            (None, None) => {}
1772            pair => panic!("origin_time_s differs: {pair:?}"),
1773        }
1774        match (&actual.covariance, &expected.covariance) {
1775            (Some(actual), Some(expected)) => {
1776                assert_covariance_bits(actual, expected, "covariance");
1777            }
1778            (None, None) => {}
1779            pair => panic!("covariance presence differs: {pair:?}"),
1780        }
1781        assert_eq!(actual.residuals.len(), expected.residuals.len());
1782        for (index, (actual, expected)) in
1783            actual.residuals.iter().zip(&expected.residuals).enumerate()
1784        {
1785            assert_eq!(actual.sensor_index, expected.sensor_index);
1786            assert_eq!(
1787                actual.reference_sensor_index,
1788                expected.reference_sensor_index
1789            );
1790            assert_f64_bits(
1791                actual.residual_s,
1792                expected.residual_s,
1793                &format!("residuals[{index}].residual_s"),
1794            );
1795        }
1796        assert_eq!(actual.geometry_quality.tier, expected.geometry_quality.tier);
1797        assert_eq!(
1798            actual.geometry_quality.redundancy,
1799            expected.geometry_quality.redundancy
1800        );
1801        assert_eq!(actual.geometry_quality.rank, expected.geometry_quality.rank);
1802        assert_f64_bits(
1803            actual.geometry_quality.condition_number,
1804            expected.geometry_quality.condition_number,
1805            "geometry_quality.condition_number",
1806        );
1807        assert_f64_bits(
1808            actual.geometry_quality.gdop,
1809            expected.geometry_quality.gdop,
1810            "geometry_quality.gdop",
1811        );
1812        assert_eq!(
1813            actual.geometry_quality.raim_checkable,
1814            expected.geometry_quality.raim_checkable
1815        );
1816        assert_eq!(
1817            actual.geometry_quality.covariance_validated,
1818            expected.geometry_quality.covariance_validated
1819        );
1820        assert_eq!(
1821            actual.initial_guess.position_m.len(),
1822            expected.initial_guess.position_m.len()
1823        );
1824        for (axis, (&actual, &expected)) in actual
1825            .initial_guess
1826            .position_m
1827            .iter()
1828            .zip(&expected.initial_guess.position_m)
1829            .enumerate()
1830        {
1831            assert_f64_bits(
1832                actual,
1833                expected,
1834                &format!("initial_guess.position_m[{axis}]"),
1835            );
1836        }
1837        match (
1838            actual.initial_guess.origin_time_s,
1839            expected.initial_guess.origin_time_s,
1840        ) {
1841            (Some(actual), Some(expected)) => {
1842                assert_f64_bits(actual, expected, "initial_guess.origin_time_s");
1843            }
1844            (None, None) => {}
1845            pair => panic!("initial_guess.origin_time_s differs: {pair:?}"),
1846        }
1847        assert_f64_bits(
1848            actual.initial_guess.residual_rms_s,
1849            expected.initial_guess.residual_rms_s,
1850            "initial_guess.residual_rms_s",
1851        );
1852        assert_eq!(actual.status, expected.status);
1853        assert_eq!(actual.nfev, expected.nfev);
1854        assert_eq!(actual.njev, expected.njev);
1855        assert_f64_bits(actual.cost, expected.cost, "cost");
1856        assert_f64_bits(actual.optimality, expected.optimality, "optimality");
1857    }
1858
1859    struct SplitMix64 {
1860        state: u64,
1861        spare_normal: Option<f64>,
1862    }
1863
1864    impl SplitMix64 {
1865        fn new(seed: u64) -> Self {
1866            Self {
1867                state: seed,
1868                spare_normal: None,
1869            }
1870        }
1871
1872        fn next_u64(&mut self) -> u64 {
1873            self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
1874            let mut value = self.state;
1875            value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1876            value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1877            value ^ (value >> 31)
1878        }
1879
1880        fn unit_f64(&mut self) -> f64 {
1881            let bits = 0x3ff0_0000_0000_0000 | (self.next_u64() >> 12);
1882            f64::from_bits(bits) - 1.0
1883        }
1884
1885        fn standard_normal(&mut self) -> f64 {
1886            if let Some(value) = self.spare_normal.take() {
1887                return value;
1888            }
1889            loop {
1890                let u = 2.0 * self.unit_f64() - 1.0;
1891                let v = 2.0 * self.unit_f64() - 1.0;
1892                let radius_squared = u * u + v * v;
1893                if radius_squared > 0.0 && radius_squared < 1.0 {
1894                    let scale = (-2.0 * libm::log(radius_squared) / radius_squared).sqrt();
1895                    self.spare_normal = Some(v * scale);
1896                    return u * scale;
1897                }
1898            }
1899        }
1900    }
1901
1902    #[test]
1903    fn closed_form_toa_initializer_recovers_clean_3d() {
1904        let sensors = vec![
1905            Sensor::new(vec![0.0, 0.0, 0.0]),
1906            Sensor::new(vec![1200.0, 0.0, 0.0]),
1907            Sensor::new(vec![0.0, 900.0, 0.0]),
1908            Sensor::new(vec![0.0, 0.0, 700.0]),
1909            Sensor::new(vec![1100.0, 800.0, 600.0]),
1910        ];
1911        let source = vec![320.0, 260.0, 180.0];
1912        let origin = 12.5;
1913        let speed = 343.0;
1914        let times = arrivals(&sensors, &source, origin, speed);
1915
1916        let seed =
1917            closed_form_initial_guess(&sensors, &times, speed, SourceSolveMode::Toa).expect("seed");
1918        assert_vec_close(&seed.position_m, &source, 1.0e-8);
1919        assert!((seed.origin_time_s.unwrap() - origin).abs() < 1.0e-10);
1920        assert!(seed.residual_rms_s < 1.0e-11);
1921    }
1922
1923    #[test]
1924    fn locate_source_toa_recovers_clean_3d() {
1925        let sensors = vec![
1926            Sensor::new(vec![0.0, 0.0, 0.0]),
1927            Sensor::new(vec![1200.0, 0.0, 0.0]),
1928            Sensor::new(vec![0.0, 900.0, 0.0]),
1929            Sensor::new(vec![0.0, 0.0, 700.0]),
1930            Sensor::new(vec![1100.0, 800.0, 600.0]),
1931        ];
1932        let source = vec![320.0, 260.0, 180.0];
1933        let origin = 12.5;
1934        let speed = 343.0;
1935        let times = arrivals(&sensors, &source, origin, speed);
1936        let mut options = SourceLocateOptions::default();
1937        options.timing_sigma_s = 0.001;
1938
1939        let solution = locate_source(&sensors, &times, speed, &options).expect("solution");
1940        assert_vec_close(&solution.position_m, &source, 1.0e-7);
1941        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-10);
1942        assert!(solution.covariance.is_some());
1943        assert!(solution
1944            .residuals
1945            .iter()
1946            .all(|row| row.residual_s.abs() < 1.0e-10));
1947    }
1948
1949    #[test]
1950    fn locate_source_toa_recovers_clean_2d() {
1951        let sensors = vec![
1952            Sensor::new(vec![0.0, 0.0]),
1953            Sensor::new(vec![700.0, 0.0]),
1954            Sensor::new(vec![0.0, 600.0]),
1955            Sensor::new(vec![650.0, 550.0]),
1956        ];
1957        let source = [210.0, 170.0];
1958        let origin = 2.75;
1959        let speed = 343.0;
1960        let times = arrivals(&sensors, &source, origin, speed);
1961        let options = SourceLocateOptions::default();
1962        let config = no_influence(options);
1963
1964        let solution = locate_source_with(&sensors, &times, speed, &config).expect("solution");
1965
1966        assert_vec_close(&solution.position_m, &source, 1.0e-8);
1967        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-10);
1968        assert!(solution.per_sensor_influence.is_empty());
1969    }
1970
1971    #[test]
1972    fn locate_source_toa_recovers_negative_origin_time() {
1973        // Arrival times measured against an epoch later than the emission:
1974        // the origin time is negative and the seed's emission-distance unknown
1975        // `c * t0` is negative with it. That is a valid problem, not a
1976        // rejected root.
1977        let sensors = vec![
1978            Sensor::new(vec![0.0, 0.0, 0.0]),
1979            Sensor::new(vec![1200.0, 0.0, 0.0]),
1980            Sensor::new(vec![0.0, 900.0, 0.0]),
1981            Sensor::new(vec![0.0, 0.0, 700.0]),
1982            Sensor::new(vec![1100.0, 800.0, 600.0]),
1983        ];
1984        let source = [320.0, 260.0, 180.0];
1985        let origin = -4.5;
1986        let speed = 343.0;
1987        let times = arrivals(&sensors, &source, origin, speed);
1988        let options = SourceLocateOptions::default();
1989        let config = no_influence(options);
1990
1991        let seed =
1992            closed_form_initial_guess(&sensors, &times, speed, SourceSolveMode::Toa).expect("seed");
1993        assert!((seed.origin_time_s.unwrap() - origin).abs() < 1.0e-9);
1994
1995        let solution = locate_source_with(&sensors, &times, speed, &config).expect("solution");
1996        assert_vec_close(&solution.position_m, &source, 1.0e-7);
1997        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-10);
1998    }
1999
2000    #[test]
2001    fn influence_opt_out_preserves_every_other_output_bit() {
2002        let sensors = vec![
2003            Sensor::new(vec![0.0, 0.0, 0.0]),
2004            Sensor::new(vec![1200.0, 0.0, 0.0]),
2005            Sensor::new(vec![0.0, 900.0, 0.0]),
2006            Sensor::new(vec![0.0, 0.0, 700.0]),
2007            Sensor::new(vec![1100.0, 800.0, 600.0]),
2008        ];
2009        let source = [320.0, 260.0, 180.0];
2010        let speed = 343.0;
2011        let mut times = arrivals(&sensors, &source, 12.5, speed);
2012        for (time, noise) in times
2013            .iter_mut()
2014            .zip([0.00031, -0.00022, 0.00017, -0.00008, 0.00041])
2015        {
2016            *time += noise;
2017        }
2018        let mut with_influence_options = SourceLocateOptions::default();
2019        with_influence_options.timing_sigma_s = 0.001;
2020        let with_influence = locate_source(&sensors, &times, speed, &with_influence_options)
2021            .expect("solution with influence");
2022        assert_eq!(with_influence.per_sensor_influence.len(), sensors.len());
2023
2024        let without_influence_options = with_influence_options.clone();
2025        let without_influence_config = no_influence(without_influence_options);
2026        let without_influence =
2027            locate_source_with(&sensors, &times, speed, &without_influence_config)
2028                .expect("solution without influence");
2029
2030        assert!(without_influence.per_sensor_influence.is_empty());
2031        assert_solution_bits_except_influence(&without_influence, &with_influence);
2032    }
2033
2034    #[test]
2035    fn locate_source_toa_geometry_quality_is_nominal() {
2036        let sensors = vec![
2037            Sensor::new(vec![0.0, 0.0, 0.0]),
2038            Sensor::new(vec![2.0, 0.0, 0.0]),
2039            Sensor::new(vec![0.0, 2.0, 0.0]),
2040            Sensor::new(vec![0.0, 0.0, 2.0]),
2041            Sensor::new(vec![2.0, 2.0, 2.0]),
2042        ];
2043        let source = vec![0.4, 0.6, 0.5];
2044        let origin = 1.25;
2045        let speed = 1.0;
2046        let times = arrivals(&sensors, &source, origin, speed);
2047
2048        let solution = locate_source(&sensors, &times, speed, &SourceLocateOptions::default())
2049            .expect("well-posed source solve");
2050
2051        assert_eq!(
2052            solution.geometry_quality.tier,
2053            crate::geometry_quality::ObservabilityTier::Nominal
2054        );
2055        assert_eq!(solution.geometry_quality.rank, 4);
2056        assert_eq!(solution.geometry_quality.redundancy, 1);
2057        assert!(solution.geometry_quality.raim_checkable);
2058        assert!(solution.geometry_quality.covariance_validated);
2059    }
2060
2061    #[test]
2062    fn locate_source_tdoa_recovers_clean_2d() {
2063        let sensors = vec![
2064            Sensor::new(vec![0.0, 0.0]),
2065            Sensor::new(vec![1000.0, 0.0]),
2066            Sensor::new(vec![0.0, 800.0]),
2067            Sensor::new(vec![900.0, 900.0]),
2068        ];
2069        let source = vec![300.0, 260.0];
2070        let origin = 4.0;
2071        let speed = 340.0;
2072        let times = arrivals(&sensors, &source, origin, speed);
2073        let mut options = SourceLocateOptions::default();
2074        options.mode = SourceSolveMode::Tdoa {
2075            reference_sensor: 0,
2076        };
2077        options.timing_sigma_s = 0.001;
2078
2079        let solution = locate_source(&sensors, &times, speed, &options).expect("solution");
2080        assert_vec_close(&solution.position_m, &source, 1.0e-7);
2081        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-9);
2082        assert_eq!(solution.residuals.len(), sensors.len() - 1);
2083    }
2084
2085    #[test]
2086    fn locate_source_tdoa_recovers_clean_3d() {
2087        let sensors = vec![
2088            Sensor::new(vec![0.0, 0.0, 0.0]),
2089            Sensor::new(vec![1000.0, 0.0, 0.0]),
2090            Sensor::new(vec![0.0, 900.0, 0.0]),
2091            Sensor::new(vec![0.0, 0.0, 800.0]),
2092            Sensor::new(vec![900.0, 850.0, 750.0]),
2093        ];
2094        let source = [280.0, 310.0, 190.0];
2095        let origin = 6.25;
2096        let speed = 343.0;
2097        let times = arrivals(&sensors, &source, origin, speed);
2098        let mut options = SourceLocateOptions::default();
2099        options.mode = SourceSolveMode::Tdoa {
2100            reference_sensor: 3,
2101        };
2102        let config = no_influence(options);
2103
2104        let solution = locate_source_with(&sensors, &times, speed, &config).expect("solution");
2105
2106        assert_vec_close(&solution.position_m, &source, 1.0e-7);
2107        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-9);
2108        assert_eq!(solution.residuals.len(), sensors.len() - 1);
2109    }
2110
2111    #[test]
2112    fn tdoa_influence_populates_excluded_reference_sensor() {
2113        let sensors = vec![
2114            Sensor::new(vec![0.0, 0.0]),
2115            Sensor::new(vec![1000.0, 0.0]),
2116            Sensor::new(vec![0.0, 800.0]),
2117            Sensor::new(vec![900.0, 900.0]),
2118            Sensor::new(vec![-350.0, 500.0]),
2119        ];
2120        let source = [300.0, 260.0];
2121        let speed = 340.0;
2122        let times = arrivals(&sensors, &source, 4.0, speed);
2123        let reference_sensor = 2;
2124        let mut options = SourceLocateOptions::default();
2125        options.mode = SourceSolveMode::Tdoa { reference_sensor };
2126        options.timing_sigma_s = 0.001;
2127
2128        let solution = locate_source(&sensors, &times, speed, &options).expect("solution");
2129        let reference = solution
2130            .per_sensor_influence
2131            .iter()
2132            .find(|record| record.sensor_index == reference_sensor)
2133            .expect("reference influence record");
2134
2135        assert!(reference
2136            .leave_one_out_residual_s
2137            .is_some_and(f64::is_finite));
2138        assert!(reference
2139            .position_delta_m
2140            .is_some_and(|value| value.is_finite() && value >= 0.0));
2141        assert!(reference
2142            .origin_time_delta_s
2143            .is_some_and(|value| value.is_finite() && value >= 0.0));
2144        assert!(reference.score.is_finite());
2145    }
2146
2147    #[test]
2148    fn per_sensor_speed_override_refines_from_uniform_seed() {
2149        let sensors = vec![
2150            Sensor::new(vec![0.0, 0.0, 0.0]),
2151            Sensor::with_speed(vec![1200.0, 0.0, 0.0], 330.0),
2152            Sensor::new(vec![0.0, 900.0, 0.0]),
2153            Sensor::new(vec![0.0, 0.0, 700.0]),
2154            Sensor::new(vec![1100.0, 800.0, 600.0]),
2155        ];
2156        let source = vec![320.0, 260.0, 180.0];
2157        let origin = 12.5;
2158        let speed = 343.0;
2159        let times = arrivals(&sensors, &source, origin, speed);
2160
2161        let solution =
2162            locate_source(&sensors, &times, speed, &SourceLocateOptions::default()).expect("solve");
2163        assert_vec_close(&solution.position_m, &source, 1.0e-6);
2164        assert!((solution.origin_time_s.unwrap() - origin).abs() < 1.0e-9);
2165    }
2166
2167    #[test]
2168    fn source_dop_matches_hand_computed_square_layout() {
2169        let sensors = vec![
2170            Sensor::new(vec![100.0, 0.0]),
2171            Sensor::new(vec![-100.0, 0.0]),
2172            Sensor::new(vec![0.0, 100.0]),
2173            Sensor::new(vec![0.0, -100.0]),
2174        ];
2175        let source = vec![0.0, 0.0];
2176        let speed = 10.0;
2177
2178        let d = source_dop(&sensors, &source, speed).expect("dop");
2179        assert!((d.pdop - 10.0).abs() < 1.0e-12);
2180        assert!((d.hdop - 10.0).abs() < 1.0e-12);
2181        assert_eq!(d.vdop.to_bits(), 0.0_f64.to_bits());
2182        assert!((d.tdop - 0.5).abs() < 1.0e-12);
2183        assert!((d.gdop - 100.25_f64.sqrt()).abs() < 1.0e-12);
2184
2185        let crlb = source_crlb(&sensors, &source, speed, 0.01).expect("crlb");
2186        assert!((crlb.covariance.position_m2[0][0] - 0.005).abs() < 1.0e-15);
2187        assert!((crlb.covariance.position_m2[1][1] - 0.005).abs() < 1.0e-15);
2188        assert!((crlb.covariance.origin_time_s2.unwrap() - 0.000025).abs() < 1.0e-18);
2189    }
2190
2191    #[test]
2192    fn corrupted_arrival_is_downweighted_and_flagged() {
2193        let sensors = vec![
2194            Sensor::new(vec![100.0, 0.0]),
2195            Sensor::new(vec![-100.0, 0.0]),
2196            Sensor::new(vec![0.0, 100.0]),
2197            Sensor::new(vec![0.0, -100.0]),
2198            Sensor::new(vec![120.0, 120.0]),
2199            Sensor::new(vec![-120.0, 80.0]),
2200            Sensor::new(vec![80.0, -140.0]),
2201            Sensor::new(vec![-160.0, -100.0]),
2202            Sensor::new(vec![200.0, 0.0]),
2203            Sensor::new(vec![-200.0, 0.0]),
2204            Sensor::new(vec![0.0, 200.0]),
2205            Sensor::new(vec![0.0, -200.0]),
2206            Sensor::new(vec![300.0, 0.0]),
2207            Sensor::new(vec![-300.0, 0.0]),
2208            Sensor::new(vec![0.0, 300.0]),
2209            Sensor::new(vec![0.0, -300.0]),
2210            Sensor::new(vec![500.0, 0.0]),
2211            Sensor::new(vec![-500.0, 0.0]),
2212            Sensor::new(vec![0.0, 500.0]),
2213            Sensor::new(vec![0.0, -500.0]),
2214        ];
2215        let source = vec![15.0, -20.0];
2216        let origin = 1.25;
2217        let speed = 50.0;
2218        let mut times = arrivals(&sensors, &source, origin, speed);
2219        times[4] += 0.5;
2220        let mut options = SourceLocateOptions::default();
2221        options.loss = Loss::Huber;
2222        options.f_scale_s = 0.01;
2223        options.timing_sigma_s = 0.01;
2224
2225        let solution = locate_source(&sensors, &times, speed, &options).expect("solution");
2226        let worst = solution
2227            .per_sensor_influence
2228            .iter()
2229            .max_by(|a, b| a.score.total_cmp(&b.score))
2230            .expect("influence");
2231        let runner_up = solution
2232            .per_sensor_influence
2233            .iter()
2234            .filter(|record| record.sensor_index != worst.sensor_index)
2235            .map(|record| record.score)
2236            .max_by(f64::total_cmp)
2237            .expect("runner-up influence");
2238        assert_eq!(worst.sensor_index, 4);
2239        assert!(worst.loss_weight < 0.05);
2240        assert!(
2241            worst.score > 20.0 * runner_up,
2242            "corrupted score {} was not twenty times runner-up {}",
2243            worst.score,
2244            runner_up
2245        );
2246        let expected = worst
2247            .leave_one_out_residual_s
2248            .unwrap_or(worst.residual_s)
2249            .abs()
2250            .max(worst.residual_s.abs())
2251            / options.timing_sigma_s;
2252        assert_f64_bits(worst.score, expected, "corrupted influence score");
2253    }
2254
2255    #[test]
2256    fn influence_score_matches_hand_computation() {
2257        assert_f64_bits(
2258            influence_score(-0.03, Some(0.08), 0.01),
2259            8.0,
2260            "leave-one-out score",
2261        );
2262        assert_f64_bits(influence_score(-0.03, None, 0.01), 3.0, "fallback score");
2263    }
2264
2265    #[test]
2266    fn tdoa_huber_origin_time_improves_on_unweighted_mean() {
2267        let sensors = vec![
2268            Sensor::new(vec![100.0, 0.0]),
2269            Sensor::new(vec![-100.0, 0.0]),
2270            Sensor::new(vec![0.0, 100.0]),
2271            Sensor::new(vec![0.0, -100.0]),
2272            Sensor::new(vec![120.0, 120.0]),
2273            Sensor::new(vec![-120.0, 80.0]),
2274        ];
2275        let source = [15.0, -20.0];
2276        let origin = 1.25;
2277        let speed = 50.0;
2278        let mut times = arrivals(&sensors, &source, origin, speed);
2279        times[4] += 0.5;
2280        let mut options = SourceLocateOptions::default();
2281        options.mode = SourceSolveMode::Tdoa {
2282            reference_sensor: 0,
2283        };
2284        options.loss = Loss::Huber;
2285        options.f_scale_s = 0.01;
2286        let config = no_influence(options);
2287
2288        let solution = locate_source_with(&sensors, &times, speed, &config).expect("solution");
2289        let speeds = sensor_speeds(&sensors, speed).expect("speeds");
2290        let unweighted = estimate_origin_time_s(&sensors, &times, &speeds, &solution.position_m);
2291        let weighted = solution.origin_time_s.expect("TDOA origin time");
2292
2293        assert!(
2294            (weighted - origin).abs() < (unweighted - origin).abs(),
2295            "weighted error {}, unweighted error {}",
2296            (weighted - origin).abs(),
2297            (unweighted - origin).abs()
2298        );
2299    }
2300
2301    #[test]
2302    fn seeded_toa_noise_rms_tracks_crlb() {
2303        const TRIALS: usize = 256;
2304        const TIMING_SIGMA_S: f64 = 2.0e-4;
2305
2306        let sensors = vec![
2307            Sensor::new(vec![-200.0, -100.0]),
2308            Sensor::new(vec![250.0, -80.0]),
2309            Sensor::new(vec![-150.0, 260.0]),
2310            Sensor::new(vec![220.0, 240.0]),
2311            Sensor::new(vec![20.0, -300.0]),
2312            Sensor::new(vec![350.0, 100.0]),
2313        ];
2314        let source = [40.0, 30.0];
2315        let origin = 3.0;
2316        let speed = 343.0;
2317        let clean_times = arrivals(&sensors, &source, origin, speed);
2318        let predicted = source_crlb(&sensors, &source, speed, TIMING_SIGMA_S)
2319            .expect("CRLB")
2320            .covariance
2321            .position_m2;
2322        let predicted_rms = (predicted[0][0] + predicted[1][1]).sqrt();
2323        let mut options = SourceLocateOptions::default();
2324        options.timing_sigma_s = TIMING_SIGMA_S;
2325        let config = no_influence(options);
2326        let mut rng = SplitMix64::new(0x534f_5552_4345_4d43);
2327        let mut squared_position_error_sum = 0.0;
2328
2329        for _ in 0..TRIALS {
2330            let mut noisy_times = clean_times.clone();
2331            for time in &mut noisy_times {
2332                *time += TIMING_SIGMA_S * rng.standard_normal();
2333            }
2334            let solution =
2335                locate_source_with(&sensors, &noisy_times, speed, &config).expect("noisy solution");
2336            squared_position_error_sum += solution
2337                .position_m
2338                .iter()
2339                .zip(source)
2340                .map(|(estimated, truth)| (estimated - truth) * (estimated - truth))
2341                .sum::<f64>();
2342        }
2343
2344        let sample_rms = (squared_position_error_sum / TRIALS as f64).sqrt();
2345        let ratio = sample_rms / predicted_rms;
2346        assert!(
2347            (0.8..=1.2).contains(&ratio),
2348            "sample RMS {sample_rms} m, predicted RMS {predicted_rms} m, ratio {ratio}"
2349        );
2350    }
2351
2352    #[test]
2353    fn degenerate_seed_geometry_reports_initializer_singular() {
2354        let sensors = vec![
2355            Sensor::new(vec![0.0, 0.0]),
2356            Sensor::new(vec![100.0, 0.0]),
2357            Sensor::new(vec![200.0, 0.0]),
2358            Sensor::new(vec![300.0, 0.0]),
2359        ];
2360        let times = arrivals(&sensors, &[50.0, 20.0], 1.0, 300.0);
2361
2362        let error = closed_form_initial_guess(&sensors, &times, 300.0, SourceSolveMode::Toa)
2363            .expect_err("collinear seed must be singular");
2364
2365        assert_eq!(error, SourceLocalizationError::InitializerSingular);
2366    }
2367
2368    #[test]
2369    fn exhausted_solver_budget_reports_did_not_converge() {
2370        let sensors = vec![
2371            Sensor::new(vec![0.0, 0.0, 0.0]),
2372            Sensor::new(vec![1200.0, 0.0, 0.0]),
2373            Sensor::new(vec![0.0, 900.0, 0.0]),
2374            Sensor::new(vec![0.0, 0.0, 700.0]),
2375            Sensor::new(vec![1100.0, 800.0, 600.0]),
2376        ];
2377        let source = [320.0, 260.0, 180.0];
2378        let speed = 343.0;
2379        let mut times = arrivals(&sensors, &source, 12.5, speed);
2380        for (time, noise) in times
2381            .iter_mut()
2382            .zip([0.00031, -0.00022, 0.00017, -0.00008, 0.00041])
2383        {
2384            *time += noise;
2385        }
2386        let mut options = SourceLocateOptions::default();
2387        options.max_nfev = Some(1);
2388        let config = no_influence(options);
2389
2390        let error = locate_source_with(&sensors, &times, speed, &config)
2391            .expect_err("one evaluation cannot converge");
2392
2393        assert_eq!(error, SourceLocalizationError::DidNotConverge { status: 0 });
2394    }
2395
2396    #[test]
2397    fn empty_sensor_input_names_the_invalid_field() {
2398        let error = locate_source(&[], &[], 343.0, &SourceLocateOptions::default())
2399            .expect_err("empty sensors");
2400
2401        assert_eq!(
2402            error,
2403            SourceLocalizationError::InvalidInput {
2404                field: "sensors",
2405                reason: "must not be empty",
2406            }
2407        );
2408    }
2409
2410    #[test]
2411    fn degenerate_collinear_geometry_reports_singular_dop() {
2412        let sensors = vec![
2413            Sensor::new(vec![0.0, 0.0]),
2414            Sensor::new(vec![100.0, 0.0]),
2415            Sensor::new(vec![200.0, 0.0]),
2416            Sensor::new(vec![300.0, 0.0]),
2417        ];
2418        let err = source_dop(&sensors, &[50.0, 0.0], 300.0).expect_err("singular");
2419        assert!(matches!(
2420            err,
2421            SourceLocalizationError::Geometry(DopError::Singular)
2422        ));
2423    }
2424
2425    #[test]
2426    fn source_collinear_timing_design_classifies_rank_deficient() {
2427        let jac = [
2428            1.0 / 300.0,
2429            0.0,
2430            1.0,
2431            -1.0 / 300.0,
2432            0.0,
2433            1.0,
2434            -1.0 / 300.0,
2435            0.0,
2436            1.0,
2437            -1.0 / 300.0,
2438            0.0,
2439            1.0,
2440        ];
2441        let quality = source_geometry_quality_from_jacobian(&jac, 4, 3).expect("quality");
2442        let pseudocofactor_trace =
2443            cofactor_trace_from_jacobian(&jac, 4, 3).expect("SVD pseudocofactor trace");
2444
2445        assert_eq!(
2446            quality.tier,
2447            crate::geometry_quality::ObservabilityTier::RankDeficient
2448        );
2449        assert!(!quality.raim_checkable);
2450        assert!(!quality.covariance_validated);
2451        assert!(pseudocofactor_trace.is_finite() && pseudocofactor_trace >= 0.0);
2452    }
2453}