1use 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
105const QUADRATIC_REL_EPS: f64 = 32.0 * f64::EPSILON;
110
111#[derive(Debug, Clone, PartialEq)]
117pub struct Sensor {
118 pub position_m: Vec<f64>,
120 pub propagation_speed_m_s: Option<f64>,
122}
123
124impl Sensor {
125 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum SourceSolveMode {
145 #[default]
147 Toa,
148 Tdoa {
154 reference_sensor: usize,
156 },
157}
158
159#[derive(Debug, Clone, PartialEq)]
165pub struct SourceLocateOptions {
166 pub mode: SourceSolveMode,
168 pub timing_sigma_s: f64,
171 pub loss: Loss,
173 pub f_scale_s: f64,
175 pub ftol: Option<f64>,
177 pub xtol: Option<f64>,
179 pub gtol: Option<f64>,
181 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#[non_exhaustive]
214#[derive(Debug, Clone, PartialEq)]
215pub struct SourceLocateConfig {
216 pub options: SourceLocateOptions,
218 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#[derive(Debug, Clone, PartialEq)]
247pub struct SourceInitialGuess {
248 pub position_m: Vec<f64>,
250 pub origin_time_s: Option<f64>,
252 pub residual_rms_s: f64,
254}
255
256#[derive(Debug, Clone, PartialEq)]
258pub struct SourceResidual {
259 pub sensor_index: usize,
261 pub reference_sensor_index: Option<usize>,
263 pub residual_s: f64,
265}
266
267#[derive(Debug, Clone, PartialEq)]
269pub struct SourceSensorInfluence {
270 pub sensor_index: usize,
272 pub residual_s: f64,
276 pub leave_one_out_residual_s: Option<f64>,
281 pub position_delta_m: Option<f64>,
283 pub origin_time_delta_s: Option<f64>,
285 pub loss_weight: f64,
289 pub score: f64,
296}
297
298#[derive(Debug, Clone, PartialEq)]
304pub struct SourceCovariance {
305 pub state: Vec<Vec<f64>>,
307 pub position_m2: Vec<Vec<f64>>,
309 pub origin_time_s2: Option<f64>,
311 pub timing_sigma_s: f64,
313}
314
315#[derive(Debug, Clone, PartialEq)]
317pub struct SourceSolution {
318 pub position_m: Vec<f64>,
320 pub origin_time_s: Option<f64>,
322 pub covariance: Option<SourceCovariance>,
324 pub residuals: Vec<SourceResidual>,
326 pub per_sensor_influence: Vec<SourceSensorInfluence>,
328 pub geometry_quality: GeometryQuality,
334 pub initial_guess: SourceInitialGuess,
336 pub status: i32,
340 pub nfev: usize,
342 pub njev: usize,
344 pub cost: f64,
346 pub optimality: f64,
348}
349
350impl SourceSolution {
351 pub fn crlb(&self) -> Option<&SourceCovariance> {
353 self.covariance.as_ref()
354 }
355}
356
357#[derive(Debug, Clone, PartialEq)]
359pub struct SourceCrlb {
360 pub dop: Dop,
362 pub covariance: SourceCovariance,
364}
365
366#[derive(Debug, Clone, PartialEq)]
368pub enum SourceLocalizationError {
369 InvalidInput {
371 field: &'static str,
373 reason: &'static str,
375 },
376 TooFewSensors {
378 sensors: usize,
380 needed: usize,
382 },
383 InitializerSingular,
385 Geometry(DopError),
387 Solver(TrfError),
389 DidNotConverge {
391 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
440pub 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
471pub 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
495pub 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(
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
551pub 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
575pub 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 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 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 #![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, ×, 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, ×, 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, ×, 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 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, ×, 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, ×, 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, ×, 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, ×, 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, ×, 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, ×, 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, ×, 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, ×, 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, ×, 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, ×, 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, ×, speed, &config).expect("solution");
2275 let speeds = sensor_speeds(&sensors, speed).expect("speeds");
2276 let unweighted = estimate_origin_time_s(&sensors, ×, &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, ×, 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, ×, 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}