1use crate::astro::math::robust::{huber_weight, mad_scale, HUBER_K};
65use crate::astro::math::vec3::{dot3, sub3, unit3};
66use crate::estimation::recipe::NormalRecipe;
67use crate::estimation::substrate::normal::NormalAssembler;
68use crate::estimation::substrate::rows::ResidualRow;
69use crate::validate::{self, FieldError};
70use crate::GnssSatelliteId;
71
72const DEFAULT_MINIMUM_OBSERVATIONS: usize = 4;
73const DEFAULT_ROBUST_SCALE_FLOOR_M_S: f64 = 0.05;
74const DEFAULT_ROBUST_MAX_OUTER: usize = 2;
75const DEFAULT_ROBUST_OUTER_TOL_M_S: f64 = 1.0e-6;
76const VELOCITY_UNKNOWNS: usize = 4;
77const VELOCITY_NORMAL_ASSEMBLER: NormalAssembler =
78 NormalAssembler::new(NormalRecipe::PppDenseLastTie);
79
80#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct VelocityConfig {
83 pub minimum_observations: usize,
85 pub robust: Option<VelocityRobustConfig>,
87}
88
89impl Default for VelocityConfig {
90 fn default() -> Self {
91 Self {
92 minimum_observations: DEFAULT_MINIMUM_OBSERVATIONS,
93 robust: None,
94 }
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct VelocityRobustConfig {
107 pub huber_k: f64,
109 pub scale_floor_m_s: f64,
111 pub max_outer: usize,
113 pub outer_tol_m_s: f64,
115}
116
117impl Default for VelocityRobustConfig {
118 fn default() -> Self {
119 Self {
120 huber_k: HUBER_K,
121 scale_floor_m_s: DEFAULT_ROBUST_SCALE_FLOOR_M_S,
122 max_outer: DEFAULT_ROBUST_MAX_OUTER,
123 outer_tol_m_s: DEFAULT_ROBUST_OUTER_TOL_M_S,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct ReceiverVelocityState {
131 pub position_m: [f64; 3],
133 pub velocity_m_s: [f64; 3],
135 pub clock_drift_m_s: f64,
137}
138
139impl ReceiverVelocityState {
140 pub const fn stationary_at(position_m: [f64; 3]) -> Self {
142 Self {
143 position_m,
144 velocity_m_s: [0.0; 3],
145 clock_drift_m_s: 0.0,
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq)]
152pub struct VelocityObservation {
153 pub sat: GnssSatelliteId,
155 pub satellite_position_m: [f64; 3],
157 pub satellite_velocity_m_s: [f64; 3],
159 pub measured_range_rate_m_s: f64,
161 pub sigma_m_s: f64,
163 pub satellite_clock_drift_m_s: f64,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq)]
169pub struct RangeRatePrediction {
170 pub los_unit: [f64; 3],
172 pub range_rate_m_s: f64,
174}
175
176#[derive(Debug, Clone, PartialEq)]
178pub struct VelocitySolution {
179 pub velocity_m_s: [f64; 3],
181 pub clock_drift_m_s: f64,
183 pub residual_rms_m_s: f64,
185 pub used_sats: Vec<GnssSatelliteId>,
187 pub downweighted_sats: Vec<GnssSatelliteId>,
189 pub robust_iterations: usize,
191 pub robust_scale_m_s: Option<f64>,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum VelocitySolveError {
198 TooFewObservations {
200 required: usize,
202 actual: usize,
204 },
205 InvalidInput {
207 field: &'static str,
209 reason: &'static str,
211 },
212 SingularGeometry,
214}
215
216impl core::fmt::Display for VelocitySolveError {
217 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
218 match self {
219 Self::TooFewObservations { required, actual } => write!(
220 f,
221 "too few velocity observations: required {required}, got {actual}"
222 ),
223 Self::InvalidInput { field, reason } => {
224 write!(f, "invalid velocity input {field}: {reason}")
225 }
226 Self::SingularGeometry => {
227 write!(f, "velocity geometry is singular")
228 }
229 }
230 }
231}
232
233impl std::error::Error for VelocitySolveError {}
234
235pub fn predict_range_rate_m_s(
240 observation: &VelocityObservation,
241 receiver: ReceiverVelocityState,
242) -> Option<RangeRatePrediction> {
243 if !finite3(observation.satellite_position_m)
244 || !finite3(observation.satellite_velocity_m_s)
245 || !finite3(receiver.position_m)
246 || !finite3(receiver.velocity_m_s)
247 || !receiver.clock_drift_m_s.is_finite()
248 || !observation.satellite_clock_drift_m_s.is_finite()
249 {
250 return None;
251 }
252 let los_unit = unit3(sub3(observation.satellite_position_m, receiver.position_m))?;
253 let relative_velocity_m_s = sub3(observation.satellite_velocity_m_s, receiver.velocity_m_s);
254 let range_rate_m_s = dot3(los_unit, relative_velocity_m_s) + receiver.clock_drift_m_s
255 - observation.satellite_clock_drift_m_s;
256 if !finite3(los_unit) || !range_rate_m_s.is_finite() {
257 return None;
258 }
259 Some(RangeRatePrediction {
260 los_unit,
261 range_rate_m_s,
262 })
263}
264
265pub fn solve_velocity_least_squares(
271 observations: &[VelocityObservation],
272 receiver_position_m: [f64; 3],
273 config: VelocityConfig,
274) -> Result<VelocitySolution, VelocitySolveError> {
275 validate_velocity_config(config)?;
276 let rows = build_velocity_rows(observations, receiver_position_m, config)?;
277 let mut solution = solve_velocity_rows(&rows)?;
278 let mut residuals = postfit_residuals_m_s(&rows, solution);
279 let mut robust_iterations = 0usize;
280 let mut robust_scale_m_s = None;
281 let mut robust_multipliers = vec![1.0; rows.len()];
282
283 if let Some(robust) = config.robust {
284 for _ in 0..robust.max_outer.saturating_sub(1) {
285 let (reweighted_rows, multipliers, scale_m_s) =
286 robust_reweighted_rows(&rows, &residuals, robust)?;
287 let next_solution = solve_velocity_rows(&reweighted_rows)?;
288 let step_m_s = solution_step_norm(solution, next_solution);
289 solution = next_solution;
290 residuals = postfit_residuals_m_s(&rows, solution);
291 robust_iterations += 1;
292 robust_scale_m_s = Some(scale_m_s);
293 robust_multipliers = multipliers;
294 if step_m_s < robust.outer_tol_m_s {
295 break;
296 }
297 }
298 }
299
300 assemble_solution(
301 observations,
302 solution,
303 &residuals,
304 &robust_multipliers,
305 robust_iterations,
306 robust_scale_m_s,
307 )
308}
309
310pub fn solve_velocity(
316 observations: &[VelocityObservation],
317 receiver_position_m: [f64; 3],
318 config: VelocityConfig,
319) -> Result<VelocitySolution, VelocitySolveError> {
320 solve_velocity_least_squares(observations, receiver_position_m, config)
321}
322
323fn build_velocity_rows(
324 observations: &[VelocityObservation],
325 receiver_position_m: [f64; 3],
326 config: VelocityConfig,
327) -> Result<Vec<ResidualRow>, VelocitySolveError> {
328 let required = config.minimum_observations.max(VELOCITY_UNKNOWNS);
329 if observations.len() < required {
330 return Err(VelocitySolveError::TooFewObservations {
331 required,
332 actual: observations.len(),
333 });
334 }
335 validate::finite_vec3(receiver_position_m, "velocity receiver position_m")
336 .map_err(invalid_input)?;
337 let receiver = ReceiverVelocityState::stationary_at(receiver_position_m);
338 let mut rows = Vec::with_capacity(observations.len());
339 for observation in observations {
340 validate_observation(observation)?;
341 let sigma_m_s =
342 validate::finite_positive(observation.sigma_m_s, "velocity observation sigma_m_s")
343 .map_err(invalid_input)?;
344 let prediction = predict_range_rate_m_s(observation, receiver)
345 .ok_or(VelocitySolveError::SingularGeometry)?;
346 let y = observation.measured_range_rate_m_s - prediction.range_rate_m_s;
347 validate::finite(y, "velocity observation residual_m_s").map_err(invalid_input)?;
348 let weight = 1.0 / sigma_m_s;
349 validate::finite_positive(weight, "velocity observation weight").map_err(invalid_input)?;
350 rows.push(ResidualRow {
351 h: vec![
352 -prediction.los_unit[0],
353 -prediction.los_unit[1],
354 -prediction.los_unit[2],
355 1.0,
356 ],
357 y,
358 weight,
359 });
360 }
361 Ok(rows)
362}
363
364fn validate_velocity_config(config: VelocityConfig) -> Result<(), VelocitySolveError> {
365 if let Some(robust) = config.robust {
366 validate::finite_positive(robust.huber_k, "velocity robust huber_k")
367 .map_err(invalid_input)?;
368 validate::finite_positive(robust.scale_floor_m_s, "velocity robust scale_floor_m_s")
369 .map_err(invalid_input)?;
370 validate::finite_positive(robust.outer_tol_m_s, "velocity robust outer_tol_m_s")
371 .map_err(invalid_input)?;
372 if robust.max_outer == 0 {
373 return Err(VelocitySolveError::InvalidInput {
374 field: "velocity robust max_outer",
375 reason: "not positive",
376 });
377 }
378 }
379 Ok(())
380}
381
382fn validate_observation(observation: &VelocityObservation) -> Result<(), VelocitySolveError> {
383 validate::finite_vec3(
384 observation.satellite_position_m,
385 "velocity observation satellite_position_m",
386 )
387 .map_err(invalid_input)?;
388 validate::finite_vec3(
389 observation.satellite_velocity_m_s,
390 "velocity observation satellite_velocity_m_s",
391 )
392 .map_err(invalid_input)?;
393 validate::finite(
394 observation.measured_range_rate_m_s,
395 "velocity observation measured_range_rate_m_s",
396 )
397 .map_err(invalid_input)?;
398 validate::finite(
399 observation.satellite_clock_drift_m_s,
400 "velocity observation satellite_clock_drift_m_s",
401 )
402 .map_err(invalid_input)?;
403 Ok(())
404}
405
406fn finite3(value: [f64; 3]) -> bool {
407 value.iter().all(|x| x.is_finite())
408}
409
410fn solve_velocity_rows(rows: &[ResidualRow]) -> Result<[f64; 4], VelocitySolveError> {
411 let solution = VELOCITY_NORMAL_ASSEMBLER
412 .solve_dense_last_tie(rows.iter().map(ResidualRow::as_weighted), VELOCITY_UNKNOWNS)
413 .ok_or(VelocitySolveError::SingularGeometry)?;
414 let solution: [f64; 4] = solution
415 .try_into()
416 .map_err(|_| VelocitySolveError::SingularGeometry)?;
417 validate_finite_slice(&solution, "velocity solution")?;
418 Ok(solution)
419}
420
421fn postfit_residuals_m_s(rows: &[ResidualRow], solution: [f64; 4]) -> Vec<f64> {
422 rows.iter()
423 .map(|row| {
424 row.y
425 - (row.h[0] * solution[0]
426 + row.h[1] * solution[1]
427 + row.h[2] * solution[2]
428 + row.h[3] * solution[3])
429 })
430 .collect()
431}
432
433fn robust_reweighted_rows(
434 rows: &[ResidualRow],
435 residuals_m_s: &[f64],
436 robust: VelocityRobustConfig,
437) -> Result<(Vec<ResidualRow>, Vec<f64>, f64), VelocitySolveError> {
438 let scale_m_s =
439 mad_scale(residuals_m_s, robust.scale_floor_m_s).map_err(invalid_robust_input)?;
440 let multipliers: Vec<f64> = residuals_m_s
441 .iter()
442 .map(|residual| huber_weight(residual / scale_m_s, robust.huber_k))
443 .collect();
444 validate_finite_slice(&multipliers, "velocity robust multiplier")?;
445 let rows = rows
446 .iter()
447 .zip(multipliers.iter())
448 .map(|(row, multiplier)| {
449 let mut row = row.clone();
450 row.weight *= multiplier.sqrt();
451 validate::finite_positive(row.weight, "velocity robust row weight")
452 .map_err(invalid_input)?;
453 Ok(row)
454 })
455 .collect::<Result<Vec<_>, _>>()?;
456 Ok((rows, multipliers, scale_m_s))
457}
458
459fn solution_step_norm(previous: [f64; 4], next: [f64; 4]) -> f64 {
460 ((next[0] - previous[0]).powi(2)
461 + (next[1] - previous[1]).powi(2)
462 + (next[2] - previous[2]).powi(2)
463 + (next[3] - previous[3]).powi(2))
464 .sqrt()
465}
466
467fn assemble_solution(
468 observations: &[VelocityObservation],
469 solution: [f64; 4],
470 residuals_m_s: &[f64],
471 robust_multipliers: &[f64],
472 robust_iterations: usize,
473 robust_scale_m_s: Option<f64>,
474) -> Result<VelocitySolution, VelocitySolveError> {
475 validate_finite_slice(&solution, "velocity solution")?;
476 validate_finite_slice(residuals_m_s, "velocity residual_m_s")?;
477 validate_finite_slice(robust_multipliers, "velocity robust multiplier")?;
478 if let Some(scale_m_s) = robust_scale_m_s {
479 validate::finite_positive(scale_m_s, "velocity robust scale_m_s").map_err(invalid_input)?;
480 }
481 let residual_rms_m_s = rms(residuals_m_s);
482 validate::finite(residual_rms_m_s, "velocity residual_rms_m_s").map_err(invalid_input)?;
483 Ok(VelocitySolution {
484 velocity_m_s: [solution[0], solution[1], solution[2]],
485 clock_drift_m_s: solution[3],
486 residual_rms_m_s,
487 used_sats: observations.iter().map(|obs| obs.sat).collect(),
488 downweighted_sats: observations
489 .iter()
490 .zip(robust_multipliers.iter())
491 .filter_map(|(observation, multiplier)| (*multiplier < 1.0).then_some(observation.sat))
492 .collect(),
493 robust_iterations,
494 robust_scale_m_s,
495 })
496}
497
498fn rms(values: &[f64]) -> f64 {
499 if values.is_empty() {
500 return 0.0;
501 }
502 (values.iter().map(|value| value * value).sum::<f64>() / values.len() as f64).sqrt()
503}
504
505fn validate_finite_slice(values: &[f64], field: &'static str) -> Result<(), VelocitySolveError> {
506 for value in values {
507 validate::finite(*value, field).map_err(invalid_input)?;
508 }
509 Ok(())
510}
511
512fn invalid_input(error: FieldError) -> VelocitySolveError {
513 VelocitySolveError::InvalidInput {
514 field: error.field(),
515 reason: error.reason(),
516 }
517}
518
519fn invalid_robust_input(error: crate::astro::math::robust::RobustError) -> VelocitySolveError {
520 VelocitySolveError::InvalidInput {
521 field: error.field(),
522 reason: error.reason(),
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::GnssSystem;
530
531 fn sat() -> GnssSatelliteId {
532 GnssSatelliteId::new(GnssSystem::Gps, 7).expect("valid satellite id")
533 }
534
535 fn sat_prn(prn: u8) -> GnssSatelliteId {
536 GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid satellite id")
537 }
538
539 fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
540 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
541 }
542
543 fn synthetic_observations(
544 receiver_position_m: [f64; 3],
545 receiver_velocity_m_s: [f64; 3],
546 clock_drift_m_s: f64,
547 ) -> Vec<VelocityObservation> {
548 let satellite_positions_m = [
549 [20_200_000.0, 0.0, 0.0],
550 [0.0, 21_100_000.0, 0.0],
551 [0.0, 0.0, 22_300_000.0],
552 [-20_900_000.0, 19_700_000.0, 21_500_000.0],
553 [18_600_000.0, -20_400_000.0, 23_100_000.0],
554 [-18_800_000.0, -21_200_000.0, 19_400_000.0],
555 [21_900_000.0, 18_300_000.0, -20_700_000.0],
556 [-22_100_000.0, 17_900_000.0, -18_500_000.0],
557 ];
558 let satellite_velocities_m_s = [
559 [120.0, -40.0, 30.0],
560 [-80.0, 90.0, 10.0],
561 [20.0, -70.0, 105.0],
562 [35.0, 45.0, -95.0],
563 [-55.0, 65.0, 75.0],
564 [70.0, -85.0, 40.0],
565 [-25.0, -45.0, 95.0],
566 [45.0, 55.0, -65.0],
567 ];
568 satellite_positions_m
569 .into_iter()
570 .zip(satellite_velocities_m_s)
571 .enumerate()
572 .map(|(idx, (satellite_position_m, satellite_velocity_m_s))| {
573 let satellite_clock_drift_m_s = 0.01 * idx as f64;
574 let mut observation = VelocityObservation {
575 sat: sat_prn((idx + 1) as u8),
576 satellite_position_m,
577 satellite_velocity_m_s,
578 measured_range_rate_m_s: 0.0,
579 sigma_m_s: 0.1 + 0.05 * idx as f64,
580 satellite_clock_drift_m_s,
581 };
582 observation.measured_range_rate_m_s = predict_range_rate_m_s(
583 &observation,
584 ReceiverVelocityState {
585 position_m: receiver_position_m,
586 velocity_m_s: receiver_velocity_m_s,
587 clock_drift_m_s,
588 },
589 )
590 .expect("synthetic line of sight")
591 .range_rate_m_s;
592 observation
593 })
594 .collect()
595 }
596
597 #[test]
598 fn velocity_types_construct() {
599 let config = VelocityConfig::default();
600 assert_eq!(config.minimum_observations, 4);
601 assert_eq!(config.robust, None);
602
603 let robust = VelocityRobustConfig::default();
604 assert_eq!(robust.huber_k, HUBER_K);
605 assert_eq!(robust.max_outer, 2);
606
607 let receiver = ReceiverVelocityState {
608 position_m: [1.0, 2.0, 3.0],
609 velocity_m_s: [0.1, -0.2, 0.3],
610 clock_drift_m_s: 0.4,
611 };
612 assert_eq!(receiver.position_m, [1.0, 2.0, 3.0]);
613 assert_eq!(
614 ReceiverVelocityState::stationary_at([4.0, 5.0, 6.0]),
615 ReceiverVelocityState {
616 position_m: [4.0, 5.0, 6.0],
617 velocity_m_s: [0.0; 3],
618 clock_drift_m_s: 0.0,
619 }
620 );
621
622 let observation = VelocityObservation {
623 sat: sat(),
624 satellite_position_m: [20_200_000.0, 14_000_000.0, 21_700_000.0],
625 satellite_velocity_m_s: [120.0, -30.0, 80.0],
626 measured_range_rate_m_s: -425.0,
627 sigma_m_s: 0.08,
628 satellite_clock_drift_m_s: 0.02,
629 };
630 assert_eq!(observation.sat, sat());
631 assert_eq!(observation.sigma_m_s, 0.08);
632 }
633
634 #[test]
635 fn predicted_range_rate_zero_receiver_matches_los_projected_satellite_velocity() {
636 let observation = VelocityObservation {
637 sat: sat(),
638 satellite_position_m: [3.0, 4.0, 0.0],
639 satellite_velocity_m_s: [6.0, 8.0, 10.0],
640 measured_range_rate_m_s: 0.0,
641 sigma_m_s: 1.0,
642 satellite_clock_drift_m_s: 0.0,
643 };
644 let receiver = ReceiverVelocityState::stationary_at([0.0, 0.0, 0.0]);
645
646 let prediction =
647 predict_range_rate_m_s(&observation, receiver).expect("nonzero line of sight");
648
649 let expected = (3.0 / 5.0) * observation.satellite_velocity_m_s[0]
650 + (4.0 / 5.0) * observation.satellite_velocity_m_s[1];
651 assert!((prediction.range_rate_m_s - expected).abs() < 1.0e-12);
652 assert!((prediction.los_unit[0] - 3.0 / 5.0).abs() < 1.0e-12);
653 assert!((prediction.los_unit[1] - 4.0 / 5.0).abs() < 1.0e-12);
654 assert_eq!(prediction.los_unit[2], 0.0);
655 }
656
657 #[test]
658 fn predicted_range_rate_rejects_non_finite_state() {
659 let observation = VelocityObservation {
660 sat: sat(),
661 satellite_position_m: [f64::NAN, 4.0, 0.0],
662 satellite_velocity_m_s: [6.0, 8.0, 10.0],
663 measured_range_rate_m_s: 0.0,
664 sigma_m_s: 1.0,
665 satellite_clock_drift_m_s: 0.0,
666 };
667 let receiver = ReceiverVelocityState::stationary_at([0.0, 0.0, 0.0]);
668
669 assert_eq!(predict_range_rate_m_s(&observation, receiver), None);
670 }
671
672 #[test]
673 fn design_row_matches_velocity_linearization() {
674 let observation = VelocityObservation {
675 sat: sat(),
676 satellite_position_m: [3.0, 4.0, 0.0],
677 satellite_velocity_m_s: [6.0, 8.0, 10.0],
678 measured_range_rate_m_s: 42.0,
679 sigma_m_s: 0.25,
680 satellite_clock_drift_m_s: 0.5,
681 };
682
683 let rows = build_velocity_rows(
684 &[observation; 4],
685 [0.0, 0.0, 0.0],
686 VelocityConfig::default(),
687 )
688 .expect("rows");
689
690 assert!((rows[0].h[0] + 3.0 / 5.0).abs() < 1.0e-15);
691 assert!((rows[0].h[1] + 4.0 / 5.0).abs() < 1.0e-15);
692 assert_eq!(rows[0].h[2], -0.0);
693 assert_eq!(rows[0].h[3], 1.0);
694 let predicted_at_zero = dot(
695 [3.0 / 5.0, 4.0 / 5.0, 0.0],
696 observation.satellite_velocity_m_s,
697 ) - observation.satellite_clock_drift_m_s;
698 assert!(
699 (rows[0].y - (observation.measured_range_rate_m_s - predicted_at_zero)).abs() < 1.0e-12
700 );
701 assert_eq!(rows[0].weight, 4.0);
702 }
703
704 #[test]
705 fn weighted_least_squares_recovers_clean_synthetic_velocity() {
706 let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
707 let receiver_velocity_m_s = [12.25, -3.5, 0.75];
708 let clock_drift_m_s = -0.35;
709 let observations =
710 synthetic_observations(receiver_position_m, receiver_velocity_m_s, clock_drift_m_s);
711
712 let solution = solve_velocity_least_squares(
713 &observations,
714 receiver_position_m,
715 VelocityConfig::default(),
716 )
717 .expect("velocity solution");
718
719 for (got, expected) in solution
720 .velocity_m_s
721 .iter()
722 .zip(receiver_velocity_m_s.iter())
723 {
724 assert!((got - expected).abs() < 1.0e-9, "{got} != {expected}");
725 }
726 assert!((solution.clock_drift_m_s - clock_drift_m_s).abs() < 1.0e-9);
727 assert!(solution.residual_rms_m_s < 1.0e-9);
728 assert_eq!(solution.used_sats.len(), observations.len());
729 assert_eq!(solution.used_sats[0], sat_prn(1));
730 assert_eq!(solution.downweighted_sats, Vec::<GnssSatelliteId>::new());
731 assert_eq!(solution.robust_iterations, 0);
732 assert_eq!(solution.robust_scale_m_s, None);
733 }
734
735 #[test]
736 fn singular_or_underdetermined_geometry_returns_error() {
737 let receiver_position_m = [0.0, 0.0, 0.0];
738 let mut observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
739 let too_few = observations[..3].to_vec();
740 assert_eq!(
741 solve_velocity_least_squares(&too_few, receiver_position_m, VelocityConfig::default()),
742 Err(VelocitySolveError::TooFewObservations {
743 required: 4,
744 actual: 3
745 })
746 );
747
748 for (idx, observation) in observations.iter_mut().take(4).enumerate() {
749 observation.satellite_position_m = [20_000_000.0 + idx as f64, 0.0, 0.0];
750 }
751
752 assert_eq!(
753 solve_velocity_least_squares(
754 &observations[..4],
755 receiver_position_m,
756 VelocityConfig::default()
757 ),
758 Err(VelocitySolveError::SingularGeometry)
759 );
760 }
761
762 #[test]
763 fn rejects_non_finite_velocity_inputs() {
764 let receiver_position_m = [0.0, 0.0, 0.0];
765 let mut observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
766 observations[0].measured_range_rate_m_s = f64::NAN;
767
768 assert_eq!(
769 solve_velocity_least_squares(
770 &observations,
771 receiver_position_m,
772 VelocityConfig::default()
773 ),
774 Err(VelocitySolveError::InvalidInput {
775 field: "velocity observation measured_range_rate_m_s",
776 reason: "not finite"
777 })
778 );
779
780 observations[0].measured_range_rate_m_s = 0.0;
781 observations[0].sigma_m_s = 0.0;
782 assert_eq!(
783 solve_velocity_least_squares(
784 &observations,
785 receiver_position_m,
786 VelocityConfig::default()
787 ),
788 Err(VelocitySolveError::InvalidInput {
789 field: "velocity observation sigma_m_s",
790 reason: "not positive"
791 })
792 );
793
794 observations[0].sigma_m_s = f64::from_bits(1);
795 assert_eq!(
796 solve_velocity_least_squares(
797 &observations,
798 receiver_position_m,
799 VelocityConfig::default()
800 ),
801 Err(VelocitySolveError::InvalidInput {
802 field: "velocity observation weight",
803 reason: "not finite"
804 })
805 );
806 observations[0].sigma_m_s = 0.1;
807
808 let mut config = VelocityConfig {
809 robust: Some(VelocityRobustConfig {
810 huber_k: f64::INFINITY,
811 ..VelocityRobustConfig::default()
812 }),
813 ..VelocityConfig::default()
814 };
815 assert_eq!(
816 solve_velocity_least_squares(&observations, receiver_position_m, config),
817 Err(VelocitySolveError::InvalidInput {
818 field: "velocity robust huber_k",
819 reason: "not finite"
820 })
821 );
822
823 config.robust = Some(VelocityRobustConfig {
824 scale_floor_m_s: -1.0,
825 ..VelocityRobustConfig::default()
826 });
827 assert_eq!(
828 solve_velocity_least_squares(&observations, receiver_position_m, config),
829 Err(VelocitySolveError::InvalidInput {
830 field: "velocity robust scale_floor_m_s",
831 reason: "not positive"
832 })
833 );
834 }
835
836 #[test]
837 fn robust_velocity_rejects_zero_outer_limit_only_when_enabled() {
838 let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
839 let observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
840
841 let mut robust = VelocityRobustConfig {
842 max_outer: 0,
843 ..VelocityRobustConfig::default()
844 };
845 assert_eq!(
846 solve_velocity_least_squares(
847 &observations,
848 receiver_position_m,
849 VelocityConfig {
850 robust: Some(robust),
851 ..VelocityConfig::default()
852 }
853 ),
854 Err(VelocitySolveError::InvalidInput {
855 field: "velocity robust max_outer",
856 reason: "not positive"
857 })
858 );
859
860 robust.max_outer = 1;
861 let robust_solution = solve_velocity_least_squares(
862 &observations,
863 receiver_position_m,
864 VelocityConfig {
865 robust: Some(robust),
866 ..VelocityConfig::default()
867 },
868 )
869 .expect("max_outer=1 robust velocity solution");
870 assert_eq!(robust_solution.robust_iterations, 0);
871
872 let non_robust = solve_velocity_least_squares(
873 &observations,
874 receiver_position_m,
875 VelocityConfig::default(),
876 )
877 .expect("non-robust velocity solution");
878 assert_eq!(non_robust.robust_iterations, 0);
879 }
880
881 #[test]
882 fn robust_reweighting_downweights_doppler_outlier() {
883 let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
884 let receiver_velocity_m_s = [12.25, -3.5, 0.75];
885 let clock_drift_m_s = -0.35;
886 let observations =
887 synthetic_observations(receiver_position_m, receiver_velocity_m_s, clock_drift_m_s);
888 let clean = solve_velocity_least_squares(
889 &observations,
890 receiver_position_m,
891 VelocityConfig::default(),
892 )
893 .expect("clean solution");
894
895 let mut corrupted = observations.clone();
896 let outlier_sat = corrupted[3].sat;
897 corrupted[3].measured_range_rate_m_s += 75.0;
898
899 let bare = solve_velocity_least_squares(
900 &corrupted,
901 receiver_position_m,
902 VelocityConfig::default(),
903 )
904 .expect("bare outlier solution");
905 let robust = solve_velocity_least_squares(
906 &corrupted,
907 receiver_position_m,
908 VelocityConfig {
909 robust: Some(VelocityRobustConfig {
910 scale_floor_m_s: 0.05,
911 max_outer: 5,
912 outer_tol_m_s: 1.0e-12,
913 ..VelocityRobustConfig::default()
914 }),
915 ..VelocityConfig::default()
916 },
917 )
918 .expect("robust outlier solution");
919
920 let bare_error = velocity_error_m_s(&bare.velocity_m_s, &clean.velocity_m_s);
921 let robust_error = velocity_error_m_s(&robust.velocity_m_s, &clean.velocity_m_s);
922 assert!(
923 robust_error < bare_error,
924 "robust_error={robust_error}, bare_error={bare_error}"
925 );
926 assert!(
927 robust_error < 1.0,
928 "robust velocity should stay close to truth, got {robust_error}"
929 );
930 assert!(robust.downweighted_sats.contains(&outlier_sat));
931 assert!(robust.robust_iterations >= 1);
932 assert!(robust.robust_scale_m_s.is_some());
933 }
934
935 #[test]
936 fn robust_reweighting_applies_sqrt_huber_multiplier_to_velocity_rows() {
937 let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
938 let receiver_velocity_m_s = [12.25, -3.5, 0.75];
939 let clock_drift_m_s = -0.35;
940 let mut observations =
941 synthetic_observations(receiver_position_m, receiver_velocity_m_s, clock_drift_m_s);
942 observations[3].measured_range_rate_m_s += 75.0;
943
944 let robust = VelocityRobustConfig {
945 scale_floor_m_s: 0.05,
946 max_outer: 2,
947 outer_tol_m_s: 1.0e-12,
948 ..VelocityRobustConfig::default()
949 };
950 let rows = build_velocity_rows(
951 &observations,
952 receiver_position_m,
953 VelocityConfig {
954 robust: Some(robust),
955 ..VelocityConfig::default()
956 },
957 )
958 .expect("velocity rows");
959 let base_solution = solve_velocity_rows(&rows).expect("base velocity solution");
960 let residuals = postfit_residuals_m_s(&rows, base_solution);
961 let scale_m_s =
962 mad_scale(&residuals, robust.scale_floor_m_s).expect("valid robust residual scale");
963 let multipliers: Vec<f64> = residuals
964 .iter()
965 .map(|residual| huber_weight(residual / scale_m_s, robust.huber_k))
966 .collect();
967 assert!(multipliers.iter().any(|multiplier| *multiplier < 1.0));
968
969 let intended_rows = scaled_rows(&rows, &multipliers, f64::sqrt);
970 let squared_weight_rows = scaled_rows(&rows, &multipliers, core::convert::identity);
971 let intended_solution =
972 solve_velocity_rows(&intended_rows).expect("intended robust solution");
973 let squared_weight_solution =
974 solve_velocity_rows(&squared_weight_rows).expect("squared-weight solution");
975
976 let robust_solution = solve_velocity_least_squares(
977 &observations,
978 receiver_position_m,
979 VelocityConfig {
980 robust: Some(robust),
981 ..VelocityConfig::default()
982 },
983 )
984 .expect("public robust solution");
985
986 assert_eq!(robust_solution.robust_iterations, 1);
987 assert_solution_close(
988 solution_vector(&robust_solution),
989 intended_solution,
990 1.0e-10,
991 );
992 assert!(
993 solution_delta(solution_vector(&robust_solution), squared_weight_solution) > 1.0e-3,
994 "sqrt-Huber solution should differ from the old squared-weight behavior"
995 );
996 }
997
998 #[test]
999 fn public_solve_velocity_driver_matches_low_level_solve() {
1000 let receiver_position_m = [3_512_900.0, 780_500.0, 5_248_700.0];
1001 let observations = synthetic_observations(receiver_position_m, [1.0, 2.0, 3.0], 0.4);
1002
1003 assert_eq!(
1004 solve_velocity(
1005 &observations,
1006 receiver_position_m,
1007 VelocityConfig::default()
1008 ),
1009 solve_velocity_least_squares(
1010 &observations,
1011 receiver_position_m,
1012 VelocityConfig::default()
1013 )
1014 );
1015 }
1016
1017 fn velocity_error_m_s(got: &[f64; 3], expected: &[f64; 3]) -> f64 {
1018 ((got[0] - expected[0]).powi(2)
1019 + (got[1] - expected[1]).powi(2)
1020 + (got[2] - expected[2]).powi(2))
1021 .sqrt()
1022 }
1023
1024 fn scaled_rows(
1025 rows: &[ResidualRow],
1026 multipliers: &[f64],
1027 scale_multiplier: impl Fn(f64) -> f64,
1028 ) -> Vec<ResidualRow> {
1029 rows.iter()
1030 .zip(multipliers)
1031 .map(|(row, multiplier)| {
1032 let mut row = row.clone();
1033 row.weight *= scale_multiplier(*multiplier);
1034 row
1035 })
1036 .collect()
1037 }
1038
1039 fn solution_vector(solution: &VelocitySolution) -> [f64; 4] {
1040 [
1041 solution.velocity_m_s[0],
1042 solution.velocity_m_s[1],
1043 solution.velocity_m_s[2],
1044 solution.clock_drift_m_s,
1045 ]
1046 }
1047
1048 fn assert_solution_close(actual: [f64; 4], expected: [f64; 4], tolerance: f64) {
1049 for idx in 0..4 {
1050 assert!(
1051 (actual[idx] - expected[idx]).abs() <= tolerance,
1052 "solution[{idx}] {} != {}",
1053 actual[idx],
1054 expected[idx]
1055 );
1056 }
1057 }
1058
1059 fn solution_delta(a: [f64; 4], b: [f64; 4]) -> f64 {
1060 ((a[0] - b[0]).powi(2)
1061 + (a[1] - b[1]).powi(2)
1062 + (a[2] - b[2]).powi(2)
1063 + (a[3] - b[3]).powi(2))
1064 .sqrt()
1065 }
1066}