534 lines
21 KiB
Rust
534 lines
21 KiB
Rust
//! What sunlight does to a spacecraft: pushes on it, and powers it.
|
|
//!
|
|
//! One model rather than two, because both answer to the same geometry — where the Sun
|
|
//! is, how much of it is visible, and which way the spacecraft is facing. Splitting them
|
|
//! would mean two ephemeris lookups and two eclipse calculations per evaluation to
|
|
//! produce two halves of one answer.
|
|
//!
|
|
//! If you only want one or the other, you can exclude it from your Layout and it won't be
|
|
//! calculated
|
|
|
|
use nalgebra::Vector3;
|
|
use ordinary_diffeq::scalar::Scalar;
|
|
|
|
use super::super::{Context, Contribution, DynamicsModel};
|
|
use crate::attitude;
|
|
use crate::body::Body;
|
|
use crate::eclipse;
|
|
use crate::frames::Frame;
|
|
use crate::state::{HasPosition, State};
|
|
|
|
/// Total solar irradiance at one astronomical unit, W/m^2.
|
|
///
|
|
/// The measured mean, which varies by about a watt over a solar cycle. Anything that
|
|
/// cared about that variation would have to take it as an input.
|
|
pub const IRRADIANCE_AT_1AU: f64 = 1361.0;
|
|
|
|
/// One astronomical unit, m. Exact by definition since 2012.
|
|
pub const ASTRONOMICAL_UNIT: f64 = 1.495978707e11;
|
|
|
|
/// Speed of light in vacuum, m/s. Exact by definition.
|
|
pub const LIGHT_SPEED: f64 = 299792458.0;
|
|
|
|
/// Solar irradiance at `distance` from the Sun, W/m^2, reduced by `illumination`.
|
|
pub fn irradiance<T: Scalar>(distance: T, illumination: T) -> T {
|
|
(T::lift(ASTRONOMICAL_UNIT) / distance).powi(2) * IRRADIANCE_AT_1AU * illumination
|
|
}
|
|
|
|
/// Radiation pressure at `distance` from the Sun, N/m^2, reduced by `illumination`.
|
|
///
|
|
/// Pressure is irradiance over the speed of light. At one astronomical unit in full sun
|
|
/// that is 4.54e-6 N/m^2, which on a square meter of a 500 kg spacecraft is an
|
|
/// acceleration of 1e-8 m/s^2 — small, and not negligible over a day.
|
|
pub fn pressure<T: Scalar>(distance: T, illumination: T) -> T {
|
|
irradiance(distance, illumination) / LIGHT_SPEED
|
|
}
|
|
|
|
/// Radiation pressure and photovoltaic power.
|
|
///
|
|
/// The force needs a surface, and so a [`Spacecraft`](crate::spacecraft::Spacecraft) in
|
|
/// the [`Context`]. Power needs a
|
|
/// [`SolarArray`](crate::spacecraft::SolarArray) as well, and is only contributed when
|
|
/// the layout has somewhere to put it — a propagation that is not integrating stored
|
|
/// energy is not asking about power, and routing it nowhere would be worse than not
|
|
/// computing it.
|
|
///
|
|
/// A [`Faceted`](crate::spacecraft::SurfaceModel::Faceted) surface and a fixed array both
|
|
/// need to know which way the spacecraft is pointing, so both need a layout carrying an
|
|
/// attitude. [`Propagation::run`](crate::propagation::Propagation::run) checks for that
|
|
/// rather than letting it produce a quietly wrong area.
|
|
///
|
|
/// # What is not modeled
|
|
///
|
|
/// The force is along the Sun line, scaled by the surface's effective area. A faceted
|
|
/// surface really reflects specularly as well as absorbing, which tilts the force off that
|
|
/// line; representing that needs a per-face force rather than a per-face area, and is the
|
|
/// box-wing work in M2. Earth-reflected sunlight is not here either.
|
|
pub struct Sunshine<T: Scalar> {
|
|
sun: Frame,
|
|
radius: f64,
|
|
scalar: core::marker::PhantomData<T>,
|
|
}
|
|
|
|
impl<T: Scalar> Sunshine<T> {
|
|
/// Sunlight from the Sun.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
sun: Frame::inertial(Body::SUN),
|
|
radius: Body::SUN.equatorial_radius,
|
|
scalar: core::marker::PhantomData,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: Scalar> Default for Sunshine<T> {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl<T: Scalar, const D: usize, L: HasPosition<D>> DynamicsModel<T, D, L> for Sunshine<T> {
|
|
fn contribution(&self, t: T, y: &State<T, D, L>, context: &Context) -> Contribution<T> {
|
|
let almanac = context
|
|
.almanac()
|
|
.expect("sunlight needs an almanac; Propagation::run checks");
|
|
let spacecraft = context
|
|
.spacecraft()
|
|
.expect("sunlight needs a spacecraft to fall on; Propagation::run checks");
|
|
let ephemeris = almanac
|
|
.translate_geometric(
|
|
self.sun.anise(),
|
|
Frame::inertial(context.frame.center).anise(),
|
|
context.epoch_at(t),
|
|
)
|
|
.expect("no ephemeris for the Sun; check the kernels cover the epoch");
|
|
|
|
// ANISE works in kilometers, and the Sun's position depends on the epoch and not
|
|
// on the state, so lifting it carries the correct derivative of zero.
|
|
let sun = Vector3::new(
|
|
T::lift(ephemeris.radius_km[0] * 1e3),
|
|
T::lift(ephemeris.radius_km[1] * 1e3),
|
|
T::lift(ephemeris.radius_km[2] * 1e3),
|
|
);
|
|
|
|
let position = y.position();
|
|
let to_sun = sun - position;
|
|
let distance = to_sun.norm();
|
|
let toward_sun = to_sun / distance;
|
|
|
|
let visible = eclipse::illumination(
|
|
position,
|
|
sun,
|
|
context.frame.center.equatorial_radius,
|
|
self.radius,
|
|
);
|
|
|
|
// A faceted surface is asked which way the light comes from in its own frame. A
|
|
// cannonball ignores the direction, which is why a translational layout needs no
|
|
// attitude to use one.
|
|
let incident = match L::try_attitude(&y.vector) {
|
|
Some(attitude) => attitude::to_dcm(attitude) * toward_sun,
|
|
None => toward_sun,
|
|
};
|
|
|
|
let area = spacecraft.surface.srp_coefficient_area(incident);
|
|
// Pressure acts along the direction the light travels, which is away from the Sun.
|
|
let force = -toward_sun * (pressure(distance, visible) * area);
|
|
|
|
// How well the cells can be turned toward the Sun is the mounting's business, so
|
|
// it lives there rather than here. What is generated is still capped by the
|
|
// battery, which assembly does, because a second power source would otherwise need
|
|
// to know about the cap too.
|
|
let power = match (L::ENERGY_AT, spacecraft.solar_array) {
|
|
(Some(_), Some(array)) => array.power(irradiance(distance, visible), incident),
|
|
_ => T::lift(0.0),
|
|
};
|
|
|
|
Contribution {
|
|
force,
|
|
power,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn name(&self) -> &'static str {
|
|
"sunshine"
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::dynamics::Dynamics;
|
|
use crate::kepler::{Keplerian, TranslationalLayout};
|
|
use crate::kernels;
|
|
use crate::propagation::{Accuracy, Propagation};
|
|
use crate::spacecraft::{SolarArray, Spacecraft, SurfaceModel};
|
|
use crate::state::{Full, Layout, Orbit, OrbitMass};
|
|
use anise::almanac::Almanac;
|
|
use approx::assert_relative_eq;
|
|
use hifitime::{Duration, Epoch};
|
|
use std::sync::Arc;
|
|
|
|
fn epoch() -> Epoch {
|
|
Epoch::from_gregorian_utc_hms(2026, 1, 1, 0, 0, 0)
|
|
}
|
|
|
|
fn almanac() -> Option<Arc<Almanac>> {
|
|
let directory = kernels::test_directory()?;
|
|
Some(Arc::new(
|
|
kernels::load(&directory).expect("kernels are present"),
|
|
))
|
|
}
|
|
|
|
/// Where the Sun is, from the almanac, so a test can place a spacecraft relative to it.
|
|
fn sun_position(almanac: &Almanac) -> Vector3<f64> {
|
|
let state = almanac
|
|
.translate_geometric(
|
|
Frame::inertial(Body::SUN).anise(),
|
|
Frame::EARTH_J2000.anise(),
|
|
epoch(),
|
|
)
|
|
.unwrap();
|
|
Vector3::new(
|
|
state.radius_km[0] * 1e3,
|
|
state.radius_km[1] * 1e3,
|
|
state.radius_km[2] * 1e3,
|
|
)
|
|
}
|
|
|
|
/// The textbook figure, from outside this crate: 4.54 micronewtons per square meter at
|
|
/// one astronomical unit.
|
|
#[test]
|
|
fn test_the_pressure_at_one_au() {
|
|
assert_relative_eq!(
|
|
pressure(ASTRONOMICAL_UNIT, 1.0),
|
|
4.5394e-6,
|
|
max_relative = 1e-4
|
|
);
|
|
assert_relative_eq!(irradiance(ASTRONOMICAL_UNIT, 1.0), 1361.0, epsilon = 1e-9);
|
|
|
|
// Inverse square, and linear in how much of the Sun is showing.
|
|
assert_relative_eq!(
|
|
pressure(ASTRONOMICAL_UNIT * 2.0, 1.0),
|
|
pressure(ASTRONOMICAL_UNIT, 1.0) / 4.0,
|
|
max_relative = 1e-12
|
|
);
|
|
assert_relative_eq!(
|
|
pressure(ASTRONOMICAL_UNIT, 0.25),
|
|
pressure(ASTRONOMICAL_UNIT, 1.0) * 0.25,
|
|
max_relative = 1e-12
|
|
);
|
|
}
|
|
|
|
/// A cannonball's force is pressure times coefficient times area, pointing away from
|
|
/// the Sun, and nothing else.
|
|
#[test]
|
|
fn test_a_cannonball_is_pushed_directly_away_from_the_sun() {
|
|
let Some(almanac) = almanac() else {
|
|
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
|
return;
|
|
};
|
|
let sun = sun_position(&almanac);
|
|
|
|
// Out to the sunward side, well clear of any shadow.
|
|
let position = sun.normalize() * 7.0e6;
|
|
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3);
|
|
let context = Context::new(epoch(), Frame::EARTH_J2000)
|
|
.with_spacecraft(spacecraft.clone())
|
|
.with_almanac(almanac.clone());
|
|
|
|
let got = Sunshine::new()
|
|
.contribution(0.0, &Orbit.with_position(position), &context)
|
|
.force;
|
|
|
|
let distance = (sun - position).norm();
|
|
let expected = pressure(distance, 1.0) * 4.0 * 1.3;
|
|
assert_relative_eq!(got.norm(), expected, max_relative = 1e-9);
|
|
|
|
// Along the anti-sun line to the last digit of the direction.
|
|
assert_relative_eq!(
|
|
got.normalize().dot(&sun.normalize()),
|
|
-1.0,
|
|
max_relative = 1e-12
|
|
);
|
|
}
|
|
|
|
/// In the Earth's shadow there is no pressure, which is the eclipse model earning its
|
|
/// place: a cylindrical shadow would get the boundary wrong by a couple of minutes,
|
|
/// and no shadow at all would push on the night side.
|
|
#[test]
|
|
fn test_the_shadow_removes_the_force() {
|
|
let Some(almanac) = almanac() else {
|
|
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
|
return;
|
|
};
|
|
let sun = sun_position(&almanac);
|
|
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3);
|
|
let context = Context::new(epoch(), Frame::EARTH_J2000)
|
|
.with_spacecraft(spacecraft)
|
|
.with_almanac(almanac.clone());
|
|
let model = Sunshine::<f64>::new();
|
|
|
|
let behind = sun.normalize() * -7.0e6;
|
|
assert_eq!(
|
|
model
|
|
.contribution(0.0, &Orbit.with_position(behind), &context)
|
|
.force,
|
|
Vector3::zeros()
|
|
);
|
|
|
|
// And the penumbra is in between, not a step: somewhere across the terminator the
|
|
// force is neither zero nor full.
|
|
let full = model
|
|
.contribution(0.0, &Orbit.with_position(sun.normalize() * 7.0e6), &context)
|
|
.force
|
|
.norm();
|
|
let across = sun.cross(&Vector3::z()).normalize();
|
|
let partial = (0..4000).any(|step| {
|
|
let angle = core::f64::consts::PI * (1.0 - 0.5 * step as f64 / 4000.0);
|
|
let position = (sun.normalize() * angle.cos() + across * angle.sin()) * 7.0e6;
|
|
let force = model
|
|
.contribution(0.0, &Orbit.with_position(position), &context)
|
|
.force
|
|
.norm();
|
|
force > 1e-3 * full && force < 0.999 * full
|
|
});
|
|
assert!(partial, "no penumbra anywhere across the terminator");
|
|
}
|
|
|
|
/// A faceted spacecraft presents a different area as it turns, so the force follows
|
|
/// the attitude. This is the whole reason the surface model is not a single number.
|
|
#[test]
|
|
fn test_a_faceted_force_follows_the_attitude() {
|
|
let Some(almanac) = almanac() else {
|
|
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
|
return;
|
|
};
|
|
let sun = sun_position(&almanac);
|
|
let position = sun.normalize() * 7.0e6;
|
|
let spacecraft = Spacecraft::rectangular_box(500.0, Vector3::new(2.0, 1.0, 3.0), 2.2, 1.3);
|
|
let context = Context::new(epoch(), Frame::EARTH_J2000)
|
|
.with_spacecraft(spacecraft)
|
|
.with_almanac(almanac.clone());
|
|
let model = Sunshine::<f64>::new();
|
|
|
|
let force = |attitude: Vector3<f64>| {
|
|
model
|
|
.contribution(
|
|
0.0,
|
|
&Full
|
|
.with_position(position)
|
|
.with_mass(500.0)
|
|
.with_attitude(attitude),
|
|
&context,
|
|
)
|
|
.force
|
|
.norm()
|
|
};
|
|
|
|
// A quarter turn about each axis in turn presents a different face, and the box
|
|
// has three different face areas, so at least two of these must differ.
|
|
let quarter = (core::f64::consts::FRAC_PI_2 / 4.0).tan();
|
|
let magnitudes = [
|
|
force(Vector3::zeros()),
|
|
force(Vector3::new(quarter, 0.0, 0.0)),
|
|
force(Vector3::new(0.0, quarter, 0.0)),
|
|
];
|
|
assert!(
|
|
magnitudes
|
|
.iter()
|
|
.any(|m| (m - magnitudes[0]).abs() > 1e-9 * magnitudes[0]),
|
|
"attitude changed nothing: {magnitudes:?}"
|
|
);
|
|
assert!(magnitudes.iter().all(|m| *m > 0.0));
|
|
}
|
|
|
|
/// Power is generated only where there is somewhere to put it. A layout that is not
|
|
/// integrating stored energy is not asking the question.
|
|
#[test]
|
|
fn test_power_needs_a_layout_that_tracks_it() {
|
|
let Some(almanac) = almanac() else {
|
|
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
|
return;
|
|
};
|
|
let sun = sun_position(&almanac);
|
|
let position = sun.normalize() * 7.0e6;
|
|
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3)
|
|
.with_solar_array(SolarArray::fully_articulated(6.0, 0.3));
|
|
let context = Context::new(epoch(), Frame::EARTH_J2000)
|
|
.with_spacecraft(spacecraft)
|
|
.with_almanac(almanac.clone());
|
|
let model = Sunshine::<f64>::new();
|
|
|
|
// `Full` carries stored energy, so the array is read.
|
|
let with_energy = model
|
|
.contribution(
|
|
0.0,
|
|
&Full.with_position(position).with_mass(500.0),
|
|
&context,
|
|
)
|
|
.power;
|
|
let expected = irradiance((sun - position).norm(), 1.0) * 6.0 * 0.3;
|
|
assert_relative_eq!(with_energy, expected, max_relative = 1e-9);
|
|
// Around 2.4 kW, which is a plausible array.
|
|
assert!((1.0e3..5.0e3).contains(&with_energy), "{with_energy} W");
|
|
|
|
// `OrbitMass` does not, so nothing is generated and nothing is dropped on the
|
|
// floor by the assembly either.
|
|
let without = model
|
|
.contribution(
|
|
0.0,
|
|
&OrbitMass.with_position(position).with_mass(500.0),
|
|
&context,
|
|
)
|
|
.power;
|
|
assert_eq!(without, 0.0);
|
|
}
|
|
|
|
/// A body-mounted array is projected onto the Sun line, so it generates most facing
|
|
/// the Sun, nothing edge on, and nothing at all turned away.
|
|
#[test]
|
|
fn test_a_mounted_array_is_projected_and_never_negative() {
|
|
let Some(almanac) = almanac() else {
|
|
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
|
return;
|
|
};
|
|
let sun = sun_position(&almanac);
|
|
let position = sun.normalize() * 7.0e6;
|
|
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3)
|
|
.with_solar_array(SolarArray::mounted(6.0, 0.3, Vector3::z()));
|
|
let context = Context::new(epoch(), Frame::EARTH_J2000)
|
|
.with_spacecraft(spacecraft)
|
|
.with_almanac(almanac.clone());
|
|
let model = Sunshine::<f64>::new();
|
|
|
|
let power = |attitude: Vector3<f64>| {
|
|
model
|
|
.contribution(
|
|
0.0,
|
|
&Full
|
|
.with_position(position)
|
|
.with_mass(500.0)
|
|
.with_attitude(attitude),
|
|
&context,
|
|
)
|
|
.power
|
|
};
|
|
|
|
// Search attitudes for the one that points the panel at the Sun. Parameters with
|
|
// no third component are rotations about an axis in the body x-y plane, and such a
|
|
// rotation can take the body z axis to any direction at all, so this family
|
|
// reaches the best attitude there is. Sweeping one axis would not: it only reaches
|
|
// within the Sun's angle off that axis.
|
|
let samples = 80;
|
|
let mut best = f64::NEG_INFINITY;
|
|
let mut worst = f64::INFINITY;
|
|
for i in 0..samples {
|
|
for j in 0..samples {
|
|
let x = -1.0 + 2.0 * i as f64 / (samples - 1) as f64;
|
|
let y = -1.0 + 2.0 * j as f64 / (samples - 1) as f64;
|
|
let generated = power(Vector3::new(x, y, 0.0));
|
|
best = best.max(generated);
|
|
worst = worst.min(generated);
|
|
}
|
|
}
|
|
|
|
// Pointed at the Sun, a mounted array matches one that tracks it.
|
|
let articulated = irradiance((sun - position).norm(), 1.0) * 6.0 * 0.3;
|
|
assert_relative_eq!(best, articulated, max_relative = 1e-3);
|
|
|
|
// Turned away it generates nothing, and never less than nothing.
|
|
assert_eq!(worst, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sunlight_moves_an_orbit_by_a_plausible_amount() {
|
|
let Some(almanac) = almanac() else {
|
|
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
|
return;
|
|
};
|
|
|
|
let orbit = Keplerian::new(
|
|
6.7781e6,
|
|
1.0e-3,
|
|
51.6_f64.to_radians(),
|
|
0.4,
|
|
0.2,
|
|
0.0,
|
|
Frame::EARTH_J2000,
|
|
);
|
|
let spacecraft = Spacecraft::cannonball(500.0, 10.0, 2.2, 1.3);
|
|
let arc = Duration::from_seconds(86400.0);
|
|
|
|
let run = |specs: &[Dynamics]| {
|
|
let mut propagation = Propagation::new(epoch(), Orbit.with_keplerian(orbit), specs)
|
|
.almanac(almanac.clone())
|
|
.spacecraft(spacecraft.clone())
|
|
.duration(arc)
|
|
.accuracy(Accuracy::High);
|
|
Orbit
|
|
.state(*propagation.run().states.last().unwrap())
|
|
.position()
|
|
};
|
|
|
|
let moved = (run(&[Dynamics::TwoBody { body: Body::EARTH }, Dynamics::Sunshine])
|
|
- run(&[Dynamics::TwoBody { body: Body::EARTH }]))
|
|
.norm();
|
|
|
|
// Ten square meters on 500 kg is about 1.2e-7 m/s^2 in full sun, and the answer is
|
|
// around 9 m over a day — far less than `a t^2 / 2` would suggest, for the same
|
|
// reason the third body is: a nearly inertially fixed acceleration turns once per
|
|
// orbit in the orbit frame and mostly cancels. What survives comes from the
|
|
// eclipse breaking that symmetry.
|
|
//
|
|
// The range is wide because the sharp checks on this model are the direct force
|
|
// ones above. What this catches is being orders of magnitude out, which is what a
|
|
// unit error or a missing inverse square would give.
|
|
assert!(
|
|
(1.0..1.0e3).contains(&moved),
|
|
"sunlight moved the orbit {moved:e} m over a day"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "needs a spacecraft")]
|
|
fn test_sunshine_without_a_spacecraft_is_refused() {
|
|
let orbit = Keplerian::new(7.0e6, 0.0, 0.9, 0.0, 0.0, 0.0, Frame::EARTH_J2000);
|
|
let mut propagation =
|
|
Propagation::new(epoch(), Orbit.with_keplerian(orbit), &[Dynamics::Sunshine])
|
|
.duration(Duration::from_seconds(60.0));
|
|
propagation.run();
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "faceted surface needs to know which way")]
|
|
fn test_a_faceted_surface_without_an_attitude_is_refused() {
|
|
let orbit = Keplerian::new(7.0e6, 0.0, 0.9, 0.0, 0.0, 0.0, Frame::EARTH_J2000);
|
|
let mut propagation =
|
|
Propagation::new(epoch(), Orbit.with_keplerian(orbit), &[Dynamics::Sunshine])
|
|
.spacecraft(Spacecraft::rectangular_box(
|
|
500.0,
|
|
Vector3::new(2.0, 1.0, 3.0),
|
|
2.2,
|
|
1.3,
|
|
))
|
|
.duration(Duration::from_seconds(60.0));
|
|
propagation.run();
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "solar array needs to know which way")]
|
|
fn test_a_mounted_array_without_an_attitude_is_refused() {
|
|
let orbit = Keplerian::new(7.0e6, 0.0, 0.9, 0.0, 0.0, 0.0, Frame::EARTH_J2000);
|
|
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3)
|
|
.with_solar_array(SolarArray::mounted(6.0, 0.3, Vector3::z()))
|
|
.with_surface(SurfaceModel::cannonball(4.0, 2.2, 1.3));
|
|
let mut propagation =
|
|
Propagation::new(epoch(), Orbit.with_keplerian(orbit), &[Dynamics::Sunshine])
|
|
.spacecraft(spacecraft)
|
|
.duration(Duration::from_seconds(60.0));
|
|
propagation.run();
|
|
}
|
|
}
|