use serde::Deserialize;
const AP_TABLE: [f64; 28] = [
0.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 9.0, 12.0, 15.0, 18.0, 22.0, 27.0, 32.0, 39.0, 48.0, 56.0,
67.0, 80.0, 94.0, 111.0, 132.0, 154.0, 179.0, 207.0, 236.0, 300.0, 400.0,
];
pub fn ap_from_kp(kp: f64) -> f64 {
let kp = kp.clamp(0.0, 9.0);
let idx = (kp * 3.0).round() as usize;
AP_TABLE[idx.min(AP_TABLE.len() - 1)]
}
pub fn kp_from_ap(ap: f64) -> f64 {
let mut best_idx = 0usize;
let mut best_d = (ap - AP_TABLE[0]).abs();
for (i, &a) in AP_TABLE.iter().enumerate().skip(1) {
let d = (ap - a).abs();
if d < best_d {
best_idx = i;
best_d = d;
}
}
best_idx as f64 / 3.0
}
pub fn daily_ap(ap_3hourly: &[f64; 8]) -> f64 {
ap_3hourly.iter().sum::<f64>() / 8.0
}
pub fn f107a_centered(series: &[f64], i: usize) -> f64 {
if series.is_empty() {
return 0.0;
}
let half = 40usize;
let lo = i.saturating_sub(half);
let hi = (i + half + 1).min(series.len());
let w = &series[lo..hi];
w.iter().sum::<f64>() / w.len() as f64
}
pub fn exospheric_temperature(f107: f64, f107a: f64, kp: f64) -> f64 {
let kp = kp.clamp(0.0, 9.0);
let t_c = 379.0 + 3.24 * f107a + 1.3 * (f107 - f107a);
let dt_geo = 28.0 * kp + 0.03 * kp.exp();
t_c + dt_geo
}
const REFERENCE_EXOSPHERIC_TEMP_K: f64 = 1000.0;
const THERMOSPHERE_BASE_KM: f64 = 120.0;
const DENSITY_TEMP_COUPLING_K_PER_KM: f64 = 9.14;
pub fn density_activity_factor(altitude_m: f64, t_inf_k: f64) -> f64 {
let h_km = altitude_m / 1000.0;
if h_km <= THERMOSPHERE_BASE_KM || t_inf_k <= 0.0 {
return 1.0;
}
let dh = h_km - THERMOSPHERE_BASE_KM;
(DENSITY_TEMP_COUPLING_K_PER_KM * dh * (1.0 / REFERENCE_EXOSPHERIC_TEMP_K - 1.0 / t_inf_k))
.exp()
}
pub fn space_weather_density(altitude_m: f64, sw: &SpaceWeather) -> f64 {
crate::forces::atmospheric_density(altitude_m)
* density_activity_factor(altitude_m, sw.exospheric_temperature())
}
#[derive(Clone, Copy, Debug)]
pub struct SpaceWeather {
pub f107: f64,
pub f107a: f64,
pub kp: f64,
}
impl SpaceWeather {
pub fn ap(&self) -> f64 {
ap_from_kp(self.kp)
}
pub fn exospheric_temperature(&self) -> f64 {
exospheric_temperature(self.f107, self.f107a, self.kp)
}
pub fn density(&self, altitude_m: f64) -> f64 {
space_weather_density(altitude_m, self)
}
}
fn sw_default_f107() -> f64 {
150.0
}
fn sw_default_kp() -> f64 {
3.0
}
fn sw_default_altitudes() -> Vec<f64> {
vec![300.0, 400.0, 500.0, 800.0]
}
#[derive(Deserialize)]
pub struct SpaceWeatherScenario {
#[serde(default = "sw_default_f107")]
pub f107: f64,
#[serde(default)]
pub f107a: Option<f64>,
#[serde(default = "sw_default_kp")]
pub kp: f64,
#[serde(default = "sw_default_altitudes")]
pub altitudes_km: Vec<f64>,
}
impl SpaceWeatherScenario {
pub fn run_json(&self) -> Result<(String, String), String> {
let f107a = self.f107a.unwrap_or(self.f107);
if !self.f107.is_finite() || self.f107 <= 0.0 {
return Err("f107 must be finite and positive".to_string());
}
if !f107a.is_finite() || f107a <= 0.0 {
return Err("f107a must be finite and positive".to_string());
}
if !self.kp.is_finite() || !(0.0..=9.0).contains(&self.kp) {
return Err("kp must be in [0, 9]".to_string());
}
if self.altitudes_km.is_empty() {
return Err("altitudes_km must be non-empty".to_string());
}
for &h in &self.altitudes_km {
if !h.is_finite() || h <= 0.0 {
return Err(format!("altitude {h} km must be finite and positive"));
}
}
let sw = SpaceWeather {
f107: self.f107,
f107a,
kp: self.kp,
};
let t_inf = sw.exospheric_temperature();
let rows: Vec<serde_json::Value> = self
.altitudes_km
.iter()
.map(|&h| {
let alt_m = h * 1000.0;
let stat = crate::forces::atmospheric_density(alt_m);
let factor = density_activity_factor(alt_m, t_inf);
serde_json::json!({
"altitude_km": h,
"static_density_kg_m3": stat,
"activity_density_kg_m3": stat * factor,
"activity_factor": factor,
})
})
.collect();
let json = serde_json::json!({
"kind": "space-weather",
"label": "MODELLED — solar/geomagnetic indices + Jacchia-71 exospheric \
temperature; density is a calibrated first-order activity \
correction, NOT a data-validated (NRLMSISE) atmosphere",
"f107": self.f107,
"f107a": f107a,
"kp": self.kp,
"ap": sw.ap(),
"exospheric_temperature_k": t_inf,
"reference_exospheric_temperature_k": REFERENCE_EXOSPHERIC_TEMP_K,
"altitudes": rows,
});
let summary = format!(
"space-weather: F10.7={:.0} F10.7a={:.0} Kp={:.1} (ap={:.0}) -> T_inf={:.0} K; \
density x{:.2} at {:.0} km (MODELLED)",
self.f107,
f107a,
self.kp,
sw.ap(),
t_inf,
density_activity_factor(self.altitudes_km[0] * 1000.0, t_inf),
self.altitudes_km[0],
);
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 kp_to_ap_matches_the_definitional_table_at_grid_points() {
assert_eq!(ap_from_kp(0.0), 0.0);
assert_eq!(ap_from_kp(1.0), 4.0);
assert_eq!(ap_from_kp(3.0), 15.0);
assert_eq!(ap_from_kp(4.0), 27.0);
assert_eq!(ap_from_kp(5.0), 48.0);
assert_eq!(ap_from_kp(9.0), 400.0);
assert_eq!(ap_from_kp(8.0 / 3.0), 12.0);
assert_eq!(ap_from_kp(14.0 / 3.0), 39.0);
}
#[test]
fn kp_ap_round_trips_and_clamps() {
for (i, &ap) in AP_TABLE.iter().enumerate() {
let kp = i as f64 / 3.0;
assert_eq!(ap_from_kp(kp), ap);
assert!((kp_from_ap(ap) - kp).abs() < 1e-9, "ap {ap} -> kp");
}
assert_eq!(ap_from_kp(-1.0), 0.0);
assert_eq!(ap_from_kp(20.0), 400.0);
}
#[test]
fn ap_is_monotonic_in_kp() {
let mut prev = -1.0;
for i in 0..=27 {
let ap = ap_from_kp(i as f64 / 3.0);
assert!(ap > prev, "ap not strictly increasing at step {i}");
prev = ap;
}
}
#[test]
fn daily_ap_is_the_mean_of_eight() {
assert_eq!(daily_ap(&[4.0; 8]), 4.0);
assert_eq!(
daily_ap(&[0.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 9.0]),
36.0 / 8.0
);
}
#[test]
fn f107a_is_a_centred_average() {
let flat = vec![120.0; 200];
assert!((f107a_centered(&flat, 100) - 120.0).abs() < 1e-9);
let mut s = vec![70.0; 100];
s.extend(vec![230.0; 100]);
let m = f107a_centered(&s, 100);
assert!(
(70.0..230.0).contains(&m),
"centred mean {m} not between levels"
);
}
#[test]
fn exospheric_temperature_matches_published_solar_anchors() {
let tmin = exospheric_temperature(70.0, 70.0, 0.0);
let tmean = exospheric_temperature(150.0, 150.0, 0.0);
let tmax = exospheric_temperature(230.0, 230.0, 0.0);
assert!((tmin - 605.8).abs() < 1.0, "solar-min T_inf {tmin}");
assert!((tmean - 865.0).abs() < 1.0, "solar-mean T_inf {tmean}");
assert!((tmax - 1124.2).abs() < 1.0, "solar-max T_inf {tmax}");
assert!(tmin < tmean && tmean < tmax, "T_inf must rise with F10.7");
}
#[test]
fn geomagnetic_storm_raises_exospheric_temperature() {
let quiet = exospheric_temperature(150.0, 150.0, 0.0);
let storm = exospheric_temperature(150.0, 150.0, 6.0);
let dt = storm - quiet;
assert!((dt - 180.1).abs() < 1.0, "storm increment {dt}");
assert!(storm > quiet);
}
#[test]
fn density_factor_is_unity_at_reference_and_below_the_thermosphere() {
assert!(
(density_activity_factor(400_000.0, REFERENCE_EXOSPHERIC_TEMP_K) - 1.0).abs() < 1e-12
);
assert_eq!(density_activity_factor(100_000.0, 1500.0), 1.0);
assert_eq!(density_activity_factor(100_000.0, 600.0), 1.0);
}
#[test]
fn density_factor_increases_with_activity_at_altitude() {
let cold = density_activity_factor(400_000.0, 606.0); let hot = density_activity_factor(400_000.0, 1124.0); assert!(
cold < 1.0,
"solar-min density should fall below USSA76: {cold}"
);
assert!(
hot > 1.0,
"solar-max density should rise above USSA76: {hot}"
);
assert!(hot > cold);
}
#[test]
fn solar_cycle_density_swing_at_400km_is_in_the_observed_band() {
let swing =
density_activity_factor(400_000.0, 1124.0) / density_activity_factor(400_000.0, 606.0);
assert!(
(5.0..=10.0).contains(&swing),
"400 km solar-cycle swing {swing}x"
);
}
#[test]
fn space_weather_density_brackets_the_static_model() {
let alt = 500_000.0;
let stat = crate::forces::atmospheric_density(alt);
let active = SpaceWeather {
f107: 230.0,
f107a: 230.0,
kp: 4.0,
}
.density(alt);
let quiet = SpaceWeather {
f107: 70.0,
f107a: 70.0,
kp: 0.0,
}
.density(alt);
assert!(
active > stat && stat > quiet,
"active {active} stat {stat} quiet {quiet}"
);
assert!(active.is_finite() && quiet > 0.0);
}
#[test]
fn scenario_runs_reproducibly_and_is_modelled() {
let scn = SpaceWeatherScenario {
f107: 200.0,
f107a: Some(180.0),
kp: 5.0,
altitudes_km: vec![300.0, 400.0, 600.0],
};
let (j1, _s) = scn.run_json().unwrap();
let (j2, _s) = scn.run_json().unwrap();
assert_eq!(j1, j2, "scenario must be reproducible");
let v: serde_json::Value = serde_json::from_str(&j1).unwrap();
assert_eq!(v["kind"], "space-weather");
assert!(v["label"].as_str().unwrap().contains("MODELLED"));
assert!(
!j1.contains("VALIDATED"),
"a MODELLED model must not claim VALIDATED"
);
assert_eq!(v["ap"], 48.0);
assert!(v["exospheric_temperature_k"].as_f64().unwrap() > 800.0);
assert_eq!(v["altitudes"].as_array().unwrap().len(), 3);
}
#[test]
fn scenario_rejects_out_of_range_inputs() {
let bad_kp = SpaceWeatherScenario {
f107: 150.0,
f107a: None,
kp: 12.0,
altitudes_km: vec![400.0],
};
assert!(bad_kp.run_json().is_err());
let bad_alt = SpaceWeatherScenario {
f107: 150.0,
f107a: None,
kp: 3.0,
altitudes_km: vec![-10.0],
};
assert!(bad_alt.run_json().is_err());
let bad_flux = SpaceWeatherScenario {
f107: 0.0,
f107a: None,
kp: 3.0,
altitudes_km: vec![400.0],
};
assert!(bad_flux.run_json().is_err());
}
}