Finished Sunshine model
This commit is contained in:
1 parent
0bb728ee5c
commit
36e96b0b65
7 files changed
+463
-59
No files matched your search
+47
-12
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user