use crate::frames::{geodetic_to_ecef, is_visible, teme_to_ecef, Geodetic};
use crate::orbit::{dop, Propagator};
use crate::sgp4::{wgs72, Sgp4};
use serde::{Deserialize, Serialize};
use std::f64::consts::TAU;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct WalkerSgp4 {
pub altitude_km: f64,
pub inclination_deg: f64,
pub planes: usize,
pub sats_per_plane: usize,
#[serde(default)]
pub phasing_f: f64,
}
pub const WALKER_EPOCH_DAYS_1950: f64 = 25_000.0;
pub fn walker_epoch_jd() -> f64 {
2_433_281.5 + WALKER_EPOCH_DAYS_1950
}
impl WalkerSgp4 {
pub fn total(&self) -> usize {
self.planes * self.sats_per_plane
}
pub fn mean_motion_kozai_rad_min(&self) -> f64 {
let g = wgs72();
let a_er = (g.radiusearthkm + self.altitude_km) / g.radiusearthkm;
g.xke / a_er.powf(1.5)
}
pub fn satellites(&self) -> Vec<crate::orbit::Propagator> {
let g = wgs72();
let no_kozai = self.mean_motion_kozai_rad_min();
let inclo = self.inclination_deg.to_radians();
let total = self.total() as f64;
let mut sats = Vec::with_capacity(self.total());
for p in 0..self.planes {
let nodeo = TAU * p as f64 / self.planes as f64;
for s in 0..self.sats_per_plane {
let mo = (TAU
* (s as f64 / self.sats_per_plane as f64 + self.phasing_f * p as f64 / total))
% TAU;
let sgp4 = Sgp4::new(
g,
false,
WALKER_EPOCH_DAYS_1950,
0.0, 0.0, 0.0, inclo,
mo,
no_kozai,
nodeo,
);
sats.push(crate::orbit::Propagator::Sgp4(Box::new(sgp4)));
}
}
sats
}
}
fn visible_ecef(
sats: &[Propagator],
station: Geodetic,
t_sec: f64,
mask_deg: f64,
) -> Vec<[f64; 3]> {
let jd = walker_epoch_jd() + t_sec / 86_400.0;
sats.iter()
.map(|p| teme_to_ecef(p.position_eci(t_sec), jd))
.filter(|&r| is_visible(station, r, mask_deg))
.collect()
}
fn median_sorted(mut v: Vec<f64>) -> Option<f64> {
if v.is_empty() {
return None;
}
v.sort_by(f64::total_cmp);
let n = v.len();
Some(if n % 2 == 1 {
v[n / 2]
} else {
0.5 * (v[n / 2 - 1] + v[n / 2])
})
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct SweepCell {
pub planes: usize,
pub sats_per_plane: usize,
pub inclination_deg: f64,
pub total: usize,
pub coverage_fraction: f64,
pub median_pdop: Option<f64>,
pub worst_pdop: Option<f64>,
}
#[allow(clippy::too_many_arguments)]
pub fn pdop_sweep(
altitude_km: f64,
planes_grid: &[usize],
sats_grid: &[usize],
inclination_grid: &[f64],
phasing_f: f64,
station: Geodetic,
step_s: f64,
duration_s: f64,
mask_deg: f64,
) -> Vec<SweepCell> {
let n = (duration_s / step_s).round() as usize;
let mut cells = Vec::new();
for &planes in planes_grid {
for &sats_per_plane in sats_grid {
for &inclination_deg in inclination_grid {
let walker = WalkerSgp4 {
altitude_km,
inclination_deg,
planes,
sats_per_plane,
phasing_f,
};
let sats = walker.satellites();
let mut pdops = Vec::new();
for i in 0..=n {
let t = i as f64 * step_s;
if let Some(d) = dop(
geodetic_to_ecef(station),
&visible_ecef(&sats, station, t, mask_deg),
) {
pdops.push(d.pdop);
}
}
let worst = pdops
.iter()
.copied()
.fold(None, |a: Option<f64>, p| Some(a.map_or(p, |x| x.max(p))));
cells.push(SweepCell {
planes,
sats_per_plane,
inclination_deg,
total: walker.total(),
coverage_fraction: pdops.len() as f64 / (n as f64 + 1.0),
median_pdop: median_sorted(pdops.clone()),
worst_pdop: worst,
});
}
}
}
cells
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct CoverageReport {
pub samples_total: usize,
pub samples_covered: usize,
pub coverage_fraction: f64,
pub max_revisit_gap_s: f64,
pub mean_revisit_gap_s: f64,
}
pub fn coverage_revisit(
sats: &[Propagator],
station: Geodetic,
step_s: f64,
duration_s: f64,
mask_deg: f64,
min_sats: usize,
) -> CoverageReport {
let n = (duration_s / step_s).round() as usize;
let mut covered = 0usize;
let mut gaps: Vec<f64> = Vec::new();
let mut current_gap = 0usize; for i in 0..=n {
let t = i as f64 * step_s;
let visible = visible_ecef(sats, station, t, mask_deg).len();
if visible >= min_sats {
covered += 1;
if current_gap > 0 {
gaps.push(current_gap as f64 * step_s);
current_gap = 0;
}
} else {
current_gap += 1;
}
}
if current_gap > 0 {
gaps.push(current_gap as f64 * step_s);
}
let max_gap = gaps.iter().copied().fold(0.0_f64, f64::max);
let mean_gap = if gaps.is_empty() {
0.0
} else {
gaps.iter().sum::<f64>() / gaps.len() as f64
};
let total = n + 1;
CoverageReport {
samples_total: total,
samples_covered: covered,
coverage_fraction: covered as f64 / total as f64,
max_revisit_gap_s: max_gap,
mean_revisit_gap_s: mean_gap,
}
}
pub fn coverage_half_angle_rad(altitude_km: f64, min_elev_deg: f64) -> Option<f64> {
if altitude_km <= 0.0 {
return None;
}
let re = wgs72().radiusearthkm;
let r = re + altitude_km;
let eps = min_elev_deg.to_radians();
let x = (re / r) * eps.cos();
if !(-1.0..=1.0).contains(&x) {
return None;
}
Some(x.acos() - eps)
}
pub fn street_half_width_rad(lambda_rad: f64, sats_per_plane: usize) -> Option<f64> {
if sats_per_plane == 0 {
return None;
}
let half_spacing = std::f64::consts::PI / sats_per_plane as f64;
let denom = half_spacing.cos();
if denom == 0.0 {
return None;
}
let cos_c = lambda_rad.cos() / denom;
if !(-1.0..=1.0).contains(&cos_c) {
return None; }
Some(cos_c.acos())
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DesignObjective {
MinSatellitesForCoverage { min_fraction: f64 },
MaxCoverage,
MinWorstPdop,
}
#[allow(clippy::too_many_arguments)]
pub fn optimize_walker_design(
altitude_km: f64,
planes_grid: &[usize],
sats_grid: &[usize],
inclination_grid: &[f64],
phasing_f: f64,
station: Geodetic,
step_s: f64,
duration_s: f64,
mask_deg: f64,
objective: DesignObjective,
) -> Option<(SweepCell, Vec<SweepCell>)> {
let cells = pdop_sweep(
altitude_km,
planes_grid,
sats_grid,
inclination_grid,
phasing_f,
station,
step_s,
duration_s,
mask_deg,
);
let best = match objective {
DesignObjective::MinSatellitesForCoverage { min_fraction } => cells
.iter()
.filter(|c| c.coverage_fraction >= min_fraction)
.min_by(|a, b| {
a.total
.cmp(&b.total)
.then_with(|| cmp_pdop(a.worst_pdop, b.worst_pdop))
}),
DesignObjective::MaxCoverage => cells.iter().max_by(|a, b| {
a.coverage_fraction
.total_cmp(&b.coverage_fraction)
.then_with(|| b.total.cmp(&a.total))
}),
DesignObjective::MinWorstPdop => cells
.iter()
.filter(|c| c.worst_pdop.is_some())
.min_by(|a, b| cmp_pdop(a.worst_pdop, b.worst_pdop).then(a.total.cmp(&b.total))),
};
best.cloned().map(|b| (b, cells))
}
fn cmp_pdop(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
match (a, b) {
(Some(x), Some(y)) => x.total_cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
}
#[derive(Clone, Copy, Debug)]
pub struct StreetsCoverageDesign {
pub planes: usize,
pub sats_per_plane: usize,
pub total: usize,
pub lambda_rad: f64,
pub street_half_width_rad: f64,
}
pub fn min_satellites_streets_of_coverage(
altitude_km: f64,
min_elev_deg: f64,
sats_per_plane: usize,
) -> Option<StreetsCoverageDesign> {
let lambda = coverage_half_angle_rad(altitude_km, min_elev_deg)?;
let c = street_half_width_rad(lambda, sats_per_plane)?;
if c <= 0.0 {
return None;
}
let planes = (std::f64::consts::PI / (2.0 * c)).ceil().max(1.0) as usize;
Some(StreetsCoverageDesign {
planes,
sats_per_plane,
total: planes * sats_per_plane,
lambda_rad: lambda,
street_half_width_rad: c,
})
}
#[derive(Clone, Debug)]
pub struct ConstellationComparison {
pub name: String,
pub cell: SweepCell,
}
#[allow(clippy::too_many_arguments)]
pub fn compare_constellations(
designs: &[(&str, usize, usize, f64)],
altitude_km: f64,
phasing_f: f64,
station: Geodetic,
step_s: f64,
duration_s: f64,
mask_deg: f64,
) -> Vec<ConstellationComparison> {
designs
.iter()
.map(|&(name, planes, sats, incl)| {
let cells = pdop_sweep(
altitude_km,
&[planes],
&[sats],
&[incl],
phasing_f,
station,
step_s,
duration_s,
mask_deg,
);
ConstellationComparison {
name: name.to_string(),
cell: cells
.into_iter()
.next()
.expect("pdop_sweep over single-element grids yields exactly one cell"),
}
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct WalkerDesignCell {
pub planes: usize,
pub sats_per_plane: usize,
pub inclination_deg: f64,
pub total: usize,
pub coverage_fraction: f64,
pub worst_pdop: Option<f64>,
pub max_revisit_gap_s: f64,
pub mean_revisit_gap_s: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct WalkerDesignReport {
pub cells: Vec<WalkerDesignCell>,
pub pareto_indices: Vec<usize>,
}
impl WalkerDesignReport {
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self)
.expect("WalkerDesignReport (Vecs of numeric cells, no maps) always serialises")
}
}
pub fn pareto_front(cells: &[WalkerDesignCell]) -> Vec<usize> {
let pdop = |c: &WalkerDesignCell| c.worst_pdop.unwrap_or(f64::INFINITY);
let dominates = |a: &WalkerDesignCell, b: &WalkerDesignCell| {
let no_worse = a.total <= b.total
&& a.coverage_fraction >= b.coverage_fraction
&& pdop(a) <= pdop(b)
&& a.max_revisit_gap_s <= b.max_revisit_gap_s;
let strictly_better = a.total < b.total
|| a.coverage_fraction > b.coverage_fraction
|| pdop(a) < pdop(b)
|| a.max_revisit_gap_s < b.max_revisit_gap_s;
no_worse && strictly_better
};
(0..cells.len())
.filter(|&i| !(0..cells.len()).any(|j| j != i && dominates(&cells[j], &cells[i])))
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn walker_design_sweep(
altitude_km: f64,
planes_grid: &[usize],
sats_grid: &[usize],
inclination_deg: f64,
phasing_f: f64,
station: Geodetic,
step_s: f64,
duration_s: f64,
mask_deg: f64,
min_sats: usize,
) -> WalkerDesignReport {
let n = (duration_s / step_s).round() as usize;
let mut cells = Vec::new();
for &planes in planes_grid {
for &sats_per_plane in sats_grid {
let walker = WalkerSgp4 {
altitude_km,
inclination_deg,
planes,
sats_per_plane,
phasing_f,
};
let sats = walker.satellites();
let mut worst: Option<f64> = None;
for i in 0..=n {
let t = i as f64 * step_s;
if let Some(d) = dop(
geodetic_to_ecef(station),
&visible_ecef(&sats, station, t, mask_deg),
) {
worst = Some(worst.map_or(d.pdop, |x| x.max(d.pdop)));
}
}
let cov = coverage_revisit(&sats, station, step_s, duration_s, mask_deg, min_sats);
cells.push(WalkerDesignCell {
planes,
sats_per_plane,
inclination_deg,
total: walker.total(),
coverage_fraction: cov.coverage_fraction,
worst_pdop: worst,
max_revisit_gap_s: cov.max_revisit_gap_s,
mean_revisit_gap_s: cov.mean_revisit_gap_s,
});
}
}
let pareto_indices = pareto_front(&cells);
WalkerDesignReport {
cells,
pareto_indices,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orbit::Propagator;
fn radius(p: [f64; 3]) -> f64 {
(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt()
}
fn gps_like() -> WalkerSgp4 {
WalkerSgp4 {
altitude_km: 20_180.0,
inclination_deg: 55.0,
planes: 6,
sats_per_plane: 4,
phasing_f: 1.0,
}
}
#[test]
fn walker_builds_sgp4_satellites_on_the_target_shell() {
let w = gps_like();
let sats = w.satellites();
assert_eq!(sats.len(), 24, "6 planes x 4 sats");
let target_r = (wgs72().radiusearthkm + w.altitude_km) * 1000.0;
for s in &sats {
assert!(
matches!(s, Propagator::Sgp4(_)),
"Walker must emit SGP4 propagators (the validated core)"
);
let r = radius(s.position_eci(0.0));
assert!(
(r - target_r).abs() / target_r < 0.01,
"Walker SGP4 radius {r:.0} m vs target {target_r:.0} m"
);
}
}
#[test]
fn walker_planes_are_equally_spaced_in_raan() {
let w = WalkerSgp4 {
altitude_km: 20_180.0,
inclination_deg: 55.0,
planes: 4,
sats_per_plane: 1,
phasing_f: 0.0,
};
let sats = w.satellites();
assert_eq!(sats.len(), 4);
let raan_deg = |p: &Propagator| {
let r = p.position_eci(0.0);
let v = p.velocity_eci(0.0);
let hx = r[1] * v[2] - r[2] * v[1];
let hy = r[2] * v[0] - r[0] * v[2];
hx.atan2(-hy).rem_euclid(TAU).to_degrees()
};
let raans: Vec<f64> = sats.iter().map(raan_deg).collect();
for (k, &om) in raans.iter().enumerate() {
let expect = 90.0 * k as f64;
let diff = (om - expect).rem_euclid(360.0);
let diff = diff.min(360.0 - diff);
assert!(
diff < 0.5,
"plane {k} RAAN {om:.2}°, expected {expect:.0}° (off by {diff:.2}°)"
);
}
}
#[test]
fn walker_mean_motion_matches_the_orbital_period() {
let w = gps_like();
let n_rad_min = w.mean_motion_kozai_rad_min();
let period_min = TAU / n_rad_min;
assert!(
(period_min - 718.0).abs() < 2.0,
"GPS-shell period {period_min:.1} min, expected ~718 min"
);
}
fn munich() -> Geodetic {
Geodetic {
lat_rad: 48.0_f64.to_radians(),
lon_rad: 11.0_f64.to_radians(),
alt_m: 600.0,
}
}
#[test]
fn pdop_sweep_improves_with_more_satellites() {
let cells = pdop_sweep(
20_180.0,
&[6],
&[2, 4, 6],
&[55.0],
1.0,
munich(),
600.0,
43_200.0,
5.0,
);
assert_eq!(cells.len(), 3);
assert_eq!(
cells.iter().map(|c| c.total).collect::<Vec<_>>(),
vec![12, 24, 36]
);
let med: Vec<f64> = cells.iter().map(|c| c.median_pdop.unwrap()).collect();
assert!(
med[0] > med[1] && med[1] > med[2],
"median PDOP must fall as satellites are added: {med:?}"
);
let cov: Vec<f64> = cells.iter().map(|c| c.coverage_fraction).collect();
assert!(
cov[0] <= cov[1] && cov[1] <= cov[2],
"coverage must not fall as satellites are added: {cov:?}"
);
let gps = &cells[1];
assert_eq!(gps.total, 24);
assert!(
(gps.coverage_fraction - 1.0).abs() < 1e-9,
"24-sat coverage {}",
gps.coverage_fraction
);
let m = gps.median_pdop.unwrap();
assert!((1.0..2.5).contains(&m), "24-sat median PDOP {m}");
assert!(
gps.worst_pdop.unwrap() >= m,
"worst PDOP must dominate median"
);
}
#[test]
fn coverage_and_revisit_track_constellation_size() {
let dense = gps_like();
let dr = coverage_revisit(&dense.satellites(), munich(), 600.0, 86_400.0, 5.0, 4);
assert!(
(dr.coverage_fraction - 1.0).abs() < 1e-9,
"dense coverage {}",
dr.coverage_fraction
);
assert_eq!(
dr.max_revisit_gap_s, 0.0,
"a full GPS-like constellation should have no coverage gaps"
);
let thin = WalkerSgp4 {
altitude_km: 20_180.0,
inclination_deg: 55.0,
planes: 3,
sats_per_plane: 3,
phasing_f: 1.0,
};
let tr = coverage_revisit(&thin.satellites(), munich(), 600.0, 86_400.0, 5.0, 4);
assert!(
tr.coverage_fraction > 0.0 && tr.coverage_fraction < 1.0,
"thin coverage {} should be partial",
tr.coverage_fraction
);
assert!(
tr.max_revisit_gap_s > 0.0 && tr.max_revisit_gap_s < 86_400.0,
"thin max revisit gap {} should be a real finite gap",
tr.max_revisit_gap_s
);
assert!(
dr.coverage_fraction > tr.coverage_fraction,
"densifying must raise coverage"
);
assert!(
dr.max_revisit_gap_s < tr.max_revisit_gap_s,
"densifying must shrink the worst revisit gap"
);
assert!(
tr.mean_revisit_gap_s <= tr.max_revisit_gap_s,
"mean gap cannot exceed the max gap"
);
}
#[test]
fn coverage_half_angle_matches_hand_geometry() {
let lambda = coverage_half_angle_rad(20_180.0, 5.0).unwrap();
assert!(
(lambda.to_degrees() - 71.16).abs() < 0.3,
"λ = {}°, expected ≈ 71.16°",
lambda.to_degrees()
);
let horizon = coverage_half_angle_rad(20_180.0, 0.0).unwrap();
assert!(
(horizon.to_degrees() - 76.10).abs() < 0.3 && horizon > lambda,
"horizon λ = {}°",
horizon.to_degrees()
);
assert!(coverage_half_angle_rad(-1.0, 5.0).is_none());
}
#[test]
fn street_half_width_matches_hand_geometry_and_detects_gaps() {
let lambda = coverage_half_angle_rad(20_180.0, 5.0).unwrap();
let c = street_half_width_rad(lambda, 4).unwrap();
assert!(
(c.to_degrees() - 62.83).abs() < 0.5,
"street half-width {}°, expected ≈ 62.83°",
c.to_degrees()
);
let c6 = street_half_width_rad(lambda, 6).unwrap();
assert!(c6 > c, "6 sats/plane should widen the street: {c6} vs {c}");
assert!(street_half_width_rad(20.0_f64.to_radians(), 6).is_none());
}
#[test]
fn optimizer_selects_the_brute_force_best_for_each_objective() {
let planes = [4, 6];
let sats = [3, 4];
let inc = [55.0];
let (best_cov, table) = optimize_walker_design(
20_180.0,
&planes,
&sats,
&inc,
1.0,
munich(),
600.0,
43_200.0,
5.0,
DesignObjective::MinSatellitesForCoverage { min_fraction: 0.99 },
)
.expect("a design reaches 99% coverage");
let expect = table
.iter()
.filter(|c| c.coverage_fraction >= 0.99)
.min_by(|a, b| {
a.total
.cmp(&b.total)
.then(cmp_pdop(a.worst_pdop, b.worst_pdop))
})
.unwrap();
assert_eq!(
best_cov, *expect,
"min-satellites pick disagrees with brute force"
);
let (best_pdop, table2) = optimize_walker_design(
20_180.0,
&planes,
&sats,
&inc,
1.0,
munich(),
600.0,
43_200.0,
5.0,
DesignObjective::MinWorstPdop,
)
.unwrap();
let expect_pdop = table2
.iter()
.filter(|c| c.worst_pdop.is_some())
.min_by(|a, b| cmp_pdop(a.worst_pdop, b.worst_pdop).then(a.total.cmp(&b.total)))
.unwrap();
assert_eq!(best_pdop, *expect_pdop, "min-worst-PDOP pick disagrees");
for c in &table2 {
if let (Some(b), Some(x)) = (best_pdop.worst_pdop, c.worst_pdop) {
assert!(b <= x + 1e-12, "a cell beats the chosen worst-PDOP optimum");
}
}
}
#[test]
fn worked_example_gps_walker_24_vs_18_degrades() {
let cells = pdop_sweep(
20_180.0,
&[6],
&[3, 4],
&[55.0],
1.0,
munich(),
600.0,
86_400.0,
5.0,
);
let s18 = cells.iter().find(|c| c.total == 18).unwrap();
let s24 = cells.iter().find(|c| c.total == 24).unwrap();
assert!(
s24.coverage_fraction >= s18.coverage_fraction,
"24 sats must cover at least as well as 18: {} vs {}",
s24.coverage_fraction,
s18.coverage_fraction
);
assert!(
(s24.coverage_fraction - 1.0).abs() < 1e-9,
"the full 24-sat design should give continuous coverage, got {}",
s24.coverage_fraction
);
if let (Some(w24), Some(w18)) = (s24.worst_pdop, s18.worst_pdop) {
assert!(
w24 <= w18 + 1e-9,
"24-sat worst PDOP {w24} should beat 18-sat {w18}"
);
}
}
#[test]
fn streets_of_coverage_sizes_a_global_constellation() {
let d = min_satellites_streets_of_coverage(20_180.0, 5.0, 4).expect("sizable");
assert!((d.lambda_rad - 1.2420).abs() < 1e-3, "λ = {}", d.lambda_rad);
assert!(
(d.street_half_width_rad - 1.0965).abs() < 1e-3,
"c = {}",
d.street_half_width_rad
);
assert_eq!(d.planes, 2);
assert_eq!(d.total, 8);
let dense = min_satellites_streets_of_coverage(20_180.0, 5.0, 8).expect("sizable");
assert!(dense.planes <= d.planes);
}
#[test]
fn streets_of_coverage_rejects_under_population() {
assert!(min_satellites_streets_of_coverage(500.0, 10.0, 3).is_none());
}
#[test]
fn compare_constellations_ranks_density() {
let cmp = compare_constellations(
&[("GPS-24", 6, 4, 55.0), ("Thinned-18", 6, 3, 55.0)],
20_180.0,
1.0,
munich(),
600.0,
43_200.0,
5.0,
);
assert_eq!(cmp.len(), 2);
let gps = cmp.iter().find(|c| c.name == "GPS-24").unwrap();
let thin = cmp.iter().find(|c| c.name == "Thinned-18").unwrap();
assert_eq!(gps.cell.total, 24);
assert_eq!(thin.cell.total, 18);
assert!(
gps.cell.coverage_fraction >= thin.cell.coverage_fraction,
"GPS-24 coverage {} vs thinned {}",
gps.cell.coverage_fraction,
thin.cell.coverage_fraction
);
if let (Some(wg), Some(wt)) = (gps.cell.worst_pdop, thin.cell.worst_pdop) {
assert!(wg <= wt + 1e-9, "GPS-24 worst PDOP {wg} vs thinned {wt}");
}
}
fn rot_z(v: [f64; 3], a: f64) -> [f64; 3] {
let (sn, cs) = a.sin_cos();
[v[0] * cs - v[1] * sn, v[0] * sn + v[1] * cs, v[2]]
}
fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
#[test]
fn walker_formation_matches_the_formula_to_1km_over_24h() {
let w = WalkerSgp4 {
altitude_km: 1200.0,
inclination_deg: 60.0,
planes: 5,
sats_per_plane: 3,
phasing_f: 0.0,
};
let sats = w.satellites(); let s = w.sats_per_plane;
let dphi = TAU / w.planes as f64;
for &t in &[0.0, 43_200.0, 86_400.0] {
let r0 = sats[0].position_eci(t); for p in 1..w.planes {
let rp = sats[p * s].position_eci(t); let expect = rot_z(r0, dphi * p as f64);
let d = dist(rp, expect);
assert!(
d < 1000.0,
"plane {p} at t={t}s: {d:.3} m from R_z(2π·{p}/{}) of plane 0",
w.planes
);
}
}
}
#[test]
fn walker_in_plane_slots_are_spaced_by_the_formula_in_the_mean() {
let w = WalkerSgp4 {
altitude_km: 1200.0,
inclination_deg: 60.0,
planes: 1,
sats_per_plane: 4,
phasing_f: 0.0,
};
let sats = w.satellites();
let sep = |t: f64, i: usize, j: usize| {
let a = sats[i].position_eci(t);
let b = sats[j].position_eci(t);
let dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let na = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
let nb = (b[0] * b[0] + b[1] * b[1] + b[2] * b[2]).sqrt();
(dot / (na * nb)).clamp(-1.0, 1.0).acos().to_degrees()
};
let nsamp = 1440usize; for i in 0..3 {
let mean: f64 = (0..nsamp)
.map(|k| sep(k as f64 * 60.0, i, i + 1))
.sum::<f64>()
/ nsamp as f64;
assert!(
(mean - 90.0).abs() < 0.02,
"slot {i}->{} mean sep {mean:.4}°",
i + 1
);
}
}
#[test]
fn pareto_front_selects_non_dominated_designs() {
let cells = vec![
WalkerDesignCell {
planes: 3,
sats_per_plane: 3,
inclination_deg: 55.0,
total: 9,
coverage_fraction: 0.90,
worst_pdop: Some(2.0),
max_revisit_gap_s: 100.0,
mean_revisit_gap_s: 50.0,
},
WalkerDesignCell {
planes: 4,
sats_per_plane: 5,
inclination_deg: 55.0,
total: 20,
coverage_fraction: 0.90,
worst_pdop: Some(3.0),
max_revisit_gap_s: 200.0,
mean_revisit_gap_s: 90.0,
},
WalkerDesignCell {
planes: 6,
sats_per_plane: 5,
inclination_deg: 55.0,
total: 30,
coverage_fraction: 0.99,
worst_pdop: Some(1.5),
max_revisit_gap_s: 50.0,
mean_revisit_gap_s: 20.0,
},
];
let front = pareto_front(&cells);
assert_eq!(
front,
vec![0, 2],
"A and C are non-dominated; B is dominated by A"
);
}
#[test]
fn walker_design_sweep_emits_a_pareto_table_and_revisit_json() {
let report = walker_design_sweep(
1500.0,
&[3, 4, 6],
&[2, 3, 4],
55.0,
1.0,
munich(),
120.0,
7200.0,
10.0,
1,
);
assert_eq!(report.cells.len(), 9, "3×3 design grid");
assert!(
!report.pareto_indices.is_empty(),
"a non-empty Pareto front"
);
assert_eq!(report.pareto_indices, pareto_front(&report.cells));
let json = report.to_json();
assert!(
json.contains("max_revisit_gap_s"),
"revisit-time appears in JSON"
);
assert!(json.contains("mean_revisit_gap_s"));
assert!(json.contains("pareto_indices"));
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["cells"].as_array().unwrap().len() == 9);
assert!(parsed["cells"][0]["max_revisit_gap_s"].is_number());
}
}