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