266 lines
9.4 KiB
Rust
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(())
|
|
}
|