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