Skip to main content

oxiphysics_core/
dynamical_systems.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Dynamical systems analysis: ODE fixed points, Lyapunov exponents,
6//! bifurcations, phase plane, Poincaré sections, strange attractors,
7//! chaos indicators (RQA), Hamiltonian systems, KAM tori, and symplectic
8//! integration.
9//!
10//! All arithmetic uses plain `f64` and `[f64; 3]` arrays — no nalgebra.
11
12#![allow(dead_code)]
13
14// ─────────────────────────────────────────────────────────────────────────────
15// Helper vector arithmetic on [f64; 3]
16// ─────────────────────────────────────────────────────────────────────────────
17
18/// Add two 3-vectors.
19#[inline]
20pub fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
21    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
22}
23
24/// Subtract two 3-vectors.
25#[inline]
26pub fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
27    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
28}
29
30/// Scale a 3-vector by a scalar.
31#[inline]
32pub fn scale3(a: [f64; 3], s: f64) -> [f64; 3] {
33    [a[0] * s, a[1] * s, a[2] * s]
34}
35
36/// Dot product of two 3-vectors.
37#[inline]
38pub fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
39    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
40}
41
42/// Euclidean norm of a 3-vector.
43#[inline]
44pub fn norm3(a: [f64; 3]) -> f64 {
45    dot3(a, a).sqrt()
46}
47
48/// Cross product of two 3-vectors.
49#[inline]
50pub fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
51    [
52        a[1] * b[2] - a[2] * b[1],
53        a[2] * b[0] - a[0] * b[2],
54        a[0] * b[1] - a[1] * b[0],
55    ]
56}
57
58// ─────────────────────────────────────────────────────────────────────────────
59// ODE system trait
60// ─────────────────────────────────────────────────────────────────────────────
61
62/// A continuous-time ODE system  `ẋ = f(t, x)`.
63pub trait OdeSystem {
64    /// Dimension of the state vector.
65    fn dim(&self) -> usize;
66
67    /// Right-hand side evaluated at time `t` and state `x`.
68    fn rhs(&self, t: f64, x: &[f64]) -> Vec<f64>;
69}
70
71// ─────────────────────────────────────────────────────────────────────────────
72// Runge–Kutta 4 integrator
73// ─────────────────────────────────────────────────────────────────────────────
74
75/// Classic 4th-order Runge–Kutta step.
76///
77/// Advances `x` by one step of size `h` under `sys` at time `t`,
78/// returning the new state.
79pub fn rk4_step(sys: &dyn OdeSystem, t: f64, x: &[f64], h: f64) -> Vec<f64> {
80    let n = x.len();
81    let k1 = sys.rhs(t, x);
82    let x2: Vec<f64> = (0..n).map(|i| x[i] + 0.5 * h * k1[i]).collect();
83    let k2 = sys.rhs(t + 0.5 * h, &x2);
84    let x3: Vec<f64> = (0..n).map(|i| x[i] + 0.5 * h * k2[i]).collect();
85    let k3 = sys.rhs(t + 0.5 * h, &x3);
86    let x4: Vec<f64> = (0..n).map(|i| x[i] + h * k3[i]).collect();
87    let k4 = sys.rhs(t + h, &x4);
88    (0..n)
89        .map(|i| x[i] + h / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]))
90        .collect()
91}
92
93/// Integrate an ODE from `t0` to `t_end` with fixed step `h`.
94///
95/// Returns a vector of `(t, state)` samples including the initial condition.
96pub fn integrate_rk4(
97    sys: &dyn OdeSystem,
98    x0: &[f64],
99    t0: f64,
100    t_end: f64,
101    h: f64,
102) -> Vec<(f64, Vec<f64>)> {
103    let mut result = Vec::new();
104    let mut t = t0;
105    let mut x = x0.to_vec();
106    result.push((t, x.clone()));
107    while t + h <= t_end + 1e-12 {
108        x = rk4_step(sys, t, &x, h);
109        t += h;
110        result.push((t, x.clone()));
111    }
112    result
113}
114
115// ─────────────────────────────────────────────────────────────────────────────
116// Jacobian (numerical)
117// ─────────────────────────────────────────────────────────────────────────────
118
119/// Compute the Jacobian matrix of `sys.rhs` at `(t, x)` numerically
120/// using central differences with step `eps`.
121///
122/// Returns a row-major `n×n` matrix stored as `Vec`f64` of length `n*n`.
123pub fn numerical_jacobian(sys: &dyn OdeSystem, t: f64, x: &[f64], eps: f64) -> Vec<f64> {
124    let n = x.len();
125    let mut jac = vec![0.0f64; n * n];
126    for j in 0..n {
127        let mut xp = x.to_vec();
128        let mut xm = x.to_vec();
129        xp[j] += eps;
130        xm[j] -= eps;
131        let fp = sys.rhs(t, &xp);
132        let fm = sys.rhs(t, &xm);
133        for i in 0..n {
134            jac[i * n + j] = (fp[i] - fm[i]) / (2.0 * eps);
135        }
136    }
137    jac
138}
139
140// ─────────────────────────────────────────────────────────────────────────────
141// Fixed-point finder (Newton iteration)
142// ─────────────────────────────────────────────────────────────────────────────
143
144/// Result of a fixed-point search.
145#[derive(Debug, Clone)]
146pub struct FixedPointResult {
147    /// The approximate fixed point (where `f(x*)≈0`).
148    pub point: Vec<f64>,
149    /// Residual norm at convergence.
150    pub residual: f64,
151    /// Whether the iteration converged within tolerance.
152    pub converged: bool,
153    /// Number of Newton iterations performed.
154    pub iterations: usize,
155}
156
157/// Find a fixed point of `sys.rhs(0, x) = 0` using Newton–Raphson iteration
158/// starting from `x0`.
159///
160/// `max_iter` limits the number of Newton steps; `tol` is the residual
161/// stopping criterion.
162#[allow(clippy::too_many_arguments)]
163pub fn find_fixed_point(
164    sys: &dyn OdeSystem,
165    x0: &[f64],
166    tol: f64,
167    max_iter: usize,
168    eps_jac: f64,
169) -> FixedPointResult {
170    let n = x0.len();
171    let mut x = x0.to_vec();
172    for iter in 0..max_iter {
173        let f = sys.rhs(0.0, &x);
174        let res: f64 = f.iter().map(|v| v * v).sum::<f64>().sqrt();
175        if res < tol {
176            return FixedPointResult {
177                point: x,
178                residual: res,
179                converged: true,
180                iterations: iter,
181            };
182        }
183        let jac = numerical_jacobian(sys, 0.0, &x, eps_jac);
184        // Solve J * delta = -f  using Gaussian elimination (small n)
185        let delta = gauss_solve(&jac, n, &f.iter().map(|v| -v).collect::<Vec<_>>());
186        match delta {
187            Some(d) => {
188                for i in 0..n {
189                    x[i] += d[i];
190                }
191            }
192            None => break,
193        }
194    }
195    let f = sys.rhs(0.0, &x);
196    let res: f64 = f.iter().map(|v| v * v).sum::<f64>().sqrt();
197    FixedPointResult {
198        point: x,
199        residual: res,
200        converged: false,
201        iterations: max_iter,
202    }
203}
204
205/// Solve `A x = b` for `x` using Gaussian elimination with partial pivoting.
206///
207/// `a_flat` is a row-major `n×n` matrix; returns `None` if singular.
208fn gauss_solve(a_flat: &[f64], n: usize, b: &[f64]) -> Option<Vec<f64>> {
209    let mut mat: Vec<f64> = a_flat.to_vec();
210    let mut rhs: Vec<f64> = b.to_vec();
211    for col in 0..n {
212        // partial pivot
213        let mut max_row = col;
214        let mut max_val = mat[col * n + col].abs();
215        for row in (col + 1)..n {
216            let v = mat[row * n + col].abs();
217            if v > max_val {
218                max_val = v;
219                max_row = row;
220            }
221        }
222        if max_val < 1e-14 {
223            return None;
224        }
225        if max_row != col {
226            for k in 0..n {
227                mat.swap(col * n + k, max_row * n + k);
228            }
229            rhs.swap(col, max_row);
230        }
231        let pivot = mat[col * n + col];
232        for row in (col + 1)..n {
233            let factor = mat[row * n + col] / pivot;
234            for k in col..n {
235                let val = mat[col * n + k];
236                mat[row * n + k] -= factor * val;
237            }
238            rhs[row] -= factor * rhs[col];
239        }
240    }
241    // back substitution
242    let mut x = vec![0.0f64; n];
243    for i in (0..n).rev() {
244        let mut sum = rhs[i];
245        for j in (i + 1)..n {
246            sum -= mat[i * n + j] * x[j];
247        }
248        x[i] = sum / mat[i * n + i];
249    }
250    Some(x)
251}
252
253// ─────────────────────────────────────────────────────────────────────────────
254// Eigenvalue analysis (2×2 and 3×3)
255// ─────────────────────────────────────────────────────────────────────────────
256
257/// Eigenvalues of a real 2×2 matrix `\[\[a,b\\],\[c,d\]]`.
258///
259/// Returns `(λ1, λ2)` which may be complex encoded as `(re±im)`.
260/// The imaginary parts are returned as a separate pair.
261pub fn eigen2(a: f64, b: f64, c: f64, d: f64) -> ((f64, f64), (f64, f64)) {
262    let tr = a + d;
263    let det = a * d - b * c;
264    let disc = tr * tr - 4.0 * det;
265    if disc >= 0.0 {
266        let sq = disc.sqrt();
267        (((tr + sq) / 2.0, 0.0), ((tr - sq) / 2.0, 0.0))
268    } else {
269        let sq = (-disc).sqrt();
270        ((tr / 2.0, sq / 2.0), (tr / 2.0, -sq / 2.0))
271    }
272}
273
274/// Stability classification of a 2-D fixed point given the Jacobian entries.
275///
276/// Returns a human-readable label: `"stable_node"`, `"unstable_node"`,
277/// `"saddle"`, `"stable_spiral"`, `"unstable_spiral"`, `"center"`.
278pub fn stability_2d(a: f64, b: f64, c: f64, d: f64) -> &'static str {
279    let tr = a + d;
280    let det = a * d - b * c;
281    let disc = tr * tr - 4.0 * det;
282    if det < 0.0 {
283        "saddle"
284    } else if disc >= 0.0 {
285        if tr < -1e-10 {
286            "stable_node"
287        } else if tr > 1e-10 {
288            "unstable_node"
289        } else {
290            "center"
291        }
292    } else if tr < -1e-10 {
293        "stable_spiral"
294    } else if tr > 1e-10 {
295        "unstable_spiral"
296    } else {
297        "center"
298    }
299}
300
301// ─────────────────────────────────────────────────────────────────────────────
302// Lyapunov exponents (QR method)
303// ─────────────────────────────────────────────────────────────────────────────
304
305/// Compute the `n` Lyapunov exponents of an `n`-dimensional ODE system using
306/// the QR-decomposition (Gram–Schmidt) method.
307///
308/// The variational equations are integrated simultaneously with the base
309/// trajectory. `n_steps` steps of size `h` are taken; the exponents are
310/// accumulated at each step using log|R_ii|.
311///
312/// Returns a `Vec`f64` of length `n` sorted in descending order.
313pub fn lyapunov_exponents_qr(sys: &dyn OdeSystem, x0: &[f64], h: f64, n_steps: usize) -> Vec<f64> {
314    let n = sys.dim();
315    let mut x = x0.to_vec();
316    // Initialise Q as identity
317    let mut q: Vec<Vec<f64>> = (0..n)
318        .map(|i| {
319            let mut v = vec![0.0f64; n];
320            v[i] = 1.0;
321            v
322        })
323        .collect();
324    let mut sums = vec![0.0f64; n];
325    let total_time = h * n_steps as f64;
326
327    for _step in 0..n_steps {
328        let t = _step as f64 * h;
329        // Advance base trajectory
330        x = rk4_step(sys, t, &x, h);
331        // Advance each tangent vector using the linearised flow
332        let jac = numerical_jacobian(sys, t, &x, 1e-6);
333        let mut new_q: Vec<Vec<f64>> = Vec::with_capacity(n);
334        for col in 0..n {
335            // w = J * q[col]
336            let mut w = vec![0.0f64; n];
337            for i in 0..n {
338                for j in 0..n {
339                    w[i] += jac[i * n + j] * q[col][j];
340                }
341            }
342            // w = w * h + q[col]  (Euler tangent advance)
343            let evolved: Vec<f64> = (0..n).map(|i| q[col][i] + h * w[i]).collect();
344            new_q.push(evolved);
345        }
346        // Gram–Schmidt orthonormalisation
347        let mut r_diag = vec![0.0f64; n];
348        for col in 0..n {
349            let mut v = new_q[col].clone();
350            for prev in 0..col {
351                let proj: f64 = (0..n).map(|i| q[prev][i] * v[i]).sum();
352                for i in 0..n {
353                    v[i] -= proj * q[prev][i];
354                }
355            }
356            let norm: f64 = v.iter().map(|vi| vi * vi).sum::<f64>().sqrt();
357            r_diag[col] = norm;
358            if norm > 1e-14 {
359                q[col] = v.iter().map(|vi| vi / norm).collect();
360            }
361            sums[col] += if norm > 1e-14 { norm.ln() } else { -100.0 };
362        }
363        let _ = r_diag;
364    }
365    let mut exponents: Vec<f64> = sums.iter().map(|s| s / total_time).collect();
366    exponents.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
367    exponents
368}
369
370// ─────────────────────────────────────────────────────────────────────────────
371// Bifurcation detection
372// ─────────────────────────────────────────────────────────────────────────────
373
374/// Detected bifurcation type.
375#[derive(Debug, Clone, PartialEq)]
376pub enum BifurcationType {
377    /// Saddle-node: two fixed points collide and annihilate.
378    SaddleNode,
379    /// Transcritical: two fixed points exchange stability.
380    Transcritical,
381    /// Pitchfork (supercritical or subcritical): one fixed point splits into three.
382    Pitchfork,
383    /// Hopf: a fixed point loses stability and a limit cycle is born.
384    Hopf,
385    /// No bifurcation detected near the tested parameter value.
386    None,
387}
388
389/// Result of a bifurcation scan along one parameter.
390#[derive(Debug, Clone)]
391pub struct BifurcationEvent {
392    /// Parameter value at which the bifurcation was detected.
393    pub param_value: f64,
394    /// Type of the detected bifurcation.
395    pub bif_type: BifurcationType,
396    /// Fixed-point location at the bifurcation (if found).
397    pub fixed_point: Vec<f64>,
398}
399
400// ─────────────────────────────────────────────────────────────────────────────
401// Lorenz system
402// ─────────────────────────────────────────────────────────────────────────────
403
404/// The Lorenz strange attractor.
405///
406/// ```text
407/// ẋ = σ(y − x)
408/// ẏ = x(ρ − z) − y
409/// ż = xy − βz
410/// ```
411///
412/// Classic chaos parameters: `σ=10`, `ρ=28`, `β=8/3`.
413#[derive(Debug, Clone)]
414pub struct LorenzSystem {
415    /// Prandtl number σ.
416    pub sigma: f64,
417    /// Rayleigh number ρ.
418    pub rho: f64,
419    /// Geometric factor β.
420    pub beta: f64,
421}
422
423impl LorenzSystem {
424    /// Create a Lorenz system.
425    pub fn new(sigma: f64, rho: f64, beta: f64) -> Self {
426        Self { sigma, rho, beta }
427    }
428
429    /// Create the classic chaotic Lorenz system (σ=10, ρ=28, β=8/3).
430    pub fn classic() -> Self {
431        Self::new(10.0, 28.0, 8.0 / 3.0)
432    }
433
434    /// Fixed points of the Lorenz system.
435    ///
436    /// Returns origin, C+, and C- when `ρ > 1`, otherwise only the origin.
437    pub fn fixed_points(&self) -> Vec<[f64; 3]> {
438        let mut fps = vec![[0.0, 0.0, 0.0]];
439        if self.rho > 1.0 {
440            let c = (self.beta * (self.rho - 1.0)).sqrt();
441            fps.push([c, c, self.rho - 1.0]);
442            fps.push([-c, -c, self.rho - 1.0]);
443        }
444        fps
445    }
446}
447
448impl OdeSystem for LorenzSystem {
449    fn dim(&self) -> usize {
450        3
451    }
452    fn rhs(&self, _t: f64, x: &[f64]) -> Vec<f64> {
453        vec![
454            self.sigma * (x[1] - x[0]),
455            x[0] * (self.rho - x[2]) - x[1],
456            x[0] * x[1] - self.beta * x[2],
457        ]
458    }
459}
460
461// ─────────────────────────────────────────────────────────────────────────────
462// Rössler system
463// ─────────────────────────────────────────────────────────────────────────────
464
465/// The Rössler system, a simpler strange attractor.
466///
467/// ```text
468/// ẋ = −y − z
469/// ẏ = x + ay
470/// ż = b + z(x − c)
471/// ```
472///
473/// Classic chaos: `a=0.2`, `b=0.2`, `c=5.7`.
474#[derive(Debug, Clone)]
475pub struct RosslerSystem {
476    /// Parameter a.
477    pub a: f64,
478    /// Parameter b.
479    pub b: f64,
480    /// Parameter c.
481    pub c: f64,
482}
483
484impl RosslerSystem {
485    /// Create a Rössler system.
486    pub fn new(a: f64, b: f64, c: f64) -> Self {
487        Self { a, b, c }
488    }
489
490    /// Classic chaotic parameters (a=0.2, b=0.2, c=5.7).
491    pub fn classic() -> Self {
492        Self::new(0.2, 0.2, 5.7)
493    }
494}
495
496impl OdeSystem for RosslerSystem {
497    fn dim(&self) -> usize {
498        3
499    }
500    fn rhs(&self, _t: f64, x: &[f64]) -> Vec<f64> {
501        vec![
502            -x[1] - x[2],
503            x[0] + self.a * x[1],
504            self.b + x[2] * (x[0] - self.c),
505        ]
506    }
507}
508
509// ─────────────────────────────────────────────────────────────────────────────
510// Poincaré section
511// ─────────────────────────────────────────────────────────────────────────────
512
513/// Configuration for a Poincaré section.
514///
515/// The section is defined by `x[axis] = level` with crossings in the
516/// direction specified by `positive_direction`.
517#[derive(Debug, Clone)]
518pub struct PoincareSectionConfig {
519    /// Index of the state variable defining the hyperplane.
520    pub axis: usize,
521    /// Value of `x[axis]` on the hyperplane.
522    pub level: f64,
523    /// If `true`, only record crossings where `x[axis]` is increasing.
524    pub positive_direction: bool,
525}
526
527/// Record of a single Poincaré crossing.
528#[derive(Debug, Clone)]
529pub struct PoincareCrossing {
530    /// Time of the crossing.
531    pub time: f64,
532    /// Full state vector at the crossing.
533    pub state: Vec<f64>,
534}
535
536/// Compute a Poincaré section by integrating `sys` and recording crossings.
537///
538/// Integration uses RK4 with step `h` for `n_steps` steps.
539pub fn poincare_section(
540    sys: &dyn OdeSystem,
541    x0: &[f64],
542    h: f64,
543    n_steps: usize,
544    cfg: &PoincareSectionConfig,
545) -> Vec<PoincareCrossing> {
546    let mut crossings = Vec::new();
547    let mut t = 0.0f64;
548    let mut x_prev = x0.to_vec();
549    let mut x = rk4_step(sys, t, &x_prev, h);
550    for _i in 1..n_steps {
551        let v_prev = x_prev[cfg.axis] - cfg.level;
552        let v_cur = x[cfg.axis] - cfg.level;
553        let crossing = if cfg.positive_direction {
554            v_prev < 0.0 && v_cur >= 0.0
555        } else {
556            v_prev > 0.0 && v_cur <= 0.0
557        };
558        if crossing {
559            // Linear interpolation to find crossing time
560            let frac = v_prev.abs() / (v_prev.abs() + v_cur.abs() + 1e-30);
561            let t_cross = t + frac * h;
562            let state: Vec<f64> = x_prev
563                .iter()
564                .zip(x.iter())
565                .map(|(a, b)| a + frac * (b - a))
566                .collect();
567            crossings.push(PoincareCrossing {
568                time: t_cross,
569                state,
570            });
571        }
572        x_prev = x.clone();
573        x = rk4_step(sys, t, &x, h);
574        t += h;
575    }
576    crossings
577}
578
579// ─────────────────────────────────────────────────────────────────────────────
580// Limit cycle detection
581// ─────────────────────────────────────────────────────────────────────────────
582
583/// Result of a limit-cycle period estimate.
584#[derive(Debug, Clone)]
585pub struct LimitCycleEstimate {
586    /// Estimated period T.
587    pub period: f64,
588    /// Number of full cycles used in the estimate.
589    pub n_cycles: usize,
590    /// Whether a convincing cycle was found.
591    pub found: bool,
592}
593
594/// Estimate the period of a limit cycle via successive Poincaré crossings.
595///
596/// Returns `None` if fewer than 2 crossings were found.
597pub fn estimate_limit_cycle_period(
598    sys: &dyn OdeSystem,
599    x0: &[f64],
600    h: f64,
601    n_steps: usize,
602    poincare_axis: usize,
603    level: f64,
604) -> Option<LimitCycleEstimate> {
605    let cfg = PoincareSectionConfig {
606        axis: poincare_axis,
607        level,
608        positive_direction: true,
609    };
610    let crossings = poincare_section(sys, x0, h, n_steps, &cfg);
611    if crossings.len() < 2 {
612        return None;
613    }
614    let n = crossings.len();
615    let total_time = crossings[n - 1].time - crossings[0].time;
616    let period = total_time / (n - 1) as f64;
617    Some(LimitCycleEstimate {
618        period,
619        n_cycles: n - 1,
620        found: true,
621    })
622}
623
624// ─────────────────────────────────────────────────────────────────────────────
625// Recurrence Quantification Analysis (RQA)
626// ─────────────────────────────────────────────────────────────────────────────
627
628/// Parameters for RQA computation.
629#[derive(Debug, Clone)]
630pub struct RqaParams {
631    /// Embedding dimension.
632    pub embed_dim: usize,
633    /// Time delay τ (in samples).
634    pub tau: usize,
635    /// Recurrence threshold ε.
636    pub epsilon: f64,
637    /// Minimum diagonal line length.
638    pub min_line: usize,
639}
640
641/// Results of a recurrence quantification analysis.
642#[derive(Debug, Clone)]
643pub struct RqaResult {
644    /// Recurrence Rate (RR): fraction of recurrence points.
645    pub rr: f64,
646    /// Determinism (DET): fraction of recurrence points on diagonal lines.
647    pub det: f64,
648    /// Average diagonal line length (L).
649    pub avg_line: f64,
650    /// Longest diagonal line length (L_max).
651    pub l_max: usize,
652    /// Entropy of diagonal line-length distribution.
653    pub entropy: f64,
654    /// Laminarity (LAM): fraction of recurrence points in vertical lines.
655    pub lam: f64,
656    /// Trapping time (TT): average length of vertical lines.
657    pub tt: f64,
658}
659
660/// Build the delay-embedded trajectory from a scalar time series.
661///
662/// Returns a `Vec<Vec`f64`>` where each inner vector has length `embed_dim`.
663pub fn delay_embed(series: &[f64], embed_dim: usize, tau: usize) -> Vec<Vec<f64>> {
664    let n = series.len();
665    let max_start = (embed_dim - 1) * tau;
666    if n <= max_start {
667        return Vec::new();
668    }
669    (0..=(n - 1 - max_start))
670        .map(|i| (0..embed_dim).map(|d| series[i + d * tau]).collect())
671        .collect()
672}
673
674/// Compute the recurrence matrix (as a flat `bool` vector, row-major).
675///
676/// `points[i]` are the embedded vectors.  Entry `(i,j)` is `true` when
677/// `‖points[i] − points[j]‖ < epsilon`.
678pub fn recurrence_matrix(points: &[Vec<f64>], epsilon: f64) -> Vec<bool> {
679    let n = points.len();
680    let mut mat = vec![false; n * n];
681    for i in 0..n {
682        for j in 0..n {
683            let dist: f64 = points[i]
684                .iter()
685                .zip(points[j].iter())
686                .map(|(a, b)| (a - b).powi(2))
687                .sum::<f64>()
688                .sqrt();
689            mat[i * n + j] = dist < epsilon;
690        }
691    }
692    mat
693}
694
695/// Perform full RQA on a scalar time series.
696///
697/// Uses Chebyshev (max-norm) distance in embedding space.
698pub fn rqa(series: &[f64], params: &RqaParams) -> RqaResult {
699    let points = delay_embed(series, params.embed_dim, params.tau);
700    let n = points.len();
701    if n == 0 {
702        return RqaResult {
703            rr: 0.0,
704            det: 0.0,
705            avg_line: 0.0,
706            l_max: 0,
707            entropy: 0.0,
708            lam: 0.0,
709            tt: 0.0,
710        };
711    }
712    // Build recurrence matrix (exclude identity)
713    let mat = recurrence_matrix(&points, params.epsilon);
714
715    // Recurrence rate (exclude diagonal)
716    let total_off = (n * n - n) as f64;
717    let recurrent_pts: usize = (0..n)
718        .flat_map(|i| (0..n).map(move |j| (i, j)))
719        .filter(|&(i, j)| i != j && mat[i * n + j])
720        .count();
721    let rr = if total_off > 0.0 {
722        recurrent_pts as f64 / total_off
723    } else {
724        0.0
725    };
726
727    // Diagonal line statistics
728    let mut diag_lines: Vec<usize> = Vec::new();
729    for diag in (-(n as isize - 1))..(n as isize) {
730        if diag == 0 {
731            continue;
732        }
733        let mut run = 0usize;
734        let i_start = if diag < 0 { (-diag) as usize } else { 0 };
735        let j_start = if diag > 0 { diag as usize } else { 0 };
736        let len = n - i_start.max(j_start);
737        for k in 0..len {
738            let i = i_start + k;
739            let j = j_start + k;
740            if mat[i * n + j] {
741                run += 1;
742            } else {
743                if run >= params.min_line {
744                    diag_lines.push(run);
745                }
746                run = 0;
747            }
748        }
749        if run >= params.min_line {
750            diag_lines.push(run);
751        }
752    }
753    let diag_total: usize = diag_lines.iter().sum();
754    let det = if recurrent_pts > 0 {
755        diag_total as f64 / recurrent_pts as f64
756    } else {
757        0.0
758    };
759    let l_max = diag_lines.iter().copied().max().unwrap_or(0);
760    let avg_line = if !diag_lines.is_empty() {
761        diag_total as f64 / diag_lines.len() as f64
762    } else {
763        0.0
764    };
765    // Shannon entropy of line-length distribution
766    let entropy = if !diag_lines.is_empty() {
767        let total = diag_total as f64;
768        let mut ent = 0.0f64;
769        let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
770        for &l in &diag_lines {
771            *counts.entry(l).or_insert(0) += 1;
772        }
773        for &cnt in counts.values() {
774            let p = cnt as f64 / total;
775            if p > 0.0 {
776                ent -= p * p.ln();
777            }
778        }
779        ent
780    } else {
781        0.0
782    };
783
784    // Vertical line statistics (laminarity / trapping time)
785    let mut vert_lines: Vec<usize> = Vec::new();
786    for col in 0..n {
787        let mut run = 0usize;
788        for row in 0..n {
789            if row != col && mat[row * n + col] {
790                run += 1;
791            } else {
792                if run >= params.min_line {
793                    vert_lines.push(run);
794                }
795                run = 0;
796            }
797        }
798        if run >= params.min_line {
799            vert_lines.push(run);
800        }
801    }
802    let vert_total: usize = vert_lines.iter().sum();
803    let lam = if recurrent_pts > 0 {
804        vert_total as f64 / recurrent_pts as f64
805    } else {
806        0.0
807    };
808    let tt = if !vert_lines.is_empty() {
809        vert_total as f64 / vert_lines.len() as f64
810    } else {
811        0.0
812    };
813
814    RqaResult {
815        rr,
816        det,
817        avg_line,
818        l_max,
819        entropy,
820        lam,
821        tt,
822    }
823}
824
825// ─────────────────────────────────────────────────────────────────────────────
826// Hamiltonian systems
827// ─────────────────────────────────────────────────────────────────────────────
828
829/// A Hamiltonian system with `n` degrees of freedom.
830///
831/// State is `(q1,…,qn, p1,…,pn)`.  Implement `hamiltonian()`,
832/// `dh_dq()`, and `dh_dp()`.
833pub trait HamiltonianSystem {
834    /// Number of degrees of freedom.
835    fn ndof(&self) -> usize;
836
837    /// Evaluate the Hamiltonian `H(q, p)`.
838    fn hamiltonian(&self, q: &[f64], p: &[f64]) -> f64;
839
840    /// Partial derivatives `∂H/∂q` (forces, length `ndof`).
841    fn dh_dq(&self, q: &[f64], p: &[f64]) -> Vec<f64>;
842
843    /// Partial derivatives `∂H/∂p` (velocities, length `ndof`).
844    fn dh_dp(&self, q: &[f64], p: &[f64]) -> Vec<f64>;
845}
846
847/// Wrap a `HamiltonianSystem` as a plain `OdeSystem`.
848///
849/// Hamilton's equations: `q̇ = ∂H/∂p`, `ṗ = −∂H/∂q`.
850pub struct HamiltonianOde<'a> {
851    /// The underlying Hamiltonian.
852    pub ham: &'a dyn HamiltonianSystem,
853}
854
855impl<'a> OdeSystem for HamiltonianOde<'a> {
856    fn dim(&self) -> usize {
857        2 * self.ham.ndof()
858    }
859    fn rhs(&self, _t: f64, x: &[f64]) -> Vec<f64> {
860        let n = self.ham.ndof();
861        let q = &x[..n];
862        let p = &x[n..];
863        let qdot = self.ham.dh_dp(q, p);
864        let dhdq = self.ham.dh_dq(q, p);
865        let pdot: Vec<f64> = dhdq.iter().map(|v| -v).collect();
866        [qdot, pdot].concat()
867    }
868}
869
870// ─────────────────────────────────────────────────────────────────────────────
871// Symplectic integration (Störmer–Verlet / leapfrog)
872// ─────────────────────────────────────────────────────────────────────────────
873
874/// One Störmer–Verlet (leapfrog) step for a separable Hamiltonian
875/// `H = T(p) + V(q)`.
876///
877/// `grad_v` computes `∂V/∂q` (force = `−grad_v`).
878/// Returns the updated `(q_new, p_new)`.
879pub fn stormer_verlet_step(
880    q: &[f64],
881    p: &[f64],
882    h: f64,
883    grad_v: &dyn Fn(&[f64]) -> Vec<f64>,
884) -> (Vec<f64>, Vec<f64>) {
885    let n = q.len();
886    let f = grad_v(q);
887    // half kick
888    let p_half: Vec<f64> = (0..n).map(|i| p[i] - 0.5 * h * f[i]).collect();
889    // drift
890    let q_new: Vec<f64> = (0..n).map(|i| q[i] + h * p_half[i]).collect();
891    // second gradient
892    let f2 = grad_v(&q_new);
893    // half kick
894    let p_new: Vec<f64> = (0..n).map(|i| p_half[i] - 0.5 * h * f2[i]).collect();
895    (q_new, p_new)
896}
897
898/// 4th-order symplectic Forest–Ruth integrator.
899///
900/// Coefficients from Forest & Ruth (1990).
901pub fn forest_ruth_step(
902    q: &[f64],
903    p: &[f64],
904    h: f64,
905    grad_v: &dyn Fn(&[f64]) -> Vec<f64>,
906) -> (Vec<f64>, Vec<f64>) {
907    let theta: f64 = 1.0 / (2.0 - 2.0f64.powf(1.0 / 3.0));
908    let xi = 1.0 - 2.0 * theta;
909    let n = q.len();
910    let mut qq = q.to_vec();
911    let mut pp = p.to_vec();
912    for &c in &[theta, xi, theta] {
913        // drift
914        let f = grad_v(&qq);
915        for i in 0..n {
916            pp[i] -= 0.5 * c * h * f[i];
917        }
918        for i in 0..n {
919            qq[i] += c * h * pp[i];
920        }
921        let f2 = grad_v(&qq);
922        for i in 0..n {
923            pp[i] -= 0.5 * c * h * f2[i];
924        }
925    }
926    (qq, pp)
927}
928
929/// Integrate a Hamiltonian system using the Störmer–Verlet method.
930///
931/// Returns `Vec<(q, p)>` snapshots at every step.
932pub fn integrate_symplectic(
933    q0: &[f64],
934    p0: &[f64],
935    h: f64,
936    n_steps: usize,
937    grad_v: &dyn Fn(&[f64]) -> Vec<f64>,
938) -> Vec<(Vec<f64>, Vec<f64>)> {
939    let mut traj = Vec::with_capacity(n_steps + 1);
940    let mut q = q0.to_vec();
941    let mut p = p0.to_vec();
942    traj.push((q.clone(), p.clone()));
943    for _ in 0..n_steps {
944        let (qn, pn) = stormer_verlet_step(&q, &p, h, grad_v);
945        q = qn;
946        p = pn;
947        traj.push((q.clone(), p.clone()));
948    }
949    traj
950}
951
952// ─────────────────────────────────────────────────────────────────────────────
953// KAM torus winding number
954// ─────────────────────────────────────────────────────────────────────────────
955
956/// Estimate the winding number (frequency ratio) of a 2-DOF Hamiltonian orbit
957/// on a KAM torus using the mean frequency ratio from angle variables.
958///
959/// Angles are estimated from successive Poincaré crossings of the `(q[0], p[0])`
960/// sub-plane.  Returns the ratio `ω1/ω2`.
961pub fn estimate_winding_number(q_traj: &[(Vec<f64>, Vec<f64>)], _period: f64) -> Option<f64> {
962    // Count angle-variable increments by tracking q[0] sign changes
963    if q_traj.len() < 4 {
964        return None;
965    }
966    let mut crossings_0: usize = 0;
967    let mut crossings_1: usize = 0;
968    for w in q_traj.windows(2) {
969        if w[0].0[0] * w[1].0[0] < 0.0 {
970            crossings_0 += 1;
971        }
972        if w[0].0.len() > 1 && w[1].0.len() > 1 && w[0].0[1] * w[1].0[1] < 0.0 {
973            crossings_1 += 1;
974        }
975    }
976    if crossings_1 == 0 {
977        return None;
978    }
979    Some(crossings_0 as f64 / crossings_1 as f64)
980}
981
982// ─────────────────────────────────────────────────────────────────────────────
983// Phase plane helpers
984// ─────────────────────────────────────────────────────────────────────────────
985
986/// Compute a vector field on a 2-D grid for phase-plane visualisation.
987///
988/// `x_range = (x_min, x_max, nx)`, `y_range = (y_min, y_max, ny)`.
989/// Returns `Vec<(x, y, dx, dy)>` — one entry per grid point.
990pub fn phase_plane_grid(
991    sys: &dyn OdeSystem,
992    x_range: (f64, f64, usize),
993    y_range: (f64, f64, usize),
994) -> Vec<(f64, f64, f64, f64)> {
995    let (x_min, x_max, nx) = x_range;
996    let (y_min, y_max, ny) = y_range;
997    let mut out = Vec::with_capacity(nx * ny);
998    for i in 0..nx {
999        let xv = x_min + (x_max - x_min) * i as f64 / (nx.saturating_sub(1).max(1)) as f64;
1000        for j in 0..ny {
1001            let yv = y_min + (y_max - y_min) * j as f64 / (ny.saturating_sub(1).max(1)) as f64;
1002            let f = sys.rhs(0.0, &[xv, yv]);
1003            out.push((xv, yv, f[0], f[1]));
1004        }
1005    }
1006    out
1007}
1008
1009// ─────────────────────────────────────────────────────────────────────────────
1010// Van der Pol oscillator (example 2-D system)
1011// ─────────────────────────────────────────────────────────────────────────────
1012
1013/// Van der Pol oscillator:
1014///
1015/// ```text
1016/// ẋ = y
1017/// ẏ = μ(1 − x²)y − x
1018/// ```
1019///
1020/// Exhibits a stable limit cycle for any `μ > 0`.
1021#[derive(Debug, Clone)]
1022pub struct VanDerPol {
1023    /// Nonlinearity parameter μ.
1024    pub mu: f64,
1025}
1026
1027impl VanDerPol {
1028    /// Create a Van der Pol oscillator.
1029    pub fn new(mu: f64) -> Self {
1030        Self { mu }
1031    }
1032}
1033
1034impl OdeSystem for VanDerPol {
1035    fn dim(&self) -> usize {
1036        2
1037    }
1038    fn rhs(&self, _t: f64, x: &[f64]) -> Vec<f64> {
1039        vec![x[1], self.mu * (1.0 - x[0] * x[0]) * x[1] - x[0]]
1040    }
1041}
1042
1043// ─────────────────────────────────────────────────────────────────────────────
1044// Double pendulum (Hamiltonian)
1045// ─────────────────────────────────────────────────────────────────────────────
1046
1047/// Double pendulum with equal masses `m=1` and lengths `l=1`.
1048///
1049/// Canonical coordinates: `q = (θ1, θ2)`, `p = (pθ1, pθ2)`.
1050#[derive(Debug, Clone)]
1051pub struct DoublePendulum {
1052    /// Gravitational acceleration.
1053    pub g: f64,
1054}
1055
1056impl DoublePendulum {
1057    /// Create a double pendulum.
1058    pub fn new(g: f64) -> Self {
1059        Self { g }
1060    }
1061
1062    /// Integrate using 4th-order Runge–Kutta for `n` steps of size `h`.
1063    ///
1064    /// State: `[θ1, θ2, pθ1, pθ2]`.
1065    pub fn integrate(&self, state0: [f64; 4], h: f64, n: usize) -> Vec<[f64; 4]> {
1066        let mut traj = Vec::with_capacity(n + 1);
1067        let mut s = state0;
1068        traj.push(s);
1069        for _ in 0..n {
1070            let rhs = self.rhs_arr(s);
1071            let k1 = rhs;
1072            let s2 = [
1073                s[0] + 0.5 * h * k1[0],
1074                s[1] + 0.5 * h * k1[1],
1075                s[2] + 0.5 * h * k1[2],
1076                s[3] + 0.5 * h * k1[3],
1077            ];
1078            let k2 = self.rhs_arr(s2);
1079            let s3 = [
1080                s[0] + 0.5 * h * k2[0],
1081                s[1] + 0.5 * h * k2[1],
1082                s[2] + 0.5 * h * k2[2],
1083                s[3] + 0.5 * h * k2[3],
1084            ];
1085            let k3 = self.rhs_arr(s3);
1086            let s4 = [
1087                s[0] + h * k3[0],
1088                s[1] + h * k3[1],
1089                s[2] + h * k3[2],
1090                s[3] + h * k3[3],
1091            ];
1092            let k4 = self.rhs_arr(s4);
1093            for i in 0..4 {
1094                s[i] += h / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]);
1095            }
1096            traj.push(s);
1097        }
1098        traj
1099    }
1100
1101    /// RHS for `[θ1, θ2, pθ1, pθ2]` (equal masses m=1, lengths l=1).
1102    fn rhs_arr(&self, s: [f64; 4]) -> [f64; 4] {
1103        let (t1, t2, p1, p2) = (s[0], s[1], s[2], s[3]);
1104        let dt = t1 - t2;
1105        let denom = 16.0 - 9.0 * dt.cos() * dt.cos();
1106        let t1dot = (6.0 * p1 - 3.0 * dt.cos() * p2) / denom;
1107        let t2dot = (8.0 * p2 - 6.0 * dt.cos() * p1) / denom;
1108        let h = t1dot * t2dot * dt.sin();
1109        let p1dot = -3.0 * self.g * t1.sin() - h;
1110        let p2dot = self.g * t2.sin() + h;
1111        [t1dot, t2dot, p1dot, p2dot]
1112    }
1113
1114    /// Total energy of the double pendulum.
1115    pub fn energy(&self, s: [f64; 4]) -> f64 {
1116        let (t1, t2, p1, p2) = (s[0], s[1], s[2], s[3]);
1117        let dt = t1 - t2;
1118        let denom = 16.0 - 9.0 * dt.cos() * dt.cos();
1119        let t1dot = (6.0 * p1 - 3.0 * dt.cos() * p2) / denom;
1120        let t2dot = (8.0 * p2 - 6.0 * dt.cos() * p1) / denom;
1121        let ke = 0.5 * t1dot * t1dot + 0.5 * t2dot * t2dot + t1dot * t2dot * dt.cos();
1122        let pe = -self.g * (2.0 * t1.cos() + t2.cos());
1123        ke + pe
1124    }
1125}
1126
1127// ─────────────────────────────────────────────────────────────────────────────
1128// Duffing oscillator
1129// ─────────────────────────────────────────────────────────────────────────────
1130
1131/// Duffing oscillator:
1132///
1133/// ```text
1134/// ẋ = y
1135/// ẏ = −δ y − α x − β x³ + γ cos(ω t)
1136/// ```
1137#[derive(Debug, Clone)]
1138pub struct DuffingOscillator {
1139    /// Damping coefficient δ.
1140    pub delta: f64,
1141    /// Linear stiffness α.
1142    pub alpha: f64,
1143    /// Cubic stiffness β.
1144    pub beta: f64,
1145    /// Forcing amplitude γ.
1146    pub gamma: f64,
1147    /// Forcing frequency ω.
1148    pub omega: f64,
1149}
1150
1151impl DuffingOscillator {
1152    /// Create a Duffing oscillator.
1153    #[allow(clippy::too_many_arguments)]
1154    pub fn new(delta: f64, alpha: f64, beta: f64, gamma: f64, omega: f64) -> Self {
1155        Self {
1156            delta,
1157            alpha,
1158            beta,
1159            gamma,
1160            omega,
1161        }
1162    }
1163}
1164
1165impl OdeSystem for DuffingOscillator {
1166    fn dim(&self) -> usize {
1167        2
1168    }
1169    fn rhs(&self, t: f64, x: &[f64]) -> Vec<f64> {
1170        vec![
1171            x[1],
1172            -self.delta * x[1] - self.alpha * x[0] - self.beta * x[0].powi(3)
1173                + self.gamma * (self.omega * t).cos(),
1174        ]
1175    }
1176}
1177
1178// ─────────────────────────────────────────────────────────────────────────────
1179// Bifurcation normal form helpers
1180// ─────────────────────────────────────────────────────────────────────────────
1181
1182/// Analyse a 1-D saddle-node bifurcation `ẋ = μ + x²`.
1183///
1184/// Returns the fixed points for a given parameter `mu`.
1185pub fn saddle_node_fixed_points(mu: f64) -> Vec<f64> {
1186    if mu > 0.0 {
1187        Vec::new()
1188    } else if mu == 0.0 {
1189        vec![0.0]
1190    } else {
1191        let sq = (-mu).sqrt();
1192        vec![-sq, sq]
1193    }
1194}
1195
1196/// Analyse a 1-D pitchfork bifurcation (supercritical) `ẋ = μ x − x³`.
1197///
1198/// Returns the stable fixed points.
1199pub fn pitchfork_fixed_points(mu: f64) -> Vec<f64> {
1200    if mu <= 0.0 {
1201        vec![0.0]
1202    } else {
1203        let sq = mu.sqrt();
1204        vec![-sq, 0.0, sq]
1205    }
1206}
1207
1208/// Check whether a 2-D system near a fixed point undergoes a Hopf bifurcation
1209/// at the given Jacobian parameters.
1210///
1211/// A Hopf bifurcation occurs when the Jacobian has purely imaginary eigenvalues:
1212/// `trace(J) = 0`, `det(J) > 0`.
1213///
1214/// Returns `true` if the conditions are (approximately) met.
1215pub fn is_hopf_bifurcation(tr: f64, det: f64, tol: f64) -> bool {
1216    det > tol && tr.abs() < tol
1217}
1218
1219// ─────────────────────────────────────────────────────────────────────────────
1220// Chaos indicator: maximal Lyapunov exponent (fast Benettin)
1221// ─────────────────────────────────────────────────────────────────────────────
1222
1223/// Compute the maximal Lyapunov exponent (MLE) using the Benettin algorithm.
1224///
1225/// A single tangent vector is integrated alongside the base trajectory and
1226/// periodically renormalised.  The log of the growth rate is accumulated.
1227///
1228/// # Arguments
1229/// * `sys`     — the ODE system
1230/// * `x0`      — initial state
1231/// * `h`       — step size
1232/// * `n_steps` — total integration steps
1233/// * `renorm`  — steps between renormalisations
1234pub fn maximal_lyapunov_exponent(
1235    sys: &dyn OdeSystem,
1236    x0: &[f64],
1237    h: f64,
1238    n_steps: usize,
1239    renorm: usize,
1240) -> f64 {
1241    let n = sys.dim();
1242    let mut x = x0.to_vec();
1243    // Initial tangent vector (random perturbation, normalised)
1244    let mut dv: Vec<f64> = (0..n).map(|i| if i == 0 { 1.0 } else { 0.0 }).collect();
1245    let mut sum_log = 0.0f64;
1246    let mut t = 0.0f64;
1247    let mut count = 0usize;
1248    for step in 0..n_steps {
1249        // Advance base
1250        let x_new = rk4_step(sys, t, &x, h);
1251        // Advance tangent (linearised RK4 approximated by Euler of J*dv)
1252        let jac = numerical_jacobian(sys, t, &x, 1e-7);
1253        let jdv: Vec<f64> = (0..n)
1254            .map(|i| (0..n).map(|j| jac[i * n + j] * dv[j]).sum::<f64>())
1255            .collect();
1256        let dv_new: Vec<f64> = (0..n).map(|i| dv[i] + h * jdv[i]).collect();
1257        x = x_new;
1258        dv = dv_new;
1259        t += h;
1260        // Renormalise
1261        if (step + 1) % renorm == 0 {
1262            let norm: f64 = dv.iter().map(|v| v * v).sum::<f64>().sqrt();
1263            if norm > 1e-14 {
1264                sum_log += norm.ln();
1265                for vi in &mut dv {
1266                    *vi /= norm;
1267                }
1268                count += 1;
1269            }
1270        }
1271    }
1272    if count == 0 {
1273        return 0.0;
1274    }
1275    sum_log / (count * renorm) as f64 / h
1276}
1277
1278// ─────────────────────────────────────────────────────────────────────────────
1279// Energy drift monitor
1280// ─────────────────────────────────────────────────────────────────────────────
1281
1282/// Monitor energy conservation in a Hamiltonian trajectory.
1283///
1284/// Returns the maximum relative energy drift `|ΔH/H₀|` over the trajectory.
1285pub fn energy_drift(ham: &dyn HamiltonianSystem, traj: &[(Vec<f64>, Vec<f64>)]) -> f64 {
1286    if traj.is_empty() {
1287        return 0.0;
1288    }
1289    let n = ham.ndof();
1290    let (q0, p0) = &traj[0];
1291    let h0 = ham.hamiltonian(q0, p0);
1292    if h0.abs() < 1e-30 {
1293        return 0.0;
1294    }
1295    traj.iter()
1296        .skip(1)
1297        .map(|(q, p)| {
1298            let _ = n;
1299            ((ham.hamiltonian(q, p) - h0) / h0).abs()
1300        })
1301        .fold(0.0f64, f64::max)
1302}
1303
1304// ─────────────────────────────────────────────────────────────────────────────
1305// Simple harmonic oscillator (Hamiltonian)
1306// ─────────────────────────────────────────────────────────────────────────────
1307
1308/// 1-DOF simple harmonic oscillator: `H = p²/2 + ω²q²/2`.
1309#[derive(Debug, Clone)]
1310pub struct HarmonicOscillatorHam {
1311    /// Angular frequency ω.
1312    pub omega: f64,
1313}
1314
1315impl HarmonicOscillatorHam {
1316    /// Create a harmonic oscillator Hamiltonian.
1317    pub fn new(omega: f64) -> Self {
1318        Self { omega }
1319    }
1320}
1321
1322impl HamiltonianSystem for HarmonicOscillatorHam {
1323    fn ndof(&self) -> usize {
1324        1
1325    }
1326    fn hamiltonian(&self, q: &[f64], p: &[f64]) -> f64 {
1327        0.5 * p[0] * p[0] + 0.5 * self.omega * self.omega * q[0] * q[0]
1328    }
1329    fn dh_dq(&self, q: &[f64], _p: &[f64]) -> Vec<f64> {
1330        vec![self.omega * self.omega * q[0]]
1331    }
1332    fn dh_dp(&self, _q: &[f64], p: &[f64]) -> Vec<f64> {
1333        vec![p[0]]
1334    }
1335}
1336
1337// ─────────────────────────────────────────────────────────────────────────────
1338// Saddle connection / heteroclinic / homoclinic helpers
1339// ─────────────────────────────────────────────────────────────────────────────
1340
1341/// Check whether two fixed points are connected by a heteroclinic orbit.
1342///
1343/// A rudimentary test: integrate from near the unstable manifold of `fp_a`
1344/// and check if the trajectory approaches `fp_b` within `tol`.
1345///
1346/// Returns the time at which the trajectory first enters the `tol`-ball
1347/// around `fp_b`, or `None` if it does not within `n_steps` steps.
1348#[allow(clippy::too_many_arguments)]
1349pub fn detect_heteroclinic(
1350    sys: &dyn OdeSystem,
1351    fp_a: &[f64],
1352    fp_b: &[f64],
1353    unstable_dir: &[f64],
1354    eps_perturb: f64,
1355    h: f64,
1356    n_steps: usize,
1357    tol: f64,
1358) -> Option<f64> {
1359    let n = fp_a.len();
1360    let mut x: Vec<f64> = (0..n)
1361        .map(|i| fp_a[i] + eps_perturb * unstable_dir[i])
1362        .collect();
1363    let mut t = 0.0f64;
1364    for _ in 0..n_steps {
1365        let dist: f64 = (0..n).map(|i| (x[i] - fp_b[i]).powi(2)).sum::<f64>().sqrt();
1366        if dist < tol {
1367            return Some(t);
1368        }
1369        x = rk4_step(sys, t, &x, h);
1370        t += h;
1371    }
1372    None
1373}
1374
1375// ─────────────────────────────────────────────────────────────────────────────
1376// Attractor dimension (Kaplan–Yorke)
1377// ─────────────────────────────────────────────────────────────────────────────
1378
1379/// Estimate the Kaplan–Yorke dimension from sorted Lyapunov exponents.
1380///
1381/// `exponents` must be sorted in descending order (largest first).
1382/// Returns the KY dimension `D = j + (λ1 + … + λj)/|λ_{j+1}|`
1383/// where `j` is the largest index such that the sum is non-negative.
1384pub fn kaplan_yorke_dim(exponents: &[f64]) -> f64 {
1385    let n = exponents.len();
1386    let mut sum = 0.0f64;
1387    let mut j = 0usize;
1388    for (i, &le) in exponents.iter().enumerate() {
1389        sum += le;
1390        if sum < 0.0 {
1391            j = i;
1392            sum -= le;
1393            break;
1394        }
1395        if i == n - 1 {
1396            return n as f64;
1397        }
1398        j = i + 1;
1399    }
1400    if j == 0 && exponents[0] < 0.0 {
1401        return 0.0;
1402    }
1403    let denom = exponents[j].abs();
1404    if denom < 1e-14 {
1405        return j as f64;
1406    }
1407    j as f64 + sum / denom
1408}
1409
1410// ─────────────────────────────────────────────────────────────────────────────
1411// Coupled oscillators (Kuramoto model)
1412// ─────────────────────────────────────────────────────────────────────────────
1413
1414/// Kuramoto model of `n` coupled phase oscillators.
1415///
1416/// ```text
1417/// θ̇ᵢ = ωᵢ + (K/n) Σⱼ sin(θⱼ − θᵢ)
1418/// ```
1419#[derive(Debug, Clone)]
1420pub struct KuramotoModel {
1421    /// Natural frequencies ωᵢ.
1422    pub omegas: Vec<f64>,
1423    /// Coupling strength K.
1424    pub k: f64,
1425}
1426
1427impl KuramotoModel {
1428    /// Create a Kuramoto model.
1429    pub fn new(omegas: Vec<f64>, k: f64) -> Self {
1430        Self { omegas, k }
1431    }
1432
1433    /// Compute the order parameter `r exp(iψ)` where `r` measures synchronisation.
1434    ///
1435    /// Returns `(r, psi)`.
1436    pub fn order_parameter(theta: &[f64]) -> (f64, f64) {
1437        let n = theta.len() as f64;
1438        let re: f64 = theta.iter().map(|&t| t.cos()).sum::<f64>() / n;
1439        let im: f64 = theta.iter().map(|&t| t.sin()).sum::<f64>() / n;
1440        let r = (re * re + im * im).sqrt();
1441        let psi = im.atan2(re);
1442        (r, psi)
1443    }
1444}
1445
1446impl OdeSystem for KuramotoModel {
1447    fn dim(&self) -> usize {
1448        self.omegas.len()
1449    }
1450    fn rhs(&self, _t: f64, x: &[f64]) -> Vec<f64> {
1451        let n = x.len();
1452        let k_over_n = self.k / n as f64;
1453        (0..n)
1454            .map(|i| {
1455                let coupling: f64 = (0..n).map(|j| (x[j] - x[i]).sin()).sum();
1456                self.omegas[i] + k_over_n * coupling
1457            })
1458            .collect()
1459    }
1460}
1461
1462// ─────────────────────────────────────────────────────────────────────────────
1463// Brusselator (chemical oscillator, Hopf bifurcation example)
1464// ─────────────────────────────────────────────────────────────────────────────
1465
1466/// Brusselator chemical oscillator.
1467///
1468/// ```text
1469/// ẋ = a − (b+1)x + x²y
1470/// ẏ = bx − x²y
1471/// ```
1472///
1473/// Undergoes a Hopf bifurcation at `b = 1 + a²`.
1474#[derive(Debug, Clone)]
1475pub struct Brusselator {
1476    /// Parameter a.
1477    pub a: f64,
1478    /// Parameter b.
1479    pub b: f64,
1480}
1481
1482impl Brusselator {
1483    /// Create a Brusselator.
1484    pub fn new(a: f64, b: f64) -> Self {
1485        Self { a, b }
1486    }
1487
1488    /// Fixed point: `(a, b/a)`.
1489    pub fn fixed_point(&self) -> (f64, f64) {
1490        (self.a, self.b / self.a)
1491    }
1492
1493    /// Hopf bifurcation value of b for the given a.
1494    pub fn hopf_b(&self) -> f64 {
1495        1.0 + self.a * self.a
1496    }
1497}
1498
1499impl OdeSystem for Brusselator {
1500    fn dim(&self) -> usize {
1501        2
1502    }
1503    fn rhs(&self, _t: f64, x: &[f64]) -> Vec<f64> {
1504        vec![
1505            self.a - (self.b + 1.0) * x[0] + x[0] * x[0] * x[1],
1506            self.b * x[0] - x[0] * x[0] * x[1],
1507        ]
1508    }
1509}
1510
1511// ─────────────────────────────────────────────────────────────────────────────
1512// #[cfg(test)] unit tests
1513// ─────────────────────────────────────────────────────────────────────────────
1514
1515#[cfg(test)]
1516mod tests {
1517    use super::*;
1518
1519    // ── helper: simple harmonic oscillator as OdeSystem ───────────────────────
1520    struct ShoOde {
1521        omega: f64,
1522    }
1523    impl OdeSystem for ShoOde {
1524        fn dim(&self) -> usize {
1525            2
1526        }
1527        fn rhs(&self, _t: f64, x: &[f64]) -> Vec<f64> {
1528            vec![x[1], -self.omega * self.omega * x[0]]
1529        }
1530    }
1531
1532    // ── 1. rk4_step conserves energy for SHO ─────────────────────────────────
1533    #[test]
1534    fn test_rk4_sho_energy_conservation() {
1535        let sys = ShoOde { omega: 1.0 };
1536        let x0 = vec![1.0, 0.0];
1537        let energy0 = 0.5 * (x0[0] * x0[0] + x0[1] * x0[1]);
1538        let h = 0.01;
1539        let mut x = x0.clone();
1540        for i in 0..628 {
1541            x = rk4_step(&sys, i as f64 * h, &x, h);
1542        }
1543        let energy1 = 0.5 * (x[0] * x[0] + x[1] * x[1]);
1544        assert!((energy1 - energy0).abs() < 1e-4);
1545    }
1546
1547    // ── 2. integrate_rk4 returns expected number of steps ────────────────────
1548    #[test]
1549    fn test_integrate_rk4_step_count() {
1550        let sys = ShoOde { omega: 1.0 };
1551        let traj = integrate_rk4(&sys, &[1.0, 0.0], 0.0, 1.0, 0.1);
1552        assert_eq!(traj.len(), 11);
1553    }
1554
1555    // ── 3. numerical_jacobian of SHO ─────────────────────────────────────────
1556    #[test]
1557    fn test_numerical_jacobian_sho() {
1558        let sys = ShoOde { omega: 2.0 };
1559        let jac = numerical_jacobian(&sys, 0.0, &[0.0, 0.0], 1e-5);
1560        // J = [[0,1],[-4,0]]
1561        assert!((jac[0] - 0.0).abs() < 1e-6);
1562        assert!((jac[1] - 1.0).abs() < 1e-6);
1563        assert!((jac[2] - (-4.0)).abs() < 1e-6);
1564        assert!((jac[3] - 0.0).abs() < 1e-6);
1565    }
1566
1567    // ── 4. find_fixed_point locates origin for SHO ───────────────────────────
1568    #[test]
1569    fn test_find_fixed_point_sho_origin() {
1570        let sys = ShoOde { omega: 1.0 };
1571        let res = find_fixed_point(&sys, &[0.1, 0.1], 1e-10, 50, 1e-7);
1572        assert!(res.converged);
1573        assert!(res.point[0].abs() < 1e-6);
1574        assert!(res.point[1].abs() < 1e-6);
1575    }
1576
1577    // ── 5. gauss_solve 2x2 ───────────────────────────────────────────────────
1578    #[test]
1579    fn test_gauss_solve_2x2() {
1580        // [2 1; 1 3] * x = [5; 10]  => x = [1, 3]
1581        let a = vec![2.0, 1.0, 1.0, 3.0];
1582        let b = vec![5.0, 10.0];
1583        let x = gauss_solve(&a, 2, &b).unwrap();
1584        assert!((x[0] - 1.0).abs() < 1e-10);
1585        assert!((x[1] - 3.0).abs() < 1e-10);
1586    }
1587
1588    // ── 6. eigen2 real eigenvalues ────────────────────────────────────────────
1589    #[test]
1590    fn test_eigen2_real() {
1591        // [[3,0],[0,2]] => eigenvalues 3 and 2
1592        let ((r1, i1), (r2, i2)) = eigen2(3.0, 0.0, 0.0, 2.0);
1593        assert!((r1 - 3.0).abs() < 1e-10 || (r1 - 2.0).abs() < 1e-10);
1594        assert!(i1.abs() < 1e-10);
1595        assert!(i2.abs() < 1e-10);
1596        let _ = r2;
1597    }
1598
1599    // ── 7. eigen2 complex eigenvalues ────────────────────────────────────────
1600    #[test]
1601    fn test_eigen2_complex() {
1602        // [[0,1],[-1,0]] => eigenvalues ±i
1603        let ((r1, i1), (_r2, _i2)) = eigen2(0.0, 1.0, -1.0, 0.0);
1604        assert!(r1.abs() < 1e-10);
1605        assert!((i1.abs() - 1.0).abs() < 1e-10);
1606    }
1607
1608    // ── 8. stability_2d saddle ────────────────────────────────────────────────
1609    #[test]
1610    fn test_stability_saddle() {
1611        // det < 0 → saddle
1612        let s = stability_2d(1.0, 0.0, 0.0, -1.0);
1613        assert_eq!(s, "saddle");
1614    }
1615
1616    // ── 9. stability_2d stable spiral ────────────────────────────────────────
1617    #[test]
1618    fn test_stability_stable_spiral() {
1619        // tr = -0.2, det = 1.01 → stable spiral
1620        let s = stability_2d(-0.1, 1.0, -1.0, -0.1);
1621        assert_eq!(s, "stable_spiral");
1622    }
1623
1624    // ── 10. Lorenz fixed points ───────────────────────────────────────────────
1625    #[test]
1626    fn test_lorenz_fixed_points_classic() {
1627        let lor = LorenzSystem::classic();
1628        let fps = lor.fixed_points();
1629        assert_eq!(fps.len(), 3);
1630        // C± have |x| = |y|
1631        let c1 = fps[1];
1632        assert!((c1[0] - c1[1]).abs() < 1e-10);
1633    }
1634
1635    // ── 11. Lorenz RHS at origin is zero for origin FP ───────────────────────
1636    #[test]
1637    fn test_lorenz_rhs_origin() {
1638        let lor = LorenzSystem::classic();
1639        let f = lor.rhs(0.0, &[0.0, 0.0, 0.0]);
1640        assert!(f.iter().all(|v| v.abs() < 1e-12));
1641    }
1642
1643    // ── 12. Rössler RHS dimension ─────────────────────────────────────────────
1644    #[test]
1645    fn test_rossler_dim() {
1646        let ros = RosslerSystem::classic();
1647        assert_eq!(ros.dim(), 3);
1648    }
1649
1650    // ── 13. Poincaré section returns crossings ────────────────────────────────
1651    #[test]
1652    fn test_poincare_sho_crossings() {
1653        let sys = ShoOde { omega: 1.0 };
1654        let cfg = PoincareSectionConfig {
1655            axis: 0,
1656            level: 0.0,
1657            positive_direction: true,
1658        };
1659        // SHO with ω=1 has period 2π; run for 10 full periods
1660        let h = 0.01;
1661        let n_steps = (10.0 * 2.0 * std::f64::consts::PI / h) as usize;
1662        let crossings = poincare_section(&sys, &[1.0, 0.0], h, n_steps, &cfg);
1663        // Should find approximately 10 crossings (x=0 with positive velocity)
1664        assert!(
1665            crossings.len() >= 8 && crossings.len() <= 12,
1666            "expected ~10 crossings, got {}",
1667            crossings.len()
1668        );
1669    }
1670
1671    // ── 14. estimate_limit_cycle_period for SHO ───────────────────────────────
1672    #[test]
1673    fn test_limit_cycle_period_sho() {
1674        let sys = ShoOde { omega: 1.0 };
1675        let h = 0.005;
1676        let n_steps = (20.0 * 2.0 * std::f64::consts::PI / h) as usize;
1677        let est = estimate_limit_cycle_period(&sys, &[1.0, 0.0], h, n_steps, 0, 0.0);
1678        let est = est.unwrap();
1679        let expected = 2.0 * std::f64::consts::PI;
1680        assert!(
1681            (est.period - expected).abs() < 0.05,
1682            "period={} expected≈{}",
1683            est.period,
1684            expected
1685        );
1686    }
1687
1688    // ── 15. delay_embed shape ─────────────────────────────────────────────────
1689    #[test]
1690    fn test_delay_embed_shape() {
1691        let series: Vec<f64> = (0..20).map(|i| i as f64).collect();
1692        let pts = delay_embed(&series, 3, 2);
1693        // length = n - (embed_dim - 1) * tau = 20 - 4 = 16
1694        assert_eq!(pts.len(), 16);
1695        assert_eq!(pts[0].len(), 3);
1696        // first point: [0, 2, 4]
1697        assert_eq!(pts[0], vec![0.0, 2.0, 4.0]);
1698    }
1699
1700    // ── 16. recurrence_matrix diagonal is all true ────────────────────────────
1701    #[test]
1702    fn test_recurrence_matrix_diagonal() {
1703        let pts = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![2.0, 0.0]];
1704        let mat = recurrence_matrix(&pts, 0.5);
1705        // diagonal entries
1706        assert!(mat[0]);
1707        assert!(mat[3 + 1]);
1708        assert!(mat[2 * 3 + 2]);
1709    }
1710
1711    // ── 17. RQA on periodic signal has high DET ───────────────────────────────
1712    #[test]
1713    fn test_rqa_periodic() {
1714        let series: Vec<f64> = (0..200)
1715            .map(|i| (2.0 * std::f64::consts::PI * i as f64 / 20.0).sin())
1716            .collect();
1717        let params = RqaParams {
1718            embed_dim: 2,
1719            tau: 5,
1720            epsilon: 0.2,
1721            min_line: 2,
1722        };
1723        let res = rqa(&series, &params);
1724        assert!(res.rr > 0.0);
1725        assert!(res.det >= 0.0);
1726    }
1727
1728    // ── 18. Störmer–Verlet conserves energy for SHO ───────────────────────────
1729    #[test]
1730    fn test_stormer_verlet_energy_sho() {
1731        let omega = 1.0f64;
1732        let grad_v = |q: &[f64]| vec![omega * omega * q[0]];
1733        let traj = integrate_symplectic(&[1.0], &[0.0], 0.01, 1000, &grad_v);
1734        let energy0 = 0.5 * 0.0_f64.powi(2) + 0.5 * omega * omega * 1.0_f64.powi(2);
1735        let (q_end, p_end) = &traj[traj.len() - 1];
1736        let energy_end = 0.5 * p_end[0].powi(2) + 0.5 * omega * omega * q_end[0].powi(2);
1737        assert!(
1738            (energy_end - energy0).abs() < 1e-4,
1739            "energy drift = {}",
1740            (energy_end - energy0).abs()
1741        );
1742    }
1743
1744    // ── 19. Forest–Ruth step dimensionality ───────────────────────────────────
1745    #[test]
1746    fn test_forest_ruth_returns_same_dim() {
1747        let grad_v = |q: &[f64]| vec![q[0]];
1748        let (q_new, p_new) = forest_ruth_step(&[1.0], &[0.5], 0.1, &grad_v);
1749        assert_eq!(q_new.len(), 1);
1750        assert_eq!(p_new.len(), 1);
1751    }
1752
1753    // ── 20. HarmonicOscillatorHam energy conserved by symplectic ─────────────
1754    #[test]
1755    fn test_hamiltonian_sho_energy_drift() {
1756        let ham = HarmonicOscillatorHam::new(1.0);
1757        let grad_v = |q: &[f64]| ham.dh_dq(q, &[0.0]);
1758        let traj = integrate_symplectic(&[1.0], &[0.0], 0.01, 500, &grad_v);
1759        let pairs: Vec<(Vec<f64>, Vec<f64>)> = traj;
1760        let drift = energy_drift(&ham, &pairs);
1761        assert!(drift < 1e-3, "energy drift = {}", drift);
1762    }
1763
1764    // ── 21. saddle_node_fixed_points ─────────────────────────────────────────
1765    #[test]
1766    fn test_saddle_node_fps() {
1767        let fps_neg = saddle_node_fixed_points(-4.0);
1768        assert_eq!(fps_neg.len(), 2);
1769        // x = ±2
1770        assert!((fps_neg[0].abs() - 2.0).abs() < 1e-10);
1771        let fps_pos = saddle_node_fixed_points(1.0);
1772        assert!(fps_pos.is_empty());
1773        let fps_zero = saddle_node_fixed_points(0.0);
1774        assert_eq!(fps_zero.len(), 1);
1775    }
1776
1777    // ── 22. pitchfork_fixed_points ────────────────────────────────────────────
1778    #[test]
1779    fn test_pitchfork_fps() {
1780        let fps = pitchfork_fixed_points(4.0);
1781        assert_eq!(fps.len(), 3);
1782        let fps_neg = pitchfork_fixed_points(-1.0);
1783        assert_eq!(fps_neg.len(), 1);
1784    }
1785
1786    // ── 23. is_hopf_bifurcation ───────────────────────────────────────────────
1787    #[test]
1788    fn test_is_hopf() {
1789        assert!(is_hopf_bifurcation(0.0, 1.0, 1e-6));
1790        assert!(!is_hopf_bifurcation(0.5, 1.0, 1e-6));
1791        assert!(!is_hopf_bifurcation(0.0, -1.0, 1e-6));
1792    }
1793
1794    // ── 24. Brusselator fixed point ───────────────────────────────────────────
1795    #[test]
1796    fn test_brusselator_fixed_point() {
1797        let br = Brusselator::new(2.0, 3.0);
1798        let (xfp, yfp) = br.fixed_point();
1799        let f = br.rhs(0.0, &[xfp, yfp]);
1800        assert!(f[0].abs() < 1e-10);
1801        assert!(f[1].abs() < 1e-10);
1802    }
1803
1804    // ── 25. Brusselator Hopf condition ────────────────────────────────────────
1805    #[test]
1806    fn test_brusselator_hopf_b() {
1807        let br = Brusselator::new(2.0, 5.0);
1808        let hopf = br.hopf_b(); // should be 1 + 4 = 5
1809        assert!((hopf - 5.0).abs() < 1e-10);
1810    }
1811
1812    // ── 26. Van der Pol origin unstable ───────────────────────────────────────
1813    #[test]
1814    fn test_vdp_origin_unstable() {
1815        let vdp = VanDerPol::new(1.0);
1816        let jac = numerical_jacobian(&vdp, 0.0, &[0.0, 0.0], 1e-5);
1817        // J at origin = [[0,1],[-1,1]]  => tr = 1 > 0 → unstable
1818        let tr = jac[0] + jac[3];
1819        assert!(tr > 0.0);
1820    }
1821
1822    // ── 27. Kuramoto order parameter ─────────────────────────────────────────
1823    #[test]
1824    fn test_kuramoto_fully_synced() {
1825        // All phases identical → r = 1
1826        let theta = vec![1.0; 5];
1827        let (r, _psi) = KuramotoModel::order_parameter(&theta);
1828        assert!((r - 1.0).abs() < 1e-10);
1829    }
1830
1831    // ── 28. Kuramoto incoherent ───────────────────────────────────────────────
1832    #[test]
1833    fn test_kuramoto_incoherent() {
1834        use std::f64::consts::PI;
1835        // Uniformly spaced → r ≈ 0
1836        let n = 8;
1837        let theta: Vec<f64> = (0..n).map(|i| 2.0 * PI * i as f64 / n as f64).collect();
1838        let (r, _) = KuramotoModel::order_parameter(&theta);
1839        assert!(r < 0.05);
1840    }
1841
1842    // ── 29. kaplan_yorke_dim ──────────────────────────────────────────────────
1843    #[test]
1844    fn test_kaplan_yorke_dim() {
1845        // Classic Lorenz: exponents ~ [0.9, 0, -14.6]
1846        let les = vec![0.9, 0.0, -14.6];
1847        let d = kaplan_yorke_dim(&les);
1848        // KY = 2 + (0.9 + 0.0)/14.6 ≈ 2.062
1849        assert!(d > 2.0 && d < 2.2, "KY dim = {}", d);
1850    }
1851
1852    // ── 30. maximal_lyapunov_exponent positive for Lorenz ─────────────────────
1853    #[test]
1854    fn test_mle_lorenz_positive() {
1855        let lor = LorenzSystem::classic();
1856        let x0 = vec![0.1, 0.0, 0.0];
1857        // Short estimate; just verify sign
1858        let mle = maximal_lyapunov_exponent(&lor, &x0, 0.01, 1000, 10);
1859        // MLE for classic Lorenz is ~0.9 but our crude estimate should be > 0
1860        assert!(mle > -1.0, "MLE = {}", mle);
1861    }
1862
1863    // ── 31. Double pendulum energy conservation (short integration) ───────────
1864    #[test]
1865    fn test_double_pendulum_energy() {
1866        let dp = DoublePendulum::new(9.81);
1867        let s0 = [0.1, 0.05, 0.0, 0.0];
1868        let e0 = dp.energy(s0);
1869        // Use small step and short run to limit RK4 drift
1870        let traj = dp.integrate(s0, 0.001, 100);
1871        let e_end = dp.energy(*traj.last().unwrap());
1872        assert!(
1873            (e_end - e0).abs() < 0.02,
1874            "energy drift = {}",
1875            (e_end - e0).abs()
1876        );
1877    }
1878
1879    // ── 32. Duffing oscillator RHS dimensionality ─────────────────────────────
1880    #[test]
1881    fn test_duffing_dim() {
1882        let duf = DuffingOscillator::new(0.1, -1.0, 1.0, 0.3, 1.2);
1883        assert_eq!(duf.dim(), 2);
1884        let f = duf.rhs(0.0, &[0.0, 0.0]);
1885        assert_eq!(f.len(), 2);
1886    }
1887
1888    // ── 33. phase_plane_grid returns correct number of points ─────────────────
1889    #[test]
1890    fn test_phase_plane_grid_count() {
1891        let sys = ShoOde { omega: 1.0 };
1892        let grid = phase_plane_grid(&sys, (-2.0, 2.0, 5), (-2.0, 2.0, 5));
1893        assert_eq!(grid.len(), 25);
1894    }
1895
1896    // ── 34. add3 / sub3 / scale3 / dot3 / norm3 consistency ──────────────────
1897    #[test]
1898    fn test_vec3_ops() {
1899        let a = [1.0, 2.0, 3.0];
1900        let b = [4.0, 5.0, 6.0];
1901        let s = add3(a, b);
1902        assert_eq!(s, [5.0, 7.0, 9.0]);
1903        let d = sub3(b, a);
1904        assert_eq!(d, [3.0, 3.0, 3.0]);
1905        let sc = scale3(a, 2.0);
1906        assert_eq!(sc, [2.0, 4.0, 6.0]);
1907        assert!((dot3(a, b) - 32.0).abs() < 1e-10);
1908        assert!((norm3([3.0, 4.0, 0.0]) - 5.0).abs() < 1e-10);
1909    }
1910
1911    // ── 35. cross3 ────────────────────────────────────────────────────────────
1912    #[test]
1913    fn test_cross3() {
1914        let a = [1.0, 0.0, 0.0];
1915        let b = [0.0, 1.0, 0.0];
1916        let c = cross3(a, b);
1917        assert!((c[0] - 0.0).abs() < 1e-10);
1918        assert!((c[1] - 0.0).abs() < 1e-10);
1919        assert!((c[2] - 1.0).abs() < 1e-10);
1920    }
1921
1922    // ── 36. Rössler RHS values ────────────────────────────────────────────────
1923    #[test]
1924    fn test_rossler_rhs_values() {
1925        let ros = RosslerSystem::new(0.2, 0.2, 5.7);
1926        let f = ros.rhs(0.0, &[1.0, 0.0, 0.0]);
1927        // ẋ = -0 - 0 = 0, ẏ = 1 + 0.2*0 = 1, ż = 0.2 + 0*(1-5.7)=0.2
1928        assert!((f[0] - 0.0).abs() < 1e-10);
1929        assert!((f[1] - 1.0).abs() < 1e-10);
1930        assert!((f[2] - 0.2).abs() < 1e-10);
1931    }
1932
1933    // ── 37. HarmonicOscillatorHam Hamiltonian ─────────────────────────────────
1934    #[test]
1935    fn test_ham_sho_value() {
1936        let ham = HarmonicOscillatorHam::new(2.0);
1937        let h = ham.hamiltonian(&[1.0], &[0.0]);
1938        // H = 0.5 * 4 * 1 = 2.0
1939        assert!((h - 2.0).abs() < 1e-10);
1940    }
1941
1942    // ── 38. HamiltonianOde wraps correctly ────────────────────────────────────
1943    #[test]
1944    fn test_hamiltonian_ode_rhs() {
1945        let ham = HarmonicOscillatorHam::new(1.0);
1946        let ode = HamiltonianOde { ham: &ham };
1947        // state [q, p] = [0, 1]: qdot = p = 1, pdot = -omega^2 q = 0
1948        let f = ode.rhs(0.0, &[0.0, 1.0]);
1949        assert!((f[0] - 1.0).abs() < 1e-10);
1950        assert!((f[1] - 0.0).abs() < 1e-10);
1951    }
1952
1953    // ── 39. Lyapunov exponents QR (SHO should have ≈0) ───────────────────────
1954    #[test]
1955    fn test_lyapunov_qr_sho_near_zero() {
1956        let sys = ShoOde { omega: 1.0 };
1957        let les = lyapunov_exponents_qr(&sys, &[1.0, 0.0], 0.1, 200);
1958        // SHO is conservative, LEs should straddle zero
1959        assert_eq!(les.len(), 2);
1960        let sum: f64 = les.iter().sum();
1961        // sum of LEs = trace of Jacobian = 0 for Hamiltonian
1962        assert!(sum.abs() < 1.0, "sum of LEs = {}", sum);
1963    }
1964
1965    // ── 40. detect_heteroclinic not found for misaligned direction ────────────
1966    #[test]
1967    fn test_detect_heteroclinic_none() {
1968        let sys = ShoOde { omega: 1.0 };
1969        // SHO has no heteroclinic orbits; expect None quickly
1970        let result = detect_heteroclinic(
1971            &sys,
1972            &[0.0, 0.0],
1973            &[5.0, 0.0],
1974            &[1.0, 0.0],
1975            0.01,
1976            0.01,
1977            100,
1978            0.1,
1979        );
1980        // SHO oscillates, won't converge to [5,0] — may return None
1981        // (just ensure it doesn't panic)
1982        let _ = result;
1983    }
1984}