Files
astrokit/examples/full.rs
T
2026-10-01 18:27:06 -04:00

266 lines
9.4 KiB
Rust

//! A full propagation: a spherical harmonic field, the Sun and Moon, sunlight pushing on
//! a faceted spacecraft and charging it, attitude carried alongside the orbit, and the
//! ground track that follows from both.
//!
//! Run with `cargo run --example full`. Needs kernels — `scripts/fetch-kernels.sh`.
//!
//! Add `--release` if you run it often. A day of degree-70 gravity takes about six
//! seconds unoptimized and two tenths of a second optimized.
use std::path::PathBuf;
use std::sync::Arc;
use astrokit::eclipse;
use astrokit::prelude::*;
use astrokit::{attitude, kernels};
use nalgebra::Vector3;
const DEGREE: usize = 70;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// `kernels::directory` reads ASTROKIT_KERNELS and nothing else, since a path
// relative to a source tree means nothing to a library. A program knows where it
// was run from and can fall back.
let directory = kernels::directory().unwrap_or_else(|| PathBuf::from("data/kernels"));
if !kernels::manifest()
.iter()
.all(|kernel| directory.join(kernel.name).is_file())
{
println!(
"kernels not found in {}; run scripts/fetch-kernels.sh",
directory.display()
);
return Ok(());
}
let almanac = Arc::new(kernels::load(&directory)?);
let epoch = Epoch::from_gregorian_utc_hms(2026, 1, 1, 0, 0, 0);
let frame = Frame::EARTH_J2000;
let arc = Duration::from_seconds(86400.0);
// A 400 km orbit at the inclination of the space station, very slightly eccentric
// so the periapsis is defined and the elements are not a degenerate set.
let orbit = Keplerian::new(
6.7781e6,
1.0e-3,
51.6_f64.to_radians(),
0.4,
0.2,
0.0,
frame,
);
let spacecraft = Spacecraft::rectangular_box(500.0, Vector3::new(2.0, 1.0, 3.0), 2.2, 1.3)
.with_solar_array(SolarArray::articulated(6.0, 0.3, Vector3::y()))
.with_battery(Battery::watt_hours(4000.0));
let tumble = Vector3::new(0.0012, 0.0028, -0.0007);
let initial = Full
.with_keplerian(orbit)
.with_mass(spacecraft.mass)
.with_angular_rate(tumble);
// What a run records of itself. Plain data, so it serializes as the explicit force
// list rather than as a mode flag, and names the coefficient set rather than only
// how far it was truncated.
let forces = vec![
Gravity {
field: GravityModel::Egm96,
degree: DEGREE,
order: DEGREE,
},
ThirdBody { body: Body::MOON },
ThirdBody { body: Body::SUN },
Sunshine,
];
let field = GravityModel::Egm96.field(DEGREE, DEGREE)?;
let mut propagation = Propagation::new(epoch, initial, &forces)
.frame(frame)
.spacecraft(spacecraft.clone())
.almanac(almanac.clone())
.duration(arc)
.accuracy(Accuracy::Medium);
let solution = propagation.run();
let final_state = Full.state(*solution.states.last().unwrap());
// Every event that changed the state leaves a repeated time, so this counts the
// shadow-set switches and the battery filling together.
let jumps = jump_times(&solution.times).len();
let steps = solution.times.len() - 1 - jumps;
// The same arc under less physics, so each piece can be given a size.
let ending_with = |specs: &[Dynamics]| {
let mut plain = Propagation::new(epoch, initial, specs)
.frame(frame)
.spacecraft(spacecraft.clone())
.almanac(almanac.clone())
.duration(arc)
.accuracy(Accuracy::Medium);
Full.state(*plain.run().states.last().unwrap()).position()
};
let point_mass = ending_with(&[TwoBody { body: Body::EARTH }]);
let without_third_bodies = ending_with(&forces[..1]);
let without_sunlight = ending_with(&forces[..3]);
let at_end = time::advance(epoch, arc.to_seconds());
let elements = final_state.keplerian(frame)?;
let ground = final_state
.cartesian(frame)
.to_frame(Frame::EARTH_FIXED, at_end, &almanac)?
.geodetic()?;
// Drag and radiation pressure see the face turned into the flow, which follows from
// the attitude. Nothing here uses it yet; the geometry is ready for when they do.
let presented = |state: &State<f64, 14, Full>| {
let incident = attitude::to_dcm(state.attitude()) * -state.velocity().normalize();
spacecraft.surface.drag_coefficient_area(incident)
};
let inertia = &spacecraft.inertia;
let momentum = |rate: Vector3<f64>| (inertia * rate).norm();
println!("kernels {}", directory.display());
println!(
"spacecraft {:.0} kg, {} faces, inertia diag({:.1}, {:.1}, {:.1}) kg m^2",
spacecraft.mass,
match &spacecraft.surface {
SurfaceModel::Faceted { faces } => faces.len(),
_ => 1,
},
inertia[(0, 0)],
inertia[(1, 1)],
inertia[(2, 2)]
);
println!(
"field EGM96 degree {} order {}, mu {:.9e}, reference radius {} m",
field.degree(),
field.order(),
field.mu(),
field.reference_radius()
);
println!("epoch {epoch}");
println!("arc {arc}");
println!(
"initial a = {:.3} km, e = {:.6}, i = {:.3} deg, period {}",
orbit.semi_major_axis / 1e3,
orbit.eccentricity,
orbit.inclination.to_degrees(),
Duration::from_seconds(orbit.period())
);
println!();
println!("steps {steps}, state jumps {jumps}");
println!(
"elements a = {:.3} km, e = {:.6}, i = {:.3} deg ({:?})",
elements.semi_major_axis / 1e3,
elements.eccentricity,
elements.inclination.to_degrees(),
elements.degeneracy
);
println!(
"node {:.4} -> {:.4} deg, so {:.4} deg of regression",
orbit.raan.to_degrees(),
elements.raan.to_degrees(),
(elements.raan - orbit.raan).to_degrees()
);
println!(
"sub-satellite {:.4} deg N, {:.4} deg E, {:.3} km up",
ground.latitude.to_degrees(),
ground.longitude.to_degrees(),
ground.height / 1e3
);
println!(
"attitude rate {:.4} -> {:.4} deg/s, momentum drift {:.3e}",
tumble.norm().to_degrees(),
final_state.angular_rate().norm().to_degrees(),
(momentum(final_state.angular_rate()) - momentum(tumble)).abs() / momentum(tumble)
);
println!(
"presented area {:.3} -> {:.3} m^2 of C_d A",
presented(&initial),
presented(&final_state)
);
// How much of the arc was spent in sunlight, measured along the trajectory rather than
// inferred from the energy: the battery fills early and caps generation, so the energy
// stops being a record of the daylight.
let mut lit_seconds = 0.0;
let mut total_seconds = 0.0;
for (pair, state) in solution.times.windows(2).zip(&solution.states) {
let interval = pair[1] - pair[0];
if interval == 0.0 {
// A repeated time is a jump, not an interval.
continue;
}
let sun = almanac
.translate_geometric(
Frame::inertial(Body::SUN).anise(),
frame.anise(),
time::advance(epoch, pair[0]),
)?
.radius_km
* 1e3;
lit_seconds += interval
* eclipse::illumination(
Full.state(*state).position(),
sun,
frame.center.equatorial_radius,
Body::SUN.equatorial_radius,
);
total_seconds += interval;
}
let battery = spacecraft.battery.expect("a battery was fitted");
println!(
"sunlight {:.1}% of the arc",
100.0 * lit_seconds / total_seconds
);
println!(
"battery {:.2} of {:.2} kWh{}",
final_state.energy() / 3.6e6,
battery.capacity / 3.6e6,
if final_state.energy() >= battery.capacity {
", full"
} else {
""
}
);
println!(
"against a point mass {:.3} km",
(final_state.position() - point_mass).norm() / 1e3
);
println!(
"of that, the sun and moon {:.3} km",
(final_state.position() - without_third_bodies).norm() / 1e3
);
println!(
"of that, sunlight {:.3} km",
(final_state.position() - without_sunlight).norm() / 1e3
);
// Dense output: any time in the arc, not only the ones a step landed on.
let midway = Full.state(solution.interpolate(arc.to_seconds() / 2.0));
let midway_ground = midway
.cartesian(frame)
.to_frame(
Frame::EARTH_FIXED,
time::advance(epoch, arc.to_seconds() / 2.0),
&almanac,
)?
.geodetic()?;
println!(
"halfway {:.4} deg N, {:.4} deg E, {:.3} km up",
midway_ground.latitude.to_degrees(),
midway_ground.longitude.to_degrees(),
midway_ground.height / 1e3
);
// The force list is plain data, so the record of what ran is the composition itself
// rather than the name of a mode. Each body carries the constants it was used with,
// so reading this back does not depend on those constants not having changed.
println!();
println!("provenance");
println!("{}", serde_json::to_string_pretty(&forces)?);
Ok(())
}