use crate::error::{IntegrateError, IntegrateResult};
use scirs2_core::ndarray::{Array1, Array2};
#[inline]
fn to_f(v: f64) -> f64 { v }
fn gauss_solve(a: &mut Array2<f64>, b: &mut Array1<f64>) -> IntegrateResult<Array1<f64>> {
let n = b.len();
for col in 0..n {
let mut max_row = col;
let mut max_val = a[[col, col]].abs();
for row in (col + 1)..n {
let v = a[[row, col]].abs();
if v > max_val {
max_val = v;
max_row = row;
}
}
if max_val < 1e-300 {
return Err(IntegrateError::LinearSolveError(
"Singular matrix in shooting solve".to_string(),
));
}
if max_row != col {
for j in col..n {
let tmp = a[[col, j]]; a[[col, j]] = a[[max_row, j]]; a[[max_row, j]] = tmp;
}
b.swap(col, max_row);
}
let pivot = a[[col, col]];
for row in (col + 1)..n {
let factor = a[[row, col]] / pivot;
for j in col..n {
let u = factor * a[[col, j]]; a[[row, j]] -= u;
}
let bup = factor * b[col]; b[row] -= bup;
}
}
let mut x = Array1::<f64>::zeros(n);
for i in (0..n).rev() {
let mut s = b[i];
for j in (i + 1)..n { s -= a[[i, j]] * x[j]; }
x[i] = s / a[[i, i]];
}
Ok(x)
}
fn rk4_step<F>(f: &F, t: f64, y: &Array1<f64>, h: f64) -> Array1<f64>
where
F: Fn(f64, &Array1<f64>) -> Array1<f64>,
{
let n = y.len();
let k1 = f(t, y);
let mut y2 = Array1::<f64>::zeros(n);
for i in 0..n { y2[i] = y[i] + 0.5 * h * k1[i]; }
let k2 = f(t + 0.5 * h, &y2);
let mut y3 = Array1::<f64>::zeros(n);
for i in 0..n { y3[i] = y[i] + 0.5 * h * k2[i]; }
let k3 = f(t + 0.5 * h, &y3);
let mut y4 = Array1::<f64>::zeros(n);
for i in 0..n { y4[i] = y[i] + h * k3[i]; }
let k4 = f(t + h, &y4);
let mut y_new = Array1::<f64>::zeros(n);
for i in 0..n {
y_new[i] = y[i] + h / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]);
}
y_new
}
fn integrate_rk4<F>(f: &F, t0: f64, y0: &Array1<f64>, t1: f64, n_steps: usize) -> Array1<f64>
where
F: Fn(f64, &Array1<f64>) -> Array1<f64>,
{
let h = (t1 - t0) / n_steps as f64;
let mut t = t0;
let mut y = y0.clone();
for _ in 0..n_steps {
y = rk4_step(f, t, &y, h);
t += h;
}
y
}
fn numerical_jacobian<G>(g: &G, s: &Array1<f64>, eps: f64) -> Array2<f64>
where
G: Fn(&Array1<f64>) -> Array1<f64>,
{
let n = s.len();
let m = g(s).len();
let mut jac = Array2::<f64>::zeros((m, n));
for j in 0..n {
let mut sp = s.clone(); sp[j] += eps;
let mut sm = s.clone(); sm[j] -= eps;
let fp = g(&sp);
let fm = g(&sm);
for i in 0..m { jac[[i, j]] = (fp[i] - fm[i]) / (2.0 * eps); }
}
jac
}
#[derive(Debug, Clone)]
pub struct BVPResult {
pub t: Vec<f64>,
pub y: Vec<Array1<f64>>,
pub residual: f64,
pub error: f64,
pub n_newton_iters: usize,
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ShootingConfig {
pub n_steps: usize,
pub newton_tol: f64,
pub max_newton_iter: usize,
pub fd_eps: f64,
pub n_subintervals: usize,
}
impl Default for ShootingConfig {
fn default() -> Self {
Self {
n_steps: 100,
newton_tol: 1e-8,
max_newton_iter: 50,
fd_eps: 1e-7,
n_subintervals: 5,
}
}
}
pub struct SingleShooting;
impl SingleShooting {
pub fn solve<ODE, BC, IC>(
ode: &ODE,
bc: &BC,
initial_condition: &IC,
t_span: [f64; 2],
s0: Array1<f64>,
cfg: &ShootingConfig,
) -> IntegrateResult<BVPResult>
where
ODE: Fn(f64, &Array1<f64>) -> Array1<f64>,
BC: Fn(&Array1<f64>, &Array1<f64>) -> Array1<f64>,
IC: Fn(&Array1<f64>) -> Array1<f64>,
{
let [ta, tb] = t_span;
let n_s = s0.len();
let shoot = |s: &Array1<f64>| -> Array1<f64> {
let ya = initial_condition(s);
let yb = integrate_rk4(ode, ta, &ya, tb, cfg.n_steps);
bc(&ya, &yb)
};
let mut s = s0.clone();
let mut n_iters = 0usize;
let mut converged = false;
for iter in 0..cfg.max_newton_iter {
let res = shoot(&s);
let res_norm: f64 = res.iter().map(|&v| v * v).sum::<f64>().sqrt();
if res_norm < cfg.newton_tol {
n_iters = iter + 1;
converged = true;
break;
}
let mut jac = numerical_jacobian(&shoot, &s, cfg.fd_eps);
let mut neg_res = res.mapv(|v| -v);
match gauss_solve(&mut jac, &mut neg_res) {
Ok(delta) => {
for i in 0..n_s { s[i] += delta[i]; }
}
Err(_) => {
let res_ref = shoot(&s);
let grad_norm_sq: f64 = res_ref.iter().map(|&v| v * v).sum();
if grad_norm_sq > 0.0 {
for i in 0..n_s {
s[i] -= cfg.fd_eps * res_ref[i.min(res_ref.len() - 1)];
}
}
}
}
}
if !converged {
n_iters = cfg.max_newton_iter;
}
let ya = initial_condition(&s);
let (t_traj, y_traj) = trajectory_rk4(ode, ta, &ya, tb, cfg.n_steps);
let yb = y_traj.last().cloned().unwrap_or_else(|| ya.clone());
let final_res = bc(&ya, &yb);
let residual: f64 = final_res.iter().map(|&v| v * v).sum::<f64>().sqrt();
Ok(BVPResult {
t: t_traj,
y: y_traj,
residual,
error: residual,
n_newton_iters: n_iters,
success: converged,
message: if converged {
"Single shooting converged".to_string()
} else {
format!("Single shooting did not converge in {} iterations", cfg.max_newton_iter)
},
})
}
}
fn trajectory_rk4<F>(
f: &F,
t0: f64,
y0: &Array1<f64>,
t1: f64,
n_steps: usize,
) -> (Vec<f64>, Vec<Array1<f64>>)
where
F: Fn(f64, &Array1<f64>) -> Array1<f64>,
{
let h = (t1 - t0) / n_steps as f64;
let mut t = t0;
let mut y = y0.clone();
let mut ts = vec![t];
let mut ys = vec![y.clone()];
for _ in 0..n_steps {
y = rk4_step(f, t, &y, h);
t += h;
ts.push(t);
ys.push(y.clone());
}
(ts, ys)
}
pub struct MultipleShooting;
impl MultipleShooting {
pub fn solve<ODE, BC>(
ode: &ODE,
bc: &BC,
t_nodes: &[f64],
s0: Vec<Array1<f64>>,
cfg: &ShootingConfig,
) -> IntegrateResult<BVPResult>
where
ODE: Fn(f64, &Array1<f64>) -> Array1<f64>,
BC: Fn(&Array1<f64>, &Array1<f64>) -> Array1<f64>,
{
if t_nodes.len() < 2 {
return Err(IntegrateError::InvalidInput(
"t_nodes must have at least 2 elements".to_string(),
));
}
let m = t_nodes.len() - 1; if s0.len() != m {
return Err(IntegrateError::DimensionMismatch(format!(
"s0 length {} must equal number of subintervals {}",
s0.len(), m
)));
}
let n = s0[0].len(); let total_unknowns = m * n;
let mut s_flat = Array1::<f64>::zeros(total_unknowns);
for (i, si) in s0.iter().enumerate() {
for j in 0..n { s_flat[i * n + j] = si[j]; }
}
let mut n_iters = 0usize;
let mut converged = false;
for iter in 0..cfg.max_newton_iter {
let n_bc_eqs = {
let ya = s_flat.slice(scirs2_core::ndarray::s![..n]).to_owned();
let yb_start = &s_flat.slice(scirs2_core::ndarray::s![(m - 1) * n..]).to_owned();
let t_last = t_nodes[m - 1];
let t_end = t_nodes[m];
let yb = integrate_rk4(ode, t_last, yb_start, t_end, cfg.n_steps);
bc(&ya, &yb).len()
};
let residual_len = n_bc_eqs + (m - 1) * n;
let mut res = Array1::<f64>::zeros(residual_len);
let ya = s_flat.slice(scirs2_core::ndarray::s![..n]).to_owned();
let yb_start = s_flat.slice(scirs2_core::ndarray::s![(m - 1) * n..]).to_owned();
let yb = integrate_rk4(ode, t_nodes[m - 1], &yb_start, t_nodes[m], cfg.n_steps);
let bc_res = bc(&ya, &yb);
for i in 0..n_bc_eqs { res[i] = bc_res[i]; }
for interval in 0..(m - 1) {
let si = s_flat
.slice(scirs2_core::ndarray::s![interval * n..(interval + 1) * n])
.to_owned();
let si_next = s_flat
.slice(scirs2_core::ndarray::s![(interval + 1) * n..(interval + 2) * n])
.to_owned();
let y_shot = integrate_rk4(ode, t_nodes[interval], &si, t_nodes[interval + 1], cfg.n_steps);
for j in 0..n {
res[n_bc_eqs + interval * n + j] = y_shot[j] - si_next[j];
}
}
let res_norm: f64 = res.iter().map(|&v| v * v).sum::<f64>().sqrt();
if res_norm < cfg.newton_tol {
n_iters = iter + 1;
converged = true;
break;
}
let shoot_residual = |s: &Array1<f64>| {
let mut r = Array1::<f64>::zeros(residual_len);
let ya = s.slice(scirs2_core::ndarray::s![..n]).to_owned();
let yb_s = s.slice(scirs2_core::ndarray::s![(m - 1) * n..]).to_owned();
let yb = integrate_rk4(ode, t_nodes[m - 1], &yb_s, t_nodes[m], cfg.n_steps);
let bcr = bc(&ya, &yb);
for i in 0..n_bc_eqs { r[i] = bcr[i]; }
for interval in 0..(m - 1) {
let si = s.slice(scirs2_core::ndarray::s![interval * n..(interval + 1) * n]).to_owned();
let si_next = s.slice(scirs2_core::ndarray::s![(interval + 1) * n..(interval + 2) * n]).to_owned();
let y_shot = integrate_rk4(ode, t_nodes[interval], &si, t_nodes[interval + 1], cfg.n_steps);
for j in 0..n { r[n_bc_eqs + interval * n + j] = y_shot[j] - si_next[j]; }
}
r
};
let mut jac = numerical_jacobian(&shoot_residual, &s_flat, cfg.fd_eps);
let mut neg_res = res.mapv(|v| -v);
match gauss_solve(&mut jac, &mut neg_res) {
Ok(delta) => {
for i in 0..total_unknowns { s_flat[i] += delta[i]; }
}
Err(_) => {
return Err(IntegrateError::LinearSolveError(
"Multiple shooting: singular Jacobian".to_string(),
));
}
}
}
if !converged { n_iters = cfg.max_newton_iter; }
let mut t_traj = Vec::new();
let mut y_traj = Vec::new();
for interval in 0..m {
let si = s_flat
.slice(scirs2_core::ndarray::s![interval * n..(interval + 1) * n])
.to_owned();
let (ts, ys) = trajectory_rk4(ode, t_nodes[interval], &si, t_nodes[interval + 1], cfg.n_steps / m.max(1));
if interval == 0 {
t_traj.extend_from_slice(&ts);
y_traj.extend_from_slice(&ys);
} else {
t_traj.extend_from_slice(&ts[1..]);
y_traj.extend_from_slice(&ys[1..]);
}
}
let ya = y_traj.first().cloned().unwrap_or_else(|| Array1::<f64>::zeros(n));
let yb = y_traj.last().cloned().unwrap_or_else(|| Array1::<f64>::zeros(n));
let final_bc = bc(&ya, &yb);
let residual: f64 = final_bc.iter().map(|&v| v * v).sum::<f64>().sqrt();
Ok(BVPResult {
t: t_traj,
y: y_traj,
residual,
error: residual,
n_newton_iters: n_iters,
success: converged,
message: if converged {
"Multiple shooting converged".to_string()
} else {
format!("Multiple shooting did not converge in {} iterations", cfg.max_newton_iter)
},
})
}
}
fn gauss_legendre_nodes(order: usize) -> Vec<f64> {
match order {
1 => vec![0.0],
2 => vec![-1.0 / 3.0_f64.sqrt(), 1.0 / 3.0_f64.sqrt()],
3 => vec![-0.7745966692, 0.0, 0.7745966692],
4 => vec![-0.8611363116, -0.3399810436, 0.3399810436, 0.8611363116],
5 => vec![-0.9061798459, -0.5384693101, 0.0, 0.5384693101, 0.9061798459],
_ => vec![-1.0 / 3.0_f64.sqrt(), 1.0 / 3.0_f64.sqrt()], }
}
fn gauss_legendre_weights(order: usize) -> Vec<f64> {
match order {
1 => vec![2.0],
2 => vec![1.0, 1.0],
3 => vec![0.5555555556, 0.8888888889, 0.5555555556],
4 => vec![0.3478548451, 0.6521451549, 0.6521451549, 0.3478548451],
5 => vec![0.2369268851, 0.4786286705, 0.5688888889, 0.4786286705, 0.2369268851],
_ => vec![1.0, 1.0],
}
}
#[derive(Debug, Clone)]
pub struct CollocationConfig {
pub n_subintervals: usize,
pub collocation_order: usize,
pub newton_tol: f64,
pub max_newton_iter: usize,
pub fd_eps: f64,
}
impl Default for CollocationConfig {
fn default() -> Self {
Self {
n_subintervals: 10,
collocation_order: 3,
newton_tol: 1e-8,
max_newton_iter: 30,
fd_eps: 1e-7,
}
}
}
pub struct OrthogonalCollocation;
impl OrthogonalCollocation {
pub fn solve<ODE, BC, Guess>(
ode: &ODE,
bc: &BC,
t_span: [f64; 2],
y_init_guess: &Guess,
n_state: usize,
cfg: &CollocationConfig,
) -> IntegrateResult<BVPResult>
where
ODE: Fn(f64, &Array1<f64>) -> Array1<f64>,
BC: Fn(&Array1<f64>, &Array1<f64>) -> Array1<f64>,
Guess: Fn(f64) -> Array1<f64>,
{
let [ta, tb] = t_span;
let m = cfg.n_subintervals;
let k = cfg.collocation_order.min(5).max(1);
let h = (tb - ta) / m as f64;
let nodes: Vec<f64> = (0..=m).map(|i| ta + i as f64 * h).collect();
let ref_nodes = gauss_legendre_nodes(k);
let n = n_state;
let total_unknowns = (m + 1) * n;
let mut y_flat = Array1::<f64>::zeros(total_unknowns);
for i in 0..=m {
let guess = y_init_guess(nodes[i]);
for j in 0..n { y_flat[i * n + j] = guess[j]; }
}
let n_bc_eqs = bc(
&y_flat.slice(scirs2_core::ndarray::s![..n]).to_owned(),
&y_flat.slice(scirs2_core::ndarray::s![m * n..]).to_owned(),
).len();
let build_residual = |yf: &Array1<f64>| {
let rlen = n_bc_eqs + m * n;
let mut r = Array1::<f64>::zeros(rlen);
let ya = yf.slice(scirs2_core::ndarray::s![..n]).to_owned();
let ym = yf.slice(scirs2_core::ndarray::s![m * n..]).to_owned();
let bcr = bc(&ya, &ym);
for i in 0..n_bc_eqs { r[i] = bcr[i]; }
for interval in 0..m {
let ti = nodes[interval];
let tip1 = nodes[interval + 1];
let yi = yf.slice(scirs2_core::ndarray::s![interval * n..(interval + 1) * n]).to_owned();
let yip1 = yf.slice(scirs2_core::ndarray::s![(interval + 1) * n..(interval + 2) * n]).to_owned();
let hi = tip1 - ti;
let wts = gauss_legendre_weights(k);
let mut integral = Array1::<f64>::zeros(n);
for (q, &xi) in ref_nodes.iter().enumerate() {
let tc = ti + (xi + 1.0) * 0.5 * hi;
let alpha = (xi + 1.0) * 0.5;
let mut yc = Array1::<f64>::zeros(n);
for j in 0..n { yc[j] = (1.0 - alpha) * yi[j] + alpha * yip1[j]; }
let fc = ode(tc, &yc);
let wt = wts[q] * hi * 0.5;
for j in 0..n { integral[j] += wt * fc[j]; }
}
let base = n_bc_eqs + interval * n;
for j in 0..n { r[base + j] = yip1[j] - yi[j] - integral[j]; }
}
r
};
let mut n_iters = 0usize;
let mut converged = false;
for iter in 0..cfg.max_newton_iter {
let res = build_residual(&y_flat);
let res_norm: f64 = res.iter().map(|&v| v * v).sum::<f64>().sqrt();
if res_norm < cfg.newton_tol {
n_iters = iter + 1;
converged = true;
break;
}
let mut jac = numerical_jacobian(&build_residual, &y_flat, cfg.fd_eps);
let mut neg_res = res.mapv(|v| -v);
match gauss_solve(&mut jac, &mut neg_res) {
Ok(delta) => {
for i in 0..total_unknowns { y_flat[i] += delta[i]; }
}
Err(e) => return Err(e),
}
}
if !converged { n_iters = cfg.max_newton_iter; }
let t_traj: Vec<f64> = nodes.clone();
let y_traj: Vec<Array1<f64>> = (0..=m)
.map(|i| y_flat.slice(scirs2_core::ndarray::s![i * n..(i + 1) * n]).to_owned())
.collect();
let ya = y_traj.first().cloned().unwrap_or_else(|| Array1::<f64>::zeros(n));
let yb = y_traj.last().cloned().unwrap_or_else(|| Array1::<f64>::zeros(n));
let final_bc = bc(&ya, &yb);
let residual: f64 = final_bc.iter().map(|&v| v * v).sum::<f64>().sqrt();
Ok(BVPResult {
t: t_traj,
y: y_traj,
residual,
error: residual,
n_newton_iters: n_iters,
success: converged,
message: if converged {
"Orthogonal collocation converged".to_string()
} else {
format!("Collocation did not converge in {} iterations", cfg.max_newton_iter)
},
})
}
}
#[derive(Debug, Clone)]
pub struct PeriodicOrbitConfig {
pub n_steps: usize,
pub newton_tol: f64,
pub max_newton_iter: usize,
pub fd_eps: f64,
pub phase_condition_idx: usize,
}
impl Default for PeriodicOrbitConfig {
fn default() -> Self {
Self {
n_steps: 500,
newton_tol: 1e-8,
max_newton_iter: 50,
fd_eps: 1e-7,
phase_condition_idx: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct PeriodicOrbitResult {
pub t: Vec<f64>,
pub y: Vec<Array1<f64>>,
pub period: f64,
pub y0: Array1<f64>,
pub residual: f64,
pub n_newton_iters: usize,
pub success: bool,
pub message: String,
}
pub struct PeriodicOrbitFinder;
impl PeriodicOrbitFinder {
pub fn find<ODE>(
ode: &ODE,
y_guess: &Array1<f64>,
t_guess: f64,
cfg: &PeriodicOrbitConfig,
) -> IntegrateResult<PeriodicOrbitResult>
where
ODE: Fn(f64, &Array1<f64>) -> Array1<f64>,
{
let n = y_guess.len();
let phase_idx = cfg.phase_condition_idx.min(n - 1);
let y0_ref_phase = y_guess[phase_idx];
let mut z = Array1::<f64>::zeros(n + 1);
for i in 0..n { z[i] = y_guess[i]; }
z[n] = t_guess;
let residual_fn = |zv: &Array1<f64>| {
let mut y_cur = Array1::<f64>::zeros(n);
for i in 0..n { y_cur[i] = zv[i]; }
let period = zv[n].max(1e-10);
let y_end = integrate_rk4(ode, 0.0, &y_cur, period, cfg.n_steps);
let mut r = Array1::<f64>::zeros(n + 1);
for i in 0..n { r[i] = y_end[i] - y_cur[i]; }
r[n] = y_cur[phase_idx] - y0_ref_phase;
r
};
let mut n_iters = 0usize;
let mut converged = false;
for iter in 0..cfg.max_newton_iter {
let res = residual_fn(&z);
let res_norm: f64 = res.iter().map(|&v| v * v).sum::<f64>().sqrt();
if res_norm < cfg.newton_tol {
n_iters = iter + 1;
converged = true;
break;
}
let mut jac = numerical_jacobian(&residual_fn, &z, cfg.fd_eps);
let mut neg_res = res.mapv(|v| -v);
match gauss_solve(&mut jac, &mut neg_res) {
Ok(delta) => {
for i in 0..=n { z[i] += delta[i]; }
if z[n] < 1e-10 { z[n] = 1e-10; }
}
Err(_) => {
return Err(IntegrateError::LinearSolveError(
"Periodic orbit: singular Jacobian".to_string(),
));
}
}
}
if !converged { n_iters = cfg.max_newton_iter; }
let mut y_star = Array1::<f64>::zeros(n);
for i in 0..n { y_star[i] = z[i]; }
let period = z[n];
let (t_traj, y_traj) = trajectory_rk4(ode, 0.0, &y_star, period, cfg.n_steps);
let final_res = residual_fn(&z);
let residual: f64 = final_res.iter().map(|&v| v * v).sum::<f64>().sqrt();
Ok(PeriodicOrbitResult {
t: t_traj,
y: y_traj,
period,
y0: y_star,
residual,
n_newton_iters: n_iters,
success: converged,
message: if converged {
format!("Periodic orbit found: T = {:.6}", period)
} else {
format!("Periodic orbit not found in {} iterations", cfg.max_newton_iter)
},
})
}
}
#[allow(dead_code)]
fn _use_to_f() { let _ = to_f(0.0); }
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::array;
fn linear_ode(t: f64, y: &Array1<f64>) -> Array1<f64> {
let _ = t;
array![y[1], -y[0]]
}
fn linear_bc(ya: &Array1<f64>, yb: &Array1<f64>) -> Array1<f64> {
array![ya[0], yb[0] - 1.0]
}
#[test]
fn test_single_shooting_linear_bvp() {
let t_span = [0.0, std::f64::consts::FRAC_PI_2];
let initial_condition = |s: &Array1<f64>| array![0.0, s[0]];
let bc = |ya: &Array1<f64>, yb: &Array1<f64>| array![ya[0], yb[0] - 1.0];
let s0 = array![1.0];
let cfg = ShootingConfig {
n_steps: 200,
newton_tol: 1e-8,
..Default::default()
};
let result = SingleShooting::solve(
&linear_ode,
&bc,
&initial_condition,
t_span,
s0,
&cfg,
)
.expect("Single shooting failed");
assert!(result.success, "Should converge: {}", result.message);
assert!(result.residual < 1e-6, "Residual {} too large", result.residual);
let t_quarter = std::f64::consts::FRAC_PI_4;
let idx = result.t.iter().position(|&t| (t - t_quarter).abs() < 0.02);
if let Some(i) = idx {
let y_val = result.y[i][0];
let exact = t_quarter.sin();
assert!((y_val - exact).abs() < 0.01, "y(π/4)={} != sin(π/4)={}", y_val, exact);
}
}
#[test]
fn test_collocation_linear_bvp() {
let cfg = CollocationConfig {
n_subintervals: 8,
collocation_order: 3,
newton_tol: 1e-6,
max_newton_iter: 30,
fd_eps: 1e-6,
};
let t_span = [0.0, std::f64::consts::FRAC_PI_2];
let guess = |t: f64| array![t.sin(), t.cos()];
let result = OrthogonalCollocation::solve(
&linear_ode,
&linear_bc,
t_span,
&guess,
2,
&cfg,
)
.expect("Collocation failed");
assert!(result.residual < 1e-4, "Collocation residual {} too large", result.residual);
}
#[test]
fn test_periodic_orbit_harmonic_oscillator() {
let cfg = PeriodicOrbitConfig {
n_steps: 200,
newton_tol: 1e-8,
max_newton_iter: 20,
fd_eps: 1e-6,
phase_condition_idx: 0,
};
let y_guess = array![1.0, 0.0];
let t_guess = 2.0 * std::f64::consts::PI;
let result = PeriodicOrbitFinder::find(&linear_ode, &y_guess, t_guess, &cfg)
.expect("Periodic orbit finder failed");
assert!(
(result.period - 2.0 * std::f64::consts::PI).abs() < 0.1,
"Period {} != 2π",
result.period
);
}
}