use super::geom::{cross, dot, inverse, norm, normal_equations, scale, sub, EarthOrbit, Mat, Vec3};
use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RangeRateObs {
pub t_s: f64,
pub sat_pos: Vec3,
pub sat_vel: Vec3,
pub range_rate_mps: f64,
pub sigma_mps: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct DopplerOptions {
pub estimate_velocity: bool,
pub height_sigma_m: Option<f64>,
pub max_iter: usize,
}
impl Default for DopplerOptions {
fn default() -> Self {
Self {
estimate_velocity: false,
height_sigma_m: None,
max_iter: 30,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct DopplerFix {
pub position: [f64; 3],
pub velocity: [f64; 3],
pub drift_mps: f64,
pub sigma_enu_m: [f64; 3],
pub sigma_vel_enu_mps: [f64; 3],
pub sigma_drift_mps: f64,
pub iterations: usize,
pub converged: bool,
pub weighted_rms: f64,
pub chi2: f64,
pub n_used: usize,
#[serde(skip)]
pub covariance: Mat,
}
pub fn range_rate(r_u: Vec3, v_u: Vec3, r_s: Vec3, v_s: Vec3) -> f64 {
let d = sub(r_s, r_u);
dot(d, sub(v_s, v_u)) / norm(d)
}
fn partials(r_u: Vec3, v_u: Vec3, o: &RangeRateObs, with_vel: bool) -> Vec<f64> {
let d = sub(o.sat_pos, r_u);
let rho = norm(d);
let u = scale(d, 1.0 / rho);
let dv = sub(o.sat_vel, v_u);
let along = dot(u, dv);
let g = [
-(dv[0] - u[0] * along) / rho,
-(dv[1] - u[1] * along) / rho,
-(dv[2] - u[2] * along) / rho,
];
let mut row = vec![g[0], g[1], g[2], 1.0];
if with_vel {
for i in 0..3 {
row.push(o.t_s * g[i] - u[i]);
}
}
row
}
fn up_of(r: Vec3) -> Vec3 {
let g = crate::frames::ecef_to_geodetic(r);
super::geom::enu_at(g.lat_rad, g.lon_rad).2
}
fn enu_sigmas(q: &Mat, at: Vec3, offset: usize) -> [f64; 3] {
let g = crate::frames::ecef_to_geodetic(at);
let (e, n, u) = super::geom::enu_at(g.lat_rad, g.lon_rad);
let var = |v: Vec3| -> f64 {
let mut s = 0.0;
for i in 0..3 {
for j in 0..3 {
s += v[i] * q[offset + i][offset + j] * v[j];
}
}
s.max(0.0).sqrt()
};
[var(e), var(n), var(u)]
}
pub fn solve(
obs: &[RangeRateObs],
opts: &DopplerOptions,
apriori: Vec3,
height_ref: Option<Vec3>,
) -> Result<DopplerFix, String> {
if obs
.iter()
.any(|o| !(o.sigma_mps.is_finite() && o.sigma_mps > 0.0))
{
return Err("every range rate needs a positive, finite sigma".into());
}
let with_vel = opts.estimate_velocity;
let n = if with_vel { 7 } else { 4 };
let n_meas = obs.len() + usize::from(opts.height_sigma_m.is_some());
if n_meas < n {
return Err(format!(
"{n_meas} measurements cannot resolve {n} Doppler unknowns"
));
}
let h_target = height_ref.map(|r| crate::frames::ecef_to_geodetic(r).alt_m);
let mut x = vec![0.0; n];
x[..3].copy_from_slice(&apriori);
let mut converged = false;
let mut iterations = 0;
let mut cov = None;
let pos_at = |x: &[f64], t: f64| -> (Vec3, Vec3) {
if with_vel {
(
[x[0] + x[4] * t, x[1] + x[5] * t, x[2] + x[6] * t],
[x[4], x[5], x[6]],
)
} else {
([x[0], x[1], x[2]], [0.0; 3])
}
};
let residuals = |x: &[f64]| -> (Vec<Vec<f64>>, Vec<f64>, Vec<f64>) {
let mut h = Vec::with_capacity(n_meas);
let mut r = Vec::with_capacity(n_meas);
let mut w = Vec::with_capacity(n_meas);
for o in obs {
let (ru, vu) = pos_at(x, o.t_s);
h.push(partials(ru, vu, o, with_vel));
r.push(o.range_rate_mps - (range_rate(ru, vu, o.sat_pos, o.sat_vel) + x[3]));
w.push(1.0 / (o.sigma_mps * o.sigma_mps));
}
if let (Some(sig), Some(ht)) = (opts.height_sigma_m, h_target) {
let r0 = [x[0], x[1], x[2]];
let up = up_of(r0);
let mut row = vec![up[0], up[1], up[2], 0.0];
if with_vel {
row.extend_from_slice(&[0.0; 3]);
}
h.push(row);
r.push(ht - crate::frames::ecef_to_geodetic(r0).alt_m);
w.push(1.0 / (sig * sig));
}
(h, r, w)
};
for it in 0..opts.max_iter.max(1) {
iterations = it + 1;
let (h, r, w) = residuals(&x);
let (a, b) = normal_equations(&h, &w, &r);
let q = inverse(&a).ok_or("the Doppler geometry is singular")?;
let dx = super::geom::mat_vec(&q, &b);
let step = dx.iter().take(3).map(|d| d * d).sum::<f64>().sqrt();
let k = if step > 2.0e5 { 2.0e5 / step } else { 1.0 };
for i in 0..n {
x[i] += k * dx[i];
}
cov = Some(q);
if step < 1e-3 {
converged = true;
break;
}
}
let (h, r, w) = residuals(&x);
let (a, _) = normal_equations(&h, &w, &r);
let q = inverse(&a).or(cov).ok_or("no covariance")?;
let chi2: f64 = r.iter().zip(&w).map(|(ri, wi)| ri * ri * wi).sum();
let r0 = [x[0], x[1], x[2]];
Ok(DopplerFix {
position: r0,
velocity: if with_vel {
[x[4], x[5], x[6]]
} else {
[0.0; 3]
},
drift_mps: x[3],
sigma_enu_m: enu_sigmas(&q, r0, 0),
sigma_vel_enu_mps: if with_vel {
enu_sigmas(&q, r0, 4)
} else {
[0.0; 3]
},
sigma_drift_mps: q[3][3].max(0.0).sqrt(),
iterations,
converged,
weighted_rms: (chi2 / (n_meas.saturating_sub(n)).max(1) as f64).sqrt(),
chi2,
n_used: obs.len(),
covariance: q,
})
}
pub fn formal_covariance(
obs: &[RangeRateObs],
opts: &DopplerOptions,
truth: Vec3,
truth_vel: Vec3,
) -> Option<Mat> {
let with_vel = opts.estimate_velocity;
let mut h = Vec::new();
let mut w = Vec::new();
for o in obs {
let ru = if with_vel {
[
truth[0] + truth_vel[0] * o.t_s,
truth[1] + truth_vel[1] * o.t_s,
truth[2] + truth_vel[2] * o.t_s,
]
} else {
truth
};
h.push(partials(ru, truth_vel, o, with_vel));
w.push(1.0 / (o.sigma_mps * o.sigma_mps));
}
if let Some(sig) = opts.height_sigma_m {
let up = up_of(truth);
let mut row = vec![up[0], up[1], up[2], 0.0];
if with_vel {
row.extend_from_slice(&[0.0; 3]);
}
h.push(row);
w.push(1.0 / (sig * sig));
}
let r = vec![0.0; h.len()];
let (a, _) = normal_equations(&h, &w, &r);
inverse(&a)
}
pub fn along_cross_up(q: &Mat, user: Vec3, sat_vel: Vec3) -> [f64; 3] {
let up = up_of(user);
let h = sub(sat_vel, scale(up, dot(sat_vel, up)));
let hn = norm(h);
let along = if hn > 0.0 {
scale(h, 1.0 / hn)
} else {
[1.0, 0.0, 0.0]
};
let cr = cross(up, along);
let var = |v: Vec3| -> f64 {
let mut s = 0.0;
for i in 0..3 {
for j in 0..3 {
s += v[i] * q[i][j] * v[j];
}
}
s.max(0.0).sqrt()
};
[var(along), var(cr), var(up)]
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PassRow {
pub cross_track_offset_km: f64,
pub max_elevation_deg: f64,
pub pass_duration_s: f64,
pub n_obs: usize,
pub sigma_along_m: Option<f64>,
pub sigma_cross_m: Option<f64>,
pub sigma_up_m: Option<f64>,
}
pub fn offset_site(orbit: &EarthOrbit, t_c: f64, offset_m: f64) -> super::geom::Site {
let (r, v) = orbit.state(t_c);
let s_hat = scale(r, 1.0 / norm(r));
let track = sub(v, scale(s_hat, dot(v, s_hat)));
let t_hat = scale(track, 1.0 / norm(track));
let c_hat = cross(s_hat, t_hat);
let th = offset_m / super::geom::RE_EARTH;
let p = [
s_hat[0] * th.cos() + c_hat[0] * th.sin(),
s_hat[1] * th.cos() + c_hat[1] * th.sin(),
s_hat[2] * th.cos() + c_hat[2] * th.sin(),
];
super::geom::Site {
lat_deg: p[2].clamp(-1.0, 1.0).asin() / super::geom::DEG,
lon_deg: p[1].atan2(p[0]) / super::geom::DEG,
height_m: 0.0,
}
}
pub fn pass_observations(
orbit: &EarthOrbit,
site: &super::geom::Site,
t0: f64,
t1: f64,
dt: f64,
mask: f64,
sigma_mps: f64,
) -> Vec<RangeRateObs> {
let user = site.ecef();
let up = site.enu().2;
let mut out = Vec::new();
let mut t = t0;
while t <= t1 + 1e-9 {
let (rs, vs) = orbit.state(t);
if super::geom::elevation(user, up, rs) >= mask {
out.push(RangeRateObs {
t_s: t - t0,
sat_pos: rs,
sat_vel: vs,
range_rate_mps: range_rate(user, [0.0; 3], rs, vs),
sigma_mps,
});
}
t += dt;
}
out
}
#[allow(clippy::too_many_arguments)]
pub fn single_pass_geometry(
orbit: &EarthOrbit,
t_c: f64,
offsets_km: &[f64],
half_window_s: f64,
dt_s: f64,
mask: f64,
sigma_mps: f64,
height_sigma_m: Option<f64>,
) -> Vec<PassRow> {
let opts = DopplerOptions {
estimate_velocity: false,
height_sigma_m,
max_iter: 1,
};
offsets_km
.iter()
.map(|&off| {
let site = offset_site(orbit, t_c, off * 1e3);
let obs = pass_observations(
orbit,
&site,
t_c - half_window_s,
t_c + half_window_s,
dt_s,
mask,
sigma_mps,
);
let user = site.ecef();
let up = site.enu().2;
let max_el = obs
.iter()
.map(|o| super::geom::elevation(user, up, o.sat_pos))
.fold(f64::MIN, f64::max);
let q = if obs.len() >= 4 {
formal_covariance(&obs, &opts, user, [0.0; 3])
} else {
None
};
let vel_c = orbit.state(t_c).1;
let s = q.as_ref().map(|q| along_cross_up(q, user, vel_c));
let ok = |v: f64| (v.is_finite() && v < 1e9).then_some(v);
PassRow {
cross_track_offset_km: off,
max_elevation_deg: if obs.is_empty() {
0.0
} else {
max_el / super::geom::DEG
},
pass_duration_s: obs.len().saturating_sub(1) as f64 * dt_s,
n_obs: obs.len(),
sigma_along_m: s.and_then(|s| ok(s[0])),
sigma_cross_m: s.and_then(|s| ok(s[1])),
sigma_up_m: s.and_then(|s| ok(s[2])),
}
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct DopplerEnvelope {
pub max_doppler_hz: f64,
pub max_doppler_rate_hz_s: f64,
pub max_doppler_jerk_hz_s2: f64,
}
pub fn doppler_envelope(
orbit: &EarthOrbit,
site: &super::geom::Site,
carrier_hz: f64,
t0: f64,
t1: f64,
dt: f64,
mask: f64,
) -> DopplerEnvelope {
let lambda = super::C_LIGHT / carrier_hz;
let user = site.ecef();
let up = site.enu().2;
let rr = |t: f64| {
let (rs, vs) = orbit.state(t);
range_rate(user, [0.0; 3], rs, vs)
};
let h = 0.5;
let mut env = DopplerEnvelope {
max_doppler_hz: 0.0,
max_doppler_rate_hz_s: 0.0,
max_doppler_jerk_hz_s2: 0.0,
};
let mut t = t0;
while t <= t1 + 1e-9 {
let (rs, _) = orbit.state(t);
if super::geom::elevation(user, up, rs) >= mask {
let (m1, p0, p1) = (rr(t - h), rr(t), rr(t + h));
let acc = (p1 - m1) / (2.0 * h);
let jerk = (p1 - 2.0 * p0 + m1) / (h * h);
env.max_doppler_hz = env.max_doppler_hz.max(p0.abs() / lambda);
env.max_doppler_rate_hz_s = env.max_doppler_rate_hz_s.max(acc.abs() / lambda);
env.max_doppler_jerk_hz_s2 = env.max_doppler_jerk_hz_s2.max(jerk.abs() / lambda);
}
t += dt;
}
env
}
pub fn closest_approach(
orbit: &EarthOrbit,
site: &super::geom::Site,
t0: f64,
t1: f64,
dt: f64,
) -> f64 {
let user = site.ecef();
let range = |t: f64| norm(sub(orbit.state(t).0, user));
let mut best = (t0, f64::MAX);
let mut t = t0;
while t <= t1 {
let r = range(t);
if r < best.1 {
best = (t, r);
}
t += dt;
}
let (mut a, mut b) = (best.0 - dt, best.0 + dt);
let g = 0.5 * (5f64.sqrt() - 1.0);
for _ in 0..80 {
let c = b - g * (b - a);
let d = a + g * (b - a);
if range(c) < range(d) {
b = d;
} else {
a = c;
}
}
0.5 * (a + b)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::leo_fusion::geom::{Site, DEG};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
fn orbit() -> EarthOrbit {
EarthOrbit::circular(1080e3, 53.0 * DEG, 0.0, 0.0)
}
#[test]
fn range_acceleration_obeys_the_kinematic_identity_and_vanishes_doppler_at_closest_approach() {
let o = orbit();
let site = offset_site(&o, 600.0, 300e3);
let tca = closest_approach(&o, &site, 0.0, 1200.0, 5.0);
let user = site.ecef();
let rr = |t: f64| {
let (rs, vs) = o.state(t);
range_rate(user, [0.0; 3], rs, vs)
};
assert!(
rr(tca).abs() < 1e-3,
"zero Doppler at closest approach: {}",
rr(tca)
);
for t in [tca - 200.0, tca, tca + 150.0] {
let h = 0.05;
let acc = (rr(t + h) - rr(t - h)) / (2.0 * h);
let (rs, vs) = o.state(t);
let a = scale(sub(o.state(t + h).1, o.state(t - h).1), 0.5 / h);
let d = sub(rs, user);
let rho = norm(d);
let want = (dot(vs, vs) - rr(t).powi(2) + dot(d, a)) / rho;
assert!(((acc - want) / want).abs() < 1e-5, "t {t}: {acc} vs {want}");
}
let (rs, vs) = o.state(tca);
let straight = dot(vs, vs) / norm(sub(rs, user));
let acc = (rr(tca + 0.05) - rr(tca - 0.05)) / 0.1;
assert!(
acc < straight && acc > 0.75 * straight,
"{acc} vs {straight}"
);
}
#[test]
fn noise_free_multi_satellite_doppler_recovers_the_user() {
let site = Site {
lat_deg: 48.0,
lon_deg: 11.0,
height_m: 500.0,
};
let user = site.ecef();
let mut obs = Vec::new();
for k in 0..6 {
let o = EarthOrbit::circular(
1080e3,
(53.0 + 10.0 * k as f64) * DEG,
(k as f64 * 25.0) * DEG,
(k as f64 * 40.0) * DEG,
);
let tca = closest_approach(&o, &site, 0.0, 6400.0, 20.0);
obs.extend(pass_observations(
&o,
&site,
tca - 400.0,
tca + 400.0,
10.0,
10.0 * DEG,
0.1,
));
}
for o in &mut obs {
o.range_rate_mps += 2.5; }
let fix = solve(
&obs,
&DopplerOptions::default(),
[user[0] + 2e3, user[1] - 3e3, user[2] + 1e3],
None,
)
.unwrap();
assert!(fix.converged);
assert!(
norm(sub(fix.position, user)) < 1e-3,
"{:?}",
sub(fix.position, user)
);
assert!((fix.drift_mps - 2.5).abs() < 1e-6);
}
#[test]
fn a_single_pass_has_a_mirror_solution_across_the_ground_track() {
let o = orbit();
let site = offset_site(&o, 600.0, 400e3);
let mirror = offset_site(&o, 600.0, -400e3);
let user = site.ecef();
let tca = closest_approach(&o, &site, 0.0, 1200.0, 5.0);
let mut obs = pass_observations(&o, &site, tca - 500.0, tca + 500.0, 5.0, 10.0 * DEG, 0.05);
let mut rng = ChaCha8Rng::seed_from_u64(3);
let nrm = Normal::new(0.0, 0.05).unwrap();
for ob in &mut obs {
ob.range_rate_mps += nrm.sample(&mut rng);
}
let opts = DopplerOptions {
height_sigma_m: Some(1.0),
..Default::default()
};
let right = solve(
&obs,
&opts,
[user[0] + 5e3, user[1] + 5e3, user[2]],
Some(user),
)
.unwrap();
let wrong = solve(&obs, &opts, mirror.ecef(), Some(user)).unwrap();
assert!(
norm(sub(right.position, user)) < 50.0,
"{}",
norm(sub(right.position, user))
);
let sep = norm(sub(wrong.position, user));
assert!(
sep > 500e3,
"the mirror start should stay on its side: {sep}"
);
assert!(wrong.chi2 > right.chi2, "the true side fits better");
}
#[test]
fn the_cross_track_error_grows_as_the_pass_goes_overhead() {
let o = orbit();
let rows = single_pass_geometry(
&o,
600.0,
&[20.0, 200.0, 800.0],
700.0,
5.0,
10.0 * DEG,
0.1,
Some(1.0),
);
let c: Vec<f64> = rows.iter().map(|r| r.sigma_cross_m.unwrap()).collect();
assert!(c[0] > 5.0 * c[1], "{c:?}");
let a: Vec<f64> = rows.iter().map(|r| r.sigma_along_m.unwrap()).collect();
assert!(a.iter().all(|x| *x < 10.0), "{a:?}");
assert!(rows[0].max_elevation_deg > rows[2].max_elevation_deg);
}
#[test]
fn velocity_states_are_recovered_for_a_moving_user() {
let site = Site {
lat_deg: 30.0,
lon_deg: -20.0,
height_m: 0.0,
};
let user = site.ecef();
let (e, n, _) = site.enu();
let vel = [
15.0 * e[0] + 10.0 * n[0],
15.0 * e[1] + 10.0 * n[1],
15.0 * e[2] + 10.0 * n[2],
];
let sys: crate::leo_fusion::system::SystemCfg =
toml::from_str("name = \"X\"\nleo_preset = \"xona-pulsar\"\n").unwrap();
let orbits = sys.build().unwrap().orbits;
let up = site.enu().2;
let mut obs = Vec::new();
let mut t = 0.0;
while t <= 300.0 {
let ru = [
user[0] + vel[0] * t,
user[1] + vel[1] * t,
user[2] + vel[2] * t,
];
for o in &orbits {
let (rs, vs) = o.state(t);
if crate::leo_fusion::geom::elevation(ru, up, rs) > 10.0 * DEG {
obs.push(RangeRateObs {
t_s: t,
sat_pos: rs,
sat_vel: vs,
range_rate_mps: range_rate(ru, vel, rs, vs),
sigma_mps: 0.1,
});
}
}
t += 10.0;
}
assert!(obs.len() > 20, "{}", obs.len());
let opts = DopplerOptions {
estimate_velocity: true,
..Default::default()
};
let fix = solve(&obs, &opts, [user[0] + 1e3, user[1], user[2] - 1e3], None).unwrap();
assert!(norm(sub(fix.position, user)) < 1e-2);
assert!(norm(sub(fix.velocity, vel)) < 1e-4);
}
}