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