oxits 0.1.0

Time series classification and transformation library for Rust
Documentation
use crate::core::config::DistanceKind;
use crate::image::recurrence_plot::{RecurrencePlot, RecurrencePlotConfig};

/// Joint Recurrence Plot for multivariate time series.
///
/// Computes a recurrence plot for each feature independently,
/// then takes the element-wise product (Hadamard product) of all
/// per-feature recurrence plots.

#[derive(Debug, Clone)]
pub struct JointRecurrencePlotConfig {
    pub dimension: usize,
    pub time_delay: usize,
    pub threshold: Option<f64>,
    pub percentage: Option<f64>,
    pub distance: DistanceKind,
}

impl JointRecurrencePlotConfig {
    pub fn new() -> Self {
        Self {
            dimension: 1,
            time_delay: 1,
            threshold: None,
            percentage: None,
            distance: DistanceKind::Squared,
        }
    }
}

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

pub struct JointRecurrencePlot;

impl JointRecurrencePlot {
    /// Transform multivariate time series into joint recurrence plot images.
    ///
    /// Input: (n_samples, n_features, n_timestamps)
    /// Output: (n_samples, n_points, n_points)
    pub fn transform(
        config: &JointRecurrencePlotConfig,
        x: &[Vec<Vec<f64>>],
    ) -> Vec<Vec<Vec<f64>>> {
        assert!(!x.is_empty(), "Input must have at least one sample");
        let n_features = x[0].len();
        assert!(n_features >= 1, "Must have at least one feature");

        let rp_config = RecurrencePlotConfig {
            dimension: config.dimension,
            time_delay: config.time_delay,
            threshold: config.threshold,
            percentage: config.percentage,
            distance: config.distance,
        };

        x.iter()
            .map(|sample| {
                // Compute RP for each feature
                let mut joint_rp: Option<Vec<Vec<f64>>> = None;

                for feat in sample {
                    let rps = RecurrencePlot::transform(&rp_config, std::slice::from_ref(feat));
                    let rp = &rps[0];

                    joint_rp = Some(match joint_rp {
                        None => rp.clone(),
                        Some(existing) => {
                            // Element-wise product
                            existing
                                .iter()
                                .zip(rp.iter())
                                .map(|(row_a, row_b)| {
                                    row_a
                                        .iter()
                                        .zip(row_b.iter())
                                        .map(|(&a, &b)| a * b)
                                        .collect()
                                })
                                .collect()
                        }
                    });
                }

                joint_rp.unwrap()
            })
            .collect()
    }
}

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

    #[test]
    fn test_jrp_basic() {
        let config = JointRecurrencePlotConfig::new();
        // 1 sample, 2 features, 5 timestamps
        let x = vec![vec![
            vec![0.0, 1.0, 0.0, -1.0, 0.0],
            vec![1.0, 0.0, -1.0, 0.0, 1.0],
        ]];
        let result = JointRecurrencePlot::transform(&config, &x);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].len(), 5);
        assert_eq!(result[0][0].len(), 5);
    }

    #[test]
    fn test_jrp_symmetric() {
        let config = JointRecurrencePlotConfig::new();
        let x = vec![vec![vec![1.0, 2.0, 3.0, 4.0], vec![4.0, 3.0, 2.0, 1.0]]];
        let result = JointRecurrencePlot::transform(&config, &x);
        for i in 0..4 {
            for j in 0..4 {
                assert!(
                    (result[0][i][j] - result[0][j][i]).abs() < 1e-10,
                    "JRP should be symmetric"
                );
            }
        }
    }

    #[test]
    fn test_jrp_with_threshold() {
        let config = JointRecurrencePlotConfig {
            threshold: Some(1.0),
            ..JointRecurrencePlotConfig::new()
        };
        let x = vec![vec![vec![0.0, 0.5, 0.0, 2.0], vec![0.0, 0.5, 0.0, 2.0]]];
        let result = JointRecurrencePlot::transform(&config, &x);
        // Since both features use same data and threshold, product of binary RPs
        for row in &result[0] {
            for &v in row {
                assert!(v == 0.0 || v == 1.0, "Binary JRP should be 0 or 1");
            }
        }
    }
}