use qtty::{force::Newtons, Kilogram, Second};
use tempoch::{Time, UTC};
pub const G0_M_PER_S2: f64 = 9.80665;
#[derive(Clone, Debug)]
pub struct ThrustArc {
pub start: Time<UTC>,
pub stop: Time<UTC>,
pub thrust: Newtons,
pub isp: Second,
pub direction: [f64; 3],
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum ManeuverError {
#[error("thrust direction has non-finite or zero magnitude (got {0:?})")]
InvalidDirection([f64; 3]),
#[error("specific impulse must be strictly positive (got {0} s)")]
NonPositiveIsp(f64),
#[error("spacecraft mass must be strictly positive (got {0} kg)")]
NonPositiveMass(f64),
#[error("thrust arc is empty: start {start:?} >= stop {stop:?}")]
EmptyInterval {
start: Time<UTC>,
stop: Time<UTC>,
},
}
impl ThrustArc {
pub fn with_normalised_direction(
start: Time<UTC>,
stop: Time<UTC>,
thrust: Newtons,
isp: Second,
direction: [f64; 3],
) -> Result<Self, ManeuverError> {
let isp_val = isp.value();
if !isp_val.is_finite() || isp_val <= 0.0 {
return Err(ManeuverError::NonPositiveIsp(isp_val));
}
#[allow(clippy::neg_cmp_op_on_partial_ord)]
if !(stop > start) {
return Err(ManeuverError::EmptyInterval { start, stop });
}
let [x, y, z] = direction;
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
return Err(ManeuverError::InvalidDirection(direction));
}
let mag = (x * x + y * y + z * z).sqrt();
#[allow(clippy::neg_cmp_op_on_partial_ord)]
if !(mag > 0.0) {
return Err(ManeuverError::InvalidDirection(direction));
}
Ok(Self {
start,
stop,
thrust,
isp,
direction: [x / mag, y / mag, z / mag],
})
}
pub fn is_active(&self, epoch: Time<UTC>) -> bool {
epoch >= self.start && epoch < self.stop
}
}
pub fn thrust_acceleration(
arc: &ThrustArc,
spacecraft_mass: Kilogram,
epoch: Time<UTC>,
) -> Result<[f64; 3], ManeuverError> {
let mass_val = spacecraft_mass.value();
if !mass_val.is_finite() || mass_val <= 0.0 {
return Err(ManeuverError::NonPositiveMass(mass_val));
}
if !arc.is_active(epoch) {
return Ok([0.0, 0.0, 0.0]);
}
let f_n = arc.thrust.value();
let a = f_n / mass_val;
let [ux, uy, uz] = arc.direction;
Ok([a * ux, a * uy, a * uz])
}
pub fn mass_flow_rate(thrust: Newtons, isp: Second) -> Result<f64, ManeuverError> {
let isp_val = isp.value();
if !isp_val.is_finite() || isp_val <= 0.0 {
return Err(ManeuverError::NonPositiveIsp(isp_val));
}
Ok(thrust.value() / (isp_val * G0_M_PER_S2))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn epoch(secs_from_j2000: i64) -> Time<UTC> {
let dt = Utc
.timestamp_opt(946_728_000 + secs_from_j2000, 0)
.single()
.unwrap();
Time::<UTC>::try_from_chrono(dt).unwrap()
}
fn arc() -> ThrustArc {
ThrustArc::with_normalised_direction(
epoch(0),
epoch(60),
Newtons::new(0.5),
Second::new(1500.0),
[1.0, 0.0, 0.0],
)
.unwrap()
}
#[test]
fn zero_thrust_outside_interval() {
let a = thrust_acceleration(&arc(), Kilogram::new(100.0), epoch(120)).unwrap();
assert_eq!(a, [0.0, 0.0, 0.0]);
}
#[test]
fn acceleration_is_force_over_mass_in_direction() {
let a = thrust_acceleration(&arc(), Kilogram::new(100.0), epoch(10)).unwrap();
assert!((a[0] - 0.005).abs() < 1e-15);
assert_eq!(a[1], 0.0);
assert_eq!(a[2], 0.0);
}
#[test]
fn mass_flow_rate_uses_g0() {
let mdot = mass_flow_rate(Newtons::new(0.5), Second::new(1500.0)).unwrap();
let expected = 0.5 / (1500.0 * G0_M_PER_S2);
assert!(
(mdot - expected).abs() < 1e-18,
"mdot={mdot} expected={expected}"
);
}
#[test]
fn rejects_zero_direction() {
let r = ThrustArc::with_normalised_direction(
epoch(0),
epoch(10),
Newtons::new(1.0),
Second::new(300.0),
[0.0, 0.0, 0.0],
);
assert!(matches!(r, Err(ManeuverError::InvalidDirection(_))));
}
#[test]
fn rejects_non_positive_isp() {
let r = mass_flow_rate(Newtons::new(1.0), Second::new(0.0));
assert!(matches!(r, Err(ManeuverError::NonPositiveIsp(_))));
}
#[test]
fn rejects_non_positive_mass() {
let r = thrust_acceleration(&arc(), Kilogram::new(0.0), epoch(5));
assert!(matches!(r, Err(ManeuverError::NonPositiveMass(_))));
}
#[test]
fn direction_is_normalised() {
let a = ThrustArc::with_normalised_direction(
epoch(0),
epoch(1),
Newtons::new(1.0),
Second::new(300.0),
[3.0, 0.0, 4.0],
)
.unwrap();
let mag = (a.direction[0].powi(2) + a.direction[1].powi(2) + a.direction[2].powi(2)).sqrt();
assert!((mag - 1.0).abs() < 1e-15);
assert!((a.direction[0] - 0.6).abs() < 1e-15);
assert!((a.direction[2] - 0.8).abs() < 1e-15);
}
}