use crate::orbit::{MU_EARTH, R_EARTH_EQUATORIAL_M};
use serde::Deserialize;
pub fn mu_over_r3(altitude_m: f64) -> f64 {
let r = R_EARTH_EQUATORIAL_M + altitude_m;
MU_EARTH / (r * r * r)
}
pub fn gravity_gradient_torque_max(altitude_m: f64, delta_inertia_kg_m2: f64) -> f64 {
1.5 * mu_over_r3(altitude_m) * delta_inertia_kg_m2.abs()
}
pub fn rss(values: &[f64]) -> f64 {
values.iter().map(|v| v * v).sum::<f64>().sqrt()
}
#[derive(Clone, Debug, Deserialize)]
pub struct PointingContributor {
pub name: String,
pub sigma_arcsec: f64,
}
fn ab_default_alt() -> f64 {
600.0
}
fn ab_default_imax() -> f64 {
100.0
}
fn ab_default_imin() -> f64 {
60.0
}
fn ab_default_contributors() -> Vec<PointingContributor> {
vec![
PointingContributor {
name: "star_tracker_noise".into(),
sigma_arcsec: 5.0,
},
PointingContributor {
name: "reaction_wheel_jitter".into(),
sigma_arcsec: 8.0,
},
PointingContributor {
name: "thermal_distortion".into(),
sigma_arcsec: 4.0,
},
PointingContributor {
name: "alignment".into(),
sigma_arcsec: 3.0,
},
]
}
#[derive(Deserialize)]
pub struct AttitudeBudgetScenario {
#[serde(default = "ab_default_alt")]
pub altitude_km: f64,
#[serde(default = "ab_default_imax")]
pub i_max_kg_m2: f64,
#[serde(default = "ab_default_imin")]
pub i_min_kg_m2: f64,
#[serde(default = "ab_default_contributors")]
pub contributors: Vec<PointingContributor>,
}
impl AttitudeBudgetScenario {
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 !self.i_max_kg_m2.is_finite()
|| !self.i_min_kg_m2.is_finite()
|| self.i_max_kg_m2 <= 0.0
|| self.i_min_kg_m2 <= 0.0
{
return Err("inertias must be finite and positive".to_string());
}
if self.i_max_kg_m2 < self.i_min_kg_m2 {
return Err("i_max_kg_m2 must be >= i_min_kg_m2".to_string());
}
if self.contributors.is_empty() {
return Err("at least one pointing contributor is required".to_string());
}
for c in &self.contributors {
if !c.sigma_arcsec.is_finite() || c.sigma_arcsec < 0.0 {
return Err(format!(
"contributor '{}' sigma must be finite and >= 0",
c.name
));
}
}
let alt_m = self.altitude_km * 1000.0;
let delta_i = self.i_max_kg_m2 - self.i_min_kg_m2;
let t_gg = gravity_gradient_torque_max(alt_m, delta_i);
let sigmas: Vec<f64> = self.contributors.iter().map(|c| c.sigma_arcsec).collect();
let total = rss(&sigmas);
let dominant = self
.contributors
.iter()
.max_by(|a, b| a.sigma_arcsec.total_cmp(&b.sigma_arcsec))
.map(|c| c.name.clone())
.unwrap_or_default();
let json = serde_json::json!({
"kind": "attitude-budget",
"label": "MODELLED — scalar 3-DOF AOCS error budget: gravity-gradient \
worst-case disturbance torque + RSS pointing budget; NOT a \
control-loop / 6-DoF / flexible-mode simulation (a pre-hardware \
complement to Basilisk/42, not a replacement)",
"altitude_km": self.altitude_km,
"delta_inertia_kg_m2": delta_i,
"gravity_gradient_torque_max_nm": t_gg,
"total_pointing_error_arcsec": total,
"dominant_contributor": dominant,
"contributors": self.contributors.iter().map(|c| serde_json::json!({
"name": c.name,
"sigma_arcsec": c.sigma_arcsec,
"variance_fraction": if total > 0.0 { (c.sigma_arcsec * c.sigma_arcsec) / (total * total) } else { 0.0 },
})).collect::<Vec<_>>(),
});
let summary = format!(
"attitude-budget: {:.0} km, ΔI {:.0} kg·m² -> GG torque {:.2e} N·m; \
pointing {:.1}\" RSS (dominant: {}) (MODELLED scalar budget)",
self.altitude_km, delta_i, t_gg, total, dominant
);
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 gravity_gradient_torque_has_the_right_magnitude() {
let t = gravity_gradient_torque_max(700_000.0, 10.0);
assert!((1.5e-5..1.9e-5).contains(&t), "GG torque {t} N·m");
}
#[test]
fn gravity_gradient_vanishes_for_a_symmetric_body_and_grows_lower_down() {
assert_eq!(gravity_gradient_torque_max(600_000.0, 0.0), 0.0);
assert!(
gravity_gradient_torque_max(400_000.0, 10.0)
> gravity_gradient_torque_max(800_000.0, 10.0)
);
let a = gravity_gradient_torque_max(600_000.0, 5.0);
let b = gravity_gradient_torque_max(600_000.0, 10.0);
assert!((b - 2.0 * a).abs() < 1e-18);
}
#[test]
fn rss_is_the_quadrature_sum() {
assert!((rss(&[3.0, 4.0]) - 5.0).abs() < 1e-12);
assert_eq!(rss(&[]), 0.0);
assert!(rss(&[5.0, 8.0, 4.0]) >= 8.0);
}
#[test]
fn scenario_runs_reproducibly_and_is_modelled() {
let scn = AttitudeBudgetScenario {
altitude_km: 600.0,
i_max_kg_m2: 100.0,
i_min_kg_m2: 60.0,
contributors: ab_default_contributors(),
};
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"], "attitude-budget");
assert!(v["label"].as_str().unwrap().contains("MODELLED"));
assert!(!j1.contains("VALIDATED"));
assert!(
(v["total_pointing_error_arcsec"].as_f64().unwrap() - 114.0_f64.sqrt()).abs() < 1e-6
);
assert_eq!(v["dominant_contributor"], "reaction_wheel_jitter");
let frac: f64 = v["contributors"]
.as_array()
.unwrap()
.iter()
.map(|c| c["variance_fraction"].as_f64().unwrap())
.sum();
assert!((frac - 1.0).abs() < 1e-9);
}
#[test]
fn scenario_rejects_bad_inputs() {
let bad_inertia = AttitudeBudgetScenario {
altitude_km: 600.0,
i_max_kg_m2: 50.0,
i_min_kg_m2: 60.0, contributors: ab_default_contributors(),
};
assert!(bad_inertia.run_json().is_err());
let no_contrib = AttitudeBudgetScenario {
altitude_km: 600.0,
i_max_kg_m2: 100.0,
i_min_kg_m2: 60.0,
contributors: vec![],
};
assert!(no_contrib.run_json().is_err());
}
}