Files
astrokit/src/dynamics/models/sunshine.rs
T
2026-10-01 18:27:06 -04:00

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();
}
}