oxits 0.1.0

Time series classification and transformation library for Rust
Documentation
use crate::approximation::paa::{Paa, PaaConfig};
use crate::core::config::GafMethod;
use crate::core::traits::Transformer;

#[derive(Debug, Clone)]
pub struct GafConfig {
    pub image_size: Option<usize>,
    pub method: GafMethod,
    pub sample_range: (f64, f64),
}

impl GafConfig {
    pub fn new() -> Self {
        Self {
            image_size: None,
            method: GafMethod::Summation,
            sample_range: (-1.0, 1.0),
        }
    }
}

impl Default for GafConfig {
    fn default() -> Self {
        Self::new()
    }
}

pub struct Gaf;

impl Gaf {
    /// Transform time series into Gramian Angular Field images.
    /// Output shape: (n_samples, image_size, image_size)
    pub fn transform(config: &GafConfig, x: &[Vec<f64>]) -> Vec<Vec<Vec<f64>>> {
        assert!(!x.is_empty(), "Input must have at least one sample");

        let n_timestamps = x[0].len();
        let image_size = config.image_size.unwrap_or(n_timestamps);
        let (range_min, range_max) = config.sample_range;
        let method = config.method;

        if image_size < n_timestamps {
            // Need PAA: scale first, then reduce, then build images
            let scaled = scale_to_range(x, config.sample_range);
            let paa_config = PaaConfig::new(image_size);
            let reduced = Paa::transform(&paa_config, &scaled);

            let build = |sample: &Vec<f64>| {
                let sin_phi: Vec<f64> = sample
                    .iter()
                    .map(|&c| (1.0 - c * c).max(0.0).sqrt())
                    .collect();
                gaf_image(sample, &sin_phi, method)
            };

            #[cfg(feature = "parallel")]
            {
                use rayon::prelude::*;
                return reduced.par_iter().map(build).collect();
            }
            #[cfg(not(feature = "parallel"))]
            reduced.iter().map(build).collect()
        } else {
            // No PAA: fuse scale + phi computation, skip intermediate allocation
            let build = |raw_sample: &Vec<f64>| {
                let x_min = raw_sample.iter().copied().fold(f64::INFINITY, f64::min);
                let x_max = raw_sample.iter().copied().fold(f64::NEG_INFINITY, f64::max);
                let data_range = x_max - x_min;

                let (cos_phi, sin_phi): (Vec<f64>, Vec<f64>) = if data_range == 0.0 {
                    let c = range_min.clamp(-1.0, 1.0);
                    let s = (1.0 - c * c).max(0.0).sqrt();
                    (vec![c; raw_sample.len()], vec![s; raw_sample.len()])
                } else {
                    let scale = (range_max - range_min) / data_range;
                    raw_sample
                        .iter()
                        .map(|&v| {
                            let c = ((v - x_min) * scale + range_min).clamp(-1.0, 1.0);
                            let s = (1.0 - c * c).max(0.0).sqrt();
                            (c, s)
                        })
                        .unzip()
                };
                gaf_image(&cos_phi, &sin_phi, method)
            };

            #[cfg(feature = "parallel")]
            {
                use rayon::prelude::*;
                return x.par_iter().map(build).collect();
            }
            #[cfg(not(feature = "parallel"))]
            x.iter().map(build).collect()
        }
    }
}

/// Build GAF image. Uses flat n*n buffer for cache-friendly writes, then converts.
#[inline]
fn gaf_image(cos_phi: &[f64], sin_phi: &[f64], method: GafMethod) -> Vec<Vec<f64>> {
    let n = cos_phi.len();
    let mut buf = vec![0.0f64; n * n];

    match method {
        GafMethod::Summation => {
            for i in 0..n {
                let ci = cos_phi[i];
                let si = sin_phi[i];
                let row = i * n;
                for j in 0..n {
                    buf[row + j] = ci * cos_phi[j] - si * sin_phi[j];
                }
            }
        }
        GafMethod::Difference => {
            for i in 0..n {
                let ci = cos_phi[i];
                let si = sin_phi[i];
                let row = i * n;
                for j in 0..n {
                    buf[row + j] = si * cos_phi[j] - ci * sin_phi[j];
                }
            }
        }
    }

    buf.chunks_exact(n).map(|row| row.to_vec()).collect()
}

fn scale_to_range(x: &[Vec<f64>], range: (f64, f64)) -> Vec<Vec<f64>> {
    let (range_min, range_max) = range;
    x.iter()
        .map(|sample| {
            let x_min = sample.iter().copied().fold(f64::INFINITY, f64::min);
            let x_max = sample.iter().copied().fold(f64::NEG_INFINITY, f64::max);
            let data_range = x_max - x_min;
            if data_range == 0.0 {
                vec![range_min; sample.len()]
            } else {
                let scale = (range_max - range_min) / data_range;
                sample
                    .iter()
                    .map(|&v| ((v - x_min) * scale + range_min).clamp(-1.0, 1.0))
                    .collect()
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_gasf_shape() {
        let config = GafConfig::new();
        let x = vec![vec![1.0, 2.0, 3.0, 4.0]];
        let result = Gaf::transform(&config, &x);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].len(), 4);
        assert_eq!(result[0][0].len(), 4);
    }

    #[test]
    fn test_gasf_symmetric() {
        let config = GafConfig::new();
        let x = vec![vec![0.0, 0.5, 1.0]];
        let result = Gaf::transform(&config, &x);
        for i in 0..3 {
            for j in 0..3 {
                assert!(
                    (result[0][i][j] - result[0][j][i]).abs() < 1e-10,
                    "GASF should be symmetric"
                );
            }
        }
    }

    #[test]
    fn test_gadf_antisymmetric() {
        let config = GafConfig {
            method: GafMethod::Difference,
            ..GafConfig::new()
        };
        let x = vec![vec![0.0, 0.5, 1.0]];
        let result = Gaf::transform(&config, &x);
        for i in 0..3 {
            assert!(result[0][i][i].abs() < 1e-10, "GADF diagonal should be 0");
            for j in (i + 1)..3 {
                assert!(
                    (result[0][i][j] + result[0][j][i]).abs() < 1e-10,
                    "GADF should be antisymmetric"
                );
            }
        }
    }

    #[test]
    fn test_gaf_with_paa() {
        let config = GafConfig {
            image_size: Some(3),
            ..GafConfig::new()
        };
        let x = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]];
        let result = Gaf::transform(&config, &x);
        assert_eq!(result[0].len(), 3);
        assert_eq!(result[0][0].len(), 3);
    }

    #[test]
    fn test_gaf_values_in_range() {
        let config = GafConfig::new();
        let x = vec![vec![0.0, 1.0, 2.0, 3.0]];
        let result = Gaf::transform(&config, &x);
        for row in &result[0] {
            for &v in row {
                assert!((-1.0 - 1e-10..=1.0 + 1e-10).contains(&v));
            }
        }
    }
}