use arika::epoch::Epoch;
use nalgebra::Vector3;
use crate::model::{HasAttitude, HasMass, HasOrbit, Model};
use super::{ExternalLoads, SpacecraftState};
pub const G0: f64 = 9.80665;
pub trait ThrustProfile: Send + Sync {
fn throttle(&self, t: f64, state: &SpacecraftState, epoch: Option<&Epoch>) -> f64;
}
pub struct ConstantThrottle(pub f64);
impl ThrustProfile for ConstantThrottle {
fn throttle(&self, _t: f64, _state: &SpacecraftState, _epoch: Option<&Epoch>) -> f64 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct BurnWindow {
pub start: f64,
pub end: f64,
pub throttle: f64,
}
impl BurnWindow {
pub fn full(start: f64, end: f64) -> Self {
Self {
start,
end,
throttle: 1.0,
}
}
}
pub struct ScheduledBurn {
pub windows: Vec<BurnWindow>,
}
impl ThrustProfile for ScheduledBurn {
fn throttle(&self, t: f64, _state: &SpacecraftState, _epoch: Option<&Epoch>) -> f64 {
for w in &self.windows {
if t >= w.start && t < w.end {
return w.throttle;
}
}
0.0
}
}
pub struct Thruster {
thrust_n: f64,
isp_s: f64,
direction_body: Vector3<f64>,
offset_body: Vector3<f64>,
dry_mass: f64,
profile: Box<dyn ThrustProfile>,
}
impl Thruster {
pub fn new(thrust_n: f64, isp_s: f64, direction_body: Vector3<f64>) -> Self {
let dir = direction_body.normalize();
assert!(dir.magnitude() > 0.5, "Thrust direction must be non-zero");
Self {
thrust_n,
isp_s,
direction_body: dir,
offset_body: Vector3::zeros(),
dry_mass: 0.0,
profile: Box::new(ConstantThrottle(1.0)),
}
}
pub fn with_offset(mut self, offset: Vector3<f64>) -> Self {
self.offset_body = offset;
self
}
pub fn with_dry_mass(mut self, dry_mass: f64) -> Self {
self.dry_mass = dry_mass;
self
}
pub fn with_profile(mut self, profile: Box<dyn ThrustProfile>) -> Self {
self.profile = profile;
self
}
}
impl Thruster {
pub(crate) fn loads(
&self,
t: f64,
state: &SpacecraftState,
epoch: Option<&Epoch>,
) -> ExternalLoads {
if state.mass <= self.dry_mass {
return ExternalLoads::zeros();
}
let throttle = self.profile.throttle(t, state, epoch).clamp(0.0, 1.0);
if throttle < 1e-15 {
return ExternalLoads::zeros();
}
let f_body_n = self.thrust_n * throttle * self.direction_body;
let torque_body = self.offset_body.cross(&f_body_n);
let a_body = arika::frame::Vec3::from_raw(f_body_n / state.mass / 1000.0);
let a_inertial = state.attitude.rotation_to_eci().transform(&a_body);
let mass_rate = -(self.thrust_n * throttle) / (self.isp_s * G0);
ExternalLoads {
acceleration_inertial: a_inertial,
torque_body: arika::frame::Vec3::from_raw(torque_body),
mass_rate,
}
}
}
impl<S: HasAttitude + HasOrbit<Frame = arika::frame::SimpleEci> + HasMass> Model<S> for Thruster {
fn name(&self) -> &str {
"thruster"
}
fn eval(&self, t: f64, state: &S, epoch: Option<&Epoch>) -> ExternalLoads {
let sc_state = SpacecraftState {
orbit: state.orbit().clone(),
attitude: state.attitude().clone(),
mass: state.mass(),
};
self.loads(t, &sc_state, epoch)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::OrbitalState;
use crate::attitude::AttitudeState;
use nalgebra::Vector4;
use std::f64::consts::FRAC_PI_2;
fn sample_state() -> SpacecraftState {
SpacecraftState {
orbit: OrbitalState::new(Vector3::new(7000.0, 0.0, 0.0), Vector3::new(0.0, 7.5, 0.0)),
attitude: AttitudeState::identity(),
mass: 500.0,
}
}
fn state_with_mass(mass: f64) -> SpacecraftState {
SpacecraftState {
mass,
..sample_state()
}
}
fn rotated_90z_state() -> SpacecraftState {
let half = FRAC_PI_2 / 2.0;
SpacecraftState {
orbit: OrbitalState::new(Vector3::new(7000.0, 0.0, 0.0), Vector3::new(0.0, 7.5, 0.0)),
attitude: AttitudeState {
quaternion: Vector4::new(half.cos(), 0.0, 0.0, half.sin()),
angular_velocity: Vector3::zeros(),
},
mass: 500.0,
}
}
#[test]
fn constant_throttle_value() {
let p = ConstantThrottle(0.7);
assert!((p.throttle(0.0, &sample_state(), None) - 0.7).abs() < 1e-15);
}
#[test]
fn scheduled_burn_inside_window() {
let p = ScheduledBurn {
windows: vec![BurnWindow {
start: 10.0,
end: 20.0,
throttle: 0.8,
}],
};
assert!((p.throttle(15.0, &sample_state(), None) - 0.8).abs() < 1e-15);
}
#[test]
fn scheduled_burn_outside_window() {
let p = ScheduledBurn {
windows: vec![BurnWindow::full(10.0, 20.0)],
};
assert_eq!(p.throttle(5.0, &sample_state(), None), 0.0);
assert_eq!(p.throttle(25.0, &sample_state(), None), 0.0);
}
#[test]
fn scheduled_burn_boundary() {
let p = ScheduledBurn {
windows: vec![BurnWindow::full(10.0, 20.0)],
};
assert_eq!(p.throttle(10.0, &sample_state(), None), 1.0);
assert_eq!(p.throttle(20.0, &sample_state(), None), 0.0);
}
#[test]
fn scheduled_burn_multiple_windows() {
let p = ScheduledBurn {
windows: vec![
BurnWindow {
start: 0.0,
end: 5.0,
throttle: 0.5,
},
BurnWindow::full(10.0, 15.0),
],
};
assert!((p.throttle(3.0, &sample_state(), None) - 0.5).abs() < 1e-15);
assert_eq!(p.throttle(12.0, &sample_state(), None), 1.0);
assert_eq!(p.throttle(7.0, &sample_state(), None), 0.0);
}
#[test]
fn new_normalizes_direction() {
let t = Thruster::new(1.0, 300.0, Vector3::new(3.0, 0.0, 0.0));
assert!((t.direction_body - Vector3::new(1.0, 0.0, 0.0)).magnitude() < 1e-15);
}
#[test]
#[should_panic(expected = "Thrust direction must be non-zero")]
fn new_zero_direction_panics() {
let _t = Thruster::new(1.0, 300.0, Vector3::zeros());
}
#[test]
fn default_profile_full_throttle() {
let t = Thruster::new(1.0, 300.0, Vector3::x());
let loads = t.loads(0.0, &sample_state(), None);
assert!(loads.acceleration_inertial.magnitude() > 0.0);
}
#[test]
fn builder_with_offset_profile_dry_mass() {
let t = Thruster::new(10.0, 300.0, Vector3::x())
.with_offset(Vector3::new(0.0, 1.0, 0.0))
.with_dry_mass(100.0)
.with_profile(Box::new(ConstantThrottle(0.5)));
assert_eq!(t.offset_body, Vector3::new(0.0, 1.0, 0.0));
assert_eq!(t.dry_mass, 100.0);
}
#[test]
fn acceleration_magnitude() {
let t = Thruster::new(1.0, 300.0, Vector3::x());
let loads = t.loads(0.0, &sample_state(), None);
let expected = 1.0 / (500.0 * 1000.0);
assert!(
(loads.acceleration_inertial.magnitude() - expected).abs() < 1e-15,
"got {}, expected {}",
loads.acceleration_inertial.magnitude(),
expected
);
}
#[test]
fn acceleration_direction_identity() {
let t = Thruster::new(1.0, 300.0, Vector3::x());
let loads = t.loads(0.0, &sample_state(), None);
let a = loads.acceleration_inertial.into_inner();
assert!(a[0] > 0.0);
assert!(a[1].abs() < 1e-15);
assert!(a[2].abs() < 1e-15);
}
#[test]
fn acceleration_direction_rotated_90z() {
let t = Thruster::new(1.0, 300.0, Vector3::x());
let loads = t.loads(0.0, &rotated_90z_state(), None);
let a = loads.acceleration_inertial.into_inner();
assert!(a[0].abs() < 1e-10, "expected ~0 x-component, got {}", a[0]);
assert!(a[1] > 0.0, "expected positive y-component, got {}", a[1]);
assert!(a[2].abs() < 1e-15);
}
#[test]
fn torque_from_offset() {
let thrust = 10.0;
let t = Thruster::new(thrust, 300.0, Vector3::x()).with_offset(Vector3::new(0.0, 1.0, 0.0));
let loads = t.loads(0.0, &sample_state(), None);
let tb = loads.torque_body.into_inner();
assert!((tb[0]).abs() < 1e-15, "τx should be 0");
assert!((tb[1]).abs() < 1e-15, "τy should be 0");
assert!(
(tb[2] - (-thrust)).abs() < 1e-12,
"τz should be -F={}, got {}",
-thrust,
tb[2]
);
}
#[test]
fn torque_zero_at_com() {
let t = Thruster::new(10.0, 300.0, Vector3::x());
let loads = t.loads(0.0, &sample_state(), None);
assert!(loads.torque_body.magnitude() < 1e-15);
}
#[test]
fn mass_rate_value() {
let t = Thruster::new(1.0, 300.0, Vector3::x());
let loads = t.loads(0.0, &sample_state(), None);
let expected = -1.0 / (300.0 * G0);
assert!(
(loads.mass_rate - expected).abs() < 1e-12,
"got {}, expected {}",
loads.mass_rate,
expected
);
}
#[test]
fn zero_when_not_firing() {
let t = Thruster::new(1.0, 300.0, Vector3::x()).with_profile(Box::new(ScheduledBurn {
windows: vec![BurnWindow::full(100.0, 200.0)],
}));
let loads = t.loads(0.0, &sample_state(), None);
assert_eq!(loads.acceleration_inertial, arika::frame::Vec3::zeros());
assert_eq!(loads.torque_body, arika::frame::Vec3::zeros());
assert_eq!(loads.mass_rate, 0.0);
}
#[test]
fn zero_when_propellant_exhausted() {
let t = Thruster::new(1.0, 300.0, Vector3::x()).with_dry_mass(100.0);
let loads = t.loads(0.0, &state_with_mass(100.0), None);
assert_eq!(loads.mass_rate, 0.0);
assert_eq!(loads.acceleration_inertial, arika::frame::Vec3::zeros());
}
#[test]
fn throttle_clamped_above_one() {
let t =
Thruster::new(1.0, 300.0, Vector3::x()).with_profile(Box::new(ConstantThrottle(1.5)));
let loads = t.loads(0.0, &sample_state(), None);
let t_full = Thruster::new(1.0, 300.0, Vector3::x());
let loads_full = t_full.loads(0.0, &sample_state(), None);
assert!(
(loads.acceleration_inertial - loads_full.acceleration_inertial).magnitude() < 1e-15
);
assert!((loads.mass_rate - loads_full.mass_rate).abs() < 1e-15);
}
#[test]
fn partial_throttle() {
let t_full = Thruster::new(10.0, 300.0, Vector3::x());
let t_half =
Thruster::new(10.0, 300.0, Vector3::x()).with_profile(Box::new(ConstantThrottle(0.5)));
let state = sample_state();
let loads_full = t_full.loads(0.0, &state, None);
let loads_half = t_half.loads(0.0, &state, None);
assert!(
(loads_half.acceleration_inertial - loads_full.acceleration_inertial * 0.5).magnitude()
< 1e-15
);
assert!((loads_half.mass_rate - loads_full.mass_rate * 0.5).abs() < 1e-15);
}
#[test]
fn dynamics_uses_mass_rate() {
use crate::orbital::gravity::PointMass;
use arika::earth::MU as MU_EARTH;
use nalgebra::Matrix3;
use utsuroi::DynamicalSystem;
use super::super::SpacecraftDynamics;
let inertia = Matrix3::from_diagonal(&Vector3::new(10.0, 10.0, 10.0));
let dyn_sc = SpacecraftDynamics::new(MU_EARTH, PointMass, inertia)
.with_model(Thruster::new(10.0, 300.0, Vector3::x()));
let state = sample_state();
let d = dyn_sc.derivatives(0.0, &state.into());
assert!(
d.plant.mass < 0.0,
"mass_rate should be negative, got {}",
d.plant.mass
);
let expected = -10.0 / (300.0 * G0);
assert!(
(d.plant.mass - expected).abs() < 1e-12,
"got {}, expected {}",
d.plant.mass,
expected
);
}
}