use crate::forces::EARTH_ROTATION_RATE;
use crate::orbit::{MU_EARTH, R_EARTH_EQUATORIAL_M};
use serde::Deserialize;
pub fn earth_angular_radius(altitude_m: f64) -> f64 {
(R_EARTH_EQUATORIAL_M / (R_EARTH_EQUATORIAL_M + altitude_m)).asin()
}
pub fn elevation_from_nadir_angle(eta_rad: f64, altitude_m: f64) -> Result<f64, String> {
let rho = earth_angular_radius(altitude_m);
let c = eta_rad.sin() / rho.sin();
if !(0.0..=1.0).contains(&c) {
return Err(format!(
"nadir angle {:.2}° exceeds the Earth angular radius {:.2}° (misses Earth)",
eta_rad.to_degrees(),
rho.to_degrees()
));
}
Ok(c.acos())
}
pub fn earth_central_angle(eta_rad: f64, altitude_m: f64) -> Result<f64, String> {
let eps = elevation_from_nadir_angle(eta_rad, altitude_m)?;
Ok(std::f64::consts::FRAC_PI_2 - eta_rad - eps)
}
pub fn ground_range(eta_rad: f64, altitude_m: f64) -> Result<f64, String> {
Ok(R_EARTH_EQUATORIAL_M * earth_central_angle(eta_rad, altitude_m)?)
}
pub fn swath_width(half_fov_rad: f64, altitude_m: f64) -> Result<f64, String> {
Ok(2.0 * ground_range(half_fov_rad, altitude_m)?)
}
pub fn nadir_gsd(altitude_m: f64, ifov_microrad: f64) -> f64 {
altitude_m * ifov_microrad * 1e-6
}
pub fn circular_period(altitude_m: f64) -> f64 {
let r = R_EARTH_EQUATORIAL_M + altitude_m;
std::f64::consts::TAU * (r * r * r / MU_EARTH).sqrt()
}
pub fn ground_track_spacing_equator(period_s: f64) -> f64 {
R_EARTH_EQUATORIAL_M * EARTH_ROTATION_RATE * period_s
}
fn eo_default_alt() -> f64 {
700.0
}
fn eo_default_half_fov() -> f64 {
7.5
}
fn eo_default_ifov() -> f64 {
14.0
}
#[derive(Deserialize)]
pub struct EoCoverageScenario {
#[serde(default = "eo_default_alt")]
pub altitude_km: f64,
#[serde(default = "eo_default_half_fov")]
pub half_fov_deg: f64,
#[serde(default = "eo_default_ifov")]
pub ifov_microrad: f64,
#[serde(default)]
pub max_off_nadir_deg: Option<f64>,
}
impl EoCoverageScenario {
pub fn run_json(&self) -> Result<(String, String), String> {
if !self.altitude_km.is_finite() || self.altitude_km <= 0.0 {
return Err("altitude_km must be finite and positive".to_string());
}
if !(0.0..90.0).contains(&self.half_fov_deg) || self.half_fov_deg <= 0.0 {
return Err("half_fov_deg must be in (0, 90)".to_string());
}
if !self.ifov_microrad.is_finite() || self.ifov_microrad <= 0.0 {
return Err("ifov_microrad must be finite and positive".to_string());
}
let alt_m = self.altitude_km * 1000.0;
let rho = earth_angular_radius(alt_m);
let swath = swath_width(self.half_fov_deg.to_radians(), alt_m)
.map_err(|e| format!("half_fov too large: {e}"))?;
let gsd = nadir_gsd(alt_m, self.ifov_microrad);
let period = circular_period(alt_m);
let spacing = ground_track_spacing_equator(period);
let max_off_nadir = self
.max_off_nadir_deg
.map(|d| d.to_radians().min(rho))
.unwrap_or(rho);
let max_access_m = ground_range(max_off_nadir, alt_m).unwrap_or(0.0);
let contiguous = swath >= spacing;
let json = serde_json::json!({
"kind": "eo-coverage",
"label": "MODELLED — spherical-Earth space-triangle geometry; swath/GSD/access \
are geometric (no radiometry/MTF/atmosphere/jitter/glint), ground-track \
spacing is simple nodal R_e·ω·T (no J2 regression)",
"altitude_km": self.altitude_km,
"half_fov_deg": self.half_fov_deg,
"earth_angular_radius_deg": rho.to_degrees(),
"swath_width_km": swath / 1000.0,
"nadir_gsd_m": gsd,
"max_off_nadir_deg": max_off_nadir.to_degrees(),
"max_access_ground_range_km": max_access_m / 1000.0,
"orbital_period_min": period / 60.0,
"equatorial_ground_track_spacing_km": spacing / 1000.0,
"contiguous_equatorial_coverage": contiguous,
});
let summary = format!(
"eo-coverage: {:.0} km, ±{:.1}° FOV -> {:.0} km swath, {:.1} m nadir GSD, \
access to {:.0} km off-nadir; node spacing {:.0} km ({}) (MODELLED)",
self.altitude_km,
self.half_fov_deg,
swath / 1000.0,
gsd,
max_access_m / 1000.0,
spacing / 1000.0,
if contiguous { "contiguous" } else { "gapped" }
);
let json = serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?;
Ok((json, summary))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn earth_angular_radius_is_64_degrees_at_700km() {
let rho = earth_angular_radius(700_000.0).to_degrees();
assert!((rho - 64.28).abs() < 0.1, "angular radius {rho}°");
assert!(earth_angular_radius(35_786_000.0).to_degrees() < 9.0);
}
#[test]
fn nadir_look_sees_the_subpoint_at_zenith_with_zero_ground_range() {
let eps = elevation_from_nadir_angle(0.0, 700_000.0).unwrap();
assert!(
(eps - std::f64::consts::FRAC_PI_2).abs() < 1e-9,
"nadir elevation"
);
assert!(
ground_range(0.0, 700_000.0).unwrap().abs() < 1e-6,
"nadir ground range"
);
}
#[test]
fn horizon_look_gives_zero_elevation_and_max_central_angle() {
let rho = earth_angular_radius(700_000.0);
let eps = elevation_from_nadir_angle(rho, 700_000.0).unwrap();
assert!(eps.abs() < 1e-6, "horizon elevation ~0");
let lambda = earth_central_angle(rho, 700_000.0).unwrap();
assert!((lambda.to_degrees() - (90.0 - rho.to_degrees())).abs() < 1e-6);
let gr = ground_range(rho, 700_000.0).unwrap();
assert!(
(2_700_000.0..3_000_000.0).contains(&gr),
"max ground range {gr} m"
);
}
#[test]
fn looking_past_the_horizon_errors() {
let rho = earth_angular_radius(700_000.0);
assert!(elevation_from_nadir_angle(rho + 0.05, 700_000.0).is_err());
}
#[test]
fn swath_grows_with_fov_and_gsd_with_altitude() {
let narrow = swath_width(5.0_f64.to_radians(), 700_000.0).unwrap();
let wide = swath_width(20.0_f64.to_radians(), 700_000.0).unwrap();
assert!(wide > narrow);
assert!((nadir_gsd(700_000.0, 14.0) - 9.8).abs() < 0.01);
assert!(nadir_gsd(800_000.0, 14.0) > nadir_gsd(700_000.0, 14.0));
}
#[test]
fn ground_track_spacing_is_about_2750km_at_700km() {
let t = circular_period(700_000.0);
assert!((t / 60.0 - 98.8).abs() < 1.0, "period {} min", t / 60.0);
let s = ground_track_spacing_equator(t);
assert!(
(2_600_000.0..2_900_000.0).contains(&s),
"node spacing {s} m"
);
}
#[test]
fn scenario_runs_reproducibly_and_is_modelled() {
let scn = EoCoverageScenario {
altitude_km: 700.0,
half_fov_deg: 7.5,
ifov_microrad: 14.0,
max_off_nadir_deg: Some(45.0),
};
let (j1, _s) = scn.run_json().unwrap();
let (j2, _s) = scn.run_json().unwrap();
assert_eq!(j1, j2);
let v: serde_json::Value = serde_json::from_str(&j1).unwrap();
assert_eq!(v["kind"], "eo-coverage");
assert!(v["label"].as_str().unwrap().contains("MODELLED"));
assert!(!j1.contains("VALIDATED"));
assert!((v["earth_angular_radius_deg"].as_f64().unwrap() - 64.28).abs() < 0.1);
assert!(v["swath_width_km"].as_f64().unwrap() > 0.0);
assert_eq!(v["contiguous_equatorial_coverage"], false);
}
#[test]
fn scenario_rejects_bad_inputs() {
let bad = EoCoverageScenario {
altitude_km: -1.0,
half_fov_deg: 7.5,
ifov_microrad: 14.0,
max_off_nadir_deg: None,
};
assert!(bad.run_json().is_err());
}
}