Finished Sunshine model

This commit is contained in:
connor committed 2026-10-01 18:27:06 -04:00
1 parent 0bb728ee5c
commit 36e96b0b65
7 files changed
+463 -59

No files matched your search

+47 -12
View File
@@ -10,7 +10,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use astrokit::dynamics::models::sunshine;
use astrokit::eclipse;
use astrokit::prelude::*;
use astrokit::{attitude, kernels};
use nalgebra::Vector3;
@@ -50,7 +50,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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));
.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
@@ -82,8 +83,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let solution = propagation.run();
let final_state = Full.state(*solution.states.last().unwrap());
let switches = jump_times(&solution.times).len();
let steps = solution.times.len() - 1 - switches;
// 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]| {
@@ -145,7 +148,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Duration::from_seconds(orbit.period())
);
println!();
println!("steps {steps}, shadow-set switches {switches}");
println!("steps {steps}, state jumps {jumps}");
println!(
"elements a = {:.3} km, e = {:.6}, i = {:.3} deg ({:?})",
elements.semi_major_axis / 1e3,
@@ -177,17 +180,49 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
presented(&final_state)
);
let array = spacecraft.solar_array.expect("an array was fitted");
let sun_distance = almanac
.translate_geometric(Frame::inertial(Body::SUN).anise(), frame.anise(), epoch)?
// 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
.norm()
* 1e3;
let full_rate = sunshine::irradiance(sun_distance, 1.0) * array.area * array.efficiency;
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!(
"energy {:.2} kWh generated, so {:.1}% of the arc in sunlight",
"sunlight {:.1}% of the arc",
100.0 * lit_seconds / total_seconds
);
println!(
"battery {:.2} of {:.2} kWh{}",
final_state.energy() / 3.6e6,
100.0 * final_state.energy() / (full_rate * arc.to_seconds())
battery.capacity / 3.6e6,
if final_state.energy() >= battery.capacity {
", full"
} else {
""
}
);
println!(
"against a point mass {:.3} km",
+5
View File
@@ -231,6 +231,11 @@ impl Context {
.map(|(spacecraft, inverse)| (&spacecraft.inertia, inverse))
}
/// The vehicle's battery, if it has one.
pub fn battery(&self) -> Option<crate::spacecraft::Battery> {
self.spacecraft.as_ref().and_then(|s| s.battery)
}
/// The mass to divide a force by, in kilograms.
///
/// The state's own mass when the layout carries one, since that is what is
+14 -25
View File
@@ -135,23 +135,12 @@ impl<T: Scalar, const D: usize, L: HasPosition<D>> DynamicsModel<T, D, L> for Su
// 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)) => {
let facing = if array.articulated {
T::lift(1.0)
} else {
let normal = array.normal.normalize().map(T::lift);
let projection = normal.dot(&incident);
// A panel edge on or turned away generates nothing rather than
// negative power.
if projection.re() > 0.0 {
projection
} else {
T::lift(0.0)
}
};
irradiance(distance, visible) * (array.area * array.efficiency) * facing
}
(Some(_), Some(array)) => array.power(irradiance(distance, visible), incident),
_ => T::lift(0.0),
};
@@ -366,7 +355,7 @@ mod tests {
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::articulated(6.0, 0.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());
@@ -397,10 +386,10 @@ mod tests {
assert_eq!(without, 0.0);
}
/// A fixed array is projected onto the Sun line, so it generates most facing the Sun,
/// nothing edge on, and nothing at all turned away.
/// 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_fixed_array_is_projected_and_never_negative() {
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;
@@ -408,7 +397,7 @@ mod tests {
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::fixed(6.0, 0.3, Vector3::z()));
.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());
@@ -445,7 +434,7 @@ mod tests {
}
}
// Pointed at the Sun, a fixed array matches one that tracks it.
// 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);
@@ -529,11 +518,11 @@ mod tests {
}
#[test]
#[should_panic(expected = "fixed solar array needs to know which way")]
fn test_a_fixed_array_without_an_attitude_is_refused() {
#[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::fixed(6.0, 0.3, Vector3::z()))
.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])
+133 -1
View File
@@ -101,6 +101,68 @@ where
}
}
/// Lands the integrator on the moment the battery fills.
///
/// Assembly is what enforces the cap, by zeroing the power once the store is at capacity,
/// and that turns a corner in the derivative. This finds the corner and pins the stored
/// energy exactly at capacity.
///
/// # It fires less often than it looks like it should
///
/// The cap is self-limiting. A step's later stages are evaluated at states that have
/// already reached capacity, so their power is zero and the step cannot carry the store
/// past it — the energy converges onto capacity from below and stays. The event function
/// then never changes sign and never fires.
///
/// Measured both ways: over a day of a low orbit, where steps are short against the time
/// the battery takes to fill, it does not fire at all and the energy still lands on
/// capacity exactly. Over a short arc with steps long enough to span from well below
/// capacity to above it, enough stages have their power on that the step overshoots, and
/// it fires. So what this bounds is the overshoot when a step is long relative to the
/// headroom left, rather than the cap itself.
///
/// A [`Propagation`](crate::propagation::Propagation) attaches one itself whenever the
/// layout carries stored energy and the vehicle has a battery.
#[derive(Clone, Copy, Debug)]
pub struct BatteryFull<L> {
layout: PhantomData<L>,
}
impl<L> BatteryFull<L> {
/// A limiter. It records nothing; [`jump_times`] is how to see what it did.
pub fn new() -> Self {
Self {
layout: PhantomData,
}
}
}
impl<L> Default for BatteryFull<L> {
fn default() -> Self {
Self::new()
}
}
impl<T, const D: usize, L> Callback<T, D, Assembly<T, D, L>> for BatteryFull<L>
where
T: Scalar,
L: Layout<D>,
{
fn event(&self, _t: T, y: &SVector<T, D>, assembly: &Assembly<T, D, L>) -> T {
match (L::try_energy(y), assembly.context().battery()) {
(Some(stored), Some(battery)) => T::lift(battery.capacity) - stored,
// No store, or nowhere to store it, so nothing to fill.
_ => T::lift(1.0),
}
}
fn effect(&mut self, ode: &mut ODE<'_, T, D, Assembly<T, D, L>>) {
if let Some(battery) = ode.params.context().battery() {
L::try_set_energy(&mut ode.y, T::lift(battery.capacity));
}
}
}
/// The times at which a solution's state jumped.
///
/// A jump is recorded as the state on each side of it, both at the crossing's own
@@ -123,12 +185,14 @@ mod tests {
use crate::dynamics::Dynamics;
use crate::frames::Frame;
use crate::kepler::{Keplerian, TranslationalLayout};
use crate::kernels;
use crate::propagation::{Accuracy, Propagation};
use crate::spacecraft::Spacecraft;
use crate::spacecraft::{Battery, SolarArray, Spacecraft};
use crate::state::{Full, Layout};
use approx::assert_relative_eq;
use hifitime::{Duration, Epoch};
use nalgebra::Vector3;
use std::sync::Arc;
fn epoch() -> Epoch {
Epoch::from_gregorian_utc_hms(2026, 1, 1, 0, 0, 0)
@@ -138,6 +202,74 @@ mod tests {
Keplerian::new(6.8e6, 0.0, 0.9, 0.0, 0.0, 0.0, Frame::EARTH_J2000)
}
/// Stored energy stops at the battery's capacity, and stops exactly at it.
#[test]
fn test_the_battery_caps_the_stored_energy() {
let Some(directory) = kernels::test_directory() else {
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
return;
};
let almanac = Arc::new(kernels::load(&directory).expect("kernels are present"));
// A couple of kilowatts fills this in about ten minutes, well inside the arc.
let capacity = 1.0e6;
let array = SolarArray::fully_articulated(6.0, 0.3);
let forces = [Dynamics::TwoBody { body: Body::EARTH }, Dynamics::Sunshine];
let run = |battery: Option<Battery>| {
let mut spacecraft =
Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3).with_solar_array(array);
if let Some(battery) = battery {
spacecraft = spacecraft.with_battery(battery);
}
// No angular rate, so the attitude never moves and the only events are the
// battery's.
let initial = Full.with_keplerian(orbit()).with_mass(500.0);
let mut propagation = Propagation::new(epoch(), initial, &forces)
.spacecraft(spacecraft)
.almanac(almanac.clone())
.duration(Duration::from_seconds(3600.0))
.accuracy(Accuracy::Medium);
let solution = propagation.run();
let stored: Vec<f64> = solution
.states
.iter()
.map(|state| Full.state(*state).energy())
.collect();
(jump_times(&solution.times).len(), stored)
};
// One firing for this arc, where the steps are long against the ten minutes the
// battery takes to fill so a step overshoots and the crossing is there to find. A
// longer arc with shorter steps reaches capacity without the event firing at all,
// because the cap is self-limiting — see `BatteryFull`.
let (jumps, stored) = run(Some(Battery::new(capacity)));
assert_eq!(jumps, 1, "the battery should fill once");
assert_eq!(
*stored.last().unwrap(),
capacity,
"should finish pinned at capacity"
);
// The state recorded on the near side of the jump is the located root, so it sits
// within the finder's tolerance of capacity rather than exactly on it. What the pin
// guarantees is that nothing after it goes on charging.
for value in &stored {
assert!(
*value <= capacity * (1.0 + 1e-12),
"{value} is meaningfully over capacity"
);
}
// Without one it keeps charging, so the cap is doing something.
let (jumps, stored) = run(None);
assert_eq!(jumps, 0);
assert!(
*stored.last().unwrap() > capacity * 3.0,
"only reached {}",
stored.last().unwrap()
);
}
/// A sphere has equal moments, so a torque-free rate is constant and the rotation
/// is uniform about a fixed axis. The switches then fall at a half turn and every
/// full turn after it, which is a count that can be predicted rather than
+2 -2
View File
@@ -35,13 +35,13 @@ pub mod prelude {
pub use crate::dynamics::{Context, Contribution, Dynamics, DynamicsModel};
pub use crate::equinoctial::{Equinoctial, FromEquinoctial};
pub use crate::error::{ConversionError, ConversionResult};
pub use crate::events::{jump_times, ShadowSet};
pub use crate::events::{jump_times, BatteryFull, ShadowSet};
pub use crate::frames::{Frame, FrameError, Orientation};
pub use crate::geodetic::Geodetic;
pub use crate::gravity::{Field, GravityModel};
pub use crate::kepler::{Degeneracy, Keplerian, TranslationalLayout};
pub use crate::propagation::{Accuracy, Propagation};
pub use crate::spacecraft::{Face, SolarArray, Spacecraft, SurfaceModel};
pub use crate::spacecraft::{Battery, Face, Mounting, SolarArray, Spacecraft, SurfaceModel};
pub use crate::state::{
Full, HasAngularRate, HasAttitude, HasEnergy, HasMass, HasPosition, HasVelocity, Layout,
Orbit, OrbitMass, State,
+26 -3
View File
@@ -111,6 +111,11 @@ impl<T: Scalar, const D: usize, L: Layout<D>> Assembly<T, D, L> {
pub fn names(&self) -> Vec<&'static str> {
self.models.iter().map(|m| m.name()).collect()
}
/// What the models are being evaluated against, for a callback that needs the same.
pub fn context(&self) -> &Context {
&self.context
}
}
/// The state derivative: kinematics, plus every model's contribution routed into
@@ -164,7 +169,16 @@ where
L::set_position(&mut dy, state.velocity());
L::set_velocity(&mut dy, acceleration);
let mass_routed = L::try_set_mass(&mut dy, total.mass_rate);
let power_routed = L::try_set_energy(&mut dy, total.power);
// Generation stops at a full battery. Capping here rather than in the model that
// produced the power means a second source would be capped too, and that the cap is
// stated once. The corner this puts in the derivative is what
// [`BatteryFull`](crate::events::BatteryFull) is for.
let power = match (L::try_energy(&y), assembly.context.battery()) {
(Some(stored), Some(battery)) if stored.re() >= battery.capacity => T::lift(0.0),
_ => total.power,
};
let power_routed = L::try_set_energy(&mut dy, power);
// Rotational motion, when the layout carries both halves of it. Both indices
// are compile-time constants, so a translational layout drops this entirely.
@@ -447,11 +461,12 @@ where
}
if spacecraft
.solar_array
.is_some_and(|array| !array.articulated)
.is_some_and(|array| array.mounting.needs_attitude())
{
panic!(
"{spec:?} with a fixed solar array needs to know which way the \
"{spec:?} with a {:?} solar array needs to know which way the \
spacecraft is pointing, and layout {} carries no attitude",
spacecraft.solar_array.expect("just checked").mounting,
L::NAME
);
}
@@ -504,6 +519,14 @@ where
if self.shadow_set_switching && L::ATTITUDE_AT.is_some() {
problem = problem.with_owned_callback(Box::new(events::ShadowSet::<L>::new()));
}
if L::ENERGY_AT.is_some()
&& self
.spacecraft
.as_ref()
.is_some_and(|spacecraft| spacecraft.battery.is_some())
{
problem = problem.with_owned_callback(Box::new(events::BatteryFull::<L>::new()));
}
for callback in core::mem::take(&mut self.callbacks) {
problem = problem.with_callback(callback);
}
+236 -16
View File
@@ -158,6 +158,65 @@ impl SurfaceModel {
}
}
/// How a solar array is attached, which is what sets how well it can face the Sun.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum Mounting {
/// Fixed to the body, facing `normal`.
Mounted {
/// Outward normal of the cells in the body frame. Normalized on use.
normal: Vector3<f64>,
},
/// Driven about one axis, which is what most spacecraft have.
///
/// A single drive turns the wing about `axis`, so the normal can go anywhere in the
/// plane perpendicular to it and nowhere out of that plane. The best it manages is to
/// point at whatever part of the Sun direction lies in that plane, leaving the *sine*
/// of the angle between the axis and the Sun. An array whose axis points at the Sun
/// generates nothing however it is driven, which is the beta-angle loss that sizes
/// real arrays.
Articulated {
/// The drive axis in the body frame. Normalized on use.
axis: Vector3<f64>,
},
/// Free to face the Sun exactly whenever it can see it.
///
/// Two drives, or the idealization of them. Also the upper bound, for when the
/// mounting is not known.
FullyArticulated,
}
impl Mounting {
/// The cosine of the angle between the cells and the Sun, from the Sun direction in
/// body components. Never negative: cells turned away generate nothing.
pub fn facing<T: Scalar>(&self, toward_sun: Vector3<T>) -> T {
match self {
Self::Mounted { normal } => positive(normal.normalize().map(T::lift).dot(&toward_sun)),
Self::Articulated { axis } => {
// What a single drive cannot remove is the part of the Sun direction along
// its axis, so what is left is the sine of the angle to it.
let along = axis.normalize().map(T::lift).dot(&toward_sun);
positive(T::lift(1.0) - along.powi(2)).sqrt()
}
Self::FullyArticulated => T::lift(1.0),
}
}
/// Whether this mounting has to be told which way the spacecraft is pointing.
pub fn needs_attitude(&self) -> bool {
!matches!(self, Self::FullyArticulated)
}
}
/// `x` where it is positive, zero where it is not.
fn positive<T: Scalar>(x: T) -> T {
if x.re() > 0.0 {
x
} else {
T::lift(0.0)
}
}
/// Photovoltaics: what converts sunlight into power.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SolarArray {
@@ -165,36 +224,67 @@ pub struct SolarArray {
pub area: f64,
/// Fraction of incident power converted, dimensionless.
pub efficiency: f64,
/// The cells' outward normal in the body frame. Normalized on use.
pub normal: Vector3<f64>,
/// Whether the array tracks the Sun.
///
/// An articulated array is taken to face the Sun exactly whenever it can see it,
/// which is the useful idealization and avoids modeling a gimbal. A fixed one is
/// projected onto the Sun direction, and so needs an attitude to project against.
pub articulated: bool,
/// How it is attached.
pub mounting: Mounting,
}
/// Somewhere for generated energy to go, and a limit on how much.
///
/// Generation stops once the store is full. That is a corner in the derivative rather than
/// a jump in the state, so a propagation puts an event on it — see
/// [`BatteryFull`](crate::events::BatteryFull).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Battery {
/// What it holds when full, J.
pub capacity: f64,
}
impl Battery {
/// A store of `capacity` joules.
pub fn new(capacity: f64) -> Self {
Self { capacity }
}
/// A store of `capacity` watt-hours, which is how a datasheet gives one.
pub fn watt_hours(capacity: f64) -> Self {
Self {
capacity: capacity * 3600.0,
}
}
}
impl SolarArray {
/// A fixed array of `area` and `efficiency`, facing `normal` in the body frame.
pub fn fixed(area: f64, efficiency: f64, normal: Vector3<f64>) -> Self {
/// An array fixed to the body, facing `normal`.
pub fn mounted(area: f64, efficiency: f64, normal: Vector3<f64>) -> Self {
Self {
area,
efficiency,
normal,
articulated: false,
mounting: Mounting::Mounted { normal },
}
}
/// An array of `area` and `efficiency` that tracks the Sun.
pub fn articulated(area: f64, efficiency: f64) -> Self {
/// An array driven about `axis`.
pub fn articulated(area: f64, efficiency: f64, axis: Vector3<f64>) -> Self {
Self {
area,
efficiency,
normal: Vector3::z(),
articulated: true,
mounting: Mounting::Articulated { axis },
}
}
/// An array that can face the Sun exactly.
pub fn fully_articulated(area: f64, efficiency: f64) -> Self {
Self {
area,
efficiency,
mounting: Mounting::FullyArticulated,
}
}
/// The power generated, W, from an irradiance and a Sun direction in body components.
pub fn power<T: Scalar>(&self, irradiance: T, toward_sun: Vector3<T>) -> T {
irradiance * (self.area * self.efficiency) * self.mounting.facing(toward_sun)
}
}
/// A spacecraft's physical properties.
@@ -217,6 +307,8 @@ pub struct Spacecraft {
pub inertia: Matrix3<f64>,
/// What generates power, if anything does.
pub solar_array: Option<SolarArray>,
/// Where generated energy goes, if it is limited.
pub battery: Option<Battery>,
}
impl Spacecraft {
@@ -233,6 +325,7 @@ impl Spacecraft {
surface: SurfaceModel::cannonball(area, drag_coefficient, srp_coefficient),
inertia: Matrix3::from_diagonal(&Vector3::new(moment, moment, moment)),
solar_array: None,
battery: None,
}
}
@@ -258,6 +351,7 @@ impl Spacecraft {
mass * (x * x + y * y) / 12.0,
)),
solar_array: None,
battery: None,
}
}
@@ -285,6 +379,12 @@ impl Spacecraft {
self
}
/// The same spacecraft with a battery.
pub fn with_battery(mut self, battery: Battery) -> Self {
self.battery = Some(battery);
self
}
/// Propellant remaining, kg.
pub fn propellant(&self) -> f64 {
(self.mass - self.dry_mass).max(0.0)
@@ -370,6 +470,126 @@ mod tests {
assert!(from_corner > along_axis);
}
/// A body-mounted array is the projection of the Sun direction onto its normal, and
/// nothing when turned away.
#[test]
fn test_a_mounted_array_is_a_projection() {
let mounting = Mounting::Mounted {
// Deliberately not a unit vector: it is normalized on use.
normal: Vector3::new(0.0, 0.0, 4.0),
};
assert_relative_eq!(
mounting.facing(Vector3::<f64>::z()),
1.0,
max_relative = 1e-15
);
assert_relative_eq!(mounting.facing(Vector3::<f64>::x()), 0.0, epsilon = 1e-15);
assert_eq!(mounting.facing(-Vector3::<f64>::z()), 0.0);
for degrees in [10.0_f64, 30.0, 45.0, 60.0, 80.0] {
let angle = degrees.to_radians();
let toward_sun = Vector3::new(angle.sin(), 0.0, angle.cos());
assert_relative_eq!(
mounting.facing(toward_sun),
angle.cos(),
max_relative = 1e-12
);
}
}
/// A single drive cannot turn the cells out of the plane it sweeps, so what survives
/// is the *sine* of the angle between the axis and the Sun. An array whose axis points
/// at the Sun generates nothing however it is driven.
#[test]
fn test_a_driven_array_keeps_the_sine_of_its_axis_angle() {
let mounting = Mounting::Articulated {
axis: Vector3::new(0.0, 0.0, 2.0),
};
// Along the axis, nothing, from either end.
assert_relative_eq!(mounting.facing(Vector3::<f64>::z()), 0.0, epsilon = 1e-8);
assert_relative_eq!(mounting.facing(-Vector3::<f64>::z()), 0.0, epsilon = 1e-8);
// Across it, everything, whichever way across.
for across in [
Vector3::<f64>::x(),
-Vector3::<f64>::x(),
Vector3::<f64>::y(),
] {
assert_relative_eq!(mounting.facing(across), 1.0, max_relative = 1e-12);
}
for degrees in [10.0_f64, 30.0, 45.0, 60.0, 80.0] {
let angle = degrees.to_radians();
let toward_sun = Vector3::new(angle.sin(), 0.0, angle.cos());
assert_relative_eq!(
mounting.facing(toward_sun),
angle.sin(),
max_relative = 1e-12
);
}
}
#[test]
fn test_full_articulation_always_faces_the_sun() {
for direction in [
Vector3::<f64>::x(),
-Vector3::<f64>::z(),
Vector3::new(1.0, -2.0, 0.5).normalize(),
] {
assert_eq!(Mounting::FullyArticulated.facing(direction), 1.0);
}
}
/// The three order themselves by how free they are. Locking a drive cannot help, and
/// adding a second one cannot hurt — compared on the same wing, so the mounted normal
/// is one the drive could have reached.
#[test]
fn test_more_freedom_never_generates_less() {
let axis = Vector3::<f64>::z();
let driven = Mounting::Articulated { axis };
for degrees in [0.0_f64, 15.0, 37.0, 64.0, 90.0, 140.0] {
let angle = degrees.to_radians();
let toward_sun = Vector3::new(angle.sin(), 0.0, angle.cos());
// A normal the drive sweeps past, with the drive locked at some angle.
for locked in [0.0_f64, 0.7, 2.1] {
let mounted = Mounting::Mounted {
normal: Vector3::new(locked.cos(), locked.sin(), 0.0),
};
assert!(
driven.facing(toward_sun) >= mounted.facing(toward_sun) - 1e-12,
"locked at {locked} beat the drive at {degrees} deg"
);
}
assert!(
driven.facing(toward_sun) <= Mounting::FullyArticulated.facing(toward_sun) + 1e-12
);
}
}
#[test]
fn test_a_battery_in_watt_hours_is_joules() {
assert_eq!(Battery::watt_hours(1.0).capacity, 3600.0);
assert_eq!(Battery::new(1234.0).capacity, 1234.0);
}
#[test]
fn test_only_full_articulation_needs_no_attitude() {
assert!(!Mounting::FullyArticulated.needs_attitude());
assert!(Mounting::Articulated {
axis: Vector3::<f64>::z()
}
.needs_attitude());
assert!(Mounting::Mounted {
normal: Vector3::<f64>::z()
}
.needs_attitude());
}
#[test]
fn test_cannonball_spacecraft_has_a_spherical_inertia() {
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3);