use crate::astro::propagation::{propagate_lagrangian, propagate_lagrangian_with_stm};
use crate::error::ensure_finite;
use crate::{CartesianState, PykepError, Result, Vector3};
const EXTENDED_DIMENSION: usize = 7;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpacecraftEndpoint {
pub state: CartesianState,
pub mass: f64,
}
impl SpacecraftEndpoint {
pub fn new(state: CartesianState, mass: f64) -> Result<Self> {
validate_state(&state)?;
validate_positive("mass", mass)?;
Ok(Self { state, mass })
}
fn extended(self) -> [f64; EXTENDED_DIMENSION] {
[
self.state[0],
self.state[1],
self.state[2],
self.state[3],
self.state[4],
self.state[5],
self.mass,
]
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SimsFlanaganSettings {
pub time_of_flight: f64,
pub maximum_thrust: f64,
pub exhaust_velocity: f64,
pub mu: f64,
pub cut: f64,
}
impl SimsFlanaganSettings {
pub fn new(
time_of_flight: f64,
maximum_thrust: f64,
exhaust_velocity: f64,
mu: f64,
cut: f64,
) -> Result<Self> {
validate_non_negative("time_of_flight", time_of_flight)?;
validate_non_negative("maximum_thrust", maximum_thrust)?;
validate_positive("exhaust_velocity", exhaust_velocity)?;
validate_positive("mu", mu)?;
ensure_finite("cut", cut)?;
if !(0.0..=1.0).contains(&cut) {
return Err(PykepError::InvalidInput {
parameter: "cut",
reason: "must lie in 0..=1".into(),
});
}
Ok(Self {
time_of_flight,
maximum_thrust,
exhaust_velocity,
mu,
cut,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SimsFlanaganMismatchJacobian {
pub departure: [[f64; EXTENDED_DIMENSION]; EXTENDED_DIMENSION],
pub arrival: [[f64; EXTENDED_DIMENSION]; EXTENDED_DIMENSION],
pub controls_and_time: Vec<Vec<f64>>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SimsFlanaganLeg {
departure: SpacecraftEndpoint,
arrival: SpacecraftEndpoint,
throttles: Vec<Vector3>,
settings: SimsFlanaganSettings,
forward_segments: usize,
}
impl SimsFlanaganLeg {
pub fn new(
departure: SpacecraftEndpoint,
throttles: Vec<Vector3>,
arrival: SpacecraftEndpoint,
settings: SimsFlanaganSettings,
) -> Result<Self> {
validate_endpoint(departure)?;
validate_endpoint(arrival)?;
validate_settings(settings)?;
validate_throttles(&throttles)?;
let forward_segments = forward_count(throttles.len(), settings.cut);
Ok(Self {
departure,
arrival,
throttles,
settings,
forward_segments,
})
}
pub const fn departure(&self) -> SpacecraftEndpoint {
self.departure
}
pub const fn arrival(&self) -> SpacecraftEndpoint {
self.arrival
}
pub fn throttles(&self) -> &[Vector3] {
&self.throttles
}
pub const fn settings(&self) -> SimsFlanaganSettings {
self.settings
}
pub fn segment_count(&self) -> usize {
self.throttles.len()
}
pub const fn forward_segment_count(&self) -> usize {
self.forward_segments
}
pub fn backward_segment_count(&self) -> usize {
self.segment_count() - self.forward_segments
}
pub fn mismatch_constraints(&self) -> Result<[f64; EXTENDED_DIMENSION]> {
let duration = self.settings.time_of_flight / self.segment_count() as f64;
let durations = vec![duration; self.segment_count()];
mismatch(
self.departure,
self.arrival,
&self.throttles,
&durations,
self.settings,
self.forward_segments,
)
}
pub fn throttle_constraints(&self) -> Vec<f64> {
throttle_constraints(&self.throttles)
}
pub fn mismatch_jacobian(&self) -> Result<SimsFlanaganMismatchJacobian> {
analytic_fixed_jacobian(self)
}
pub fn throttle_jacobian(&self) -> Vec<Vec<f64>> {
throttle_jacobian(&self.throttles)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SimsFlanaganAlphaLeg {
departure: SpacecraftEndpoint,
arrival: SpacecraftEndpoint,
throttles: Vec<Vector3>,
segment_durations: Vec<f64>,
settings: SimsFlanaganSettings,
forward_segments: usize,
}
impl SimsFlanaganAlphaLeg {
pub fn new(
departure: SpacecraftEndpoint,
throttles: Vec<Vector3>,
segment_durations: Vec<f64>,
arrival: SpacecraftEndpoint,
settings: SimsFlanaganSettings,
) -> Result<Self> {
validate_endpoint(departure)?;
validate_endpoint(arrival)?;
validate_settings(settings)?;
validate_throttles(&throttles)?;
if segment_durations.len() != throttles.len() {
return Err(PykepError::DimensionMismatch {
expected: throttles.len(),
actual: segment_durations.len(),
});
}
for &duration in &segment_durations {
validate_non_negative("segment_durations", duration)?;
}
let forward_segments = forward_count(throttles.len(), settings.cut);
Ok(Self {
departure,
arrival,
throttles,
segment_durations,
settings,
forward_segments,
})
}
pub fn from_time_weights(
departure: SpacecraftEndpoint,
throttles: Vec<Vector3>,
time_weights: Vec<f64>,
arrival: SpacecraftEndpoint,
settings: SimsFlanaganSettings,
) -> Result<Self> {
for &weight in &time_weights {
validate_positive("time_weights", weight)?;
}
let sum: f64 = time_weights.iter().sum();
if !sum.is_finite() {
return Err(PykepError::NumericalOverflow {
operation: "Sims-Flanagan time-weight normalization",
});
}
let durations = time_weights
.into_iter()
.map(|weight| weight / sum * settings.time_of_flight)
.collect();
Self::new(departure, throttles, durations, arrival, settings)
}
pub const fn departure(&self) -> SpacecraftEndpoint {
self.departure
}
pub const fn arrival(&self) -> SpacecraftEndpoint {
self.arrival
}
pub fn throttles(&self) -> &[Vector3] {
&self.throttles
}
pub fn segment_durations(&self) -> &[f64] {
&self.segment_durations
}
pub const fn settings(&self) -> SimsFlanaganSettings {
self.settings
}
pub fn segment_count(&self) -> usize {
self.throttles.len()
}
pub const fn forward_segment_count(&self) -> usize {
self.forward_segments
}
pub fn backward_segment_count(&self) -> usize {
self.segment_count() - self.forward_segments
}
pub fn mismatch_constraints(&self) -> Result<[f64; EXTENDED_DIMENSION]> {
mismatch(
self.departure,
self.arrival,
&self.throttles,
&self.segment_durations,
self.settings,
self.forward_segments,
)
}
pub fn throttle_constraints(&self) -> Vec<f64> {
throttle_constraints(&self.throttles)
}
pub fn throttle_jacobian(&self) -> Vec<Vec<f64>> {
throttle_jacobian(&self.throttles)
}
}
fn validate_endpoint(endpoint: SpacecraftEndpoint) -> Result<()> {
SpacecraftEndpoint::new(endpoint.state, endpoint.mass).map(|_| ())
}
fn validate_settings(settings: SimsFlanaganSettings) -> Result<()> {
SimsFlanaganSettings::new(
settings.time_of_flight,
settings.maximum_thrust,
settings.exhaust_velocity,
settings.mu,
settings.cut,
)
.map(|_| ())
}
fn validate_state(state: &CartesianState) -> Result<()> {
for &value in state {
ensure_finite("state", value)?;
}
let radius_squared = state[0] * state[0] + state[1] * state[1] + state[2] * state[2];
if radius_squared == 0.0 {
Err(PykepError::SingularGeometry {
operation: "Sims-Flanagan endpoint radius",
})
} else if radius_squared.is_finite() {
Ok(())
} else {
Err(PykepError::NumericalOverflow {
operation: "Sims-Flanagan endpoint radius",
})
}
}
fn validate_throttles(throttles: &[Vector3]) -> Result<()> {
if throttles.is_empty() {
return Err(PykepError::InvalidInput {
parameter: "throttles",
reason: "at least one segment is required".into(),
});
}
for throttle in throttles {
for &value in throttle {
ensure_finite("throttles", value)?;
}
}
Ok(())
}
fn validate_positive(parameter: &'static str, value: f64) -> Result<()> {
ensure_finite(parameter, value)?;
if value > 0.0 {
Ok(())
} else {
Err(PykepError::InvalidInput {
parameter,
reason: "must be greater than zero".into(),
})
}
}
fn validate_non_negative(parameter: &'static str, value: f64) -> Result<()> {
ensure_finite(parameter, value)?;
if value >= 0.0 {
Ok(())
} else {
Err(PykepError::InvalidInput {
parameter,
reason: "must be greater than or equal to zero".into(),
})
}
}
fn forward_count(segment_count: usize, cut: f64) -> usize {
(segment_count as f64 * cut) as usize
}
fn throttle_constraints(throttles: &[Vector3]) -> Vec<f64> {
throttles
.iter()
.map(|control| control.iter().map(|value| value * value).sum::<f64>() - 1.0)
.collect()
}
fn throttle_jacobian(throttles: &[Vector3]) -> Vec<Vec<f64>> {
let mut result = vec![vec![0.0; throttles.len() * 3]; throttles.len()];
for (segment, throttle) in throttles.iter().enumerate() {
for component in 0..3 {
result[segment][3 * segment + component] = 2.0 * throttle[component];
}
}
result
}
fn mismatch(
departure: SpacecraftEndpoint,
arrival: SpacecraftEndpoint,
throttles: &[Vector3],
durations: &[f64],
settings: SimsFlanaganSettings,
forward_segments: usize,
) -> Result<[f64; EXTENDED_DIMENSION]> {
let forward = nominal_half(
departure,
throttles,
durations,
settings,
forward_segments,
true,
)?;
let backward = nominal_half(
arrival,
throttles,
durations,
settings,
forward_segments,
false,
)?;
let result = core::array::from_fn(|index| forward[index] - backward[index]);
validate_output("Sims-Flanagan mismatch", &result)?;
Ok(result)
}
fn nominal_half(
endpoint: SpacecraftEndpoint,
throttles: &[Vector3],
durations: &[f64],
settings: SimsFlanaganSettings,
forward_segments: usize,
forward: bool,
) -> Result<[f64; EXTENDED_DIMENSION]> {
let mut extended = endpoint.extended();
let range = if forward {
0..forward_segments
} else {
forward_segments..throttles.len()
};
if range.is_empty() {
return Ok(extended);
}
let first = if forward { range.start } else { range.end - 1 };
let direction = if forward { 1.0 } else { -1.0 };
propagate_extended(
&mut extended,
direction * durations[first] / 2.0,
settings.mu,
)?;
if forward {
for segment in range.clone() {
apply_impulse(
&mut extended,
throttles[segment],
durations[segment],
settings,
1.0,
)?;
let coast = if segment + 1 == range.end {
durations[segment] / 2.0
} else {
(durations[segment] + durations[segment + 1]) / 2.0
};
propagate_extended(&mut extended, coast, settings.mu)?;
}
} else {
for segment in range.rev() {
apply_impulse(
&mut extended,
throttles[segment],
durations[segment],
settings,
-1.0,
)?;
let coast = if segment == forward_segments {
-durations[segment] / 2.0
} else {
-(durations[segment] + durations[segment - 1]) / 2.0
};
propagate_extended(&mut extended, coast, settings.mu)?;
}
}
validate_output("Sims-Flanagan half leg", &extended)?;
Ok(extended)
}
fn propagate_extended(state: &mut [f64; EXTENDED_DIMENSION], duration: f64, mu: f64) -> Result<()> {
let cartesian: CartesianState = state[..6].try_into().expect("fixed slice length");
let propagated = propagate_lagrangian(&cartesian, duration, mu)?;
state[..6].copy_from_slice(&propagated);
Ok(())
}
fn apply_impulse(
state: &mut [f64; EXTENDED_DIMENSION],
throttle: Vector3,
duration: f64,
settings: SimsFlanaganSettings,
direction: f64,
) -> Result<()> {
let scale = settings.maximum_thrust * duration / state[6];
let impulse = throttle.map(|value| scale * value);
for component in 0..3 {
state[component + 3] += direction * impulse[component];
}
let norm = impulse
.iter()
.map(|value| value * value)
.sum::<f64>()
.sqrt();
state[6] *= (-direction * norm / settings.exhaust_velocity).exp();
validate_output("Sims-Flanagan impulse", state)
}
fn validate_output(operation: &'static str, values: &[f64]) -> Result<()> {
if values.iter().all(|value| value.is_finite()) {
Ok(())
} else {
Err(PykepError::NumericalOverflow { operation })
}
}
fn analytic_fixed_jacobian(leg: &SimsFlanaganLeg) -> Result<SimsFlanaganMismatchJacobian> {
let segment_count = leg.segment_count();
let width = EXTENDED_DIMENSION + segment_count * 3 + 1;
let time_column = width - 1;
let duration = leg.settings.time_of_flight / segment_count as f64;
let duration_derivative = 1.0 / segment_count as f64;
let mut departure_seeds = vec![vec![0.0; width]; EXTENDED_DIMENSION];
let mut arrival_seeds = vec![vec![0.0; width]; EXTENDED_DIMENSION];
for index in 0..EXTENDED_DIMENSION {
departure_seeds[index][index] = 1.0;
arrival_seeds[index][index] = 1.0;
}
let forward = sensitivity_half(
leg.departure,
&leg.throttles,
leg.settings,
leg.forward_segments,
true,
duration,
duration_derivative,
departure_seeds,
)?;
let backward = sensitivity_half(
leg.arrival,
&leg.throttles,
leg.settings,
leg.forward_segments,
false,
duration,
duration_derivative,
arrival_seeds,
)?;
let departure =
core::array::from_fn(|row| core::array::from_fn(|column| forward.1[row][column]));
let arrival =
core::array::from_fn(|row| core::array::from_fn(|column| -backward.1[row][column]));
let mut controls_and_time = vec![vec![0.0; segment_count * 3 + 1]; EXTENDED_DIMENSION];
for (row, values) in controls_and_time.iter_mut().enumerate() {
for (column, value) in values.iter_mut().take(segment_count * 3).enumerate() {
*value = forward.1[row][EXTENDED_DIMENSION + column]
- backward.1[row][EXTENDED_DIMENSION + column];
}
values[segment_count * 3] = forward.1[row][time_column] - backward.1[row][time_column];
}
validate_output(
"Sims-Flanagan mismatch Jacobian",
controls_and_time
.iter()
.flatten()
.copied()
.collect::<Vec<_>>()
.as_slice(),
)?;
Ok(SimsFlanaganMismatchJacobian {
departure,
arrival,
controls_and_time,
})
}
#[allow(clippy::too_many_arguments)]
fn sensitivity_half(
endpoint: SpacecraftEndpoint,
throttles: &[Vector3],
settings: SimsFlanaganSettings,
forward_segments: usize,
forward: bool,
duration: f64,
duration_derivative: f64,
mut sensitivities: Vec<Vec<f64>>,
) -> Result<([f64; EXTENDED_DIMENSION], Vec<Vec<f64>>)> {
let mut state = endpoint.extended();
let range = if forward {
0..forward_segments
} else {
forward_segments..throttles.len()
};
if range.is_empty() {
return Ok((state, sensitivities));
}
let direction = if forward { 1.0 } else { -1.0 };
propagate_sensitivities(
&mut state,
&mut sensitivities,
direction * duration / 2.0,
direction * duration_derivative / 2.0,
settings.mu,
)?;
if forward {
for segment in range.clone() {
impulse_sensitivities(
&mut state,
&mut sensitivities,
throttles[segment],
segment,
duration,
duration_derivative,
settings,
1.0,
)?;
let half_factor = if segment + 1 == range.end { 0.5 } else { 1.0 };
propagate_sensitivities(
&mut state,
&mut sensitivities,
duration * half_factor,
duration_derivative * half_factor,
settings.mu,
)?;
}
} else {
for segment in range.rev() {
impulse_sensitivities(
&mut state,
&mut sensitivities,
throttles[segment],
segment,
duration,
duration_derivative,
settings,
-1.0,
)?;
let half_factor = if segment == forward_segments {
0.5
} else {
1.0
};
propagate_sensitivities(
&mut state,
&mut sensitivities,
-duration * half_factor,
-duration_derivative * half_factor,
settings.mu,
)?;
}
}
Ok((state, sensitivities))
}
fn propagate_sensitivities(
state: &mut [f64; EXTENDED_DIMENSION],
sensitivities: &mut [Vec<f64>],
duration: f64,
duration_derivative: f64,
mu: f64,
) -> Result<()> {
let initial: CartesianState = state[..6].try_into().expect("fixed slice length");
let (propagated, transition) = propagate_lagrangian_with_stm(&initial, duration, mu)?;
let radius_squared = propagated[0] * propagated[0]
+ propagated[1] * propagated[1]
+ propagated[2] * propagated[2];
let gravity = -mu / radius_squared.powf(1.5);
let dynamics = [
propagated[3],
propagated[4],
propagated[5],
gravity * propagated[0],
gravity * propagated[1],
gravity * propagated[2],
];
let previous = sensitivities[..6].to_vec();
let time_column = sensitivities[0].len() - 1;
for row in 0..6 {
for column in 0..sensitivities[row].len() {
sensitivities[row][column] = (0..6)
.map(|inner| transition[row][inner] * previous[inner][column])
.sum();
}
sensitivities[row][time_column] += dynamics[row] * duration_derivative;
}
state[..6].copy_from_slice(&propagated);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn impulse_sensitivities(
state: &mut [f64; EXTENDED_DIMENSION],
sensitivities: &mut [Vec<f64>],
throttle: Vector3,
segment: usize,
duration: f64,
duration_derivative: f64,
settings: SimsFlanaganSettings,
direction: f64,
) -> Result<()> {
let width = sensitivities[0].len();
let time_column = width - 1;
let coefficient = settings.maximum_thrust * duration;
let scale = coefficient / state[6];
let impulse = throttle.map(|value| scale * value);
let norm = impulse
.iter()
.map(|value| value * value)
.sum::<f64>()
.sqrt();
let previous_mass_sensitivity = sensitivities[6].clone();
let mut impulse_derivative = vec![[0.0; 3]; width];
for (column, item) in impulse_derivative.iter_mut().enumerate() {
let coefficient_derivative = if column == time_column {
settings.maximum_thrust * duration_derivative
} else {
0.0
};
let scale_derivative = coefficient_derivative / state[6]
- coefficient / (state[6] * state[6]) * previous_mass_sensitivity[column];
for component in 0..3 {
item[component] = throttle[component] * scale_derivative;
if column == EXTENDED_DIMENSION + segment * 3 + component {
item[component] += scale;
}
sensitivities[component + 3][column] += direction * item[component];
}
}
let exponent = (-direction * norm / settings.exhaust_velocity).exp();
let new_mass = state[6] * exponent;
for column in 0..width {
let norm_derivative = if norm == 0.0 {
0.0
} else {
(0..3)
.map(|component| impulse[component] * impulse_derivative[column][component])
.sum::<f64>()
/ norm
};
sensitivities[6][column] = exponent * previous_mass_sensitivity[column]
+ new_mass * (-direction / settings.exhaust_velocity) * norm_derivative;
}
for component in 0..3 {
state[component + 3] += direction * impulse[component];
}
state[6] = new_mass;
validate_output("Sims-Flanagan impulse sensitivities", state)
}