use super::geom::{elevation, median, Site};
use super::joint_pvt::{dop, Dop, SystemClock};
use super::system::System;
use crate::jd2::Jd2;
use crate::sgp4::{teme_to_itrs_matrix, EarthFixedElements, MeanElementSet, SgpOrbit};
use serde::Serialize;
type GroupAcc = (Vec<f64>, Vec<f64>, Vec<f64>, usize, usize, usize);
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct GroupStats {
pub mean_in_view: f64,
pub median_pdop: Option<f64>,
pub median_hdop: Option<f64>,
pub median_vdop: Option<f64>,
pub availability: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PolarRow {
pub lat_deg: f64,
pub gnss: GroupStats,
pub leo: GroupStats,
pub fused: GroupStats,
}
pub type SatStates = Vec<Vec<([f64; 3], usize)>>;
pub fn polar_epoch() -> Jd2 {
Jd2::from_utc_calendar(2026, 1, 1, 0, 0, 0.0).expect("a valid calendar date")
}
pub fn element_sets(systems: &[System], epoch: Jd2) -> Vec<(MeanElementSet, usize)> {
systems
.iter()
.enumerate()
.flat_map(|(k, s)| {
s.orbits.iter().map(move |o| {
let el = EarthFixedElements {
a_m: o.a,
e: o.e,
inc_rad: o.inc,
node_lon_rad: o.raan0,
argp_rad: o.argp0,
m0_rad: o.m0,
};
(MeanElementSet::from_earth_fixed(&el, epoch), k)
})
})
.collect()
}
pub fn satellite_states_at(systems: &[System], epoch: Jd2, times_s: &[f64]) -> SatStates {
let orbits: Vec<(SgpOrbit, usize)> = element_sets(systems, epoch)
.into_iter()
.map(|(set, k)| (SgpOrbit::new(set), k))
.collect();
times_s
.iter()
.map(|&t| {
let at = epoch.add_seconds(t);
let m = teme_to_itrs_matrix(at);
orbits
.iter()
.filter_map(|(o, k)| o.itrs_state_with(at, &m).ok().map(|(r, _)| (r, *k)))
.collect()
})
.collect()
}
pub fn satellite_states(systems: &[System], times_s: &[f64]) -> SatStates {
satellite_states_at(systems, polar_epoch(), times_s)
}
#[derive(Clone, Debug, PartialEq)]
pub struct Sample {
pub lon_deg: f64,
pub epoch: usize,
pub in_view_by_system: Vec<usize>,
pub in_view: [usize; 3],
pub dop: [Option<Dop>; 3],
}
pub fn latitude_samples(
systems: &[System],
states: &[Vec<([f64; 3], usize)>],
lat_deg: f64,
lons_deg: &[f64],
) -> Vec<Sample> {
let clocks: Vec<SystemClock> = systems.iter().map(|s| s.clock).collect();
let mut out = Vec::with_capacity(lons_deg.len() * states.len());
for &lon in lons_deg {
let site = Site {
lat_deg,
lon_deg: lon,
height_m: 0.0,
};
let user = site.ecef();
let up = site.enu().2;
for (epoch, sats) in states.iter().enumerate() {
let vis: Vec<([f64; 3], usize)> = sats
.iter()
.filter(|(p, k)| elevation(user, up, *p) >= systems[*k].mask_rad)
.copied()
.collect();
let mut in_view_by_system = vec![0; systems.len()];
for (_, k) in &vis {
in_view_by_system[*k] += 1;
}
let mut in_view = [0; 3];
let mut dops: [Option<Dop>; 3] = [None; 3];
for g in 0..3 {
let sel: Vec<([f64; 3], usize)> = vis
.iter()
.filter(|(_, k)| match g {
0 => systems[*k].role == "gnss",
1 => systems[*k].role == "leo",
_ => true,
})
.copied()
.collect();
in_view[g] = sel.len();
dops[g] = dop(user, &sel, &clocks);
}
out.push(Sample {
lon_deg: lon,
epoch,
in_view_by_system,
in_view,
dop: dops,
});
}
}
out
}
pub fn latitude_sweep(
systems: &[System],
lats_deg: &[f64],
lons_deg: &[f64],
times_s: &[f64],
pdop_threshold: f64,
) -> Vec<PolarRow> {
latitude_sweep_at(
systems,
polar_epoch(),
lats_deg,
lons_deg,
times_s,
pdop_threshold,
)
}
pub fn latitude_sweep_at(
systems: &[System],
epoch: Jd2,
lats_deg: &[f64],
lons_deg: &[f64],
times_s: &[f64],
pdop_threshold: f64,
) -> Vec<PolarRow> {
let states = satellite_states_at(systems, epoch, times_s);
latitude_sweep_on_states(systems, &states, lats_deg, lons_deg, pdop_threshold)
}
pub fn latitude_sweep_on_states(
systems: &[System],
states: &[Vec<([f64; 3], usize)>],
lats_deg: &[f64],
lons_deg: &[f64],
pdop_threshold: f64,
) -> Vec<PolarRow> {
lats_deg
.iter()
.map(|&lat| {
let mut acc: [GroupAcc; 3] = Default::default();
for s in latitude_samples(systems, states, lat, lons_deg) {
for (g, a) in acc.iter_mut().enumerate() {
a.3 += s.in_view[g];
a.5 += 1;
if let Some(d) = s.dop[g] {
a.0.push(d.pdop);
a.1.push(d.hdop);
a.2.push(d.vdop);
if d.pdop <= pdop_threshold {
a.4 += 1;
}
}
}
}
let stats = |a: &GroupAcc| GroupStats {
mean_in_view: a.3 as f64 / a.5.max(1) as f64,
median_pdop: median(a.0.clone()),
median_hdop: median(a.1.clone()),
median_vdop: median(a.2.clone()),
availability: a.4 as f64 / a.5.max(1) as f64,
};
PolarRow {
lat_deg: lat,
gnss: stats(&acc[0]),
leo: stats(&acc[1]),
fused: stats(&acc[2]),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::leo_fusion::system::SystemCfg;
fn sys(src: &str) -> System {
toml::from_str::<SystemCfg>(src).unwrap().build().unwrap()
}
#[test]
fn gps_vertical_geometry_weakens_at_the_pole_and_a_polar_leo_shell_restores_it() {
let gps = sys("name = \"GPS\"\npreset = \"gps-baseline\"\n");
let leo = sys(
"name = \"Polar\"\n[[shell]]\npattern = \"star\"\ntotal = 66\nplanes = 6\nphasing = 2\n\
altitude_km = 780.0\ninclination_deg = 86.4\n",
);
let rows = latitude_sweep(
&[gps, leo],
&[0.0, 89.0],
&[0.0, 90.0, 180.0, 270.0],
&[0.0, 1800.0, 3600.0, 5400.0],
6.0,
);
let eq = &rows[0];
let pole = &rows[1];
let (v_eq, v_pole) = (eq.gnss.median_vdop.unwrap(), pole.gnss.median_vdop.unwrap());
assert!(v_pole > v_eq, "GNSS VDOP equator {v_eq} pole {v_pole}");
assert!(pole.leo.mean_in_view > eq.leo.mean_in_view);
assert!(pole.fused.median_vdop.unwrap() < v_pole);
}
}