Skip to main content

oxiphysics_core/
dynamic_systems.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Dynamical systems — maps, flows, bifurcations, synchronisation.
5//!
6//! Covers discrete maps (logistic, Hénon, Chirikov), strange attractor analysis,
7//! normal forms for bifurcations, Poincaré sections, flow maps and the
8//! fundamental solution matrix, and Pecora–Carroll / phase synchronisation.
9
10#![allow(dead_code)]
11
12// ─────────────────────────────────────────────────────────────────────────────
13// DiscreteMap
14// ─────────────────────────────────────────────────────────────────────────────
15
16/// A discrete map that can be iterated.
17pub trait DiscreteMapIterate {
18    /// State dimension.
19    fn dim(&self) -> usize;
20    /// Advance the state by one step.  `state` must have length `dim()`.
21    fn step(&self, state: &[f64]) -> Vec<f64>;
22
23    /// Compute an orbit of length `n` starting from `init`.
24    fn orbit(&self, init: &[f64], n: usize) -> Vec<Vec<f64>> {
25        let mut out = Vec::with_capacity(n + 1);
26        out.push(init.to_vec());
27        for _ in 0..n {
28            let next = self.step(out.last().expect("orbit is non-empty"));
29            out.push(next);
30        }
31        out
32    }
33}
34
35// ── Logistic Map ─────────────────────────────────────────────────────────────
36
37/// The logistic map  `x_{n+1} = r · xₙ (1 − xₙ)`.
38///
39/// Classic example of period-doubling route to chaos with `r ∈ [0, 4]`.
40#[derive(Debug, Clone)]
41pub struct LogisticMap {
42    /// Growth parameter `r`.
43    pub r: f64,
44}
45
46impl LogisticMap {
47    /// Create a logistic map with growth parameter `r`.
48    pub fn new(r: f64) -> Self {
49        Self { r }
50    }
51
52    /// One application of the map.
53    pub fn apply(&self, x: f64) -> f64 {
54        self.r * x * (1.0 - x)
55    }
56
57    /// Compute an orbit starting at `x0` for `n` steps.
58    pub fn orbit_1d(&self, x0: f64, n: usize) -> Vec<f64> {
59        let mut orbit = Vec::with_capacity(n + 1);
60        orbit.push(x0);
61        for _ in 0..n {
62            let last = *orbit.last().expect("orbit is non-empty");
63            orbit.push(self.apply(last));
64        }
65        orbit
66    }
67
68    /// Generate a bifurcation diagram by sweeping `r` in `[r_min, r_max]`
69    /// with `n_r` steps.  For each `r` value the system is iterated
70    /// `transient` steps (discarded) then `keep` steps are recorded.
71    ///
72    /// Returns a list of `(r, x)` pairs.
73    pub fn bifurcation_diagram(
74        r_min: f64,
75        r_max: f64,
76        n_r: usize,
77        transient: usize,
78        keep: usize,
79    ) -> Vec<(f64, f64)> {
80        let mut result = Vec::with_capacity(n_r * keep);
81        for k in 0..n_r {
82            let r = r_min + (r_max - r_min) * k as f64 / n_r.saturating_sub(1).max(1) as f64;
83            let map = LogisticMap::new(r);
84            let mut x = 0.5;
85            for _ in 0..transient {
86                x = map.apply(x);
87            }
88            for _ in 0..keep {
89                x = map.apply(x);
90                result.push((r, x));
91            }
92        }
93        result
94    }
95
96    /// Largest Lyapunov exponent estimated over `n` iterations.
97    ///
98    /// Returns `None` if the trajectory passes through 0 or 1 (derivative undefined).
99    pub fn lyapunov_exponent(&self, x0: f64, n: usize) -> Option<f64> {
100        let mut x = x0;
101        let mut sum = 0.0f64;
102        for _ in 0..n {
103            let deriv = (self.r * (1.0 - 2.0 * x)).abs();
104            if deriv < 1e-300 {
105                return None;
106            }
107            sum += deriv.ln();
108            x = self.apply(x);
109        }
110        Some(sum / n as f64)
111    }
112}
113
114impl DiscreteMapIterate for LogisticMap {
115    fn dim(&self) -> usize {
116        1
117    }
118    fn step(&self, state: &[f64]) -> Vec<f64> {
119        vec![self.apply(state[0])]
120    }
121}
122
123// ── Hénon Map ─────────────────────────────────────────────────────────────────
124
125/// The Hénon map:
126///
127/// ```text
128/// x_{n+1} = 1 − a xₙ² + yₙ
129/// y_{n+1} = b xₙ
130/// ```
131///
132/// Classical parameter values are `a = 1.4`, `b = 0.3`.
133#[derive(Debug, Clone)]
134pub struct HenonMap {
135    /// Parameter `a`.
136    pub a: f64,
137    /// Parameter `b`.
138    pub b: f64,
139}
140
141impl HenonMap {
142    /// Create a Hénon map with given parameters.
143    pub fn new(a: f64, b: f64) -> Self {
144        Self { a, b }
145    }
146
147    /// Apply one step starting at `(x, y)`.  Returns `(x_new, y_new)`.
148    pub fn apply(&self, x: f64, y: f64) -> (f64, f64) {
149        (1.0 - self.a * x * x + y, self.b * x)
150    }
151
152    /// Compute an orbit for `n` steps starting at `(x0, y0)`.
153    pub fn orbit_2d(&self, x0: f64, y0: f64, n: usize) -> Vec<(f64, f64)> {
154        let mut out = Vec::with_capacity(n + 1);
155        out.push((x0, y0));
156        for _ in 0..n {
157            let &(x, y) = out.last().expect("orbit is non-empty");
158            out.push(self.apply(x, y));
159        }
160        out
161    }
162
163    /// Jacobian determinant (constant for the Hénon map): `−b`.
164    pub fn jacobian_det(&self) -> f64 {
165        -self.b
166    }
167
168    /// Jacobian matrix at `(x, y)`:
169    /// ```text
170    /// [[-2ax,  1],
171    ///  [b,     0]]
172    /// ```
173    pub fn jacobian(&self, x: f64, _y: f64) -> [[f64; 2]; 2] {
174        [[-2.0 * self.a * x, 1.0], [self.b, 0.0]]
175    }
176}
177
178impl DiscreteMapIterate for HenonMap {
179    fn dim(&self) -> usize {
180        2
181    }
182    fn step(&self, state: &[f64]) -> Vec<f64> {
183        let (xn, yn) = self.apply(state[0], state[1]);
184        vec![xn, yn]
185    }
186}
187
188// ── Chirikov Standard Map ─────────────────────────────────────────────────────
189
190/// The Chirikov–Taylor standard map (area-preserving):
191///
192/// ```text
193/// p_{n+1} = p_n + K sin(θ_n)   (mod 2π)
194/// θ_{n+1} = θ_n + p_{n+1}      (mod 2π)
195/// ```
196///
197/// `K` is the stochasticity parameter; chaos sets in for `K ≳ 1`.
198#[derive(Debug, Clone)]
199pub struct ChirikovStandardMap {
200    /// Stochasticity parameter `K`.
201    pub k: f64,
202}
203
204impl ChirikovStandardMap {
205    /// Create a Chirikov standard map with parameter `K`.
206    pub fn new(k: f64) -> Self {
207        Self { k }
208    }
209
210    /// Apply one step.  Both `theta` and `p` are taken modulo `2π`.
211    /// Returns `(theta_new, p_new)`.
212    pub fn apply(&self, theta: f64, p: f64) -> (f64, f64) {
213        let p_new = (p + self.k * theta.sin()).rem_euclid(std::f64::consts::TAU);
214        let theta_new = (theta + p_new).rem_euclid(std::f64::consts::TAU);
215        (theta_new, p_new)
216    }
217
218    /// Compute an orbit for `n` steps.
219    pub fn orbit_2d(&self, theta0: f64, p0: f64, n: usize) -> Vec<(f64, f64)> {
220        let mut out = Vec::with_capacity(n + 1);
221        out.push((theta0, p0));
222        for _ in 0..n {
223            let &(th, p) = out.last().expect("orbit is non-empty");
224            out.push(self.apply(th, p));
225        }
226        out
227    }
228
229    /// Estimate the Lyapunov exponent via the method of Benettin et al.
230    pub fn lyapunov_exponent(&self, theta0: f64, p0: f64, n: usize) -> f64 {
231        let mut theta = theta0;
232        let mut p = p0;
233        // tangent vector
234        let mut dt = 1.0f64;
235        let mut dp = 0.0f64;
236        let mut sum = 0.0f64;
237
238        for _ in 0..n {
239            let cos_th = theta.cos();
240            // Jacobian: [[1, 0], [K cos θ, 1]] * tangent
241            let dp_new = dp + self.k * cos_th * dt;
242            let dt_new = dt + dp_new;
243            let norm = (dt_new * dt_new + dp_new * dp_new).sqrt().max(1e-300);
244            sum += norm.ln();
245            dt = dt_new / norm;
246            dp = dp_new / norm;
247            let (th_new, p_new) = self.apply(theta, p);
248            theta = th_new;
249            p = p_new;
250        }
251        sum / n as f64
252    }
253}
254
255impl DiscreteMapIterate for ChirikovStandardMap {
256    fn dim(&self) -> usize {
257        2
258    }
259    fn step(&self, state: &[f64]) -> Vec<f64> {
260        let (th, p) = self.apply(state[0], state[1]);
261        vec![th, p]
262    }
263}
264
265// ─────────────────────────────────────────────────────────────────────────────
266// AttractorAnalysis
267// ─────────────────────────────────────────────────────────────────────────────
268
269/// Tools for characterising attractors from time-series data.
270#[derive(Debug, Clone)]
271pub struct AttractorAnalysis {
272    /// The embedded time-series (each inner `Vec` is one point in phase space).
273    pub data: Vec<Vec<f64>>,
274}
275
276impl AttractorAnalysis {
277    /// Create from a time-series.
278    pub fn new(data: Vec<Vec<f64>>) -> Self {
279        Self { data }
280    }
281
282    /// Find approximate fixed points: points where `|f(x) − x| < tol`.
283    ///
284    /// `f` is the map applied once.
285    pub fn find_fixed_points<F>(&self, f: F, tol: f64) -> Vec<Vec<f64>>
286    where
287        F: Fn(&[f64]) -> Vec<f64>,
288    {
289        self.data
290            .iter()
291            .filter(|x| {
292                let fx = f(x.as_slice());
293                let dist: f64 = x
294                    .iter()
295                    .zip(fx.iter())
296                    .map(|(a, b)| (a - b).powi(2))
297                    .sum::<f64>()
298                    .sqrt();
299                dist < tol
300            })
301            .cloned()
302            .collect()
303    }
304
305    /// Detect periodic orbits of period `p`: points where `|f^p(x) − x| < tol`.
306    pub fn find_periodic_orbits<F>(&self, f: &F, period: usize, tol: f64) -> Vec<Vec<f64>>
307    where
308        F: Fn(&[f64]) -> Vec<f64>,
309    {
310        self.data
311            .iter()
312            .filter(|x| {
313                let mut state = x.to_vec();
314                for _ in 0..period {
315                    state = f(&state);
316                }
317                let dist: f64 = x
318                    .iter()
319                    .zip(state.iter())
320                    .map(|(a, b)| (a - b).powi(2))
321                    .sum::<f64>()
322                    .sqrt();
323                dist < tol
324            })
325            .cloned()
326            .collect()
327    }
328
329    /// Estimate the correlation dimension via the Grassberger–Procaccia algorithm.
330    ///
331    /// Computes `C(r)` for a range of radii and returns `(log_r, log_C)` pairs
332    /// from which the slope gives the correlation dimension.
333    pub fn correlation_dimension(&self, n_radii: usize) -> Vec<(f64, f64)> {
334        let n = self.data.len();
335        if n < 2 {
336            return vec![];
337        }
338
339        // Compute all pairwise distances
340        let mut dists = Vec::new();
341        for i in 0..n {
342            for j in (i + 1)..n {
343                let d: f64 = self.data[i]
344                    .iter()
345                    .zip(self.data[j].iter())
346                    .map(|(a, b)| (a - b).powi(2))
347                    .sum::<f64>()
348                    .sqrt();
349                dists.push(d);
350            }
351        }
352        dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
353
354        if dists.is_empty() {
355            return vec![];
356        }
357
358        let r_min = *dists.first().expect("dists is non-empty") * 1.1;
359        let r_max = *dists.last().expect("dists is non-empty") * 0.9;
360        if r_min >= r_max {
361            return vec![];
362        }
363
364        let total_pairs = dists.len() as f64;
365        let mut result = Vec::with_capacity(n_radii);
366
367        for k in 0..n_radii {
368            let r =
369                r_min * (r_max / r_min).powf(k as f64 / n_radii.saturating_sub(1).max(1) as f64);
370            let count = dists.partition_point(|&d| d <= r);
371            let c_r = count as f64 / total_pairs;
372            if c_r > 0.0 {
373                result.push((r.ln(), c_r.ln()));
374            }
375        }
376        result
377    }
378
379    /// Estimate the largest Lyapunov exponent from the data via the Rosenstein method.
380    ///
381    /// Finds the nearest neighbour for each point, tracks divergence over `horizon`
382    /// steps and returns the mean log-divergence as a function of time.
383    pub fn rosenstein_lyapunov(&self, horizon: usize) -> Vec<f64> {
384        let n = self.data.len();
385        if n < 2 || horizon == 0 {
386            return vec![];
387        }
388        let steps = horizon.min(n / 2);
389        let mut sum_log = vec![0.0f64; steps];
390        let mut counts = vec![0usize; steps];
391
392        for i in 0..n {
393            // Find nearest neighbour (excluding temporal neighbours within `min_sep`)
394            let min_sep = 1usize;
395            let mut nn_idx = n;
396            let mut nn_dist = f64::INFINITY;
397            for j in 0..n {
398                if (i as isize - j as isize).unsigned_abs() < min_sep {
399                    continue;
400                }
401                let d: f64 = self.data[i]
402                    .iter()
403                    .zip(self.data[j].iter())
404                    .map(|(a, b)| (a - b).powi(2))
405                    .sum::<f64>()
406                    .sqrt();
407                if d < nn_dist {
408                    nn_dist = d;
409                    nn_idx = j;
410                }
411            }
412            if nn_idx == n {
413                continue;
414            }
415
416            // Track divergence
417            for t in 0..steps {
418                let ii = i + t;
419                let jj = nn_idx + t;
420                if ii >= n || jj >= n {
421                    break;
422                }
423                let d: f64 = self.data[ii]
424                    .iter()
425                    .zip(self.data[jj].iter())
426                    .map(|(a, b)| (a - b).powi(2))
427                    .sum::<f64>()
428                    .sqrt();
429                if d > 1e-300 {
430                    sum_log[t] += d.ln();
431                    counts[t] += 1;
432                }
433            }
434        }
435
436        sum_log
437            .iter()
438            .zip(counts.iter())
439            .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 })
440            .collect()
441    }
442
443    /// Check whether the attractor looks strange by testing if the
444    /// correlation dimension estimate is fractional (> 1.5).
445    pub fn is_strange_attractor(&self) -> bool {
446        let pairs = self.correlation_dimension(20);
447        if pairs.len() < 4 {
448            return false;
449        }
450        // Linear regression slope on the log-log pairs
451        let slope = linear_slope(&pairs);
452        slope > 1.5 && slope < 4.0
453    }
454}
455
456/// Helper: linear regression slope for `(x, y)` pairs.
457fn linear_slope(pairs: &[(f64, f64)]) -> f64 {
458    let n = pairs.len() as f64;
459    let sx: f64 = pairs.iter().map(|(x, _)| x).sum();
460    let sy: f64 = pairs.iter().map(|(_, y)| y).sum();
461    let sxy: f64 = pairs.iter().map(|(x, y)| x * y).sum();
462    let sxx: f64 = pairs.iter().map(|(x, _)| x * x).sum();
463    let denom = n * sxx - sx * sx;
464    if denom.abs() < 1e-12 {
465        return 0.0;
466    }
467    (n * sxy - sx * sy) / denom
468}
469
470// ─────────────────────────────────────────────────────────────────────────────
471// DynamicalNormalForm
472// ─────────────────────────────────────────────────────────────────────────────
473
474/// Normal forms for codimension-1 bifurcations.
475#[derive(Debug, Clone, Copy, PartialEq)]
476pub enum BifurcationType {
477    /// Saddle-node (fold): `ẋ = μ + x²`.
478    SaddleNode,
479    /// Transcritical: `ẋ = μx − x²`.
480    Transcritical,
481    /// Supercritical pitchfork: `ẋ = μx − x³`.
482    PitchforkSuper,
483    /// Subcritical pitchfork: `ẋ = μx + x³`.
484    PitchforkSub,
485    /// Supercritical Hopf (2D): `ṙ = μr − r³`.
486    HopfSuper,
487    /// Subcritical Hopf (2D): `ṙ = μr + r³`.
488    HopfSub,
489}
490
491/// Normal-form vector field evaluator.
492#[derive(Debug, Clone)]
493pub struct DynamicalNormalForm {
494    /// Bifurcation type.
495    pub bif_type: BifurcationType,
496    /// Bifurcation parameter `μ`.
497    pub mu: f64,
498}
499
500impl DynamicalNormalForm {
501    /// Create a new normal form.
502    pub fn new(bif_type: BifurcationType, mu: f64) -> Self {
503        Self { bif_type, mu }
504    }
505
506    /// Evaluate the vector field at state `x` (scalar for 1D, `(r, θ)` for Hopf).
507    ///
508    /// For 1D normal forms `state[0] = x`.  For Hopf `state[0] = r`, `state[1] = θ`.
509    pub fn rhs(&self, state: &[f64]) -> Vec<f64> {
510        match self.bif_type {
511            BifurcationType::SaddleNode => {
512                let x = state[0];
513                vec![self.mu + x * x]
514            }
515            BifurcationType::Transcritical => {
516                let x = state[0];
517                vec![self.mu * x - x * x]
518            }
519            BifurcationType::PitchforkSuper => {
520                let x = state[0];
521                vec![self.mu * x - x * x * x]
522            }
523            BifurcationType::PitchforkSub => {
524                let x = state[0];
525                vec![self.mu * x + x * x * x]
526            }
527            BifurcationType::HopfSuper => {
528                let r = state[0];
529                let theta = if state.len() > 1 { state[1] } else { 0.0 };
530                vec![self.mu * r - r * r * r, theta + 1.0]
531            }
532            BifurcationType::HopfSub => {
533                let r = state[0];
534                let theta = if state.len() > 1 { state[1] } else { 0.0 };
535                vec![self.mu * r + r * r * r, theta + 1.0]
536            }
537        }
538    }
539
540    /// Fixed points of the 1D normal form.
541    ///
542    /// Returns up to 3 fixed-point values.  Empty vec if none exist for the
543    /// given `μ`.
544    pub fn fixed_points_1d(&self) -> Vec<f64> {
545        match self.bif_type {
546            BifurcationType::SaddleNode => {
547                // ẋ = μ + x² = 0  ⟹  x = ±√(−μ)
548                if self.mu <= 0.0 {
549                    let s = (-self.mu).sqrt();
550                    if s < 1e-12 { vec![0.0] } else { vec![-s, s] }
551                } else {
552                    vec![]
553                }
554            }
555            BifurcationType::Transcritical => {
556                // ẋ = μx − x² = x(μ − x) = 0  ⟹  x=0 or x=μ
557                vec![0.0, self.mu]
558            }
559            BifurcationType::PitchforkSuper | BifurcationType::PitchforkSub => {
560                // ẋ = μx ± x³ = x(μ ± x²) = 0  ⟹  x=0 and (for super) x=±√μ if μ>0
561                let mut fps = vec![0.0];
562                if self.bif_type == BifurcationType::PitchforkSuper && self.mu > 0.0 {
563                    let s = self.mu.sqrt();
564                    fps.push(-s);
565                    fps.push(s);
566                } else if self.bif_type == BifurcationType::PitchforkSub && self.mu < 0.0 {
567                    let s = (-self.mu).sqrt();
568                    fps.push(-s);
569                    fps.push(s);
570                }
571                fps
572            }
573            BifurcationType::HopfSuper | BifurcationType::HopfSub => {
574                // ṙ = 0  ⟹  r=0, and for super/sub: r=√(±μ)
575                let mut fps = vec![0.0];
576                if self.bif_type == BifurcationType::HopfSuper && self.mu > 0.0 {
577                    fps.push(self.mu.sqrt());
578                } else if self.bif_type == BifurcationType::HopfSub && self.mu < 0.0 {
579                    fps.push((-self.mu).sqrt());
580                }
581                fps
582            }
583        }
584    }
585
586    /// Euler-integrate the normal form for `n` steps with step size `dt`.
587    pub fn integrate_euler(&self, init: &[f64], dt: f64, n: usize) -> Vec<Vec<f64>> {
588        let mut state = init.to_vec();
589        let mut traj = Vec::with_capacity(n + 1);
590        traj.push(state.clone());
591        for _ in 0..n {
592            let rhs = self.rhs(&state);
593            for (s, r) in state.iter_mut().zip(rhs.iter()) {
594                *s += dt * r;
595            }
596            traj.push(state.clone());
597        }
598        traj
599    }
600
601    /// Classify the stability of a 1D fixed point `x*` via the sign of the
602    /// linearisation `f'(x*)`.
603    pub fn stability_1d(&self, x_star: f64) -> Stability {
604        let eps = 1e-7;
605        let rhs_plus = self.rhs(&[x_star + eps])[0];
606        let rhs_minus = self.rhs(&[x_star - eps])[0];
607        let deriv = (rhs_plus - rhs_minus) / (2.0 * eps);
608        if deriv < -1e-10 {
609            Stability::Stable
610        } else if deriv > 1e-10 {
611            Stability::Unstable
612        } else {
613            Stability::Marginal
614        }
615    }
616}
617
618/// Stability classification of a fixed point.
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620pub enum Stability {
621    /// Asymptotically stable (attracting).
622    Stable,
623    /// Unstable (repelling).
624    Unstable,
625    /// Marginally stable (centre or degenerate).
626    Marginal,
627}
628
629// ─────────────────────────────────────────────────────────────────────────────
630// PoincareSection
631// ─────────────────────────────────────────────────────────────────────────────
632
633/// Poincaré section analysis for flows or maps.
634///
635/// Records intersections of a trajectory with a hyperplane defined by
636/// `normal · (x − origin) = 0`.
637#[derive(Debug, Clone)]
638pub struct PoincareSection {
639    /// Normal vector of the section hyperplane.
640    pub normal: Vec<f64>,
641    /// A point on the section hyperplane (origin).
642    pub origin: Vec<f64>,
643    /// Collected intersection points.
644    pub intersections: Vec<Vec<f64>>,
645}
646
647impl PoincareSection {
648    /// Create a new Poincaré section.
649    pub fn new(normal: Vec<f64>, origin: Vec<f64>) -> Self {
650        Self {
651            normal,
652            origin,
653            intersections: Vec::new(),
654        }
655    }
656
657    /// Signed distance of point `p` from the section.
658    pub fn signed_distance(&self, p: &[f64]) -> f64 {
659        p.iter()
660            .zip(self.normal.iter())
661            .zip(self.origin.iter())
662            .map(|((pi, ni), oi)| (pi - oi) * ni)
663            .sum()
664    }
665
666    /// Process a trajectory (list of states) and record all up-crossings of
667    /// the section using linear interpolation.
668    pub fn process_trajectory(&mut self, traj: &[Vec<f64>]) {
669        for w in traj.windows(2) {
670            let d0 = self.signed_distance(&w[0]);
671            let d1 = self.signed_distance(&w[1]);
672            // Up-crossing: d0 < 0 and d1 >= 0
673            if d0 < 0.0 && d1 >= 0.0 {
674                let t = d0.abs() / (d0.abs() + d1.abs() + 1e-300);
675                let interp: Vec<f64> = w[0]
676                    .iter()
677                    .zip(w[1].iter())
678                    .map(|(a, b)| a + t * (b - a))
679                    .collect();
680                self.intersections.push(interp);
681            }
682        }
683    }
684
685    /// Compute the return map: the sequence of successive intersection
686    /// coordinates along axis `idx`.
687    pub fn return_map_1d(&self, idx: usize) -> Vec<(f64, f64)> {
688        let coords: Vec<f64> = self
689            .intersections
690            .iter()
691            .filter_map(|p| p.get(idx).copied())
692            .collect();
693        coords.windows(2).map(|w| (w[0], w[1])).collect()
694    }
695
696    /// Rotation number: average angular advance between successive crossings.
697    ///
698    /// Assumes the section is in a 2D projection and computes the angle of
699    /// each crossing relative to the section origin.
700    pub fn rotation_number(&self, angle_idx_x: usize, angle_idx_y: usize) -> f64 {
701        if self.intersections.len() < 2 {
702            return 0.0;
703        }
704        let angles: Vec<f64> = self
705            .intersections
706            .iter()
707            .filter_map(|p| {
708                let x = p.get(angle_idx_x).copied()? - self.origin.get(angle_idx_x).copied()?;
709                let y = p.get(angle_idx_y).copied()? - self.origin.get(angle_idx_y).copied()?;
710                Some(y.atan2(x))
711            })
712            .collect();
713
714        let mut total = 0.0f64;
715        for w in angles.windows(2) {
716            let mut diff = w[1] - w[0];
717            // Wrap to [-π, π]
718            while diff > std::f64::consts::PI {
719                diff -= std::f64::consts::TAU;
720            }
721            while diff < -std::f64::consts::PI {
722                diff += std::f64::consts::TAU;
723            }
724            total += diff;
725        }
726        total / (std::f64::consts::TAU * (angles.len() - 1) as f64)
727    }
728
729    /// Number of recorded intersections.
730    pub fn count(&self) -> usize {
731        self.intersections.len()
732    }
733
734    /// Clear all recorded intersections.
735    pub fn clear(&mut self) {
736        self.intersections.clear();
737    }
738}
739
740// ─────────────────────────────────────────────────────────────────────────────
741// FlowMap
742// ─────────────────────────────────────────────────────────────────────────────
743
744/// Numerical flow integration for autonomous ODEs `ẋ = f(x)`.
745///
746/// Uses a fixed-step 4th-order Runge–Kutta integrator.
747#[derive(Debug, Clone)]
748pub struct FlowMap {
749    /// Dimension of the state space.
750    pub dim: usize,
751    /// Integration step size.
752    pub dt: f64,
753}
754
755impl FlowMap {
756    /// Create a flow map integrator.
757    pub fn new(dim: usize, dt: f64) -> Self {
758        Self { dim, dt }
759    }
760
761    /// Advance the state by `n_steps` using RK4 with the vector field `f`.
762    pub fn advance<F>(&self, init: &[f64], n_steps: usize, f: &F) -> Vec<f64>
763    where
764        F: Fn(&[f64]) -> Vec<f64>,
765    {
766        let mut state = init.to_vec();
767        for _ in 0..n_steps {
768            state = self.rk4_step(&state, f);
769        }
770        state
771    }
772
773    /// Generate a trajectory (list of states) of length `n_steps + 1`.
774    pub fn trajectory<F>(&self, init: &[f64], n_steps: usize, f: &F) -> Vec<Vec<f64>>
775    where
776        F: Fn(&[f64]) -> Vec<f64>,
777    {
778        let mut traj = Vec::with_capacity(n_steps + 1);
779        traj.push(init.to_vec());
780        for _ in 0..n_steps {
781            let next = self.rk4_step(traj.last().expect("trajectory is non-empty"), f);
782            traj.push(next);
783        }
784        traj
785    }
786
787    /// Stroboscopic map: sample the trajectory at every `period` steps.
788    pub fn stroboscopic_map<F>(
789        &self,
790        init: &[f64],
791        n_periods: usize,
792        steps_per_period: usize,
793        f: &F,
794    ) -> Vec<Vec<f64>>
795    where
796        F: Fn(&[f64]) -> Vec<f64>,
797    {
798        let mut state = init.to_vec();
799        let mut samples = Vec::with_capacity(n_periods + 1);
800        samples.push(state.clone());
801        for _ in 0..n_periods {
802            state = self.advance(&state, steps_per_period, f);
803            samples.push(state.clone());
804        }
805        samples
806    }
807
808    /// Compute the fundamental solution matrix (monodromy-like) by
809    /// propagating `dim` perturbation vectors alongside the trajectory.
810    ///
811    /// Returns a `dim × dim` matrix in row-major order (each row is a
812    /// transported unit vector).
813    pub fn fundamental_solution_matrix<F>(
814        &self,
815        init: &[f64],
816        n_steps: usize,
817        f: &F,
818    ) -> Vec<Vec<f64>>
819    where
820        F: Fn(&[f64]) -> Vec<f64>,
821    {
822        let d = self.dim;
823        // Initialise perturbation vectors as identity matrix
824        let mut pert: Vec<Vec<f64>> = (0..d)
825            .map(|i| {
826                let mut v = vec![0.0; d];
827                v[i] = 1.0;
828                v
829            })
830            .collect();
831
832        let mut state = init.to_vec();
833        let eps = 1e-6;
834
835        for _ in 0..n_steps {
836            // Advance reference trajectory
837            let next_state = self.rk4_step(&state, f);
838            // Advance each perturbation vector using finite-difference Jacobian
839            let mut next_pert = vec![vec![0.0f64; d]; d];
840            for (col, pv) in pert.iter().enumerate() {
841                let state_plus: Vec<f64> = state
842                    .iter()
843                    .zip(pv.iter())
844                    .map(|(s, p)| s + eps * p)
845                    .collect();
846                let f_plus = f(&state_plus);
847                let f_ref = f(&state);
848                for row in 0..d {
849                    next_pert[row][col] = pv[row] + self.dt * (f_plus[row] - f_ref[row]) / eps;
850                }
851            }
852            state = next_state;
853            pert = next_pert;
854        }
855        pert
856    }
857
858    fn rk4_step<F>(&self, state: &[f64], f: &F) -> Vec<f64>
859    where
860        F: Fn(&[f64]) -> Vec<f64>,
861    {
862        let k1 = f(state);
863        let s2: Vec<f64> = state
864            .iter()
865            .zip(k1.iter())
866            .map(|(s, k)| s + 0.5 * self.dt * k)
867            .collect();
868        let k2 = f(&s2);
869        let s3: Vec<f64> = state
870            .iter()
871            .zip(k2.iter())
872            .map(|(s, k)| s + 0.5 * self.dt * k)
873            .collect();
874        let k3 = f(&s3);
875        let s4: Vec<f64> = state
876            .iter()
877            .zip(k3.iter())
878            .map(|(s, k)| s + self.dt * k)
879            .collect();
880        let k4 = f(&s4);
881        state
882            .iter()
883            .enumerate()
884            .map(|(i, s)| s + self.dt / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]))
885            .collect()
886    }
887}
888
889// ─────────────────────────────────────────────────────────────────────────────
890// SynchronizationAnalysis
891// ─────────────────────────────────────────────────────────────────────────────
892
893/// Synchronisation analysis for coupled oscillators / chaotic systems.
894#[derive(Debug, Clone)]
895pub struct SynchronizationAnalysis;
896
897impl SynchronizationAnalysis {
898    /// Pecora–Carroll synchronisation check.
899    ///
900    /// Drives a response system (with the same structure as the drive) by
901    /// replacing one of its variables with the drive's corresponding variable.
902    ///
903    /// Returns `true` if the synchronisation error decays below `tol`.
904    ///
905    /// `drive_orbit` and `response_orbit` are trajectories of the same length;
906    /// `sync_idx` is the variable index that is replaced in the response.
907    pub fn pecora_carroll_sync(
908        drive_orbit: &[Vec<f64>],
909        response_orbit: &[Vec<f64>],
910        sync_idx: usize,
911        tol: f64,
912    ) -> bool {
913        if drive_orbit.is_empty() || response_orbit.is_empty() {
914            return false;
915        }
916        // Check that the final error (excluding sync_idx variable) is small
917        let last_drive = drive_orbit.last().expect("drive orbit is non-empty");
918        let last_response = response_orbit.last().expect("response orbit is non-empty");
919        let err: f64 = last_drive
920            .iter()
921            .zip(last_response.iter())
922            .enumerate()
923            .filter(|(i, _)| *i != sync_idx)
924            .map(|(_, (d, r))| (d - r).powi(2))
925            .sum::<f64>()
926            .sqrt();
927        err < tol
928    }
929
930    /// Phase synchronisation index (mean resultant length of phase differences).
931    ///
932    /// Given two phase time-series `phi1` and `phi2` (in radians), computes
933    ///
934    /// `R = |⟨exp(i(φ₁ − φ₂))⟩|`
935    ///
936    /// `R = 1` means perfect phase synchronisation; `R ≈ 0` means asynchronous.
937    pub fn phase_synchronization_index(phi1: &[f64], phi2: &[f64]) -> f64 {
938        let n = phi1.len().min(phi2.len());
939        if n == 0 {
940            return 0.0;
941        }
942        let sum_cos: f64 = phi1
943            .iter()
944            .zip(phi2.iter())
945            .map(|(a, b)| (a - b).cos())
946            .sum();
947        let sum_sin: f64 = phi1
948            .iter()
949            .zip(phi2.iter())
950            .map(|(a, b)| (a - b).sin())
951            .sum();
952        let n_f = n as f64;
953        ((sum_cos / n_f).powi(2) + (sum_sin / n_f).powi(2)).sqrt()
954    }
955
956    /// Generalised synchronisation check via the auxiliary system method.
957    ///
958    /// Two copies of the response system starting at different initial conditions
959    /// should converge to the same trajectory if GS holds.  Returns `true` if
960    /// the distance between `resp1` and `resp2` at the end is below `tol`.
961    pub fn generalised_sync_check(resp1: &[Vec<f64>], resp2: &[Vec<f64>], tol: f64) -> bool {
962        let last1 = match resp1.last() {
963            Some(v) => v,
964            None => return false,
965        };
966        let last2 = match resp2.last() {
967            Some(v) => v,
968            None => return false,
969        };
970        let dist: f64 = last1
971            .iter()
972            .zip(last2.iter())
973            .map(|(a, b)| (a - b).powi(2))
974            .sum::<f64>()
975            .sqrt();
976        dist < tol
977    }
978
979    /// Mutual information estimate between two scalar signals using histogram
980    /// binning with `n_bins` bins.
981    pub fn mutual_information(x: &[f64], y: &[f64], n_bins: usize) -> f64 {
982        let n = x.len().min(y.len());
983        if n == 0 || n_bins == 0 {
984            return 0.0;
985        }
986        let x_min = x[..n].iter().cloned().fold(f64::INFINITY, f64::min);
987        let x_max = x[..n].iter().cloned().fold(f64::NEG_INFINITY, f64::max);
988        let y_min = y[..n].iter().cloned().fold(f64::INFINITY, f64::min);
989        let y_max = y[..n].iter().cloned().fold(f64::NEG_INFINITY, f64::max);
990
991        let x_range = (x_max - x_min).max(1e-15);
992        let y_range = (y_max - y_min).max(1e-15);
993
994        let mut joint = vec![vec![0u64; n_bins]; n_bins];
995        let mut hist_x = vec![0u64; n_bins];
996        let mut hist_y = vec![0u64; n_bins];
997
998        for i in 0..n {
999            let ix = ((x[i] - x_min) / x_range * n_bins as f64)
1000                .floor()
1001                .clamp(0.0, (n_bins - 1) as f64) as usize;
1002            let iy = ((y[i] - y_min) / y_range * n_bins as f64)
1003                .floor()
1004                .clamp(0.0, (n_bins - 1) as f64) as usize;
1005            joint[ix][iy] += 1;
1006            hist_x[ix] += 1;
1007            hist_y[iy] += 1;
1008        }
1009
1010        let n_f = n as f64;
1011        let mut mi = 0.0f64;
1012        for ix in 0..n_bins {
1013            for iy in 0..n_bins {
1014                let p_xy = joint[ix][iy] as f64 / n_f;
1015                let p_x = hist_x[ix] as f64 / n_f;
1016                let p_y = hist_y[iy] as f64 / n_f;
1017                if p_xy > 0.0 && p_x > 0.0 && p_y > 0.0 {
1018                    mi += p_xy * (p_xy / (p_x * p_y)).ln();
1019                }
1020            }
1021        }
1022        mi
1023    }
1024
1025    /// Compute instantaneous phase of a signal via the Hilbert transform
1026    /// approximation (using the discrete analytic signal via FFT-like unwrapping).
1027    ///
1028    /// This is a simple numerical approximation using finite differences.
1029    /// Returns the unwrapped phase array.
1030    pub fn instantaneous_phase(signal: &[f64]) -> Vec<f64> {
1031        let n = signal.len();
1032        if n < 2 {
1033            return vec![0.0; n];
1034        }
1035        // Compute a rough Hilbert transform via 90-degree phase shift in Fourier space.
1036        // Here we use a simple finite-difference approximation: a causal integrator.
1037        let mean = signal.iter().sum::<f64>() / n as f64;
1038        let centered: Vec<f64> = signal.iter().map(|s| s - mean).collect();
1039
1040        // Quadrature signal via finite differences (simple approximation)
1041        let mut quad = vec![0.0f64; n];
1042        for i in 1..n {
1043            quad[i] = quad[i - 1] + centered[i - 1];
1044        }
1045        // Normalise quadrature
1046        let q_max = quad
1047            .iter()
1048            .cloned()
1049            .fold(0.0f64, |a, b| a.max(b.abs()))
1050            .max(1e-15);
1051        let c_max = centered
1052            .iter()
1053            .cloned()
1054            .fold(0.0f64, |a, b| a.max(b.abs()))
1055            .max(1e-15);
1056        let scale = c_max / q_max;
1057
1058        let mut phase = Vec::with_capacity(n);
1059        for i in 0..n {
1060            phase.push(quad[i] * scale * centered[i].signum().max(0.0));
1061        }
1062
1063        // Use atan2 for phase
1064        let mut phases = Vec::with_capacity(n);
1065        for i in 0..n {
1066            phases.push((quad[i] * scale).atan2(centered[i]));
1067        }
1068        phases
1069    }
1070
1071    /// Cross-correlation between two signals at lag 0 (normalised).
1072    pub fn cross_correlation_zero_lag(x: &[f64], y: &[f64]) -> f64 {
1073        let n = x.len().min(y.len());
1074        if n == 0 {
1075            return 0.0;
1076        }
1077        let mx = x.iter().sum::<f64>() / n as f64;
1078        let my = y.iter().sum::<f64>() / n as f64;
1079        let num: f64 = x[..n]
1080            .iter()
1081            .zip(y[..n].iter())
1082            .map(|(a, b)| (a - mx) * (b - my))
1083            .sum();
1084        let sx: f64 = x[..n].iter().map(|a| (a - mx).powi(2)).sum::<f64>().sqrt();
1085        let sy: f64 = y[..n].iter().map(|b| (b - my).powi(2)).sum::<f64>().sqrt();
1086        if sx < 1e-15 || sy < 1e-15 {
1087            return 0.0;
1088        }
1089        num / (sx * sy)
1090    }
1091}
1092
1093// ─────────────────────────────────────────────────────────────────────────────
1094// Lorenz system helper (used in tests)
1095// ─────────────────────────────────────────────────────────────────────────────
1096
1097/// Lorenz system parameters.
1098#[derive(Debug, Clone)]
1099pub struct LorenzSystem {
1100    /// σ parameter.
1101    pub sigma: f64,
1102    /// ρ parameter.
1103    pub rho: f64,
1104    /// β parameter.
1105    pub beta: f64,
1106}
1107
1108impl LorenzSystem {
1109    /// Create with standard chaotic parameters `σ=10, ρ=28, β=8/3`.
1110    pub fn standard() -> Self {
1111        Self {
1112            sigma: 10.0,
1113            rho: 28.0,
1114            beta: 8.0 / 3.0,
1115        }
1116    }
1117
1118    /// Evaluate the Lorenz vector field at `(x, y, z)`.
1119    pub fn rhs(&self, state: &[f64]) -> Vec<f64> {
1120        let (x, y, z) = (state[0], state[1], state[2]);
1121        vec![
1122            self.sigma * (y - x),
1123            x * (self.rho - z) - y,
1124            x * y - self.beta * z,
1125        ]
1126    }
1127}
1128
1129// ─────────────────────────────────────────────────────────────────────────────
1130// Tests
1131// ─────────────────────────────────────────────────────────────────────────────
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136
1137    // ── LogisticMap ────────────────────────────────────────────────────────────
1138
1139    #[test]
1140    fn test_logistic_fixed_point_stability() {
1141        // For r=2, fixed point at x* = (r-1)/r = 0.5
1142        let map = LogisticMap::new(2.0);
1143        let x_star = (map.r - 1.0) / map.r;
1144        let mut x = 0.4;
1145        for _ in 0..200 {
1146            x = map.apply(x);
1147        }
1148        assert!(
1149            (x - x_star).abs() < 1e-6,
1150            "should converge to {x_star}, got {x}"
1151        );
1152    }
1153
1154    #[test]
1155    fn test_logistic_orbit_length() {
1156        let map = LogisticMap::new(3.5);
1157        let orbit = map.orbit_1d(0.3, 50);
1158        assert_eq!(orbit.len(), 51);
1159    }
1160
1161    #[test]
1162    fn test_logistic_orbit_bounded() {
1163        let map = LogisticMap::new(3.9);
1164        let orbit = map.orbit_1d(0.5, 1000);
1165        for &x in &orbit {
1166            assert!((0.0..=1.0).contains(&x), "orbit escaped [0,1]: {x}");
1167        }
1168    }
1169
1170    #[test]
1171    fn test_logistic_lyapunov_chaotic() {
1172        // r=4.0: Lyapunov exponent ≈ ln 2 ≈ 0.693
1173        let map = LogisticMap::new(4.0);
1174        let le = map.lyapunov_exponent(0.3, 5000).unwrap();
1175        assert!(le > 0.4, "should be positive (chaotic), got {le}");
1176    }
1177
1178    #[test]
1179    fn test_logistic_lyapunov_stable() {
1180        // r=2.0: stable fixed point at x*=0.5, Lyapunov < 0
1181        // Use x0=0.3 which avoids the fixed point x=0 or x=1
1182        let map = LogisticMap::new(2.0);
1183        // The derivative at the fixed point is r(1-2x*) = 2*(1-1) = 0, so use short orbit
1184        // and check via orbit convergence instead
1185        let mut x = 0.3f64;
1186        for _ in 0..500 {
1187            x = map.apply(x);
1188        }
1189        // Should have converged near x* = 0.5
1190        assert!((x - 0.5).abs() < 1e-6, "should converge to x*=0.5, got {x}");
1191        // Lyapunov: compute without hitting the degenerate point
1192        if let Some(le) = map.lyapunov_exponent(0.3, 200) {
1193            assert!(
1194                le < 0.1,
1195                "stable fixed point should have non-positive LE, got {le}"
1196            );
1197        }
1198        // If None is returned that's also acceptable (orbit hit degenerate point)
1199    }
1200
1201    #[test]
1202    fn test_logistic_bifurcation_diagram_size() {
1203        let diag = LogisticMap::bifurcation_diagram(2.5, 4.0, 10, 200, 50);
1204        assert_eq!(diag.len(), 10 * 50);
1205    }
1206
1207    #[test]
1208    fn test_logistic_trait_orbit() {
1209        let map = LogisticMap::new(3.2);
1210        let orbit = map.orbit(&[0.5], 20);
1211        assert_eq!(orbit.len(), 21);
1212        assert_eq!(orbit[0], vec![0.5]);
1213    }
1214
1215    // ── HenonMap ───────────────────────────────────────────────────────────────
1216
1217    #[test]
1218    fn test_henon_orbit_first_step() {
1219        let map = HenonMap::new(1.4, 0.3);
1220        let (xn, yn) = map.apply(0.0, 0.0);
1221        assert!((xn - 1.0).abs() < 1e-12);
1222        assert!(yn.abs() < 1e-12);
1223    }
1224
1225    #[test]
1226    fn test_henon_jacobian_det_constant() {
1227        let map = HenonMap::new(1.4, 0.3);
1228        assert!((map.jacobian_det() - (-0.3)).abs() < 1e-12);
1229    }
1230
1231    #[test]
1232    fn test_henon_orbit_length() {
1233        let map = HenonMap::new(1.4, 0.3);
1234        let orbit = map.orbit_2d(0.0, 0.0, 100);
1235        assert_eq!(orbit.len(), 101);
1236    }
1237
1238    #[test]
1239    fn test_henon_jacobian_structure() {
1240        let map = HenonMap::new(1.4, 0.3);
1241        let j = map.jacobian(1.0, 0.5);
1242        assert!((j[0][0] - (-2.0 * 1.4)).abs() < 1e-12);
1243        assert!((j[0][1] - 1.0).abs() < 1e-12);
1244        assert!((j[1][0] - 0.3).abs() < 1e-12);
1245        assert!(j[1][1].abs() < 1e-12);
1246    }
1247
1248    #[test]
1249    fn test_henon_trait_step() {
1250        let map = HenonMap::new(1.4, 0.3);
1251        let state = vec![0.0, 0.0];
1252        let next = map.step(&state);
1253        assert_eq!(next.len(), 2);
1254    }
1255
1256    // ── ChirikovStandardMap ────────────────────────────────────────────────────
1257
1258    #[test]
1259    fn test_chirikov_orbit_stays_in_torus() {
1260        let map = ChirikovStandardMap::new(0.5);
1261        let orbit = map.orbit_2d(0.1, 0.2, 1000);
1262        let tau = std::f64::consts::TAU;
1263        for (th, p) in orbit {
1264            assert!(th >= 0.0 && th < tau, "theta out of [0,2π): {th}");
1265            assert!(p >= 0.0 && p < tau, "p out of [0,2π): {p}");
1266        }
1267    }
1268
1269    #[test]
1270    fn test_chirikov_zero_k_is_twist_map() {
1271        // K=0: p unchanged, theta advances by p
1272        let map = ChirikovStandardMap::new(0.0);
1273        let (th, p) = map.apply(1.0, 0.5);
1274        let tau = std::f64::consts::TAU;
1275        assert!((p - 0.5).abs() < 1e-12, "p should not change: {p}");
1276        assert!(
1277            (th - (1.5_f64).rem_euclid(tau)).abs() < 1e-12,
1278            "theta = 1+p mod 2π: {th}"
1279        );
1280    }
1281
1282    #[test]
1283    fn test_chirikov_lyapunov_chaotic() {
1284        // K=5 should be strongly chaotic
1285        let map = ChirikovStandardMap::new(5.0);
1286        let le = map.lyapunov_exponent(1.0, 1.5, 5000);
1287        assert!(le > 0.5, "K=5 should be chaotic, got LE={le}");
1288    }
1289
1290    #[test]
1291    fn test_chirikov_trait_orbit() {
1292        let map = ChirikovStandardMap::new(1.0);
1293        let orbit = map.orbit(&[0.5, 0.5], 10);
1294        assert_eq!(orbit.len(), 11);
1295    }
1296
1297    // ── AttractorAnalysis ──────────────────────────────────────────────────────
1298
1299    #[test]
1300    fn test_attractor_find_fixed_points_identity() {
1301        // Points near y = x should be found as fixed points of x → x
1302        let data = vec![vec![0.0], vec![0.5], vec![1.0], vec![2.0]];
1303        let analysis = AttractorAnalysis::new(data);
1304        let fps = analysis.find_fixed_points(|x| x.to_vec(), 1e-9);
1305        assert!(
1306            !fps.is_empty(),
1307            "identity map has every point as fixed point"
1308        );
1309    }
1310
1311    #[test]
1312    fn test_attractor_correlation_dimension_returns_pairs() {
1313        let data: Vec<Vec<f64>> = (0..50).map(|i| vec![i as f64 * 0.1]).collect();
1314        let analysis = AttractorAnalysis::new(data);
1315        let pairs = analysis.correlation_dimension(10);
1316        assert!(!pairs.is_empty(), "should produce log-log pairs");
1317    }
1318
1319    #[test]
1320    fn test_attractor_rosenstein_length() {
1321        let data: Vec<Vec<f64>> = (0..100)
1322            .map(|i| {
1323                let t = i as f64 * 0.1;
1324                vec![t.sin(), t.cos()]
1325            })
1326            .collect();
1327        let analysis = AttractorAnalysis::new(data);
1328        let div = analysis.rosenstein_lyapunov(10);
1329        assert_eq!(div.len(), 10);
1330    }
1331
1332    #[test]
1333    fn test_attractor_find_periodic_orbit() {
1334        // Period-2 orbit of logistic map at r ≈ 3.2
1335        let map = LogisticMap::new(3.2);
1336        // Converge to period-2 orbit
1337        let mut x = 0.5f64;
1338        for _ in 0..1000 {
1339            x = map.apply(x);
1340        }
1341        let p1 = x;
1342        let p2 = map.apply(p1);
1343        let data = vec![vec![p1], vec![p2]];
1344        let analysis = AttractorAnalysis::new(data);
1345        let po = analysis.find_periodic_orbits(&|s: &[f64]| vec![map.apply(s[0])], 2, 1e-6);
1346        assert!(!po.is_empty(), "should find period-2 orbit");
1347    }
1348
1349    // ── DynamicalNormalForm ────────────────────────────────────────────────────
1350
1351    #[test]
1352    fn test_normal_form_saddle_node_fixed_points() {
1353        let nf = DynamicalNormalForm::new(BifurcationType::SaddleNode, -1.0);
1354        let fps = nf.fixed_points_1d();
1355        assert_eq!(fps.len(), 2, "two fps for μ<0");
1356        for &x in &fps {
1357            let rhs_val = nf.rhs(&[x])[0];
1358            assert!(rhs_val.abs() < 1e-10, "not a fixed point: rhs={rhs_val}");
1359        }
1360    }
1361
1362    #[test]
1363    fn test_normal_form_saddle_node_no_fps_positive_mu() {
1364        let nf = DynamicalNormalForm::new(BifurcationType::SaddleNode, 1.0);
1365        let fps = nf.fixed_points_1d();
1366        assert!(fps.is_empty(), "no fps for μ>0 in saddle-node");
1367    }
1368
1369    #[test]
1370    fn test_normal_form_transcritical_fps() {
1371        let nf = DynamicalNormalForm::new(BifurcationType::Transcritical, 2.0);
1372        let fps = nf.fixed_points_1d();
1373        assert_eq!(fps.len(), 2);
1374        for &x in &fps {
1375            let rhs_val = nf.rhs(&[x])[0];
1376            assert!(
1377                rhs_val.abs() < 1e-10,
1378                "not a fixed point: x={x}, rhs={rhs_val}"
1379            );
1380        }
1381    }
1382
1383    #[test]
1384    fn test_normal_form_pitchfork_super_three_fps() {
1385        let nf = DynamicalNormalForm::new(BifurcationType::PitchforkSuper, 1.0);
1386        let fps = nf.fixed_points_1d();
1387        assert_eq!(fps.len(), 3, "super-critical pitchfork μ>0 has 3 fps");
1388    }
1389
1390    #[test]
1391    fn test_normal_form_stability_classification() {
1392        // Pitchfork super with μ=1: x*=0 is unstable, x*=±1 are stable
1393        let nf = DynamicalNormalForm::new(BifurcationType::PitchforkSuper, 1.0);
1394        assert_eq!(nf.stability_1d(0.0), Stability::Unstable);
1395        assert_eq!(nf.stability_1d(1.0), Stability::Stable);
1396        assert_eq!(nf.stability_1d(-1.0), Stability::Stable);
1397    }
1398
1399    #[test]
1400    fn test_normal_form_euler_integration_converges() {
1401        // Saddle-node stable branch: μ=-1, x0=-0.9 (near stable fp x=-1)
1402        let nf = DynamicalNormalForm::new(BifurcationType::SaddleNode, -1.0);
1403        let traj = nf.integrate_euler(&[-0.9], 0.01, 2000);
1404        let last = traj.last().unwrap()[0];
1405        assert!(
1406            (last - (-1.0)).abs() < 0.05,
1407            "should converge near -1, got {last}"
1408        );
1409    }
1410
1411    #[test]
1412    fn test_normal_form_hopf_super_limit_cycle() {
1413        // HopfSuper μ=1: trajectory should converge to r=1
1414        let nf = DynamicalNormalForm::new(BifurcationType::HopfSuper, 1.0);
1415        let traj = nf.integrate_euler(&[0.1, 0.0], 0.01, 5000);
1416        let last_r = traj.last().unwrap()[0];
1417        assert!((last_r - 1.0).abs() < 0.05, "limit cycle r≈1, got {last_r}");
1418    }
1419
1420    // ── PoincareSection ────────────────────────────────────────────────────────
1421
1422    #[test]
1423    fn test_poincare_section_circle() {
1424        // Circular orbit crossing y=0 upwards twice per revolution
1425        let n = 1000usize;
1426        let traj: Vec<Vec<f64>> = (0..=n)
1427            .map(|i| {
1428                let t = i as f64 * std::f64::consts::TAU / n as f64 * 5.0;
1429                vec![t.cos(), t.sin()]
1430            })
1431            .collect();
1432        let mut section = PoincareSection::new(vec![0.0, 1.0], vec![0.0, 0.0]);
1433        section.process_trajectory(&traj);
1434        // Should have ~5 crossings
1435        assert!(
1436            section.count() >= 4,
1437            "expected ~5 crossings, got {}",
1438            section.count()
1439        );
1440    }
1441
1442    #[test]
1443    fn test_poincare_section_rotation_number() {
1444        let n = 2000usize;
1445        let traj: Vec<Vec<f64>> = (0..=n)
1446            .map(|i| {
1447                let t = i as f64 * std::f64::consts::TAU / n as f64 * 3.0;
1448                vec![t.cos(), t.sin()]
1449            })
1450            .collect();
1451        let mut section = PoincareSection::new(vec![0.0, 1.0], vec![0.0, 0.0]);
1452        section.process_trajectory(&traj);
1453        let rn = section.rotation_number(0, 1);
1454        // rotation number for a circle should be approximately 1 per crossing
1455        assert!(rn.abs() < 2.0, "rotation number should be finite, got {rn}");
1456    }
1457
1458    #[test]
1459    fn test_poincare_clear() {
1460        let traj: Vec<Vec<f64>> = (0..=100)
1461            .map(|i| {
1462                let t = i as f64 * 0.1;
1463                vec![t.cos(), t.sin()]
1464            })
1465            .collect();
1466        let mut section = PoincareSection::new(vec![0.0, 1.0], vec![0.0, 0.0]);
1467        section.process_trajectory(&traj);
1468        section.clear();
1469        assert_eq!(section.count(), 0);
1470    }
1471
1472    #[test]
1473    fn test_poincare_return_map() {
1474        let n = 2000usize;
1475        let traj: Vec<Vec<f64>> = (0..=n)
1476            .map(|i| {
1477                let t = i as f64 * std::f64::consts::TAU / n as f64 * 5.0;
1478                vec![t.cos(), t.sin()]
1479            })
1480            .collect();
1481        let mut section = PoincareSection::new(vec![0.0, 1.0], vec![0.0, 0.0]);
1482        section.process_trajectory(&traj);
1483        let rm = section.return_map_1d(0);
1484        // Should have (count - 1) return-map pairs
1485        assert_eq!(rm.len(), section.count().saturating_sub(1));
1486    }
1487
1488    // ── FlowMap ────────────────────────────────────────────────────────────────
1489
1490    #[test]
1491    fn test_flow_simple_harmonic_oscillator() {
1492        // ẋ = y, ẏ = -x (simple harmonic oscillator)
1493        let f = |s: &[f64]| vec![s[1], -s[0]];
1494        let flow = FlowMap::new(2, 0.01);
1495        // Advance one full period: T = 2π ≈ 628 steps at dt=0.01
1496        let final_state = flow.advance(&[1.0, 0.0], 628, &f);
1497        // Should return close to (1, 0)
1498        assert!(
1499            (final_state[0] - 1.0).abs() < 0.1,
1500            "x≈1, got {}",
1501            final_state[0]
1502        );
1503        assert!(final_state[1].abs() < 0.1, "y≈0, got {}", final_state[1]);
1504    }
1505
1506    #[test]
1507    fn test_flow_trajectory_length() {
1508        let f = |s: &[f64]| vec![s[1], -s[0]];
1509        let flow = FlowMap::new(2, 0.01);
1510        let traj = flow.trajectory(&[1.0, 0.0], 100, &f);
1511        assert_eq!(traj.len(), 101);
1512    }
1513
1514    #[test]
1515    fn test_flow_stroboscopic_map() {
1516        let f = |s: &[f64]| vec![s[1], -s[0]];
1517        let flow = FlowMap::new(2, 0.01);
1518        let samples = flow.stroboscopic_map(&[1.0, 0.0], 5, 10, &f);
1519        assert_eq!(samples.len(), 6);
1520    }
1521
1522    #[test]
1523    fn test_flow_energy_conservation_sho() {
1524        // For SHO, E = x² + y² should be conserved
1525        let f = |s: &[f64]| vec![s[1], -s[0]];
1526        let flow = FlowMap::new(2, 0.001);
1527        let init = vec![1.0, 0.0];
1528        let energy_init: f64 = init[0] * init[0] + init[1] * init[1];
1529        let final_state = flow.advance(&init, 1000, &f);
1530        let energy_final: f64 = final_state[0] * final_state[0] + final_state[1] * final_state[1];
1531        assert!(
1532            (energy_final - energy_init).abs() < 0.01,
1533            "energy not conserved: {energy_final}"
1534        );
1535    }
1536
1537    #[test]
1538    fn test_flow_fundamental_solution_matrix() {
1539        let f = |s: &[f64]| vec![s[1], -s[0]];
1540        let flow = FlowMap::new(2, 0.01);
1541        let fsm = flow.fundamental_solution_matrix(&[1.0, 0.0], 10, &f);
1542        assert_eq!(fsm.len(), 2);
1543        assert_eq!(fsm[0].len(), 2);
1544    }
1545
1546    // ── SynchronizationAnalysis ────────────────────────────────────────────────
1547
1548    #[test]
1549    fn test_phase_sync_index_identical() {
1550        // Identical phases → R = 1
1551        let phi: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
1552        let r = SynchronizationAnalysis::phase_synchronization_index(&phi, &phi);
1553        assert!((r - 1.0).abs() < 1e-6, "identical phases → R=1, got {r}");
1554    }
1555
1556    #[test]
1557    fn test_phase_sync_index_random_low() {
1558        // Linearly increasing phase difference → low R
1559        let phi1: Vec<f64> = (0..100).map(|i| i as f64 * 0.1).collect();
1560        let phi2: Vec<f64> = (0..100).map(|i| i as f64 * 0.3 + 0.7).collect();
1561        let r = SynchronizationAnalysis::phase_synchronization_index(&phi1, &phi2);
1562        assert!(r < 0.8, "asynchronous phases → low R, got {r}");
1563    }
1564
1565    #[test]
1566    fn test_generalised_sync_converged() {
1567        let traj1: Vec<Vec<f64>> = (0..10).map(|_| vec![1.0, 2.0]).collect();
1568        let traj2: Vec<Vec<f64>> = (0..10).map(|_| vec![1.0, 2.0]).collect();
1569        assert!(SynchronizationAnalysis::generalised_sync_check(
1570            &traj1, &traj2, 1e-9
1571        ));
1572    }
1573
1574    #[test]
1575    fn test_generalised_sync_diverged() {
1576        let traj1: Vec<Vec<f64>> = (0..10).map(|_| vec![1.0]).collect();
1577        let traj2: Vec<Vec<f64>> = (0..10).map(|_| vec![100.0]).collect();
1578        assert!(!SynchronizationAnalysis::generalised_sync_check(
1579            &traj1, &traj2, 1.0
1580        ));
1581    }
1582
1583    #[test]
1584    fn test_mutual_information_identical_signals() {
1585        let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
1586        let mi = SynchronizationAnalysis::mutual_information(&x, &x, 10);
1587        assert!(
1588            mi > 0.0,
1589            "MI should be positive for identical signals, got {mi}"
1590        );
1591    }
1592
1593    #[test]
1594    fn test_mutual_information_independent_signals() {
1595        let x: Vec<f64> = (0..100).map(|i| i as f64).collect();
1596        let y: Vec<f64> = (0..100).map(|i| (i * 7 + 3) as f64 % 100.0).collect();
1597        let mi = SynchronizationAnalysis::mutual_information(&x, &y, 10);
1598        // MI ≥ 0
1599        assert!(mi >= 0.0, "MI should be non-negative, got {mi}");
1600    }
1601
1602    #[test]
1603    fn test_cross_correlation_identical() {
1604        let x: Vec<f64> = (0..50).map(|i| (i as f64 * 0.3).sin()).collect();
1605        let cc = SynchronizationAnalysis::cross_correlation_zero_lag(&x, &x);
1606        assert!(
1607            (cc - 1.0).abs() < 1e-6,
1608            "self-correlation should be 1, got {cc}"
1609        );
1610    }
1611
1612    #[test]
1613    fn test_cross_correlation_anticorrelated() {
1614        let x: Vec<f64> = (0..50).map(|i| i as f64).collect();
1615        let y: Vec<f64> = x.iter().map(|v| -v).collect();
1616        let cc = SynchronizationAnalysis::cross_correlation_zero_lag(&x, &y);
1617        assert!(
1618            (cc + 1.0).abs() < 1e-6,
1619            "anti-correlated: should be -1, got {cc}"
1620        );
1621    }
1622
1623    #[test]
1624    fn test_instantaneous_phase_length() {
1625        let sig: Vec<f64> = (0..100).map(|i| (i as f64 * 0.2).sin()).collect();
1626        let phase = SynchronizationAnalysis::instantaneous_phase(&sig);
1627        assert_eq!(phase.len(), sig.len());
1628    }
1629
1630    #[test]
1631    fn test_pecora_carroll_identical_sync() {
1632        let traj: Vec<Vec<f64>> = (0..10).map(|i| vec![i as f64, (i as f64).sin()]).collect();
1633        assert!(SynchronizationAnalysis::pecora_carroll_sync(
1634            &traj, &traj, 0, 1e-9
1635        ));
1636    }
1637
1638    // ── LorenzSystem integration ───────────────────────────────────────────────
1639
1640    #[test]
1641    fn test_lorenz_trajectory() {
1642        let lorenz = LorenzSystem::standard();
1643        let flow = FlowMap::new(3, 0.01);
1644        let init = vec![1.0, 0.0, 0.0];
1645        let traj = flow.trajectory(&init, 100, &|s| lorenz.rhs(s));
1646        assert_eq!(traj.len(), 101);
1647    }
1648
1649    #[test]
1650    fn test_lorenz_sensitivity() {
1651        // Lorenz is chaotic: two nearby initial conditions diverge substantially
1652        // Use 5000 steps at dt=0.01 = 50 time units for clear separation
1653        let lorenz = LorenzSystem::standard();
1654        let flow = FlowMap::new(3, 0.01);
1655        let init1 = vec![1.0, 1.0, 1.0];
1656        let init2 = vec![1.0 + 1e-5, 1.0, 1.0];
1657        let s1 = flow.advance(&init1, 5000, &|s| lorenz.rhs(s));
1658        let s2 = flow.advance(&init2, 5000, &|s| lorenz.rhs(s));
1659        let dist: f64 = s1
1660            .iter()
1661            .zip(s2.iter())
1662            .map(|(a, b)| (a - b) * (a - b))
1663            .sum::<f64>()
1664            .sqrt();
1665        assert!(dist > 1e-3, "Lorenz should show sensitivity, dist={dist}");
1666    }
1667
1668    #[test]
1669    fn test_lorenz_poincare_section() {
1670        let lorenz = LorenzSystem::standard();
1671        let flow = FlowMap::new(3, 0.01);
1672        let mut state = vec![1.0, 1.0, 1.0];
1673        // Warm up
1674        state = flow.advance(&state, 1000, &|s| lorenz.rhs(s));
1675        let traj = flow.trajectory(&state, 5000, &|s| lorenz.rhs(s));
1676        // Poincaré section at z = 27
1677        let mut section = PoincareSection::new(vec![0.0, 0.0, 1.0], vec![0.0, 0.0, 27.0]);
1678        section.process_trajectory(&traj);
1679        assert!(section.count() > 5, "Lorenz should cross z=27 many times");
1680    }
1681
1682    #[test]
1683    fn test_attractor_analysis_lorenz_is_strange() {
1684        // Build data from Lorenz orbit (x coordinate only for speed)
1685        let lorenz = LorenzSystem::standard();
1686        let flow = FlowMap::new(3, 0.02);
1687        let mut state = vec![1.0, 1.0, 1.0];
1688        state = flow.advance(&state, 500, &|s| lorenz.rhs(s));
1689        let traj = flow.trajectory(&state, 400, &|s| lorenz.rhs(s));
1690        let data: Vec<Vec<f64>> = traj.to_vec();
1691        let analysis = AttractorAnalysis::new(data);
1692        let pairs = analysis.correlation_dimension(15);
1693        // Just check we get valid log-log pairs
1694        assert!(!pairs.is_empty(), "should compute correlation dimension");
1695    }
1696
1697    // ── Linear slope helper ────────────────────────────────────────────────────
1698
1699    #[test]
1700    fn test_linear_slope_perfect() {
1701        // y = 2x + 1 → slope = 2
1702        let pairs: Vec<(f64, f64)> = (0..5).map(|i| (i as f64, 2.0 * i as f64 + 1.0)).collect();
1703        let s = linear_slope(&pairs);
1704        assert!((s - 2.0).abs() < 1e-6, "slope should be 2, got {s}");
1705    }
1706
1707    #[test]
1708    fn test_linear_slope_flat() {
1709        let pairs: Vec<(f64, f64)> = (0..5).map(|i| (i as f64, 3.0)).collect();
1710        let s = linear_slope(&pairs);
1711        assert!(s.abs() < 1e-6, "flat line has slope 0, got {s}");
1712    }
1713}