use anyhow::{bail, Result};
use rayon::prelude::*;
use std::cmp::Ordering;
#[derive(Debug, Clone)]
pub struct FtleParams {
pub dt: f64,
pub k_fit: usize,
pub theiler: usize,
pub max_pairs: usize,
pub min_init_sep: f64,
}
impl Default for FtleParams {
fn default() -> Self {
Self {
dt: 0.01,
k_fit: 12,
theiler: 20,
max_pairs: 4000,
min_init_sep: 1e-12,
}
}
}
#[derive(Debug, Clone)]
pub struct LyapunovResult {
pub lambda: f64,
pub lyapunov_time: f64,
pub doubling_time: f64,
pub points_used: usize,
pub dimension: usize,
pub pairs_found: usize,
}
pub fn estimate_lyapunov(
trajectory: &[Vec<f64>],
dt: f64,
k_fit: usize,
theiler: usize,
max_pairs: usize,
min_init_sep: f64,
) -> Result<LyapunovResult> {
if dt <= 0.0 {
bail!("dt must be > 0");
}
if k_fit < 2 {
bail!("k-fit must be >= 2");
}
if trajectory.is_empty() {
bail!("empty trajectory");
}
let n = trajectory.len();
if n < k_fit + 2 {
bail!("not enough points after embedding");
}
let dim = trajectory[0].len();
if dim == 0 {
bail!("zero-dimension state");
}
let mut indices: Vec<usize> = (0..n - k_fit).collect(); let tree = VpTree::build(trajectory, &mut indices);
let mut t = Vec::with_capacity(k_fit);
for kk in 1..=k_fit {
t.push(kk as f64 * dt);
}
let t_mean = mean(&t);
let var_t = t.iter().map(|tk| (tk - t_mean) * (tk - t_mean)).sum::<f64>();
if var_t <= 0.0 {
bail!("degenerate time variance");
}
let stride = std::cmp::max(1usize, (n - k_fit) / max_pairs.max(1));
let slopes: Vec<f64> = (0..n - k_fit)
.step_by(stride)
.collect::<Vec<_>>()
.par_iter()
.filter_map(|&i| {
let query = &trajectory[i];
if let Some((j, d0)) = tree.nearest_excluding(query, i, theiler) {
if d0 <= min_init_sep || j + k_fit >= trajectory.len() || i + k_fit >= trajectory.len() {
return None;
}
let mut y = Vec::with_capacity(k_fit);
for kk in 1..=k_fit {
let d = dist(&trajectory[i + kk], &trajectory[j + kk]);
let dd = if d <= 0.0 { 1e-300 } else { d };
y.push((dd / d0).ln());
}
let y_mean = mean(&y);
let cov = t
.iter()
.zip(y.iter())
.map(|(tk, yk)| (tk - t_mean) * (yk - y_mean))
.sum::<f64>();
let slope = cov / var_t; if slope.is_finite() { Some(slope) } else { None }
} else {
None
}
})
.collect();
if slopes.is_empty() {
bail!("no valid pairs found. Try reducing theiler or k-fit, or increase max-pairs");
}
let lambda = mean(&slopes);
let doubling_time = std::f64::consts::LN_2 / lambda;
let lyapunov_time = 1.0 / lambda;
Ok(LyapunovResult {
lambda,
lyapunov_time,
doubling_time,
points_used: n,
dimension: dim,
pairs_found: slopes.len(),
})
}
pub fn estimate_lyapunov_default(trajectory: &[Vec<f64>]) -> Result<LyapunovResult> {
let params = FtleParams::default();
estimate_lyapunov(
trajectory,
params.dt,
params.k_fit,
params.theiler,
params.max_pairs,
params.min_init_sep,
)
}
pub fn estimate_lyapunov_with_params(
trajectory: &[Vec<f64>],
params: &FtleParams,
) -> Result<LyapunovResult> {
estimate_lyapunov(
trajectory,
params.dt,
params.k_fit,
params.theiler,
params.max_pairs,
params.min_init_sep,
)
}
pub fn delay_embed(series: &[f64], m: usize, tau: usize) -> Result<Vec<Vec<f64>>> {
if m == 1 {
return Ok(series.iter().map(|v| vec![*v]).collect());
}
let n = series.len();
let span = (m - 1) * tau;
if n <= span {
bail!("series too short for embedding");
}
let n_eff = n - span;
let mut x = Vec::with_capacity(n_eff);
for i in 0..n_eff {
let mut v = Vec::with_capacity(m);
for k in 0..m {
let idx = i + k * tau;
v.push(series[idx]);
}
x.push(v);
}
Ok(x)
}
#[inline]
pub fn mean(v: &[f64]) -> f64 {
let s: f64 = v.iter().sum();
s / (v.len() as f64)
}
#[inline]
pub fn dist(a: &[f64], b: &[f64]) -> f64 {
let mut acc = 0.0;
let len = a.len();
let mut i = 0;
while i + 3 < len {
let d0 = a[i] - b[i];
let d1 = a[i + 1] - b[i + 1];
let d2 = a[i + 2] - b[i + 2];
let d3 = a[i + 3] - b[i + 3];
acc += d0 * d0 + d1 * d1 + d2 * d2 + d3 * d3;
i += 4;
}
while i < len {
let d = a[i] - b[i];
acc += d * d;
i += 1;
}
acc.sqrt()
}
struct VpNode {
idx: usize, tau: f64, left: Option<Box<VpNode>>,
right: Option<Box<VpNode>>,
}
pub struct VpTree<'a> {
data: &'a [Vec<f64>],
root: Option<Box<VpNode>>,
}
impl<'a> VpTree<'a> {
pub fn build(data: &'a [Vec<f64>], indices: &mut [usize]) -> Self {
let root = Self::build_rec(data, indices);
Self { data, root }
}
fn build_rec(data: &'a [Vec<f64>], indices: &mut [usize]) -> Option<Box<VpNode>> {
if indices.is_empty() {
return None;
}
let vp = indices[indices.len() - 1];
if indices.len() == 1 {
return Some(Box::new(VpNode { idx: vp, tau: 0.0, left: None, right: None }));
}
let (left_slice, _vp_slot) = indices.split_at_mut(indices.len() - 1);
let mut dists: Vec<(usize, f64)> = left_slice
.iter()
.map(|&j| (j, dist(&data[vp], &data[j])))
.collect();
let mid = dists.len() / 2;
dists.select_nth_unstable_by(mid, |a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
let tau = dists[mid].1;
let mut inner: Vec<usize> = Vec::with_capacity(mid + 1);
let mut outer: Vec<usize> = Vec::with_capacity(dists.len() - mid);
for (j, d) in dists {
if d <= tau {
inner.push(j);
} else {
outer.push(j);
}
}
let left = Self::build_rec(data, &mut inner);
let right = Self::build_rec(data, &mut outer);
Some(Box::new(VpNode { idx: vp, tau, left, right }))
}
pub fn nearest_excluding(&self, q: &[f64], target_i: usize, theiler: usize) -> Option<(usize, f64)> {
let mut best_idx = usize::MAX;
let mut best_dist = f64::INFINITY;
self.search(&self.root, q, target_i, theiler, &mut best_idx, &mut best_dist);
if best_idx == usize::MAX { None } else { Some((best_idx, best_dist)) }
}
fn search(
&self,
node: &Option<Box<VpNode>>,
q: &[f64],
target_i: usize,
theiler: usize,
best_idx: &mut usize,
best_dist: &mut f64,
) {
let Some(n) = node else { return; };
let d = dist(q, &self.data[n.idx]);
if n.idx != target_i && !theiler_exclude(target_i, n.idx, theiler) {
if d < *best_dist {
*best_dist = d;
*best_idx = n.idx;
}
}
let first_left = d < n.tau || n.right.is_none();
let (first, second) = if first_left { (&n.left, &n.right) } else { (&n.right, &n.left) };
if first.is_some() {
self.search(first, q, target_i, theiler, best_idx, best_dist);
}
if (d - n.tau).abs() <= *best_dist {
if second.is_some() {
self.search(second, q, target_i, theiler, best_idx, best_dist);
}
}
}
}
#[inline]
pub fn theiler_exclude(i: usize, j: usize, w: usize) -> bool {
let di = if i > j { i - j } else { j - i };
di <= w
}
pub fn calculate_ftle_segment(
trajectory: &[Vec<f64>],
start_idx: usize,
time_steps: usize,
dt: f64,
) -> Result<f64> {
if start_idx + time_steps >= trajectory.len() {
bail!("trajectory segment extends beyond available data");
}
let dim = trajectory[0].len();
let eps = 1e-8;
let mut perturbations = vec![vec![0.0; dim]; dim];
for i in 0..dim {
perturbations[i][i] = eps;
}
for step in 0..time_steps {
let base_idx = start_idx + step;
if base_idx + 1 >= trajectory.len() {
break;
}
for i in 0..dim {
for j in 0..dim {
let forward_diff = if base_idx + 1 < trajectory.len() {
trajectory[base_idx + 1][i] - trajectory[base_idx][i]
} else {
0.0
};
perturbations[i][j] += forward_diff * perturbations[j][i] * dt;
}
}
if step % 10 == 0 {
gram_schmidt_orthonormalize(&mut perturbations);
}
}
gram_schmidt_orthonormalize(&mut perturbations);
let norm = perturbations[0].iter().map(|x| x * x).sum::<f64>().sqrt();
let ftle = (norm.ln()) / (time_steps as f64 * dt);
Ok(ftle)
}
fn gram_schmidt_orthonormalize(vectors: &mut [Vec<f64>]) {
let n = vectors.len();
if n == 0 { return; }
for i in 0..n {
for j in 0..i {
let dot_product: f64 = vectors[i].iter().zip(&vectors[j]).map(|(a, b)| a * b).sum();
for k in 0..vectors[i].len() {
vectors[i][k] -= dot_product * vectors[j][k];
}
}
let norm: f64 = vectors[i].iter().map(|x| x * x).sum::<f64>().sqrt();
if norm > 1e-12 {
for x in &mut vectors[i] {
*x /= norm;
}
}
}
}
pub fn calculate_ftle_field(
trajectory: &[Vec<f64>],
window_size: usize,
dt: f64,
) -> Result<Vec<f64>> {
if trajectory.len() < window_size {
bail!("trajectory too short for window size");
}
let mut ftle_field = Vec::with_capacity(trajectory.len() - window_size);
for i in 0..trajectory.len() - window_size {
match calculate_ftle_segment(trajectory, i, window_size, dt) {
Ok(ftle) => ftle_field.push(ftle),
Err(_) => ftle_field.push(f64::NAN), }
}
Ok(ftle_field)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_delay_embed() {
let series = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
let embedded = delay_embed(&series, 3, 1).unwrap();
assert_eq!(embedded.len(), 4);
assert_eq!(embedded[0], vec![1.0, 2.0, 3.0]);
assert_eq!(embedded[1], vec![2.0, 3.0, 4.0]);
assert_eq!(embedded[2], vec![3.0, 4.0, 5.0]);
assert_eq!(embedded[3], vec![4.0, 5.0, 6.0]);
}
#[test]
fn test_mean() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(mean(&data), 3.0);
}
#[test]
fn test_dist() {
let a = vec![1.0, 2.0, 3.0];
let b = vec![4.0, 5.0, 6.0];
let expected = ((3.0_f64).powi(2) + (3.0_f64).powi(2) + (3.0_f64).powi(2)).sqrt();
assert!((dist(&a, &b) - expected).abs() < 1e-10);
}
#[test]
fn test_theiler_exclude() {
assert!(theiler_exclude(10, 12, 5));
assert!(theiler_exclude(12, 10, 5));
assert!(!theiler_exclude(10, 20, 5));
}
#[test]
fn test_ftle_params_default() {
let params = FtleParams::default();
assert_eq!(params.dt, 0.01);
assert_eq!(params.k_fit, 12);
assert_eq!(params.theiler, 20);
assert_eq!(params.max_pairs, 4000);
assert_eq!(params.min_init_sep, 1e-12);
}
}