#![allow(
clippy::needless_range_loop,
clippy::assign_op_pattern,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "driver transcribes DSTODE/DLSODE index arithmetic for line-by-line auditability; test step-count casts are small exact integers"
)]
pub mod coeffs;
pub mod config;
pub mod error_weights;
pub(crate) mod linalg;
pub mod nordsieck;
pub use config::{CorrectorMethod, IntegrationMethod, LsodeConfig};
use crate::state::TranslationalState;
use coeffs::{MethodCoeffs, TestCoeffs};
use error_weights::{load_error_weights, weighted_rms_norm};
use glam::DVec3;
use nordsieck::Nordsieck;
const N_ODES: usize = 6;
const MAX_REL_CHANGE_WITHOUT_JACOBIAN: f64 = 0.3;
const MAX_STEPS_PER_JACOBIAN: usize = 20;
#[derive(Debug, Clone)]
pub struct LsodeState {
config: LsodeConfig,
method_coeffs: MethodCoeffs,
test_coeffs: TestCoeffs,
el: [f64; 13],
nordsieck: Nordsieck,
order: usize,
num_cols: usize,
max_history_size: usize,
step_size: f64,
prev_step_size: f64,
stage_target_time: f64,
cycle_target_time: f64,
order_select_para: usize,
max_step_increase_ratio: f64,
convergence_rate: f64,
num_steps_taken: usize,
internal_state: i32,
first_pass: bool,
prev_method_order: usize,
error_weight: [f64; N_ODES],
topology_dirty: bool,
iter_matrix: [[f64; N_ODES]; N_ODES],
iter_pivots: [usize; N_ODES],
jac_hl0: f64,
jacobian_current: bool,
steps_at_last_jacobian: usize,
}
impl LsodeState {
pub fn new(config: LsodeConfig) -> Self {
config.check();
let max_history_size = config.effective_max_order();
let (method_coeffs, test_coeffs) =
coeffs::calculate_integration_coefficients(config.method);
Self {
config,
method_coeffs,
test_coeffs,
el: [0.0; 13],
nordsieck: Nordsieck::new(N_ODES, max_history_size),
order: 1,
num_cols: 2,
max_history_size,
step_size: 0.0,
prev_step_size: 0.0,
stage_target_time: 0.0,
cycle_target_time: 0.0,
order_select_para: 2,
max_step_increase_ratio: 10_000.0,
convergence_rate: 0.7,
num_steps_taken: 0,
internal_state: 0,
first_pass: true,
prev_method_order: 1,
error_weight: [0.0; N_ODES],
topology_dirty: false,
iter_matrix: [[0.0; N_ODES]; N_ODES],
iter_pivots: [0; N_ODES],
jac_hl0: 0.0,
jacobian_current: false,
steps_at_last_jacobian: 0,
}
}
pub fn config(&self) -> &LsodeConfig {
&self.config
}
pub fn mark_topology_dirty(&mut self) {
self.topology_dirty = true;
}
pub fn is_topology_dirty(&self) -> bool {
self.topology_dirty
}
pub fn reset_for_topology_change(&mut self) {
let config = self.config;
*self = Self::new(config);
}
fn reset_method_coeffs(&mut self) {
for ii in 0..self.num_cols {
self.el[ii] = self.method_coeffs[ii][self.order - 1];
}
}
#[allow(
clippy::cast_precision_loss,
reason = "order ≤ 12 is exactly representable in f64"
)]
fn convergence_factor(&self) -> f64 {
0.5 / (self.order as f64 + 2.0)
}
}
fn eval_derivative(
y: &[f64; N_ODES],
accel_fn: &impl Fn(&TranslationalState, f64) -> DVec3,
frac: f64,
save: &mut [f64; N_ODES],
) {
let pos = DVec3::new(y[0], y[1], y[2]);
let vel = DVec3::new(y[3], y[4], y[5]);
let accel = accel_fn(
&TranslationalState {
position: pos,
velocity: vel,
},
frac,
);
save[0] = vel.x;
save[1] = vel.y;
save[2] = vel.z;
save[3] = accel.x;
save[4] = accel.y;
save[5] = accel.z;
}
pub fn lsode_translational_step(
state: &TranslationalState,
accel_fn: impl Fn(&TranslationalState, f64) -> DVec3,
dyn_dt: f64,
lsode: &mut LsodeState,
) -> TranslationalState {
assert!(
!lsode.topology_dirty,
"LsodeState used while topology-dirty — reset_for_topology_change() must run after \
an attach/detach before the next integration step."
);
assert!(
dyn_dt.is_finite() && dyn_dt > 0.0,
"LSODE requires a finite, strictly-positive dyn_dt (got {dyn_dt}); it is forward-time \
only. For reverse-time integration select IntegratorType::Rk4 or Rkf45."
);
let eps = f64::EPSILON;
let max_step_size_inv = if lsode.config.max_step_size > 0.0 {
1.0 / lsode.config.max_step_size
} else {
0.0
};
let mut save = [0.0_f64; N_ODES];
if lsode.first_pass {
let y0 = [
state.position.x,
state.position.y,
state.position.z,
state.velocity.x,
state.velocity.y,
state.velocity.z,
];
for i in 0..N_ODES {
lsode.nordsieck.history[i][0] = y0[i];
}
eval_derivative(&y0, &accel_fn, 0.0, &mut save);
for i in 0..N_ODES {
lsode.nordsieck.history[i][1] = save[i];
}
lsode.order = 1;
compute_inverted_ewt(lsode);
let t0 = dyn_dt.abs();
assert!(
t0 >= 2.0 * eps,
"LSODE: dyn_dt ({dyn_dt}) too small to start integration."
);
let h0 = if lsode.config.initial_step_size > 0.0 {
lsode.config.initial_step_size.copysign(dyn_dt)
} else {
let mut rtol = lsode.config.rel_tolerance;
if rtol <= 0.0 {
let atol = lsode.config.abs_tolerance;
for i in 0..N_ODES {
if y0[i] != 0.0 {
rtol = rtol.max(atol / y0[i].abs());
}
}
}
rtol = rtol.max(100.0 * eps).min(0.001);
let col1: [f64; N_ODES] = std::array::from_fn(|i| lsode.nordsieck.history[i][1]);
let ss = weighted_rms_norm(&col1, &lsode.error_weight);
let sum = 1.0 / (rtol * t0 * t0) + rtol * ss * ss;
let mut h0 = 1.0 / sum.sqrt();
h0 = h0.min(t0);
h0.copysign(dyn_dt)
};
let mut h0 = h0;
let ratio = h0.abs() * max_step_size_inv;
if ratio > 1.0 {
h0 /= ratio;
}
lsode.step_size = h0;
for i in 0..N_ODES {
lsode.nordsieck.history[i][1] *= h0;
}
lsode.num_cols = 2;
lsode.order = 1;
lsode.internal_state = 0;
lsode.stage_target_time = 0.0;
lsode.first_pass = false;
lsode.cycle_target_time = dyn_dt;
} else {
lsode.stage_target_time -= lsode.cycle_target_time;
lsode.cycle_target_time = dyn_dt;
lsode.internal_state = 1;
}
let mut steps_this_cycle = 0usize;
while (lsode.stage_target_time - lsode.cycle_target_time) * lsode.step_size < 0.0 {
steps_this_cycle += 1;
assert!(
steps_this_cycle <= lsode.config.max_num_steps,
"LSODE: exceeded max_num_steps ({}) within one cycle without reaching the target \
time (reached {} of {}). Check tolerances/step size.",
lsode.config.max_num_steps,
lsode.stage_target_time,
lsode.cycle_target_time
);
compute_inverted_ewt(lsode);
dstode_step(lsode, &accel_fn, max_step_size_inv);
}
interpolate_to_target(lsode)
}
fn compute_inverted_ewt(lsode: &mut LsodeState) {
let y0: [f64; N_ODES] = std::array::from_fn(|i| lsode.nordsieck.history[i][0]);
let mut ewt = [0.0_f64; N_ODES];
load_error_weights(
&y0,
lsode.config.rel_tolerance,
lsode.config.abs_tolerance,
&mut ewt,
);
for i in 0..N_ODES {
assert!(
ewt[i] > 0.0,
"LSODE: error weight for component {i} is {} (≤ 0). With atol = 0 this happens when \
y[{i}] is exactly 0; set abs_tolerance > 0 for components that may pass through zero.",
ewt[i]
);
lsode.error_weight[i] = 1.0 / ewt[i];
}
}
#[allow(
clippy::cast_precision_loss,
reason = "order/column counts ≤ 13 are exactly representable in f64"
)]
fn dstode_step(
lsode: &mut LsodeState,
accel_fn: &impl Fn(&TranslationalState, f64) -> DVec3,
max_step_size_inv: f64,
) {
let told = lsode.stage_target_time;
let mut step_error: i32 = 0;
let mut accum = [0.0_f64; N_ODES];
if lsode.internal_state == 0 {
lsode.order = 1;
lsode.num_cols = 2;
lsode.order_select_para = 2;
lsode.max_step_increase_ratio = 10_000.0;
lsode.convergence_rate = 0.7;
lsode.reset_method_coeffs();
lsode.internal_state = 1;
}
'attempt: loop {
lsode.stage_target_time = told + lsode.step_size;
lsode.nordsieck.predict(lsode.order);
let frac = (lsode.stage_target_time / lsode.cycle_target_time).clamp(0.0, 1.0);
let conv_factor = lsode.convergence_factor();
let el0 = lsode.el[0];
let tesco1 = lsode.test_coeffs[1][lsode.order - 1];
let (_converged, corrector_failed) = match lsode.config.corrector {
CorrectorMethod::FunctionalIteration => {
functional_corrector(lsode, accel_fn, frac, el0, tesco1, conv_factor, &mut accum)
}
CorrectorMethod::NewtonIterInternalJac => {
chord_corrector(lsode, accel_fn, frac, el0, tesco1, conv_factor, &mut accum)
}
CorrectorMethod::JacobiNewtonInternalJac => unreachable!(
"LSODE corrector JacobiNewtonInternalJac (MITER=3, diagonal Jacobi-Newton) is \
not yet ported and must be rejected by LsodeConfig::check before stepping."
),
};
if corrector_failed {
lsode.stage_target_time = told;
retract_prediction(lsode);
lsode.max_step_increase_ratio = 2.0;
assert!(
lsode.step_size.abs() > lsode.config.min_step_size * 1.00001,
"LSODE corrector failed to converge at the minimum step size — trajectory \
would be silently degraded. Review tolerances / step size."
);
apply_step_ratio(lsode, 0.25, max_step_size_inv);
continue 'attempt;
}
let dsm = weighted_rms_norm(&accum, &lsode.error_weight) / tesco1;
if dsm > 1.0 {
step_error -= 1;
lsode.stage_target_time = told;
retract_prediction(lsode);
lsode.max_step_increase_ratio = 2.0;
assert!(
lsode.step_size.abs() > lsode.config.min_step_size * 1.00001,
"LSODE error test failed at the minimum step size — trajectory would be \
silently degraded. Review tolerances / step size."
);
if step_error <= -3 {
assert!(
step_error > -10,
"LSODE: 10 consecutive error-test failures — giving up (would be silently \
wrong). Review the integration setup."
);
fail_reset_order_1(lsode, accel_fn, max_step_size_inv);
continue 'attempt;
}
select_new_order(lsode, &accum, dsm, step_error, 0.0, max_step_size_inv);
continue 'attempt;
}
lsode.num_steps_taken += 1;
lsode.prev_method_order = lsode.order;
for jj in 0..lsode.num_cols {
for i in 0..N_ODES {
lsode.nordsieck.history[i][jj] += lsode.el[jj] * accum[i];
}
}
lsode.order_select_para -= 1;
if lsode.order_select_para == 0 {
let r_inc = compute_r_inc(lsode, &accum);
select_new_order(lsode, &accum, dsm, step_error, r_inc, max_step_size_inv);
} else if lsode.order_select_para == 1 && lsode.num_cols != lsode.max_history_size {
for i in 0..N_ODES {
lsode.nordsieck.history[i][lsode.max_history_size] = accum[i];
}
}
lsode.prev_step_size = lsode.step_size;
return;
}
}
fn functional_corrector(
lsode: &mut LsodeState,
accel_fn: &impl Fn(&TranslationalState, f64) -> DVec3,
frac: f64,
el0: f64,
tesco1: f64,
conv_factor: f64,
accum: &mut [f64; N_ODES],
) -> (bool, bool) {
let pred0: [f64; N_ODES] = std::array::from_fn(|i| lsode.nordsieck.history[i][0]);
let mut y_work = pred0;
for a in accum.iter_mut() {
*a = 0.0;
}
let mut save = [0.0_f64; N_ODES];
let mut prev_iter_delta = 0.0_f64;
let mut converged = false;
let mut corrector_failed = false;
for iter in 0..lsode.config.max_correction_iters {
eval_derivative(&y_work, accel_fn, frac, &mut save);
let mut incr = [0.0_f64; N_ODES];
for i in 0..N_ODES {
save[i] = lsode.step_size * save[i] - lsode.nordsieck.history[i][1];
incr[i] = save[i] - accum[i];
}
let iter_delta = weighted_rms_norm(&incr, &lsode.error_weight);
for i in 0..N_ODES {
y_work[i] = pred0[i] + el0 * save[i];
accum[i] = save[i];
}
if iter != 0 {
lsode.convergence_rate =
(0.2 * lsode.convergence_rate).max(iter_delta / prev_iter_delta);
}
let dcon =
iter_delta * (1.0_f64).min(1.5 * lsode.convergence_rate) / (tesco1 * conv_factor);
if dcon <= 1.0 {
converged = true;
break;
}
if iter >= 1 && iter_delta > 2.0 * prev_iter_delta {
corrector_failed = true;
break;
}
prev_iter_delta = iter_delta;
}
if !converged && !corrector_failed {
corrector_failed = true; }
(converged, corrector_failed)
}
fn chord_corrector(
lsode: &mut LsodeState,
accel_fn: &impl Fn(&TranslationalState, f64) -> DVec3,
frac: f64,
el0: f64,
tesco1: f64,
conv_factor: f64,
accum: &mut [f64; N_ODES],
) -> (bool, bool) {
let pred0: [f64; N_ODES] = std::array::from_fn(|i| lsode.nordsieck.history[i][0]);
let hl0 = lsode.step_size * el0;
let drift = if lsode.jac_hl0 == 0.0 {
f64::INFINITY
} else {
(hl0 / lsode.jac_hl0 - 1.0).abs()
};
let mut need_build = !lsode.jacobian_current
|| drift > MAX_REL_CHANGE_WITHOUT_JACOBIAN
|| lsode.num_steps_taken >= lsode.steps_at_last_jacobian + MAX_STEPS_PER_JACOBIAN;
loop {
let built_now = need_build;
if need_build {
let mut f_base = [0.0_f64; N_ODES];
eval_derivative(&pred0, accel_fn, frac, &mut f_base);
if build_dense_iteration_matrix(lsode, accel_fn, frac, &pred0, &f_base, hl0).is_err() {
lsode.jacobian_current = false;
return (false, true);
}
lsode.jacobian_current = true;
lsode.jac_hl0 = hl0;
lsode.steps_at_last_jacobian = lsode.num_steps_taken;
lsode.convergence_rate = 0.7; }
let mut y_work = pred0;
for a in accum.iter_mut() {
*a = 0.0;
}
let mut save = [0.0_f64; N_ODES];
let mut prev_iter_delta = 0.0_f64;
let mut converged = false;
for iter in 0..lsode.config.max_correction_iters {
eval_derivative(&y_work, accel_fn, frac, &mut save);
let mut delta = [0.0_f64; N_ODES];
for i in 0..N_ODES {
delta[i] = lsode.step_size * save[i] - (lsode.nordsieck.history[i][1] + accum[i]);
}
linalg::lu_solve(&lsode.iter_matrix, &lsode.iter_pivots, &mut delta);
let iter_delta = weighted_rms_norm(&delta, &lsode.error_weight);
for i in 0..N_ODES {
accum[i] += delta[i];
y_work[i] = pred0[i] + el0 * accum[i];
}
if iter != 0 {
lsode.convergence_rate =
(0.2 * lsode.convergence_rate).max(iter_delta / prev_iter_delta);
}
let dcon =
iter_delta * (1.0_f64).min(1.5 * lsode.convergence_rate) / (tesco1 * conv_factor);
if dcon <= 1.0 {
converged = true;
break;
}
if iter >= 1 && iter_delta > 2.0 * prev_iter_delta {
break;
}
prev_iter_delta = iter_delta;
}
if converged {
return (true, false);
}
lsode.jacobian_current = false;
if built_now {
return (false, true);
}
need_build = true;
}
}
#[allow(
clippy::float_cmp,
reason = "exact r0==0 guard mirrors DPREPJ's fpclassify(r0)==FP_ZERO fallback to r0=1"
)]
#[allow(
clippy::cast_precision_loss,
reason = "N_ODES = 6 is exactly representable in f64"
)]
fn build_dense_iteration_matrix(
lsode: &mut LsodeState,
accel_fn: &impl Fn(&TranslationalState, f64) -> DVec3,
frac: f64,
y_base: &[f64; N_ODES],
f_base: &[f64; N_ODES],
hl0: f64,
) -> Result<(), usize> {
let eps = f64::EPSILON;
let srur = eps.sqrt(); let fac0 = weighted_rms_norm(f_base, &lsode.error_weight);
let mut r0 = 1000.0 * eps * lsode.step_size.abs() * (N_ODES as f64) * fac0;
if r0 == 0.0 {
r0 = 1.0;
}
let mut y = *y_base;
let mut ftem = [0.0_f64; N_ODES];
for j in 0..N_ODES {
let yj = y_base[j];
let r = (srur * yj.abs()).max(r0 / lsode.error_weight[j]);
y[j] = yj + r;
eval_derivative(&y, accel_fn, frac, &mut ftem);
let fac = -hl0 / r;
for i in 0..N_ODES {
lsode.iter_matrix[i][j] = (ftem[i] - f_base[i]) * fac;
}
y[j] = yj; }
for i in 0..N_ODES {
lsode.iter_matrix[i][i] += 1.0;
}
linalg::lu_factor(&mut lsode.iter_matrix, &mut lsode.iter_pivots)
}
fn retract_prediction(lsode: &mut LsodeState) {
for i_iter in (1..=lsode.order).rev() {
for j_hist in (i_iter - 1)..lsode.order {
for k_var in 0..N_ODES {
lsode.nordsieck.history[k_var][j_hist] -=
lsode.nordsieck.history[k_var][j_hist + 1];
}
}
}
}
fn apply_step_ratio(lsode: &mut LsodeState, ratio: f64, max_step_size_inv: f64) {
let mut r = ratio.min(lsode.max_step_increase_ratio);
r /= (1.0_f64).max(lsode.step_size.abs() * max_step_size_inv * r);
lsode.nordsieck.rescale_columns(r, lsode.num_cols);
lsode.step_size *= r;
lsode.order_select_para = lsode.num_cols;
}
#[allow(
clippy::cast_precision_loss,
reason = "column count ≤ 13 is exactly representable in f64"
)]
fn compute_r_inc(lsode: &LsodeState, accum: &[f64; N_ODES]) -> f64 {
if lsode.num_cols == lsode.max_history_size {
return 0.0;
}
let diff: [f64; N_ODES] =
std::array::from_fn(|i| accum[i] - lsode.nordsieck.history[i][lsode.max_history_size]);
let dup = weighted_rms_norm(&diff, &lsode.error_weight) / lsode.test_coeffs[2][lsode.order - 1];
let exup = 1.0 / (lsode.num_cols as f64 + 1.0);
1.0 / (1.4 * dup.powf(exup) + 0.0000014)
}
fn fail_reset_order_1(
lsode: &mut LsodeState,
accel_fn: &impl Fn(&TranslationalState, f64) -> DVec3,
_max_step_size_inv: f64,
) {
let ratio = (lsode.config.min_step_size / lsode.step_size.abs()).max(0.1);
lsode.step_size *= ratio;
let y0: [f64; N_ODES] = std::array::from_fn(|i| lsode.nordsieck.history[i][0]);
let mut save = [0.0_f64; N_ODES];
eval_derivative(&y0, accel_fn, 0.0, &mut save);
for i in 0..N_ODES {
lsode.nordsieck.history[i][1] = lsode.step_size * save[i];
}
lsode.order_select_para = 5;
lsode.order = 1;
lsode.num_cols = 2;
lsode.reset_method_coeffs();
}
#[allow(
clippy::cast_precision_loss,
reason = "order/column counts ≤ 13 are exactly representable in f64"
)]
fn select_new_order(
lsode: &mut LsodeState,
accum: &[f64; N_ODES],
dsm: f64,
step_error: i32,
r_inc: f64,
max_step_size_inv: f64,
) {
let exsm = 1.0 / lsode.num_cols as f64;
let r_same = 1.0 / (1.2 * dsm.powf(exsm) + 0.0000012);
let mut r_dec = 0.0;
if lsode.order != 1 {
let col: [f64; N_ODES] =
std::array::from_fn(|i| lsode.nordsieck.history[i][lsode.num_cols - 1]);
let ddn =
weighted_rms_norm(&col, &lsode.error_weight) / lsode.test_coeffs[0][lsode.order - 1];
let exdn = 1.0 / lsode.order as f64;
r_dec = 1.0 / (1.3 * ddn.powf(exdn) + 0.0000013);
}
let (new_order, ratio);
if r_same >= r_inc && r_same >= r_dec {
new_order = lsode.order;
ratio = r_same;
} else if r_inc > r_dec {
new_order = lsode.num_cols; ratio = r_inc;
if ratio < 1.1 {
lsode.order_select_para = 3;
return;
}
let r = lsode.el[lsode.num_cols - 1] / lsode.num_cols as f64;
for i in 0..N_ODES {
lsode.nordsieck.history[i][new_order] = accum[i] * r;
}
set_new_order(lsode, new_order, ratio, max_step_size_inv);
return;
} else {
new_order = lsode.order - 1;
ratio = if step_error < 0 {
r_dec.min(1.0)
} else {
r_dec
};
}
if step_error == 0 && ratio < 1.1 {
lsode.order_select_para = 3;
return;
}
let ratio = if step_error <= -2 {
ratio.min(0.2)
} else {
ratio
};
set_new_order(lsode, new_order, ratio, max_step_size_inv);
}
fn set_new_order(lsode: &mut LsodeState, new_order: usize, ratio: f64, max_step_size_inv: f64) {
if new_order == lsode.order {
let ratio = ratio.max(lsode.config.min_step_size / lsode.step_size.abs());
apply_step_ratio(lsode, ratio, max_step_size_inv);
} else {
lsode.order = new_order;
lsode.num_cols = new_order + 1;
lsode.reset_method_coeffs();
apply_step_ratio(lsode, ratio, max_step_size_inv);
}
}
fn interpolate_to_target(lsode: &LsodeState) -> TranslationalState {
let s = (lsode.cycle_target_time - lsode.stage_target_time) / lsode.step_size;
let mut y = [0.0_f64; N_ODES];
for i in 0..N_ODES {
y[i] = lsode.nordsieck.history[i][lsode.num_cols - 1];
}
for jb in 1..=lsode.order {
let j = lsode.order - jb;
for i in 0..N_ODES {
y[i] = y[i] * s + lsode.nordsieck.history[i][j];
}
}
TranslationalState {
position: DVec3::new(y[0], y[1], y[2]),
velocity: DVec3::new(y[3], y[4], y[5]),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kepler_accel(mu: f64) -> impl Fn(&TranslationalState, f64) -> DVec3 {
move |s: &TranslationalState, _frac: f64| {
let r = s.position;
let rn = r.length();
-mu * r / (rn * rn * rn)
}
}
fn damped_oscillator_accel(k: f64, c: f64) -> impl Fn(&TranslationalState, f64) -> DVec3 {
move |s: &TranslationalState, _frac: f64| {
DVec3::new(-k * s.position.x - c * s.velocity.x, 0.0, 0.0)
}
}
fn bdf_config(rel_tolerance: f64, abs_tolerance: f64) -> LsodeConfig {
LsodeConfig {
method: IntegrationMethod::ImplicitBackDiffStiff,
corrector: CorrectorMethod::NewtonIterInternalJac,
max_order: 5,
rel_tolerance,
abs_tolerance,
..LsodeConfig::default()
}
}
#[test]
fn bdf_stiff_overdamped_oscillator_matches_analytic() {
let (k, c) = (1.0_f64, 200.0_f64);
let disc = (c * c - 4.0 * k).sqrt();
let lam1 = (-c + disc) / 2.0; let lam2 = (-c - disc) / 2.0; let a_coef = lam2 / (lam2 - lam1);
let b_coef = -lam1 / (lam2 - lam1);
let analytic = |t: f64| a_coef * (lam1 * t).exp() + b_coef * (lam2 * t).exp();
let start = TranslationalState {
position: DVec3::new(1.0, 0.0, 0.0),
velocity: DVec3::ZERO,
};
let mut lsode = LsodeState::new(bdf_config(1e-7, 1e-9));
let accel = damped_oscillator_accel(k, c);
let dt = 0.05; let mut s = start;
let mut t = 0.0;
for _ in 0..40 {
s = lsode_translational_step(&s, &accel, dt, &mut lsode);
t += dt;
let want = analytic(t);
assert!(
(s.position.x - want).abs() < 1e-5,
"BDF stiff x(t={t:.2}) = {} vs analytic {want} (err {:e})",
s.position.x,
(s.position.x - want).abs()
);
}
assert!(s.position.x.abs() < 1.0, "solution did not stay bounded");
assert!(
lsode.jacobian_current,
"stiff BDF run never built/used a finite-difference Jacobian — the dense Newton \
chord corrector was not exercised"
);
assert!(
lsode.steps_at_last_jacobian > 0,
"iteration matrix was never (re)built during the stiff transient"
);
}
#[test]
fn adams_newton_corrector_matches_functional_on_orbit() {
let mu = 3.986_004_418e14_f64;
let r0 = 7_000_000.0_f64;
let v0 = (mu / r0).sqrt();
let start = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let accel = kepler_accel(mu);
let dt = 30.0;
let n = 200usize;
let mut func = LsodeState::new(LsodeConfig {
rel_tolerance: 1e-11,
abs_tolerance: 1e-6,
..LsodeConfig::default()
});
let mut newt = LsodeState::new(LsodeConfig {
corrector: CorrectorMethod::NewtonIterInternalJac,
rel_tolerance: 1e-11,
abs_tolerance: 1e-6,
..LsodeConfig::default()
});
let mut sf = start;
let mut sn = start;
for _ in 0..n {
sf = lsode_translational_step(&sf, &accel, dt, &mut func);
sn = lsode_translational_step(&sn, &accel, dt, &mut newt);
}
let pos_diff = (sf.position - sn.position).length();
let vel_diff = (sf.velocity - sn.velocity).length();
assert!(
pos_diff < 1.0,
"Newton vs functional position diverged: {pos_diff:.3e} m (>1 m ⇒ different solution)"
);
assert!(
vel_diff < 1e-3,
"Newton vs functional velocity diverged: {vel_diff:.3e} m/s"
);
}
#[test]
#[should_panic(expected = "JacobiNewtonInternalJac")]
fn jacobi_newton_diagonal_panics_until_ported() {
let start = TranslationalState {
position: DVec3::new(7_000_000.0, 0.0, 0.0),
velocity: DVec3::new(0.0, 7_546.0, 0.0),
};
let mut lsode = LsodeState::new(LsodeConfig {
method: IntegrationMethod::ImplicitBackDiffStiff,
corrector: CorrectorMethod::JacobiNewtonInternalJac,
max_order: 5,
..LsodeConfig::default()
});
let accel = kepler_accel(3.986_004_418e14);
lsode_translational_step(&start, &accel, 30.0, &mut lsode);
}
#[test]
fn lsode_tight_tolerance_run_lsode_ics_is_stable() {
let mu = 6_811_137.0_f64.powi(3) * 1.123_154_395_240_404_1e-3_f64.powi(2);
let start = TranslationalState {
position: DVec3::new(2_554_176.375, 5_859_203.640_407_667, 2_353_189.957_992_002),
velocity: DVec3::new(
6_580.790_321_332_448,
-1_407.722_362_559_127,
-3_637.771_420_498,
),
};
let mut lsode = LsodeState::new(LsodeConfig {
rel_tolerance: 2.3e-16,
abs_tolerance: 0.0,
..LsodeConfig::default()
});
let accel = kepler_accel(mu);
let dt = 15.539_530_979_805_79; let mut s = start;
for _ in 0..20 {
s = lsode_translational_step(&s, &accel, dt, &mut lsode);
}
assert!(lsode.order >= 4, "order stuck low ({})", lsode.order);
assert!(
lsode.num_steps_taken < 200,
"too many internal steps ({}) — step collapsed",
lsode.num_steps_taken
);
let e0 = 0.5 * start.velocity.length_squared() - mu / start.position.length();
let e = 0.5 * s.velocity.length_squared() - mu / s.position.length();
assert!(((e - e0) / e0).abs() < 1e-10, "energy drift too large");
}
#[test]
fn lsode_circular_orbit_closes_and_conserves_energy() {
let mu = 3.986_004_418e14_f64;
let r0 = 6_778_137.0_f64; let v0 = (mu / r0).sqrt();
let period = std::f64::consts::TAU * (r0 * r0 * r0 / mu).sqrt();
let start = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let energy =
|s: &TranslationalState| 0.5 * s.velocity.length_squared() - mu / s.position.length();
let e0 = energy(&start);
let mut lsode = LsodeState::new(LsodeConfig {
rel_tolerance: 1e-12,
abs_tolerance: 1e-6,
..LsodeConfig::default()
});
let accel = kepler_accel(mu);
let dt = 60.0;
let n = (period / dt).round() as usize;
let mut s = start;
for _ in 0..n {
s = lsode_translational_step(&s, &accel, dt, &mut lsode);
}
let e_err = ((energy(&s) - e0) / e0).abs();
assert!(
e_err < 2e-9,
"relative energy drift {e_err:.3e} too large over one orbit"
);
let r_err = (s.position.length() - r0).abs() / r0;
assert!(r_err < 1e-4, "radius drift {r_err:.3e} too large");
}
#[test]
fn lsode_quarter_period_matches_analytic_circle() {
let mu = 3.986_004_418e14_f64;
let r0 = 7_000_000.0_f64;
let v0 = (mu / r0).sqrt();
let period = std::f64::consts::TAU * (r0 * r0 * r0 / mu).sqrt();
let start = TranslationalState {
position: DVec3::new(r0, 0.0, 0.0),
velocity: DVec3::new(0.0, v0, 0.0),
};
let mut lsode = LsodeState::new(LsodeConfig {
rel_tolerance: 1e-11,
abs_tolerance: 1e-6,
..LsodeConfig::default()
});
let accel = kepler_accel(mu);
let quarter = period / 4.0;
let n = 200usize;
let dt = quarter / n as f64;
let mut s = start;
for _ in 0..n {
s = lsode_translational_step(&s, &accel, dt, &mut lsode);
}
assert!(s.position.x.abs() < 1.0e2, "x = {} not ~0", s.position.x);
assert!(
(s.position.y - r0).abs() < 1.0e2,
"y = {} not ~r0 ({r0})",
s.position.y
);
}
}