use super::*;
use crate::matrix::SquareMatrix;
use crate::ode::{OdeSolver, OdeSystem};
struct Xorshift(u64);
impl Xorshift {
fn new(seed: u64) -> Self {
Self(seed | 1)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn next_unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1_u64 << 53) as f64
}
fn next_in(&mut self, lo: f64, hi: f64) -> f64 {
lo + (hi - lo) * self.next_unit()
}
}
struct Decay {
k: f64,
}
impl OdeSystem for Decay {
fn n_eqns(&self) -> usize {
1
}
fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
dydx[0] = -self.k * y[0];
}
fn jacobian(&self, _x: f64, _y: &[f64], dfdx: &mut Vec<f64>, dfdy: &mut SquareMatrix) {
dfdx[0] = 0.0;
dfdy.set(0, 0, -self.k);
}
}
struct Harmonic;
impl OdeSystem for Harmonic {
fn n_eqns(&self) -> usize {
2
}
fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
dydx[0] = -y[1];
dydx[1] = y[0];
}
fn jacobian(&self, _x: f64, _y: &[f64], dfdx: &mut Vec<f64>, dfdy: &mut SquareMatrix) {
dfdx[0] = 0.0;
dfdx[1] = 0.0;
dfdy.set(0, 0, 0.0);
dfdy.set(0, 1, -1.0);
dfdy.set(1, 0, 1.0);
dfdy.set(1, 1, 0.0);
}
}
struct StiffPair {
s: f64,
}
impl OdeSystem for StiffPair {
fn n_eqns(&self) -> usize {
2
}
fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
dydx[0] = -y[0];
dydx[1] = -self.s * y[1];
}
fn jacobian(&self, _x: f64, _y: &[f64], dfdx: &mut Vec<f64>, dfdy: &mut SquareMatrix) {
dfdx[0] = 0.0;
dfdx[1] = 0.0;
dfdy.set(0, 0, -1.0);
dfdy.set(0, 1, 0.0);
dfdy.set(1, 0, 0.0);
dfdy.set(1, 1, -self.s);
}
}
struct Blowup;
impl OdeSystem for Blowup {
fn n_eqns(&self) -> usize {
1
}
fn derivatives(&self, _x: f64, y: &[f64], dydx: &mut Vec<f64>) {
dydx[0] = y[0] * y[0];
}
}
#[test]
fn ensemble_matches_analytic_decay() {
let n = 64;
let ks: Vec<f64> = (0..n)
.map(|i| 0.5 + 7.5 * i as f64 / (n as f64 - 1.0))
.collect();
let lanes: Vec<OdeLane<Decay>> = ks
.iter()
.map(|&k| OdeLane::new(Decay { k }, vec![1.0], 0.0, 1.0, 0.05))
.collect();
for (solver, tol) in [
(OdeSolver::euler(1, 1e-3, 1e-2), 5e-2),
(OdeSolver::rkf45(1, 1e-10, 1e-8), 1e-8),
(OdeSolver::rosenbrock23(1, 1e-10, 1e-8), 1e-8),
] {
let name = solver.name();
let ensemble = integrate_ensemble(&lanes, &solver, ComputeBackend::CpuMulti);
assert!(
ensemble.all_completed(),
"{name}: {:?}",
ensemble.first_failure().map(|(i, l)| (i, l.status()))
);
let states = ensemble.states().expect("all lanes complete");
let mut worst = 0.0_f64;
for (k, s) in ks.iter().zip(&states) {
worst = worst.max((s[0] - (-k).exp()).abs());
}
println!(
"decay ensemble: {name} worst |error| = {worst:.6e} over {n} lanes, \
total steps = {}, max lane steps = {}",
ensemble.total_steps(),
ensemble.max_steps_taken()
);
assert!(worst < tol, "{name}: worst error {worst:.6e} >= {tol:.1e}");
}
}
#[test]
fn ensemble_matches_harmonic_oscillator() {
let n = 16;
let ends: Vec<f64> = (1..=n)
.map(|m| m as f64 * std::f64::consts::FRAC_PI_2)
.collect();
let lanes: Vec<OdeLane<Harmonic>> = ends
.iter()
.map(|&x_end| OdeLane::new(Harmonic, vec![1.0, 0.0], 0.0, x_end, 0.05))
.collect();
let ensemble = integrate_ensemble(
&lanes,
&OdeSolver::rkf45(2, 1e-12, 1e-10),
ComputeBackend::CpuMulti,
);
let states = ensemble.states().expect("all lanes complete");
let mut worst = 0.0_f64;
for (x, s) in ends.iter().zip(&states) {
worst = worst
.max((s[0] - x.cos()).abs())
.max((s[1] - x.sin()).abs());
}
println!("harmonic ensemble: worst |error| = {worst:.6e} over {n} lanes");
assert!(worst < 1e-8, "worst error {worst:.6e}");
}
#[test]
fn lumped_body_accuracy_is_set_by_the_relative_tolerance() {
struct LumpedBody {
tau_s: f64,
t_inf_k: f64,
}
impl OdeSystem for LumpedBody {
fn n_eqns(&self) -> usize {
1
}
fn derivatives(&self, _t: f64, y: &[f64], dydt: &mut Vec<f64>) {
dydt[0] = -(y[0] - self.t_inf_k) / self.tau_s;
}
}
let taus = [5.0_f64, 20.0];
let exact: Vec<f64> = taus
.iter()
.map(|t| 300.0 + 200.0 * (-10.0 / t).exp())
.collect();
println!("closed form: {:.16} K, {:.16} K", exact[0], exact[1]);
let mut worst_at = Vec::new();
for (abs_tol, rel_tol) in [(1e-10, 1e-8), (1e-12, 1e-10), (1e-14, 1e-12)] {
let lanes: Vec<OdeLane<LumpedBody>> = taus
.iter()
.map(|&tau| {
OdeLane::new(
LumpedBody {
tau_s: tau,
t_inf_k: 300.0,
},
vec![500.0],
0.0,
10.0,
0.1,
)
})
.collect();
let ensemble = integrate_ensemble(
&lanes,
&OdeSolver::rkf45(1, abs_tol, rel_tol),
ComputeBackend::Serial,
);
let states = ensemble.states().expect("both lanes complete");
let e0 = (states[0][0] - exact[0]).abs();
let e1 = (states[1][0] - exact[1]).abs();
let worst = e0.max(e1);
println!(
"abs_tol {abs_tol:.0e} rel_tol {rel_tol:.0e}: \
tau=5 error {e0:.6e} K, tau=20 error {e1:.6e} K, worst {worst:.6e} K"
);
worst_at.push(worst);
}
assert!(worst_at[0] < 1e-4, "rel_tol 1e-8 worst {:.6e}", worst_at[0]);
assert!(
worst_at[2] < 1e-8,
"rel_tol 1e-12 worst {:.6e}",
worst_at[2]
);
assert!(
worst_at[0] > worst_at[1] && worst_at[1] > worst_at[2],
"tightening the tolerance must reduce the error: {worst_at:?}"
);
}
#[test]
fn stiff_pair_completes_stiff_and_fails_visibly_explicit() {
let lanes = vec![OdeLane::new(
StiffPair { s: 1.0e5 },
vec![1.0, 1.0],
0.0,
1.0,
1.0e-3,
)];
let stiff = integrate_ensemble(
&lanes,
&OdeSolver::rosenbrock23(2, 1e-10, 1e-8),
ComputeBackend::Serial,
);
let l = &stiff.lanes()[0];
println!(
"stiff pair / Rosenbrock23: status = {}, steps = {}, y = {:?}",
l.status().label(),
l.steps(),
l.last_state()
);
let state = stiff
.states()
.expect("Rosenbrock23 spans the stiff interval");
assert!(
(state[0][0] - (-1.0_f64).exp()).abs() < 1e-7,
"y1 = {}",
state[0][0]
);
assert!(state[0][1].abs() < 1e-30, "y2 = {}", state[0][1]);
let explicit = integrate_ensemble(
&lanes,
&OdeSolver::rkf45(2, 1e-10, 1e-8),
ComputeBackend::Serial,
);
let l = &explicit.lanes()[0];
println!(
"stiff pair / Rkf45: status = {}, steps = {}, x_reached = {:.6e}",
l.status().label(),
l.steps(),
l.x_reached()
);
assert!(
!l.completed(),
"Rkf45 must not claim to span a stiff interval"
);
assert!(
matches!(
l.status(),
OdeLaneStatus::MaxStepsExceeded | OdeLaneStatus::StepSizeUnderflow
),
"unexpected status {:?}",
l.status()
);
assert!(l.last_state().iter().all(|v| v.is_finite()));
assert!(l.x_reached() > 0.0 && l.x_reached() < 1.0);
assert!(explicit.states().is_err(), "all-or-nothing must refuse");
}
#[test]
fn gauss_legendre_is_exact_to_its_degree() {
let mut worst = 0.0_f64;
for order in [
GaussOrder::G2,
GaussOrder::G3,
GaussOrder::G4,
GaussOrder::G5,
GaussOrder::G8,
] {
for d in 0..=order.exact_degree() {
for (a, b) in [(0.0_f64, 1.0_f64), (-2.0, 3.0)] {
let batch = quadrature_batch_min(
&[QuadratureInterval::new(a, b)],
QuadratureRule::GaussLegendre { order, panels: 1 },
ComputeBackend::Serial,
0,
|_, x| x.powi(d as i32),
);
let got = batch.values().expect("evaluates")[0];
let exact = (b.powi(d as i32 + 1) - a.powi(d as i32 + 1)) / (d as f64 + 1.0);
let rel = (got - exact).abs() / exact.abs().max(1.0);
worst = worst.max(rel);
assert!(
rel < 1e-13,
"{order:?} degree {d} on [{a}, {b}]: {got} vs {exact}, rel {rel:.6e}"
);
}
}
}
println!("gauss exactness: worst relative error = {worst:.6e}");
}
#[test]
fn gauss_nodes_match_the_in_workspace_abramowitz_stegun_values() {
#[allow(clippy::excessive_precision)]
const AS_NODES: [f64; 4] = [
0.183_434_642_495_649_8,
0.525_532_409_916_329_0,
0.796_666_477_413_626_7,
0.960_289_856_497_536_3,
];
#[allow(clippy::excessive_precision)]
const AS_WEIGHTS: [f64; 4] = [
0.362_683_783_378_362_0,
0.313_706_645_877_887_3,
0.222_381_034_453_374_5,
0.101_228_536_290_376_3,
];
let nodes = gauss_legendre_nodes(8);
assert_eq!(nodes.len(), 8);
let mut worst_node = 0.0_f64;
let mut worst_weight = 0.0_f64;
for (k, &(x, w)) in nodes[4..].iter().enumerate() {
worst_node = worst_node.max((x - AS_NODES[k]).abs());
worst_weight = worst_weight.max((w - AS_WEIGHTS[k]).abs());
}
let weight_sum: f64 = nodes.iter().map(|&(_, w)| w).sum();
println!(
"GL8 vs A&S 25.4.30: worst |node diff| = {worst_node:.6e}, \
worst |weight diff| = {worst_weight:.6e}, weight sum = {weight_sum:.17}"
);
assert!(worst_node < 1e-15, "node mismatch {worst_node:.6e}");
assert!(worst_weight < 1e-15, "weight mismatch {worst_weight:.6e}");
assert!((weight_sum - 2.0).abs() < 1e-15, "weight sum {weight_sum}");
for k in 0..4 {
assert!((nodes[k].0 + nodes[7 - k].0).abs() < 1e-15);
assert!((nodes[k].1 - nodes[7 - k].1).abs() < 1e-15);
}
}
#[test]
fn simpson_and_trapezoid_are_exact_to_their_degree() {
let mut worst_simpson = 0.0_f64;
let mut worst_trapezoid = 0.0_f64;
for (a, b) in [(0.0_f64, 1.0_f64), (-2.0, 3.0)] {
let exact = |d: usize| (b.powi(d as i32 + 1) - a.powi(d as i32 + 1)) / (d as f64 + 1.0);
for d in 0..=3usize {
let batch = quadrature_batch_min(
&[QuadratureInterval::new(a, b)],
QuadratureRule::Simpson { panels: 3 },
ComputeBackend::Serial,
0,
|_, x| x.powi(d as i32),
);
let got = batch.values().unwrap()[0];
let rel = (got - exact(d)).abs() / exact(d).abs().max(1.0);
worst_simpson = worst_simpson.max(rel);
assert!(rel < 1e-13, "simpson degree {d}: {got} vs {}", exact(d));
}
for d in 0..=1usize {
let batch = quadrature_batch_min(
&[QuadratureInterval::new(a, b)],
QuadratureRule::Trapezoid { panels: 5 },
ComputeBackend::Serial,
0,
|_, x| x.powi(d as i32),
);
let got = batch.values().unwrap()[0];
let rel = (got - exact(d)).abs() / exact(d).abs().max(1.0);
worst_trapezoid = worst_trapezoid.max(rel);
assert!(rel < 1e-13, "trapezoid degree {d}: {got} vs {}", exact(d));
}
}
println!(
"simpson worst relative error (degree <= 3) = {worst_simpson:.6e}; \
trapezoid worst relative error (degree <= 1) = {worst_trapezoid:.6e}"
);
}
#[test]
fn fixed_rules_match_a_transcendental_reference() {
let exact = (1.0 + (-std::f64::consts::PI).exp()) / 2.0;
let iv = [QuadratureInterval::new(0.0, std::f64::consts::PI)];
let f = |_: usize, x: f64| (-x).exp() * x.sin();
let run = |rule: QuadratureRule| -> (f64, f64) {
let batch = quadrature_batch_min(&iv, rule, ComputeBackend::Serial, 0, f);
let v = batch.values().expect("evaluates")[0];
(v, (v - exact).abs())
};
let (g8, e_g8) = run(QuadratureRule::GaussLegendre {
order: GaussOrder::G8,
panels: 8,
});
let (si, e_si) = run(QuadratureRule::Simpson { panels: 64 });
let (tr, e_tr) = run(QuadratureRule::Trapezoid { panels: 128 });
println!("closed form = {exact:.17}");
println!("G8 x 8 panels (64 evals) = {g8:.17}, error {e_g8:.6e}");
println!("Simpson x 64 (129 evals) = {si:.17}, error {e_si:.6e}");
println!("Trapezoid x 128 (129 evals)= {tr:.17}, error {e_tr:.6e}");
assert!(e_g8 < 1e-12, "G8 error {e_g8:.6e}");
assert!(e_si < 1e-7, "Simpson error {e_si:.6e}");
assert!(e_tr < 1e-4, "trapezoid error {e_tr:.6e}");
assert!(e_g8 < e_si, "G8 must beat Simpson");
assert!(e_si < e_tr, "Simpson must beat trapezoid");
}
#[test]
fn adaptive_matches_closed_forms() {
let pi = std::f64::consts::PI;
let intervals = [
QuadratureInterval::new(0.0, pi),
QuadratureInterval::new(0.0, 1.0),
QuadratureInterval::new(0.0, 1.0),
];
let exact = [
(1.0 + (-pi).exp()) / 2.0,
2.0 / 3.0,
(10.0_f64).atan() / 10.0,
];
let settings = AdaptiveSettings {
abs_tol: 1e-11,
rel_tol: 1e-10,
max_subdivisions: 100_000,
};
let batch =
adaptive_quadrature_batch_min(&intervals, settings, ComputeBackend::Serial, 0, |i, x| {
match i {
0 => (-x).exp() * x.sin(),
1 => x.sqrt(),
_ => 1.0 / (1.0 + 400.0 * (x - 0.5) * (x - 0.5)),
}
});
let names = ["exp(-x) sin(x)", "sqrt(x)", "narrow peak"];
for (i, s) in batch.solutions().iter().enumerate() {
let err = (s.last_value() - exact[i]).abs();
println!(
"adaptive {:<15} value = {:.17}, error = {err:.6e}, \
estimate = {:.6e}, evals = {}, status = {}",
names[i],
s.last_value(),
s.error_estimate(),
s.evaluations(),
s.status().label()
);
assert!(s.evaluated(), "lane {i} status {:?}", s.status());
assert!(err < 1e-9, "lane {i} error {err:.6e}");
assert!(
s.error_estimate() * 100.0 >= err || err == 0.0,
"lane {i}: estimate {:.6e} badly understates error {err:.6e}",
s.error_estimate()
);
}
}
fn imbalanced_lanes(n: usize) -> Vec<OdeLane<Decay>> {
let mut rng = Xorshift::new(0x5eed_1234);
(0..n)
.map(|i| {
let k = if i % 2 == 0 {
rng.next_in(0.5, 1.5)
} else {
rng.next_in(50.0, 70.0)
};
OdeLane::new(Decay { k }, vec![1.0], 0.0, 1.0, 0.1)
})
.collect()
}
fn assert_ensembles_bitwise_equal(a: &OdeEnsemble, b: &OdeEnsemble, what: &str) {
assert_eq!(a.len(), b.len(), "{what}: length");
for (i, (x, y)) in a.lanes().iter().zip(b.lanes()).enumerate() {
assert_eq!(x.status(), y.status(), "{what}: lane {i} status");
assert_eq!(x.steps(), y.steps(), "{what}: lane {i} steps");
assert_eq!(
x.x_reached().to_bits(),
y.x_reached().to_bits(),
"{what}: lane {i} x_reached"
);
assert_eq!(
x.dx_final().to_bits(),
y.dx_final().to_bits(),
"{what}: lane {i} dx_final"
);
assert_eq!(
x.last_state().len(),
y.last_state().len(),
"{what}: lane {i} state length"
);
for (c, (p, q)) in x.last_state().iter().zip(y.last_state()).enumerate() {
assert_eq!(
p.to_bits(),
q.to_bits(),
"{what}: lane {i} component {c}: {p} vs {q}"
);
}
}
}
fn assert_batches_bitwise_equal(a: &QuadratureBatch, b: &QuadratureBatch, what: &str) {
assert_eq!(a.len(), b.len(), "{what}: length");
for (i, (x, y)) in a.solutions().iter().zip(b.solutions()).enumerate() {
assert_eq!(x.status(), y.status(), "{what}: lane {i} status");
assert_eq!(
x.evaluations(),
y.evaluations(),
"{what}: lane {i} evaluations"
);
assert_eq!(
x.last_value().to_bits(),
y.last_value().to_bits(),
"{what}: lane {i} value {} vs {}",
x.last_value(),
y.last_value()
);
assert_eq!(
x.error_estimate().to_bits(),
y.error_estimate().to_bits(),
"{what}: lane {i} error estimate"
);
}
}
#[test]
fn ensemble_bitwise_identical_across_backends() {
let lanes = imbalanced_lanes(512);
for solver in [
OdeSolver::euler(1, 1e-6, 1e-5),
OdeSolver::rkf45(1, 1e-10, 1e-8),
OdeSolver::rosenbrock23(1, 1e-10, 1e-8),
] {
let name = solver.name();
let serial = integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::Serial, 0);
let multi = integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::CpuMulti, 0);
assert_ensembles_bitwise_equal(&serial, &multi, name);
}
}
#[cfg(feature = "parallel")]
#[test]
fn ensemble_bitwise_identical_across_thread_counts() {
let lanes = imbalanced_lanes(512);
for solver in [
OdeSolver::rkf45(1, 1e-10, 1e-8),
OdeSolver::rosenbrock23(1, 1e-10, 1e-8),
] {
let name = solver.name();
let serial = integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::Serial, 0);
for threads in [1_usize, 2, 4, 8] {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("thread pool");
pool.install(|| {
let multi =
integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::CpuMulti, 0);
assert_ensembles_bitwise_equal(
&serial,
&multi,
&format!("{name} @ {threads} threads"),
);
});
}
}
}
#[test]
fn quadrature_bitwise_identical_across_backends_and_threads() {
let n = 512;
let mut rng = Xorshift::new(0xfeed_beef);
let mut intervals = Vec::with_capacity(n);
let mut a = Vec::with_capacity(n);
let mut b = Vec::with_capacity(n);
for _ in 0..n {
let lo = rng.next_in(0.0, 1.0);
intervals.push(QuadratureInterval::new(lo, lo + rng.next_in(0.5, 3.0)));
a.push(rng.next_in(0.1, 3.0));
b.push(rng.next_in(1.0, 30.0));
}
let f = |i: usize, x: f64| (-a[i] * x).exp() * (b[i] * x).sin();
let rule = QuadratureRule::GaussLegendre {
order: GaussOrder::G5,
panels: 16,
};
let settings = AdaptiveSettings {
abs_tol: 1e-11,
..AdaptiveSettings::default()
};
let fixed_serial = quadrature_batch_min(&intervals, rule, ComputeBackend::Serial, 0, f);
let fixed_multi = quadrature_batch_min(&intervals, rule, ComputeBackend::CpuMulti, 0, f);
assert_batches_bitwise_equal(&fixed_serial, &fixed_multi, "fixed backends");
let adaptive_serial =
adaptive_quadrature_batch_min(&intervals, settings, ComputeBackend::Serial, 0, f);
let adaptive_multi =
adaptive_quadrature_batch_min(&intervals, settings, ComputeBackend::CpuMulti, 0, f);
assert_batches_bitwise_equal(&adaptive_serial, &adaptive_multi, "adaptive backends");
#[cfg(feature = "parallel")]
for threads in [1_usize, 2, 4, 8] {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("thread pool");
pool.install(|| {
let fixed = quadrature_batch_min(&intervals, rule, ComputeBackend::CpuMulti, 0, f);
assert_batches_bitwise_equal(
&fixed_serial,
&fixed,
&format!("fixed @ {threads} threads"),
);
let adaptive =
adaptive_quadrature_batch_min(&intervals, settings, ComputeBackend::CpuMulti, 0, f);
assert_batches_bitwise_equal(
&adaptive_serial,
&adaptive,
&format!("adaptive @ {threads} threads"),
);
});
}
}
#[test]
fn lane_order_is_preserved() {
let n = 256;
let lanes: Vec<OdeLane<Decay>> = (0..n)
.map(|i| OdeLane::new(Decay { k: 1.0 }, vec![i as f64], 0.0, 0.0, 0.1))
.collect();
let ensemble = integrate_ensemble_min(
&lanes,
|_| OdeSolver::rkf45(1, 1e-10, 1e-8),
ComputeBackend::CpuMulti,
0,
);
for (i, s) in ensemble.states().unwrap().iter().enumerate() {
assert_eq!(s[0], i as f64, "lane {i} out of order");
}
}
#[test]
fn max_steps_exceeded_is_reported() {
let lanes = vec![OdeLane::new(Decay { k: 1.0 }, vec![1.0], 0.0, 100.0, 1e-6)];
let mut solver = OdeSolver::rkf45(1, 1e-12, 1e-12);
solver.config_mut().max_steps = 5;
solver.config_mut().max_scale = 1.0;
let ensemble = integrate_ensemble(&lanes, &solver, ComputeBackend::Serial);
let l = &ensemble.lanes()[0];
assert_eq!(l.status(), OdeLaneStatus::MaxStepsExceeded);
assert!(l.state().is_none(), "a failed lane must not offer a state");
assert!(l.last_state()[0].is_finite());
assert!(l.x_reached() < 100.0);
assert!(l.steps() > 0);
let err = ensemble.states().expect_err("must refuse");
assert_eq!(err.total, 1);
assert_eq!(err.failure_count, 1);
assert_eq!(err.first_index, 0);
assert_eq!(err.first_status, OdeLaneStatus::MaxStepsExceeded);
}
#[test]
fn invalid_lanes_are_reported() {
let solver = OdeSolver::rkf45(1, 1e-10, 1e-8);
let cases: Vec<(&str, OdeLane<Decay>)> = vec![
(
"wrong state length",
OdeLane::new(Decay { k: 1.0 }, vec![1.0, 2.0], 0.0, 1.0, 0.1),
),
(
"non-finite x_end",
OdeLane::new(Decay { k: 1.0 }, vec![1.0], 0.0, f64::INFINITY, 0.1),
),
(
"non-finite y0",
OdeLane::new(Decay { k: 1.0 }, vec![f64::NAN], 0.0, 1.0, 0.1),
),
(
"zero dx0",
OdeLane::new(Decay { k: 1.0 }, vec![1.0], 0.0, 1.0, 0.0),
),
(
"negative dx0",
OdeLane::new(Decay { k: 1.0 }, vec![1.0], 0.0, 1.0, -0.1),
),
(
"reversed interval",
OdeLane::new(Decay { k: 1.0 }, vec![1.0], 1.0, 0.0, 0.1),
),
];
for (what, lane) in cases {
let ensemble =
integrate_ensemble(std::slice::from_ref(&lane), &solver, ComputeBackend::Serial);
let l = &ensemble.lanes()[0];
assert_eq!(l.status(), OdeLaneStatus::InvalidLane, "{what}");
assert!(l.state().is_none(), "{what}");
assert!(
l.last_state().iter().all(|v| v.is_nan()),
"{what}: state should be all NaN, got {:?}",
l.last_state()
);
assert!(l.x_reached().is_nan(), "{what}");
assert_eq!(l.steps(), 0, "{what}");
}
}
#[test]
fn zero_length_interval_completes_as_a_no_op() {
let lanes = vec![OdeLane::new(Decay { k: 3.0 }, vec![7.5], 2.0, 2.0, 0.1)];
let ensemble = integrate_ensemble(
&lanes,
&OdeSolver::rkf45(1, 1e-10, 1e-8),
ComputeBackend::Serial,
);
let l = &ensemble.lanes()[0];
assert_eq!(l.status(), OdeLaneStatus::Completed);
assert_eq!(l.steps(), 0);
assert_eq!(l.x_reached(), 2.0);
assert_eq!(l.state().unwrap(), &[7.5]);
}
#[test]
fn a_diverging_model_is_reported() {
let lanes = vec![OdeLane::new(Blowup, vec![1.0], 0.0, 2.0, 0.01)];
let ensemble = integrate_ensemble(
&lanes,
&OdeSolver::rkf45(1, 1e-10, 1e-8),
ComputeBackend::Serial,
);
let l = &ensemble.lanes()[0];
println!(
"diverging model: status = {}, steps = {}, x_reached = {:.6}, y = {:?}",
l.status().label(),
l.steps(),
l.x_reached(),
l.last_state()
);
assert!(!l.completed(), "a lane through a pole must not complete");
assert!(l.state().is_none());
assert!(ensemble.states().is_err());
}
#[test]
fn partial_failure_in_a_large_ensemble_is_reported() {
let n = 300;
let lanes: Vec<OdeLane<Decay>> = (0..n)
.map(|i| {
if i % 100 == 7 {
OdeLane::new(Decay { k: 1.0 }, vec![1.0], 1.0, 0.0, 0.1)
} else {
OdeLane::new(Decay { k: 1.0 }, vec![1.0], 0.0, 1.0, 0.1)
}
})
.collect();
let ensemble = integrate_ensemble(
&lanes,
&OdeSolver::rkf45(1, 1e-10, 1e-8),
ComputeBackend::CpuMulti,
);
assert_eq!(ensemble.failure_count(), 3);
assert_eq!(ensemble.first_failure().map(|(i, _)| i), Some(7));
assert_eq!(
ensemble
.failures()
.iter()
.map(|(i, _)| *i)
.collect::<Vec<_>>(),
vec![7, 107, 207]
);
let err = ensemble.states().expect_err("must refuse");
assert_eq!(err.total, n);
assert_eq!(err.failure_count, 3);
assert_eq!(err.first_index, 7);
assert!(ensemble.lanes()[0].state().is_some());
assert!(!ensemble.all_completed());
}
#[test]
fn empty_batches_are_vacuously_ok() {
let lanes: Vec<OdeLane<Decay>> = Vec::new();
let ensemble = integrate_ensemble(
&lanes,
&OdeSolver::rkf45(1, 1e-10, 1e-8),
ComputeBackend::CpuMulti,
);
assert!(ensemble.is_empty());
assert!(ensemble.all_completed());
assert_eq!(ensemble.states().unwrap().len(), 0);
assert_eq!(ensemble.total_steps(), 0);
assert_eq!(ensemble.max_steps_taken(), 0);
let batch = quadrature_batch(
&[],
QuadratureRule::Simpson { panels: 4 },
ComputeBackend::CpuMulti,
|_, x| x,
);
assert!(batch.is_empty());
assert!(batch.all_evaluated());
assert_eq!(batch.values().unwrap().len(), 0);
assert_eq!(batch.total_evaluations(), 0);
}
#[test]
fn mixed_stepper_selection_reaches_each_lane() {
let lanes: Vec<OdeLane<Decay>> = (0..3)
.map(|_| OdeLane::new(Decay { k: 1.0 }, vec![1.0], 0.0, 1.0, 0.1))
.collect();
let ensemble = integrate_ensemble_mixed(
&lanes,
|i| match i {
0 => OdeSolver::euler(1, 1e-3, 1e-2),
1 => OdeSolver::rkf45(1, 1e-10, 1e-8),
_ => OdeSolver::rosenbrock23(1, 1e-10, 1e-8),
},
ComputeBackend::Serial,
);
let s = ensemble.states().expect("all complete");
let exact = (-1.0_f64).exp();
let e_euler = (s[0][0] - exact).abs();
let e_rkf = (s[1][0] - exact).abs();
let e_rb = (s[2][0] - exact).abs();
println!("mixed selection: euler {e_euler:.6e}, rkf45 {e_rkf:.6e}, rosenbrock23 {e_rb:.6e}");
assert!(
e_euler > 1e-5,
"euler lane looks too accurate: {e_euler:.6e}"
);
assert!(e_rkf < 1e-8, "rkf45 lane: {e_rkf:.6e}");
assert!(e_rb < 1e-8, "rosenbrock23 lane: {e_rb:.6e}");
}
#[test]
fn quadrature_invalid_interval_is_reported() {
for iv in [
QuadratureInterval::new(f64::NAN, 1.0),
QuadratureInterval::new(0.0, f64::INFINITY),
QuadratureInterval::new(f64::NEG_INFINITY, 0.0),
] {
for batch in [
quadrature_batch(
&[iv],
QuadratureRule::Simpson { panels: 4 },
ComputeBackend::Serial,
|_, x| x,
),
adaptive_quadrature_batch(
&[iv],
AdaptiveSettings::default(),
ComputeBackend::Serial,
|_, x| x,
),
] {
let s = batch.solutions()[0];
assert_eq!(s.status(), QuadratureStatus::InvalidInterval, "{iv:?}");
assert!(s.value().is_none());
assert!(s.last_value().is_nan());
assert!(batch.values().is_err());
}
}
}
#[test]
fn quadrature_not_finite_is_reported() {
let iv = [QuadratureInterval::new(0.0, 1.0)];
let batch = quadrature_batch(
&iv,
QuadratureRule::Trapezoid { panels: 4 },
ComputeBackend::Serial,
|_, x| 1.0 / x, );
let s = batch.solutions()[0];
assert_eq!(s.status(), QuadratureStatus::NotFinite);
assert!(s.value().is_none());
assert!(s.last_value().is_nan());
assert!(
s.evaluations() > 0,
"the evaluations it did make are reported"
);
}
#[test]
fn adaptive_tolerance_not_met_is_reported() {
let iv = [QuadratureInterval::new(0.0, 1.0)];
let settings = AdaptiveSettings {
abs_tol: 1e-14,
rel_tol: 0.0,
max_subdivisions: 2,
};
let batch = adaptive_quadrature_batch(&iv, settings, ComputeBackend::Serial, |_, x| {
1.0 / (1.0 + 10_000.0 * (x - 0.5) * (x - 0.5))
});
let s = batch.solutions()[0];
println!(
"adaptive budget exhausted: status = {}, value = {:.12}, estimate = {:.6e}, evals = {}",
s.status().label(),
s.last_value(),
s.error_estimate(),
s.evaluations()
);
assert_eq!(s.status(), QuadratureStatus::ToleranceNotMet);
assert!(s.value().is_none(), "must not offer an untrustworthy value");
assert!(
s.last_value().is_finite(),
"but the best estimate is still readable"
);
let err = batch.values().expect_err("must refuse");
assert_eq!(err.failure_count, 1);
assert_eq!(err.first_status, QuadratureStatus::ToleranceNotMet);
}
#[test]
fn quadrature_interval_conventions() {
let rule = QuadratureRule::GaussLegendre {
order: GaussOrder::G4,
panels: 2,
};
let f = |_: usize, x: f64| x * x * x + 2.0 * x;
let zero = quadrature_batch(
&[QuadratureInterval::new(1.25, 1.25)],
rule,
ComputeBackend::Serial,
f,
);
assert_eq!(zero.values().unwrap()[0], 0.0);
assert_eq!(zero.solutions()[0].evaluations(), 0);
let forward = quadrature_batch(
&[QuadratureInterval::new(0.0, 2.0)],
rule,
ComputeBackend::Serial,
f,
);
let reversed = quadrature_batch(
&[QuadratureInterval::new(2.0, 0.0)],
rule,
ComputeBackend::Serial,
f,
);
let (a, b) = (forward.values().unwrap()[0], reversed.values().unwrap()[0]);
assert!((a - 8.0).abs() < 1e-13, "forward {a}");
assert_eq!(a.to_bits(), (-b).to_bits(), "reversed must negate exactly");
let clamped = quadrature_batch(
&[QuadratureInterval::new(0.0, 2.0)],
QuadratureRule::Trapezoid { panels: 0 },
ComputeBackend::Serial,
|_, _| 1.0,
);
assert_eq!(clamped.values().unwrap()[0], 2.0);
assert_eq!(clamped.solutions()[0].evaluations(), 2);
}
#[test]
fn a_fixed_rule_has_no_error_estimate() {
let batch = quadrature_batch(
&[QuadratureInterval::new(0.0, 1.0)],
QuadratureRule::Simpson { panels: 4 },
ComputeBackend::Serial,
|_, x| x * x,
);
assert!(batch.solutions()[0].error_estimate().is_nan());
assert!(batch.solutions()[0].value().is_some());
}
#[test]
fn dispatch_respects_the_size_floors() {
assert_eq!(
ensemble_backend_for(ComputeBackend::CpuMulti, ODE_ENSEMBLE_MIN_LANES - 1),
ComputeBackend::Serial
);
assert_eq!(
ensemble_backend_for(ComputeBackend::CpuMulti, ODE_ENSEMBLE_MIN_LANES),
if cfg!(feature = "parallel") {
ComputeBackend::CpuMulti
} else {
ComputeBackend::Serial
}
);
assert_eq!(
quadrature_backend_for(ComputeBackend::CpuMulti, QUADRATURE_MIN_INTERVALS - 1),
ComputeBackend::Serial
);
assert_eq!(
quadrature_backend_for(ComputeBackend::CpuMulti, QUADRATURE_MIN_INTERVALS),
if cfg!(feature = "parallel") {
ComputeBackend::CpuMulti
} else {
ComputeBackend::Serial
}
);
assert_ne!(
ensemble_backend_for(ComputeBackend::Gpu, 1 << 20),
ComputeBackend::Gpu
);
assert!(ensemble_backend_for(ComputeBackend::Gpu, 1 << 20).is_available());
assert_ne!(
quadrature_backend_for(ComputeBackend::Gpu, 1 << 20),
ComputeBackend::Gpu
);
assert_eq!(
ensemble_backend_for(ComputeBackend::Serial, 1 << 20),
ComputeBackend::Serial
);
assert_eq!(
quadrature_backend_for(ComputeBackend::Serial, 1 << 20),
ComputeBackend::Serial
);
}
#[test]
fn public_entry_points_agree_across_requested_backends() {
let lanes = imbalanced_lanes(ODE_ENSEMBLE_MIN_LANES * 4);
let solver = OdeSolver::rkf45(1, 1e-10, 1e-8);
let serial = integrate_ensemble(&lanes, &solver, ComputeBackend::Serial);
for backend in [ComputeBackend::CpuMulti, ComputeBackend::Gpu] {
let other = integrate_ensemble(&lanes, &solver, backend);
assert_ensembles_bitwise_equal(&serial, &other, &format!("{backend:?}"));
}
let n = QUADRATURE_MIN_INTERVALS * 2;
let intervals: Vec<QuadratureInterval> = (0..n)
.map(|i| QuadratureInterval::new(0.0, 1.0 + i as f64 / n as f64))
.collect();
let rule = QuadratureRule::GaussLegendre {
order: GaussOrder::G5,
panels: 8,
};
let f = |i: usize, x: f64| (-(i as f64 + 1.0) * x).exp();
let q_serial = quadrature_batch(&intervals, rule, ComputeBackend::Serial, f);
for backend in [ComputeBackend::CpuMulti, ComputeBackend::Gpu] {
let other = quadrature_batch(&intervals, rule, backend, f);
assert_batches_bitwise_equal(&q_serial, &other, &format!("{backend:?}"));
}
}
#[test]
#[ignore = "measurement, not a correctness check. Run with --ignored --nocapture"]
fn ensemble_crossover_benchmark() {
use std::time::Instant;
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
println!("available_parallelism() = {cores}");
println!("parallel feature enabled = {}", cfg!(feature = "parallel"));
println!("ODE_ENSEMBLE_MIN_LANES = {ODE_ENSEMBLE_MIN_LANES}");
println!(
"{:>10} {:>14} {:>14} {:>9} {:>12}",
"lanes", "serial [us]", "cpumulti [us]", "speedup", "steps/lane"
);
for n in [8_usize, 16, 32, 64, 128, 256, 1024, 4096, 16_384] {
let lanes = imbalanced_lanes(n);
let solver = OdeSolver::rkf45(1, 1e-10, 1e-8);
let time = |backend: ComputeBackend| -> f64 {
std::hint::black_box(integrate_ensemble_min(
&lanes,
|_| solver.clone(),
backend,
0,
));
let mut best = f64::INFINITY;
for _ in 0..7 {
let t = Instant::now();
let out = integrate_ensemble_min(&lanes, |_| solver.clone(), backend, 0);
let dt = t.elapsed().as_secs_f64() * 1.0e6;
std::hint::black_box(&out);
best = best.min(dt);
}
best
};
let reference =
integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::Serial, 0);
let s = time(ComputeBackend::Serial);
let m = time(ComputeBackend::CpuMulti);
println!(
"{n:>10} {s:>14.2} {m:>14.2} {:>9.2} {:>12.1}",
s / m,
reference.total_steps() as f64 / n as f64
);
}
}
#[test]
#[ignore = "measurement, not a correctness check. Run with --ignored --nocapture"]
fn quadrature_crossover_benchmark() {
use std::time::Instant;
let cores = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1);
println!("available_parallelism() = {cores}");
println!("parallel feature enabled = {}", cfg!(feature = "parallel"));
println!("QUADRATURE_MIN_INTERVALS = {QUADRATURE_MIN_INTERVALS}");
let rule = QuadratureRule::GaussLegendre {
order: GaussOrder::G5,
panels: 16,
};
println!(
"rule = {} ({} evaluations per lane)",
rule.label(),
rule.evaluations()
);
println!(
"{:>10} {:>14} {:>14} {:>9}",
"intervals", "serial [us]", "cpumulti [us]", "speedup"
);
for n in [16_usize, 32, 64, 128, 256, 1024, 4096, 16_384] {
let mut rng = Xorshift::new(0x1357_9bdf);
let mut intervals = Vec::with_capacity(n);
let mut a = Vec::with_capacity(n);
let mut b = Vec::with_capacity(n);
for _ in 0..n {
let lo = rng.next_in(0.0, 1.0);
intervals.push(QuadratureInterval::new(lo, lo + rng.next_in(0.5, 3.0)));
a.push(rng.next_in(0.1, 3.0));
b.push(rng.next_in(1.0, 30.0));
}
let f = |i: usize, x: f64| (-a[i] * x).exp() * (b[i] * x).sin();
let time = |backend: ComputeBackend| -> f64 {
std::hint::black_box(quadrature_batch_min(&intervals, rule, backend, 0, f));
let mut best = f64::INFINITY;
for _ in 0..7 {
let t = Instant::now();
let out = quadrature_batch_min(&intervals, rule, backend, 0, f);
let dt = t.elapsed().as_secs_f64() * 1.0e6;
std::hint::black_box(&out);
best = best.min(dt);
}
best
};
let s = time(ComputeBackend::Serial);
let m = time(ComputeBackend::CpuMulti);
println!("{n:>10} {s:>14.2} {m:>14.2} {:>9.2}", s / m);
}
}
#[cfg(feature = "parallel")]
#[test]
#[ignore = "measurement, not a correctness check. Run with --ignored --nocapture"]
fn ensemble_thread_scaling_benchmark() {
use std::time::Instant;
let n = 4096_usize;
let lanes = imbalanced_lanes(n);
let solver = OdeSolver::rkf45(1, 1e-10, 1e-8);
let reference = integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::Serial, 0);
let mut serial_us = f64::INFINITY;
for _ in 0..7 {
let t = Instant::now();
let out = integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::Serial, 0);
std::hint::black_box(&out);
serial_us = serial_us.min(t.elapsed().as_secs_f64() * 1.0e6);
}
println!(
"available_parallelism() = {}",
std::thread::available_parallelism()
.map(|c| c.get())
.unwrap_or(1)
);
println!(
"ensemble = {n} imbalanced lanes, Rkf45, best of 7; total steps {}, max lane {}",
reference.total_steps(),
reference.max_steps_taken()
);
println!(
"{:>8} {:>14} {:>9} {:>10}",
"threads", "time [us]", "speedup", "bitwise"
);
println!(
"{:>8} {serial_us:>14.2} {:>9.2} {:>10}",
0, 1.0, "reference"
);
for threads in [1_usize, 2, 4, 8] {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("thread pool");
let (best, identical) = pool.install(|| {
let first =
integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::CpuMulti, 0);
let identical = first == reference;
let mut best = f64::INFINITY;
for _ in 0..7 {
let t = Instant::now();
let out =
integrate_ensemble_min(&lanes, |_| solver.clone(), ComputeBackend::CpuMulti, 0);
std::hint::black_box(&out);
best = best.min(t.elapsed().as_secs_f64() * 1.0e6);
}
(best, identical)
});
println!(
"{threads:>8} {best:>14.2} {:>9.2} {:>10}",
serial_us / best,
if identical { "identical" } else { "DIFFERENT" }
);
assert!(identical, "thread count {threads} changed the result");
}
}