Finished Sunshine model
This commit is contained in:
1 parent
0bb728ee5c
commit
36e96b0b65
7 files changed
+465
-61
No files matched your search
+49
-14
@@ -10,7 +10,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use astrokit::dynamics::models::sunshine;
|
use astrokit::eclipse;
|
||||||
use astrokit::prelude::*;
|
use astrokit::prelude::*;
|
||||||
use astrokit::{attitude, kernels};
|
use astrokit::{attitude, kernels};
|
||||||
use nalgebra::Vector3;
|
use nalgebra::Vector3;
|
||||||
@@ -50,7 +50,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
frame,
|
frame,
|
||||||
);
|
);
|
||||||
let spacecraft = Spacecraft::rectangular_box(500.0, Vector3::new(2.0, 1.0, 3.0), 2.2, 1.3)
|
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 tumble = Vector3::new(0.0012, 0.0028, -0.0007);
|
||||||
|
|
||||||
let initial = Full
|
let initial = Full
|
||||||
@@ -82,8 +83,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let solution = propagation.run();
|
let solution = propagation.run();
|
||||||
|
|
||||||
let final_state = Full.state(*solution.states.last().unwrap());
|
let final_state = Full.state(*solution.states.last().unwrap());
|
||||||
let switches = jump_times(&solution.times).len();
|
// Every event that changed the state leaves a repeated time, so this counts the
|
||||||
let steps = solution.times.len() - 1 - switches;
|
// 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.
|
// The same arc under less physics, so each piece can be given a size.
|
||||||
let ending_with = |specs: &[Dynamics]| {
|
let ending_with = |specs: &[Dynamics]| {
|
||||||
@@ -145,7 +148,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Duration::from_seconds(orbit.period())
|
Duration::from_seconds(orbit.period())
|
||||||
);
|
);
|
||||||
println!();
|
println!();
|
||||||
println!("steps {steps}, shadow-set switches {switches}");
|
println!("steps {steps}, state jumps {jumps}");
|
||||||
println!(
|
println!(
|
||||||
"elements a = {:.3} km, e = {:.6}, i = {:.3} deg ({:?})",
|
"elements a = {:.3} km, e = {:.6}, i = {:.3} deg ({:?})",
|
||||||
elements.semi_major_axis / 1e3,
|
elements.semi_major_axis / 1e3,
|
||||||
@@ -177,17 +180,49 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
presented(&final_state)
|
presented(&final_state)
|
||||||
);
|
);
|
||||||
|
|
||||||
let array = spacecraft.solar_array.expect("an array was fitted");
|
// How much of the arc was spent in sunlight, measured along the trajectory rather than
|
||||||
let sun_distance = almanac
|
// inferred from the energy: the battery fills early and caps generation, so the energy
|
||||||
.translate_geometric(Frame::inertial(Body::SUN).anise(), frame.anise(), epoch)?
|
// stops being a record of the daylight.
|
||||||
.radius_km
|
let mut lit_seconds = 0.0;
|
||||||
.norm()
|
let mut total_seconds = 0.0;
|
||||||
* 1e3;
|
for (pair, state) in solution.times.windows(2).zip(&solution.states) {
|
||||||
let full_rate = sunshine::irradiance(sun_distance, 1.0) * array.area * array.efficiency;
|
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!(
|
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,
|
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!(
|
println!(
|
||||||
"against a point mass {:.3} km",
|
"against a point mass {:.3} km",
|
||||||
|
|||||||
@@ -231,6 +231,11 @@ impl Context {
|
|||||||
.map(|(spacecraft, inverse)| (&spacecraft.inertia, inverse))
|
.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 mass to divide a force by, in kilograms.
|
||||||
///
|
///
|
||||||
/// The state's own mass when the layout carries one, since that is what is
|
/// 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.
|
// Pressure acts along the direction the light travels, which is away from the Sun.
|
||||||
let force = -toward_sun * (pressure(distance, visible) * area);
|
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) {
|
let power = match (L::ENERGY_AT, spacecraft.solar_array) {
|
||||||
(Some(_), Some(array)) => {
|
(Some(_), Some(array)) => array.power(irradiance(distance, visible), incident),
|
||||||
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
|
|
||||||
}
|
|
||||||
_ => T::lift(0.0),
|
_ => T::lift(0.0),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,7 +355,7 @@ mod tests {
|
|||||||
let sun = sun_position(&almanac);
|
let sun = sun_position(&almanac);
|
||||||
let position = sun.normalize() * 7.0e6;
|
let position = sun.normalize() * 7.0e6;
|
||||||
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3)
|
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)
|
let context = Context::new(epoch(), Frame::EARTH_J2000)
|
||||||
.with_spacecraft(spacecraft)
|
.with_spacecraft(spacecraft)
|
||||||
.with_almanac(almanac.clone());
|
.with_almanac(almanac.clone());
|
||||||
@@ -397,10 +386,10 @@ mod tests {
|
|||||||
assert_eq!(without, 0.0);
|
assert_eq!(without, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fixed array is projected onto the Sun line, so it generates most facing the Sun,
|
/// A body-mounted array is projected onto the Sun line, so it generates most facing
|
||||||
/// nothing edge on, and nothing at all turned away.
|
/// the Sun, nothing edge on, and nothing at all turned away.
|
||||||
#[test]
|
#[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 {
|
let Some(almanac) = almanac() else {
|
||||||
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
eprintln!("skipped: kernels not fetched, run scripts/fetch-kernels.sh");
|
||||||
return;
|
return;
|
||||||
@@ -408,7 +397,7 @@ mod tests {
|
|||||||
let sun = sun_position(&almanac);
|
let sun = sun_position(&almanac);
|
||||||
let position = sun.normalize() * 7.0e6;
|
let position = sun.normalize() * 7.0e6;
|
||||||
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3)
|
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)
|
let context = Context::new(epoch(), Frame::EARTH_J2000)
|
||||||
.with_spacecraft(spacecraft)
|
.with_spacecraft(spacecraft)
|
||||||
.with_almanac(almanac.clone());
|
.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;
|
let articulated = irradiance((sun - position).norm(), 1.0) * 6.0 * 0.3;
|
||||||
assert_relative_eq!(best, articulated, max_relative = 1e-3);
|
assert_relative_eq!(best, articulated, max_relative = 1e-3);
|
||||||
|
|
||||||
@@ -529,11 +518,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "fixed solar array needs to know which way")]
|
#[should_panic(expected = "solar array needs to know which way")]
|
||||||
fn test_a_fixed_array_without_an_attitude_is_refused() {
|
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 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)
|
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));
|
.with_surface(SurfaceModel::cannonball(4.0, 2.2, 1.3));
|
||||||
let mut propagation =
|
let mut propagation =
|
||||||
Propagation::new(epoch(), Orbit.with_keplerian(orbit), &[Dynamics::Sunshine])
|
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.
|
/// 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
|
/// 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::dynamics::Dynamics;
|
||||||
use crate::frames::Frame;
|
use crate::frames::Frame;
|
||||||
use crate::kepler::{Keplerian, TranslationalLayout};
|
use crate::kepler::{Keplerian, TranslationalLayout};
|
||||||
|
use crate::kernels;
|
||||||
use crate::propagation::{Accuracy, Propagation};
|
use crate::propagation::{Accuracy, Propagation};
|
||||||
use crate::spacecraft::Spacecraft;
|
use crate::spacecraft::{Battery, SolarArray, Spacecraft};
|
||||||
use crate::state::{Full, Layout};
|
use crate::state::{Full, Layout};
|
||||||
use approx::assert_relative_eq;
|
use approx::assert_relative_eq;
|
||||||
use hifitime::{Duration, Epoch};
|
use hifitime::{Duration, Epoch};
|
||||||
use nalgebra::Vector3;
|
use nalgebra::Vector3;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn epoch() -> Epoch {
|
fn epoch() -> Epoch {
|
||||||
Epoch::from_gregorian_utc_hms(2026, 1, 1, 0, 0, 0)
|
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)
|
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
|
/// 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
|
/// 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
|
/// 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::dynamics::{Context, Contribution, Dynamics, DynamicsModel};
|
||||||
pub use crate::equinoctial::{Equinoctial, FromEquinoctial};
|
pub use crate::equinoctial::{Equinoctial, FromEquinoctial};
|
||||||
pub use crate::error::{ConversionError, ConversionResult};
|
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::frames::{Frame, FrameError, Orientation};
|
||||||
pub use crate::geodetic::Geodetic;
|
pub use crate::geodetic::Geodetic;
|
||||||
pub use crate::gravity::{Field, GravityModel};
|
pub use crate::gravity::{Field, GravityModel};
|
||||||
pub use crate::kepler::{Degeneracy, Keplerian, TranslationalLayout};
|
pub use crate::kepler::{Degeneracy, Keplerian, TranslationalLayout};
|
||||||
pub use crate::propagation::{Accuracy, Propagation};
|
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::{
|
pub use crate::state::{
|
||||||
Full, HasAngularRate, HasAttitude, HasEnergy, HasMass, HasPosition, HasVelocity, Layout,
|
Full, HasAngularRate, HasAttitude, HasEnergy, HasMass, HasPosition, HasVelocity, Layout,
|
||||||
Orbit, OrbitMass, State,
|
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> {
|
pub fn names(&self) -> Vec<&'static str> {
|
||||||
self.models.iter().map(|m| m.name()).collect()
|
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
|
/// 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_position(&mut dy, state.velocity());
|
||||||
L::set_velocity(&mut dy, acceleration);
|
L::set_velocity(&mut dy, acceleration);
|
||||||
let mass_routed = L::try_set_mass(&mut dy, total.mass_rate);
|
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
|
// Rotational motion, when the layout carries both halves of it. Both indices
|
||||||
// are compile-time constants, so a translational layout drops this entirely.
|
// are compile-time constants, so a translational layout drops this entirely.
|
||||||
@@ -447,11 +461,12 @@ where
|
|||||||
}
|
}
|
||||||
if spacecraft
|
if spacecraft
|
||||||
.solar_array
|
.solar_array
|
||||||
.is_some_and(|array| !array.articulated)
|
.is_some_and(|array| array.mounting.needs_attitude())
|
||||||
{
|
{
|
||||||
panic!(
|
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 is pointing, and layout {} carries no attitude",
|
||||||
|
spacecraft.solar_array.expect("just checked").mounting,
|
||||||
L::NAME
|
L::NAME
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -504,6 +519,14 @@ where
|
|||||||
if self.shadow_set_switching && L::ATTITUDE_AT.is_some() {
|
if self.shadow_set_switching && L::ATTITUDE_AT.is_some() {
|
||||||
problem = problem.with_owned_callback(Box::new(events::ShadowSet::<L>::new()));
|
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) {
|
for callback in core::mem::take(&mut self.callbacks) {
|
||||||
problem = problem.with_callback(callback);
|
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.
|
/// Photovoltaics: what converts sunlight into power.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct SolarArray {
|
pub struct SolarArray {
|
||||||
@@ -165,36 +224,67 @@ pub struct SolarArray {
|
|||||||
pub area: f64,
|
pub area: f64,
|
||||||
/// Fraction of incident power converted, dimensionless.
|
/// Fraction of incident power converted, dimensionless.
|
||||||
pub efficiency: f64,
|
pub efficiency: f64,
|
||||||
/// The cells' outward normal in the body frame. Normalized on use.
|
/// How it is attached.
|
||||||
pub normal: Vector3<f64>,
|
pub mounting: Mounting,
|
||||||
/// Whether the array tracks the Sun.
|
}
|
||||||
///
|
|
||||||
/// An articulated array is taken to face the Sun exactly whenever it can see it,
|
/// Somewhere for generated energy to go, and a limit on how much.
|
||||||
/// 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.
|
/// Generation stops once the store is full. That is a corner in the derivative rather than
|
||||||
pub articulated: bool,
|
/// 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 {
|
impl SolarArray {
|
||||||
/// A fixed array of `area` and `efficiency`, facing `normal` in the body frame.
|
/// An array fixed to the body, facing `normal`.
|
||||||
pub fn fixed(area: f64, efficiency: f64, normal: Vector3<f64>) -> Self {
|
pub fn mounted(area: f64, efficiency: f64, normal: Vector3<f64>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
area,
|
area,
|
||||||
efficiency,
|
efficiency,
|
||||||
normal,
|
mounting: Mounting::Mounted { normal },
|
||||||
articulated: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An array of `area` and `efficiency` that tracks the Sun.
|
/// An array driven about `axis`.
|
||||||
pub fn articulated(area: f64, efficiency: f64) -> Self {
|
pub fn articulated(area: f64, efficiency: f64, axis: Vector3<f64>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
area,
|
area,
|
||||||
efficiency,
|
efficiency,
|
||||||
normal: Vector3::z(),
|
mounting: Mounting::Articulated { axis },
|
||||||
articulated: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// A spacecraft's physical properties.
|
||||||
@@ -217,6 +307,8 @@ pub struct Spacecraft {
|
|||||||
pub inertia: Matrix3<f64>,
|
pub inertia: Matrix3<f64>,
|
||||||
/// What generates power, if anything does.
|
/// What generates power, if anything does.
|
||||||
pub solar_array: Option<SolarArray>,
|
pub solar_array: Option<SolarArray>,
|
||||||
|
/// Where generated energy goes, if it is limited.
|
||||||
|
pub battery: Option<Battery>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Spacecraft {
|
impl Spacecraft {
|
||||||
@@ -233,6 +325,7 @@ impl Spacecraft {
|
|||||||
surface: SurfaceModel::cannonball(area, drag_coefficient, srp_coefficient),
|
surface: SurfaceModel::cannonball(area, drag_coefficient, srp_coefficient),
|
||||||
inertia: Matrix3::from_diagonal(&Vector3::new(moment, moment, moment)),
|
inertia: Matrix3::from_diagonal(&Vector3::new(moment, moment, moment)),
|
||||||
solar_array: None,
|
solar_array: None,
|
||||||
|
battery: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +351,7 @@ impl Spacecraft {
|
|||||||
mass * (x * x + y * y) / 12.0,
|
mass * (x * x + y * y) / 12.0,
|
||||||
)),
|
)),
|
||||||
solar_array: None,
|
solar_array: None,
|
||||||
|
battery: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +379,12 @@ impl Spacecraft {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same spacecraft with a battery.
|
||||||
|
pub fn with_battery(mut self, battery: Battery) -> Self {
|
||||||
|
self.battery = Some(battery);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Propellant remaining, kg.
|
/// Propellant remaining, kg.
|
||||||
pub fn propellant(&self) -> f64 {
|
pub fn propellant(&self) -> f64 {
|
||||||
(self.mass - self.dry_mass).max(0.0)
|
(self.mass - self.dry_mass).max(0.0)
|
||||||
@@ -370,6 +470,126 @@ mod tests {
|
|||||||
assert!(from_corner > along_axis);
|
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]
|
#[test]
|
||||||
fn test_cannonball_spacecraft_has_a_spherical_inertia() {
|
fn test_cannonball_spacecraft_has_a_spherical_inertia() {
|
||||||
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3);
|
let spacecraft = Spacecraft::cannonball(500.0, 4.0, 2.2, 1.3);
|
||||||
|
|||||||
Reference in new issue
Block a user