use std::collections::BTreeMap;
use nalgebra::{DMatrix, DVector};
use super::*;
use crate::astro::math::least_squares::jacobian_2point;
use crate::dop::line_of_sight_from_az_el_deg;
use crate::spp::{
ionosphere_for, sat_model, solve, KlobucharCoeffs, SatModelEnv, SppModelRecipe,
DEFAULT_HUBER_K, DEFAULT_ROBUST_MAX_OUTER, DEFAULT_ROBUST_OUTER_TOL_M,
DEFAULT_ROBUST_SCALE_FLOOR_M,
};
#[derive(Debug, Clone)]
struct FixedEphemeris {
positions: BTreeMap<GnssSatelliteId, [f64; 3]>,
}
impl EphemerisSource for FixedEphemeris {
fn position_clock_at_j2000_s(
&self,
sat: GnssSatelliteId,
_t_j2000_s: f64,
) -> Option<([f64; 3], f64)> {
self.positions
.get(&sat)
.copied()
.map(|position| (position, 0.0))
}
}
const TRUTH: [f64; 3] = [6_378_137.0, 0.0, 0.0];
const RANGE_M: f64 = 20_200_000.0;
fn gps(prn: u8) -> GnssSatelliteId {
GnssSatelliteId::new(GnssSystem::Gps, prn).expect("valid GPS id")
}
fn galileo(prn: u8) -> GnssSatelliteId {
GnssSatelliteId::new(GnssSystem::Galileo, prn).expect("valid Galileo id")
}
fn zero_klobuchar() -> KlobucharCoeffs {
KlobucharCoeffs {
alpha: [0.0; 4],
beta: [0.0; 4],
}
}
fn options() -> StaticSolveOptions {
StaticSolveOptions {
initial_position_m: [TRUTH[0] + 120.0, TRUTH[1] - 80.0, TRUTH[2] + 50.0],
with_geodetic: true,
robust: None,
}
}
fn robust_options() -> StaticSolveOptions {
StaticSolveOptions {
robust: Some(RobustConfig {
huber_k: DEFAULT_HUBER_K,
scale_floor_m: DEFAULT_ROBUST_SCALE_FLOOR_M,
max_outer: DEFAULT_ROBUST_MAX_OUTER,
outer_tol_m: DEFAULT_ROBUST_OUTER_TOL_M,
}),
..options()
}
}
fn sat_position(azimuth_deg: f64, elevation_deg: f64) -> [f64; 3] {
let los = line_of_sight_from_az_el_deg(
azimuth_deg,
elevation_deg,
Wgs84Geodetic {
lat_rad: 0.0,
lon_rad: 0.0,
height_m: 0.0,
},
)
.expect("valid LOS");
[
TRUTH[0] + RANGE_M * los.e_x,
TRUTH[1] + RANGE_M * los.e_y,
TRUTH[2] + RANGE_M * los.e_z,
]
}
fn epoch_angles(epoch_index: usize) -> Vec<(f64, f64)> {
match epoch_index {
0 => vec![
(0.0, 58.0),
(60.0, 47.0),
(130.0, 42.0),
(210.0, 53.0),
(300.0, 38.0),
(20.0, 32.0),
],
1 => vec![
(25.0, 55.0),
(90.0, 44.0),
(155.0, 49.0),
(240.0, 36.0),
(315.0, 46.0),
(350.0, 31.0),
],
_ => vec![
(45.0, 51.0),
(115.0, 39.0),
(185.0, 56.0),
(260.0, 43.0),
(335.0, 35.0),
(10.0, 48.0),
],
}
}
fn make_store(epoch_count: usize) -> FixedEphemeris {
let mut positions = BTreeMap::new();
for epoch_index in 0..epoch_count {
for (sat_index, (azimuth_deg, elevation_deg)) in
epoch_angles(epoch_index).into_iter().enumerate()
{
let sat = gps((epoch_index * 10 + sat_index + 1) as u8);
positions.insert(sat, sat_position(azimuth_deg, elevation_deg));
}
}
FixedEphemeris { positions }
}
fn pseudorange(
eph: &FixedEphemeris,
sat: GnssSatelliteId,
t_rx_j2000_s: f64,
day_of_year: f64,
clock_m: f64,
) -> f64 {
let inputs = SolveInputs {
observations: Vec::new(),
t_rx_j2000_s,
t_rx_second_of_day_s: 12_000.0,
day_of_year,
initial_guess: [TRUTH[0], TRUTH[1], TRUTH[2], clock_m],
corrections: Corrections::NONE,
klobuchar: zero_klobuchar(),
beidou_klobuchar: None,
galileo_nequick: None,
sbas_iono: None,
glonass_channels: BTreeMap::new(),
met: SurfaceMet::default(),
robust: None,
};
let env = SatModelEnv {
eph,
t_rx_j2000_s,
t_rx_second_of_day_s: inputs.t_rx_second_of_day_s,
day_of_year,
corrections: Corrections::NONE,
met: &inputs.met,
glonass_channels: &inputs.glonass_channels,
model: SppModelRecipe::reference(),
};
sat_model(
&env,
sat,
TRUTH,
clock_m,
RANGE_M,
ionosphere_for(sat.system, &inputs),
)
.expect("synthetic satellite in store")
.p_hat_m
}
fn make_epoch(
eph: &FixedEphemeris,
epoch_index: usize,
clock_m: f64,
corruptions_m: &[f64],
) -> StaticEpoch {
let t_rx_j2000_s = 1000.0 + epoch_index as f64 * 30.0;
let day_of_year = 120.0 + epoch_index as f64;
let measurements = epoch_angles(epoch_index)
.into_iter()
.enumerate()
.map(|(sat_index, _)| {
let sat = gps((epoch_index * 10 + sat_index + 1) as u8);
let corruption = corruptions_m.get(sat_index).copied().unwrap_or(0.0);
Observation {
satellite_id: sat,
pseudorange_m: pseudorange(eph, sat, t_rx_j2000_s, day_of_year, clock_m)
+ corruption,
}
})
.collect();
StaticEpoch {
measurements,
weights: None,
t_rx_j2000_s,
t_rx_second_of_day_s: 12_000.0,
day_of_year,
clock_initial_m: 0.0,
corrections: Corrections::NONE,
klobuchar: zero_klobuchar(),
beidou_klobuchar: None,
galileo_nequick: None,
sbas_iono: None,
glonass_channels: BTreeMap::new(),
met: SurfaceMet::default(),
}
}
fn clean_epochs(eph: &FixedEphemeris, epoch_count: usize) -> Vec<StaticEpoch> {
(0..epoch_count)
.map(|epoch_index| make_epoch(eph, epoch_index, 12.0 + epoch_index as f64 * 4.0, &[]))
.collect()
}
fn mixed_system_store_and_epoch() -> (FixedEphemeris, StaticEpoch) {
let sats = [
(gps(1), 0.0, 58.0),
(gps(2), 70.0, 45.0),
(gps(3), 150.0, 42.0),
(gps(4), 235.0, 50.0),
(galileo(1), 25.0, 54.0),
(galileo(2), 105.0, 40.0),
(galileo(3), 200.0, 47.0),
(galileo(4), 315.0, 36.0),
];
let positions = sats
.iter()
.map(|(sat, azimuth_deg, elevation_deg)| (*sat, sat_position(*azimuth_deg, *elevation_deg)))
.collect::<BTreeMap<_, _>>();
let eph = FixedEphemeris { positions };
let t_rx_j2000_s = 1000.0;
let day_of_year = 120.0;
let clock_m = 12.0;
let measurements = sats
.iter()
.map(|(sat, _, _)| Observation {
satellite_id: *sat,
pseudorange_m: pseudorange(&eph, *sat, t_rx_j2000_s, day_of_year, clock_m),
})
.collect();
(
eph,
StaticEpoch {
measurements,
weights: None,
t_rx_j2000_s,
t_rx_second_of_day_s: 12_000.0,
day_of_year,
clock_initial_m: 0.0,
corrections: Corrections::NONE,
klobuchar: zero_klobuchar(),
beidou_klobuchar: None,
galileo_nequick: None,
sbas_iono: None,
glonass_channels: BTreeMap::new(),
met: SurfaceMet::default(),
},
)
}
fn trace3(matrix: [[f64; 3]; 3]) -> f64 {
matrix[0][0] + matrix[1][1] + matrix[2][2]
}
fn assert_close(got: f64, want: f64, tol: f64, label: &str) {
assert!(
(got - want).abs() <= tol,
"{label}: got {got:e}, want {want:e}, tol {tol:e}"
);
}
fn assert_position_close(got: [f64; 3], want: [f64; 3], tol_m: f64) {
for idx in 0..3 {
assert_close(got[idx], want[idx], tol_m, "position");
}
}
#[test]
fn single_epoch_static_matches_spp_bits() {
let eph = make_store(1);
let epochs = clean_epochs(&eph, 1);
let static_solution = solve_static(&eph, &epochs, options()).expect("static solve");
let spp_inputs = solve_inputs_for_epoch(&epochs[0], options());
let spp_solution = solve(&eph, &spp_inputs, true).expect("SPP solve");
assert_eq!(
static_solution.position.x_m.to_bits(),
spp_solution.position.x_m.to_bits()
);
assert_eq!(
static_solution.position.y_m.to_bits(),
spp_solution.position.y_m.to_bits()
);
assert_eq!(
static_solution.position.z_m.to_bits(),
spp_solution.position.z_m.to_bits()
);
assert_eq!(
static_solution.per_epoch_clock[0].clock_s.to_bits(),
spp_solution.rx_clock_s.to_bits()
);
assert_eq!(static_solution.used_sats[0], spp_solution.used_sats);
}
#[test]
fn single_epoch_multi_system_static_matches_spp_bits() {
let (eph, epoch) = mixed_system_store_and_epoch();
let epochs = vec![epoch];
let static_solution = solve_static(&eph, &epochs, options()).expect("static solve");
let spp_inputs = solve_inputs_for_epoch(&epochs[0], options());
let spp_solution = solve(&eph, &spp_inputs, true).expect("SPP solve");
assert_eq!(
static_solution.position.x_m.to_bits(),
spp_solution.position.x_m.to_bits()
);
assert_eq!(
static_solution.position.y_m.to_bits(),
spp_solution.position.y_m.to_bits()
);
assert_eq!(
static_solution.position.z_m.to_bits(),
spp_solution.position.z_m.to_bits()
);
assert_eq!(
static_solution
.per_epoch_clock
.iter()
.map(|clock| (clock.system, clock.clock_s.to_bits()))
.collect::<Vec<_>>(),
spp_solution
.system_clocks_s
.iter()
.map(|(system, clock_s)| (*system, clock_s.to_bits()))
.collect::<Vec<_>>()
);
}
#[test]
fn stacked_solution_recovers_truth_and_covariance_matches_hand_normal_matrix() {
let eph = make_store(3);
let epochs = clean_epochs(&eph, 3);
let solution = solve_static(&eph, &epochs, options()).expect("static solve");
assert_position_close(solution.position.as_array(), TRUTH, 2.0e-4);
for (idx, clock) in solution.per_epoch_clock.iter().enumerate() {
let want_m = 12.0 + idx as f64 * 4.0;
assert_close(clock.clock_s * C_M_S, want_m, 2.0e-4, "clock");
}
let independent = independent_jacobian_covariance(&eph, &epochs, &solution);
for i in 0..independent.nrows() {
for j in 0..independent.ncols() {
assert_close(
solution.covariance.state_m2[i][j],
independent[(i, j)],
1.0e-8,
"covariance",
);
}
}
let hand = hand_covariance(&eph, &epochs, &solution);
for i in 0..hand.nrows() {
for j in 0..hand.ncols() {
assert_close(
solution.covariance.state_m2[i][j],
hand[(i, j)],
1.0e-1,
"hand covariance",
);
}
}
}
#[test]
fn covariance_shrinks_by_geometry_not_naive_epoch_count() {
let eph = make_store(3);
let one_epoch = clean_epochs(&eph, 1);
let three_epochs = clean_epochs(&eph, 3);
let single = solve_static(&eph, &one_epoch, options()).expect("single epoch");
let stacked = solve_static(&eph, &three_epochs, options()).expect("stacked epochs");
let single_std = trace3(single.covariance.position_ecef_m2).sqrt();
let stacked_std = trace3(stacked.covariance.position_ecef_m2).sqrt();
let naive_std = single_std / (3.0_f64).sqrt();
assert!(stacked_std < single_std);
assert!(
(stacked_std - naive_std).abs() > single_std * 1.0e-3,
"stacked covariance should follow geometry, not naive epoch count"
);
}
#[test]
fn rank_deficient_geometry_returns_typed_error() {
let mut eph = FixedEphemeris {
positions: BTreeMap::new(),
};
let duplicate_los_position = sat_position(20.0, 35.0);
let mut measurements = Vec::new();
for prn in 1..=5 {
let sat = gps(prn);
eph.positions.insert(sat, duplicate_los_position);
measurements.push(Observation {
satellite_id: sat,
pseudorange_m: RANGE_M + 5.0,
});
}
let epoch = StaticEpoch {
measurements,
weights: None,
t_rx_j2000_s: 1000.0,
t_rx_second_of_day_s: 12_000.0,
day_of_year: 120.0,
clock_initial_m: 0.0,
corrections: Corrections::NONE,
klobuchar: zero_klobuchar(),
beidou_klobuchar: None,
galileo_nequick: None,
sbas_iono: None,
glonass_channels: BTreeMap::new(),
met: SurfaceMet::default(),
};
let err = solve_static(&eph, &[epoch], options()).expect_err("rank deficient");
assert!(matches!(err, StaticSolveError::Singular(_)));
}
#[test]
fn robust_static_diagnostics_surface_bad_epoch_and_satellite() {
let eph = make_store(3);
let mut epochs = clean_epochs(&eph, 3);
let bad_sat = gps(13);
epochs[1] = make_epoch(&eph, 1, 16.0, &[80.0, -70.0, 220.0, -90.0, 65.0, -55.0]);
let solution = solve_static(&eph, &epochs, robust_options()).expect("robust static");
assert!(solution.metadata.outer_iterations > 0);
let bad_row = solution
.residuals_m
.iter()
.find(|row| row.satellite_id == bad_sat)
.expect("bad satellite row");
assert!(
bad_row.robust_weight_ratio < 0.2,
"bad satellite should be down-weighted"
);
let worst_epoch = solution
.per_epoch_influence
.iter()
.min_by(|a, b| {
a.min_robust_weight_ratio
.total_cmp(&b.min_robust_weight_ratio)
})
.expect("epoch influence");
assert_eq!(worst_epoch.epoch_index, 1);
assert!(worst_epoch.min_robust_weight_ratio < 0.2);
let worst_sat = solution
.per_satellite_influence
.iter()
.min_by(|a, b| a.robust_weight_ratio.total_cmp(&b.robust_weight_ratio))
.expect("satellite influence");
assert_eq!(worst_sat.satellite_id, bad_sat);
assert!(matches!(worst_sat.status, StaticInfluenceStatus::Solved));
let batch_sat = solution
.per_satellite_batch_influence
.iter()
.find(|influence| influence.satellite_id == bad_sat)
.expect("batch satellite influence");
assert_eq!(batch_sat.omitted_measurements, 1);
assert!(batch_sat.min_robust_weight_ratio < 0.2);
}
fn independent_jacobian_covariance(
eph: &FixedEphemeris,
epochs: &[StaticEpoch],
solution: &StaticSolution,
) -> DMatrix<f64> {
let prepared =
prepare_static(eph, epochs, options(), SppModelRecipe::reference()).expect("prepared");
let x = solution_state(solution);
let weights = solution
.residuals_m
.iter()
.map(|row| row.effective_weight.sqrt())
.collect::<Vec<_>>();
let residual = |state: &DVector<f64>| -> DVector<f64> {
let unweighted = residual_static_unweighted(
eph,
&prepared,
state.as_slice(),
SppModelRecipe::reference(),
)
.expect("residuals");
DVector::from_iterator(
unweighted.len(),
unweighted
.into_iter()
.zip(weights.iter())
.map(|(residual_m, sqrt_weight)| residual_m * sqrt_weight),
)
};
let f0 = residual(&x);
let jacobian = jacobian_2point(residual, &x, &f0).expect("independent jacobian");
normal_covariance(&jacobian, 1.0).expect("independent covariance")
}
fn solution_state(solution: &StaticSolution) -> DVector<f64> {
let mut state = Vec::with_capacity(3 + solution.per_epoch_clock.len());
let position = solution.position.as_array();
state.extend_from_slice(&position);
state.extend(
solution
.per_epoch_clock
.iter()
.map(|clock| clock.clock_s * C_M_S),
);
DVector::from_vec(state)
}
fn hand_covariance(
eph: &FixedEphemeris,
epochs: &[StaticEpoch],
solution: &StaticSolution,
) -> DMatrix<f64> {
let n_params = solution.covariance.state_m2.len();
let mut design = DMatrix::zeros(solution.residuals_m.len(), n_params);
let rx = solution.position.as_array();
let clock_m_by_epoch = solution
.per_epoch_clock
.iter()
.map(|clock| (clock.epoch_index, clock.clock_s * C_M_S))
.collect::<BTreeMap<_, _>>();
let clock_columns = solution
.per_epoch_clock
.iter()
.enumerate()
.map(|(clock_index, clock)| ((clock.epoch_index, clock.system), 3 + clock_index))
.collect::<BTreeMap<_, _>>();
for (row_index, row) in solution.residuals_m.iter().enumerate() {
let epoch = &epochs[row.epoch_index];
let p_meas = epoch
.measurements
.iter()
.find(|measurement| measurement.satellite_id == row.satellite_id)
.expect("measurement in epoch")
.pseudorange_m;
let env = SatModelEnv {
eph,
t_rx_j2000_s: epoch.t_rx_j2000_s,
t_rx_second_of_day_s: epoch.t_rx_second_of_day_s,
day_of_year: epoch.day_of_year,
corrections: epoch.corrections,
met: &epoch.met,
glonass_channels: &epoch.glonass_channels,
model: SppModelRecipe::reference(),
};
let inputs = solve_inputs_for_epoch(epoch, options());
let sat = sat_model(
&env,
row.satellite_id,
rx,
clock_m_by_epoch[&row.epoch_index],
p_meas,
ionosphere_for(row.satellite_id.system, &inputs),
)
.expect("synthetic satellite model");
let sat_pos = sat.sat_rot_ecef_m;
let dx = sat_pos[0] - rx[0];
let dy = sat_pos[1] - rx[1];
let dz = sat_pos[2] - rx[2];
let norm = (dx * dx + dy * dy + dz * dz).sqrt();
let scale = row.effective_weight.sqrt();
design[(row_index, 0)] = -dx / norm * scale;
design[(row_index, 1)] = -dy / norm * scale;
design[(row_index, 2)] = -dz / norm * scale;
let clock_col = clock_columns[&(row.epoch_index, clock_system_for(row.satellite_id))];
design[(row_index, clock_col)] = scale;
}
normal_covariance(&design, 1.0).expect("hand covariance")
}
fn clock_system_for(satellite_id: GnssSatelliteId) -> GnssSystem {
match satellite_id.system {
GnssSystem::Sbas => GnssSystem::Gps,
system => system,
}
}