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