diff --git a/examples/full.rs b/examples/full.rs index cde3ca5..9b8b495 100644 --- a/examples/full.rs +++ b/examples/full.rs @@ -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> { 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> { 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> { 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> { 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)? - .radius_km - .norm() - * 1e3; - let full_rate = sunshine::irradiance(sun_distance, 1.0) * array.area * array.efficiency; + // 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!( - "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", diff --git a/src/dynamics/mod.rs b/src/dynamics/mod.rs index afc9189..cd3b72f 100644 --- a/src/dynamics/mod.rs +++ b/src/dynamics/mod.rs @@ -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 { + 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 diff --git a/src/dynamics/models/sunshine.rs b/src/dynamics/models/sunshine.rs index 6e1598f..15f8072 100644 --- a/src/dynamics/models/sunshine.rs +++ b/src/dynamics/models/sunshine.rs @@ -135,23 +135,12 @@ impl> DynamicsModel 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]) diff --git a/src/events.rs b/src/events.rs index b7d9e09..3c159fd 100644 --- a/src/events.rs +++ b/src/events.rs @@ -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 { + layout: PhantomData, +} + +impl BatteryFull { + /// A limiter. It records nothing; [`jump_times`] is how to see what it did. + pub fn new() -> Self { + Self { + layout: PhantomData, + } + } +} + +impl Default for BatteryFull { + fn default() -> Self { + Self::new() + } +} + +impl Callback> for BatteryFull +where + T: Scalar, + L: Layout, +{ + fn event(&self, _t: T, y: &SVector, assembly: &Assembly) -> 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>) { + 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| { + 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 = 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 diff --git a/src/lib.rs b/src/lib.rs index 55f8492..64784be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, diff --git a/src/propagation.rs b/src/propagation.rs index 93fae94..05512da 100644 --- a/src/propagation.rs +++ b/src/propagation.rs @@ -111,6 +111,11 @@ impl> Assembly { 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::::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::::new())); + } for callback in core::mem::take(&mut self.callbacks) { problem = problem.with_callback(callback); } diff --git a/src/spacecraft.rs b/src/spacecraft.rs index cb4ac58..7e34540 100644 --- a/src/spacecraft.rs +++ b/src/spacecraft.rs @@ -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, + }, + /// 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, + }, + /// 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(&self, toward_sun: Vector3) -> 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(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, - /// 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) -> Self { + /// An array fixed to the body, facing `normal`. + pub fn mounted(area: f64, efficiency: f64, normal: Vector3) -> 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) -> 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(&self, irradiance: T, toward_sun: Vector3) -> 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, /// What generates power, if anything does. pub solar_array: Option, + /// Where generated energy goes, if it is limited. + pub battery: Option, } 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::::z()), + 1.0, + max_relative = 1e-15 + ); + assert_relative_eq!(mounting.facing(Vector3::::x()), 0.0, epsilon = 1e-15); + assert_eq!(mounting.facing(-Vector3::::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::::z()), 0.0, epsilon = 1e-8); + assert_relative_eq!(mounting.facing(-Vector3::::z()), 0.0, epsilon = 1e-8); + + // Across it, everything, whichever way across. + for across in [ + Vector3::::x(), + -Vector3::::x(), + Vector3::::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::::x(), + -Vector3::::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::::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::::z() + } + .needs_attitude()); + assert!(Mounting::Mounted { + normal: Vector3::::z() + } + .needs_attitude()); + } + #[test] fn test_cannonball_spacecraft_has_a_spherical_inertia() { let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3);