Skip to main content

scirs2_cluster/
time_series.rs

1//! Time series clustering algorithms with specialized distance metrics
2//!
3//! This module provides clustering algorithms specifically designed for time series data,
4//! including dynamic time warping (DTW) distance and other temporal similarity measures.
5//! These algorithms can handle time series of different lengths and temporal alignments.
6
7use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
8use scirs2_core::numeric::{Float, FromPrimitive};
9use std::fmt::Debug;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{ClusteringError, Result};
14use crate::hierarchy::{fcluster, ClusterCriterion};
15
16/// Dynamic Time Warping (DTW) distance between two time series
17///
18/// DTW finds the optimal alignment between two time series by minimizing
19/// the cumulative distance between aligned points. It can handle series
20/// of different lengths and temporal distortions.
21///
22/// # Arguments
23///
24/// * `series1` - First time series
25/// * `series2` - Second time series
26/// * `window` - Sakoe-Chiba band constraint (None for no constraint)
27///
28/// # Returns
29///
30/// DTW distance between the two series
31///
32/// # Example
33///
34/// ```
35/// use scirs2_core::ndarray::Array1;
36/// use scirs2_cluster::time_series::dtw_distance;
37///
38/// let series1 = Array1::from_vec(vec![1.0, 2.0, 3.0, 2.0, 1.0]);
39/// let series2 = Array1::from_vec(vec![1.0, 2.0, 2.0, 3.0, 2.0, 1.0]);
40///
41/// let distance = dtw_distance(series1.view(), series2.view(), None).expect("Operation failed");
42/// ```
43#[allow(dead_code)]
44pub fn dtw_distance<F>(
45    series1: ArrayView1<F>,
46    series2: ArrayView1<F>,
47    window: Option<usize>,
48) -> Result<F>
49where
50    F: Float + FromPrimitive + Debug + 'static,
51{
52    let n = series1.len();
53    let m = series2.len();
54
55    if n == 0 || m == 0 {
56        return Err(ClusteringError::InvalidInput(
57            "Time series cannot be empty".to_string(),
58        ));
59    }
60
61    // Initialize DTW matrix with infinity
62    let mut dtw = Array2::from_elem((n + 1, m + 1), F::infinity());
63    dtw[[0, 0]] = F::zero();
64
65    // Apply Sakoe-Chiba band constraint if specified
66    let effective_window = window.unwrap_or(m.max(n));
67
68    for i in 1..=n {
69        let start_j = if effective_window < i {
70            i - effective_window
71        } else {
72            1
73        };
74        let end_j = (i + effective_window).min(m + 1);
75
76        for j in start_j..end_j {
77            if j <= m {
78                let cost = (series1[i - 1] - series2[j - 1]).abs();
79
80                let candidates = [
81                    dtw[[i - 1, j]],     // Insertion
82                    dtw[[i, j - 1]],     // Deletion
83                    dtw[[i - 1, j - 1]], // Match
84                ];
85
86                let min_prev = candidates.iter().fold(F::infinity(), |acc, &x| acc.min(x));
87                dtw[[i, j]] = cost + min_prev;
88            }
89        }
90    }
91
92    Ok(dtw[[n, m]])
93}
94
95/// DTW distance with custom local distance function
96///
97/// Allows using custom distance functions for comparing individual time points.
98///
99/// # Arguments
100///
101/// * `series1` - First time series
102/// * `series2` - Second time series
103/// * `local_distance` - Function to compute distance between individual points
104/// * `window` - Sakoe-Chiba band constraint
105///
106/// # Returns
107///
108/// DTW distance using the custom local distance function
109#[allow(dead_code)]
110pub fn dtw_distance_custom<F, D>(
111    series1: ArrayView1<F>,
112    series2: ArrayView1<F>,
113    local_distance: D,
114    window: Option<usize>,
115) -> Result<F>
116where
117    F: Float + FromPrimitive + Debug + 'static,
118    D: Fn(F, F) -> F,
119{
120    let n = series1.len();
121    let m = series2.len();
122
123    if n == 0 || m == 0 {
124        return Err(ClusteringError::InvalidInput(
125            "Time series cannot be empty".to_string(),
126        ));
127    }
128
129    let mut dtw = Array2::from_elem((n + 1, m + 1), F::infinity());
130    dtw[[0, 0]] = F::zero();
131
132    let effective_window = window.unwrap_or(m.max(n));
133
134    for i in 1..=n {
135        let start_j = if effective_window < i {
136            i - effective_window
137        } else {
138            1
139        };
140        let end_j = (i + effective_window).min(m + 1);
141
142        for j in start_j..end_j {
143            if j <= m {
144                let cost = local_distance(series1[i - 1], series2[j - 1]);
145
146                let candidates = [dtw[[i - 1, j]], dtw[[i, j - 1]], dtw[[i - 1, j - 1]]];
147
148                let min_prev = candidates.iter().fold(F::infinity(), |acc, &x| acc.min(x));
149                dtw[[i, j]] = cost + min_prev;
150            }
151        }
152    }
153
154    Ok(dtw[[n, m]])
155}
156
157/// Soft DTW distance for differentiable time series clustering
158///
159/// Soft DTW is a differentiable version of DTW that uses a soft minimum
160/// operation instead of hard minimum, making it suitable for gradient-based
161/// optimization.
162///
163/// # Arguments
164///
165/// * `series1` - First time series
166/// * `series2` - Second time series
167/// * `gamma` - Smoothing parameter (smaller values approach standard DTW)
168///
169/// # Returns
170///
171/// Soft DTW distance
172#[allow(dead_code)]
173pub fn soft_dtw_distance<F>(series1: ArrayView1<F>, series2: ArrayView1<F>, gamma: F) -> Result<F>
174where
175    F: Float + FromPrimitive + Debug + 'static,
176{
177    let n = series1.len();
178    let m = series2.len();
179
180    if n == 0 || m == 0 {
181        return Err(ClusteringError::InvalidInput(
182            "Time series cannot be empty".to_string(),
183        ));
184    }
185
186    if gamma <= F::zero() {
187        return Err(ClusteringError::InvalidInput(
188            "Gamma must be positive".to_string(),
189        ));
190    }
191
192    let mut dtw = Array2::from_elem((n + 1, m + 1), F::infinity());
193    dtw[[0, 0]] = F::zero();
194
195    for i in 1..=n {
196        for j in 1..=m {
197            let cost = (series1[i - 1] - series2[j - 1]).powi(2);
198
199            let candidates = [dtw[[i - 1, j]], dtw[[i, j - 1]], dtw[[i - 1, j - 1]]];
200
201            // Soft minimum: -gamma * log(sum(exp(-x/gamma)))
202            // For numerical stability, use the log-sum-exp trick
203            let min_val = candidates.iter().fold(F::infinity(), |acc, &x| acc.min(x));
204            let sum_exp = candidates
205                .iter()
206                .map(|&x| (-(x - min_val) / gamma).exp())
207                .fold(F::zero(), |acc, x| acc + x);
208
209            let soft_min = min_val - gamma * sum_exp.ln();
210            dtw[[i, j]] = cost + soft_min;
211        }
212    }
213
214    Ok(dtw[[n, m]])
215}
216
217/// Time series clustering using k-medoids with DTW distance
218///
219/// Performs k-medoids clustering on time series data using DTW as the
220/// distance metric. This is more robust than k-means for time series
221/// as it uses actual time series as cluster centers.
222///
223/// # Arguments
224///
225/// * `time_series` - Matrix where each row is a time series
226/// * `k` - Number of clusters
227/// * `max_iterations` - Maximum number of iterations
228/// * `window` - DTW constraint window
229///
230/// # Returns
231///
232/// Tuple of (medoid_indices, cluster_assignments)
233#[allow(dead_code)]
234pub fn dtw_k_medoids<F>(
235    time_series: ArrayView2<F>,
236    k: usize,
237    max_iterations: usize,
238    window: Option<usize>,
239) -> Result<(Array1<usize>, Array1<usize>)>
240where
241    F: Float + FromPrimitive + Debug + 'static,
242{
243    let n_series = time_series.nrows();
244
245    if k > n_series {
246        return Err(ClusteringError::InvalidInput(
247            "Number of clusters cannot exceed number of time _series".to_string(),
248        ));
249    }
250
251    if n_series == 0 {
252        return Err(ClusteringError::InvalidInput(
253            "No time _series provided".to_string(),
254        ));
255    }
256
257    // Initialize medoids randomly (for deterministic results, use first k series)
258    let mut medoids: Array1<usize> = Array1::from_iter(0..k);
259    let mut assignments = Array1::zeros(n_series);
260
261    for _iteration in 0..max_iterations {
262        let mut changed = false;
263
264        // Assign each time _series to nearest medoid
265        for i in 0..n_series {
266            let mut min_distance = F::infinity();
267            let mut best_cluster = 0;
268
269            for (cluster_id, &medoid_idx) in medoids.iter().enumerate() {
270                let distance =
271                    dtw_distance(time_series.row(i), time_series.row(medoid_idx), window)?;
272
273                if distance < min_distance {
274                    min_distance = distance;
275                    best_cluster = cluster_id;
276                }
277            }
278
279            if assignments[i] != best_cluster {
280                assignments[i] = best_cluster;
281                changed = true;
282            }
283        }
284
285        // Update medoids
286        for cluster_id in 0..k {
287            let cluster_members: Vec<usize> = assignments
288                .iter()
289                .enumerate()
290                .filter(|(_, &assignment)| assignment == cluster_id)
291                .map(|(idx, _)| idx)
292                .collect();
293
294            if !cluster_members.is_empty() {
295                let mut best_medoid = medoids[cluster_id];
296                let mut min_total_distance = F::infinity();
297
298                // Try each member as potential medoid
299                for &candidate in &cluster_members {
300                    let mut total_distance = F::zero();
301
302                    for &member in &cluster_members {
303                        if candidate != member {
304                            let distance = dtw_distance(
305                                time_series.row(candidate),
306                                time_series.row(member),
307                                window,
308                            )?;
309                            total_distance = total_distance + distance;
310                        }
311                    }
312
313                    if total_distance < min_total_distance {
314                        min_total_distance = total_distance;
315                        best_medoid = candidate;
316                    }
317                }
318
319                if medoids[cluster_id] != best_medoid {
320                    medoids[cluster_id] = best_medoid;
321                    changed = true;
322                }
323            }
324        }
325
326        if !changed {
327            break;
328        }
329    }
330
331    Ok((medoids, assignments))
332}
333
334/// Hierarchical clustering for time series using DTW distance
335///
336/// Performs agglomerative hierarchical clustering using DTW distance
337/// with complete linkage.
338///
339/// # Arguments
340///
341/// * `time_series` - Matrix where each row is a time series
342/// * `window` - DTW constraint window
343///
344/// # Returns
345///
346/// Linkage matrix in the format compatible with scipy.cluster.hierarchy
347#[allow(dead_code)]
348pub fn dtw_hierarchical_clustering<F>(
349    time_series: ArrayView2<F>,
350    window: Option<usize>,
351) -> Result<Array2<F>>
352where
353    F: Float + FromPrimitive + Debug + 'static,
354{
355    let n_series = time_series.nrows();
356
357    if n_series < 2 {
358        return Err(ClusteringError::InvalidInput(
359            "Need at least 2 time _series for clustering".to_string(),
360        ));
361    }
362
363    // Compute distance matrix
364    let mut distances = Array2::zeros((n_series, n_series));
365    for i in 0..n_series {
366        for j in (i + 1)..n_series {
367            let distance = dtw_distance(time_series.row(i), time_series.row(j), window)?;
368            distances[[i, j]] = distance;
369            distances[[j, i]] = distance;
370        }
371    }
372
373    // Initialize clusters (each point is its own cluster initially). `active_ids`
374    // tracks the SciPy-convention cluster ID for each active cluster: original
375    // observations are 0..n_series and each merge mints a new ID n_series + step.
376    let mut clusters: Vec<Vec<usize>> = (0..n_series).map(|i| vec![i]).collect();
377    let mut active_ids: Vec<usize> = (0..n_series).collect();
378    let mut linkage = Vec::new();
379    let mut next_cluster_id = n_series;
380
381    while clusters.len() > 1 {
382        // Find closest pair of clusters
383        let mut min_distance = F::infinity();
384        let mut merge_i = 0;
385        let mut merge_j = 1;
386
387        for i in 0..clusters.len() {
388            for j in (i + 1)..clusters.len() {
389                // Calculate complete linkage distance
390                let mut max_dist = F::zero();
391                for &point_i in &clusters[i] {
392                    for &point_j in &clusters[j] {
393                        max_dist = max_dist.max(distances[[point_i, point_j]]);
394                    }
395                }
396
397                if max_dist < min_distance {
398                    min_distance = max_dist;
399                    merge_i = i;
400                    merge_j = j;
401                }
402            }
403        }
404
405        // Record the merge using the real SciPy cluster IDs of the two clusters.
406        let cluster_i_size = clusters[merge_i].len();
407        let cluster_j_size = clusters[merge_j].len();
408        let id_i = active_ids[merge_i];
409        let id_j = active_ids[merge_j];
410        // SciPy emits the lower ID first; this keeps the matrix canonical.
411        let (lo, hi) = if id_i <= id_j {
412            (id_i, id_j)
413        } else {
414            (id_j, id_i)
415        };
416
417        linkage.push([
418            F::from(lo).expect("Failed to convert cluster id to float"),
419            F::from(hi).expect("Failed to convert cluster id to float"),
420            min_distance,
421            F::from(cluster_i_size + cluster_j_size).expect("Failed to convert size to float"),
422        ]);
423
424        // Merge clusters
425        let mut new_cluster = clusters[merge_i].clone();
426        new_cluster.extend(&clusters[merge_j]);
427
428        // Remove old clusters (remove higher index first) and their IDs.
429        let (first, second) = if merge_i > merge_j {
430            (merge_i, merge_j)
431        } else {
432            (merge_j, merge_i)
433        };
434
435        clusters.remove(first);
436        clusters.remove(second);
437        active_ids.remove(first);
438        active_ids.remove(second);
439
440        clusters.push(new_cluster);
441        active_ids.push(next_cluster_id);
442        next_cluster_id += 1;
443    }
444
445    // Convert to ndarray
446    let linkage_array =
447        Array2::from_shape_vec((linkage.len(), 4), linkage.into_iter().flatten().collect())
448            .map_err(|_| {
449                ClusteringError::ComputationError("Failed to create linkage matrix".to_string())
450            })?;
451
452    Ok(linkage_array)
453}
454
455/// Time series k-means clustering with DTW barycenter averaging
456///
457/// Performs k-means clustering where cluster centers are computed as
458/// DTW barycenters (average time series under DTW alignment).
459///
460/// # Arguments
461///
462/// * `time_series` - Matrix where each row is a time series
463/// * `k` - Number of clusters
464/// * `max_iterations` - Maximum number of iterations
465/// * `tolerance` - Convergence tolerance
466///
467/// # Returns
468///
469/// Tuple of (cluster_centers, cluster_assignments)
470#[allow(dead_code)]
471pub fn dtw_k_means<F>(
472    time_series: ArrayView2<F>,
473    k: usize,
474    max_iterations: usize,
475    tolerance: F,
476) -> Result<(Array2<F>, Array1<usize>)>
477where
478    F: Float + FromPrimitive + Debug + 'static,
479{
480    let n_series = time_series.nrows();
481    let series_length = time_series.ncols();
482
483    if k > n_series {
484        return Err(ClusteringError::InvalidInput(
485            "Number of clusters cannot exceed number of time _series".to_string(),
486        ));
487    }
488
489    // Initialize centers with first k time _series
490    let mut centers = Array2::zeros((k, series_length));
491    for i in 0..k {
492        centers.row_mut(i).assign(&time_series.row(i));
493    }
494
495    let mut assignments = Array1::zeros(n_series);
496
497    for _iteration in 0..max_iterations {
498        let mut changed = false;
499
500        // Assign each time _series to nearest center
501        for i in 0..n_series {
502            let mut min_distance = F::infinity();
503            let mut best_cluster = 0;
504
505            for j in 0..k {
506                let distance = dtw_distance(time_series.row(i), centers.row(j), None)?;
507
508                if distance < min_distance {
509                    min_distance = distance;
510                    best_cluster = j;
511                }
512            }
513
514            if assignments[i] != best_cluster {
515                assignments[i] = best_cluster;
516                changed = true;
517            }
518        }
519
520        if !changed {
521            break;
522        }
523
524        // Update centers using DTW barycenter averaging
525        let mut center_changed = false;
526        for cluster_id in 0..k {
527            let cluster_members: Vec<usize> = assignments
528                .iter()
529                .enumerate()
530                .filter(|(_, &assignment)| assignment == cluster_id)
531                .map(|(idx, _)| idx)
532                .collect();
533
534            if !cluster_members.is_empty() {
535                let new_center = dtw_barycenter_averaging(
536                    &time_series.select(Axis(0), &cluster_members),
537                    10,
538                    tolerance,
539                )?;
540
541                let center_distance =
542                    dtw_distance(centers.row(cluster_id), new_center.view(), None)?;
543
544                if center_distance > tolerance {
545                    center_changed = true;
546                }
547
548                centers.row_mut(cluster_id).assign(&new_center);
549            }
550        }
551
552        if !center_changed {
553            break;
554        }
555    }
556
557    Ok((centers, assignments))
558}
559
560/// Compute DTW barycenter (average time series) using iterative refinement
561///
562/// The DTW barycenter is the time series that minimizes the sum of squared
563/// DTW distances to all input time series.
564///
565/// # Arguments
566///
567/// * `time_series` - Collection of time series to average
568/// * `max_iterations` - Maximum number of refinement iterations
569/// * `tolerance` - Convergence tolerance
570///
571/// # Returns
572///
573/// Barycenter time series
574#[allow(dead_code)]
575pub fn dtw_barycenter_averaging<F>(
576    time_series: &Array2<F>,
577    max_iterations: usize,
578    tolerance: F,
579) -> Result<Array1<F>>
580where
581    F: Float + FromPrimitive + Debug + 'static,
582{
583    let n_series = time_series.nrows();
584    let series_length = time_series.ncols();
585
586    if n_series == 0 {
587        return Err(ClusteringError::InvalidInput(
588            "No time _series provided".to_string(),
589        ));
590    }
591
592    if n_series == 1 {
593        return Ok(time_series.row(0).to_owned());
594    }
595
596    // Initialize barycenter as the mean of all _series
597    let mut barycenter = time_series.mean_axis(Axis(0)).expect("Operation failed");
598
599    for _iteration in 0..max_iterations {
600        let mut new_barycenter = Array1::zeros(series_length);
601        let mut weights = Array1::zeros(series_length);
602
603        // For each time series, find optimal alignment with current barycenter
604        for i in 0..n_series {
605            let (aligned_series, alignment_weights) =
606                dtw_align_series(time_series.row(i), barycenter.view())?;
607
608            new_barycenter = new_barycenter + aligned_series;
609            weights = weights + alignment_weights;
610        }
611
612        // Normalize by weights
613        for i in 0..series_length {
614            if weights[i] > F::zero() {
615                new_barycenter[i] = new_barycenter[i] / weights[i];
616            }
617        }
618
619        // Check convergence
620        let change = dtw_distance(barycenter.view(), new_barycenter.view(), None)?;
621        if change < tolerance {
622            break;
623        }
624
625        barycenter = new_barycenter;
626    }
627
628    Ok(barycenter)
629}
630
631/// Align a time series with a reference using DTW and return weighted average
632#[allow(dead_code)]
633fn dtw_align_series<F>(
634    series: ArrayView1<F>,
635    reference: ArrayView1<F>,
636) -> Result<(Array1<F>, Array1<F>)>
637where
638    F: Float + FromPrimitive + Debug + 'static,
639{
640    let n = series.len();
641    let m = reference.len();
642
643    // Compute DTW matrix
644    let mut dtw = Array2::from_elem((n + 1, m + 1), F::infinity());
645    dtw[[0, 0]] = F::zero();
646
647    for i in 1..=n {
648        for j in 1..=m {
649            let cost = (series[i - 1] - reference[j - 1]).abs();
650            let min_prev = [dtw[[i - 1, j]], dtw[[i, j - 1]], dtw[[i - 1, j - 1]]]
651                .iter()
652                .fold(F::infinity(), |acc, &x| acc.min(x));
653
654            dtw[[i, j]] = cost + min_prev;
655        }
656    }
657
658    // Backtrack to find optimal path
659    let mut i = n;
660    let mut j = m;
661    let mut aligned_series = Array1::zeros(m);
662    let mut weights = Array1::zeros(m);
663
664    while i > 0 && j > 0 {
665        // Add current series value to aligned position
666        aligned_series[j - 1] = aligned_series[j - 1] + series[i - 1];
667        weights[j - 1] = weights[j - 1] + F::one();
668
669        // Find which direction we came from
670        let candidates = [
671            (dtw[[i - 1, j - 1]], (i - 1, j - 1)), // diagonal
672            (dtw[[i - 1, j]], (i - 1, j)),         // up
673            (dtw[[i, j - 1]], (i, j - 1)),         // left
674        ];
675
676        let (_, (next_i, next_j)) = candidates
677            .iter()
678            .min_by(|a, b| a.0.partial_cmp(&b.0).expect("Operation failed"))
679            .expect("Operation failed");
680
681        i = *next_i;
682        j = *next_j;
683    }
684
685    Ok((aligned_series, weights))
686}
687
688/// Configuration for time series clustering algorithms
689#[derive(Debug, Clone, Serialize, Deserialize)]
690pub struct TimeSeriesClusteringConfig {
691    /// Algorithm to use for clustering
692    pub algorithm: TimeSeriesAlgorithm,
693    /// Number of clusters
694    pub n_clusters: usize,
695    /// Maximum number of iterations
696    pub max_iterations: usize,
697    /// Convergence tolerance
698    pub tolerance: f64,
699    /// DTW constraint window size
700    pub dtw_window: Option<usize>,
701    /// Soft DTW gamma parameter
702    pub soft_dtw_gamma: Option<f64>,
703}
704
705/// Available time series clustering algorithms
706#[derive(Debug, Clone, Serialize, Deserialize)]
707pub enum TimeSeriesAlgorithm {
708    /// K-medoids with DTW distance
709    DTWKMedoids,
710    /// K-means with DTW barycenter averaging
711    DTWKMeans,
712    /// Hierarchical clustering with DTW distance
713    DTWHierarchical,
714}
715
716impl Default for TimeSeriesClusteringConfig {
717    fn default() -> Self {
718        Self {
719            algorithm: TimeSeriesAlgorithm::DTWKMedoids,
720            n_clusters: 3,
721            max_iterations: 100,
722            tolerance: 1e-4,
723            dtw_window: None,
724            soft_dtw_gamma: None,
725        }
726    }
727}
728
729/// Perform time series clustering using the specified configuration
730///
731/// # Arguments
732///
733/// * `time_series` - Matrix where each row is a time series
734/// * `config` - Clustering configuration
735///
736/// # Returns
737///
738/// Cluster assignments for each time series
739#[allow(dead_code)]
740pub fn time_series_clustering<F>(
741    time_series: ArrayView2<F>,
742    config: &TimeSeriesClusteringConfig,
743) -> Result<Array1<usize>>
744where
745    F: Float + FromPrimitive + Debug + 'static,
746{
747    match config.algorithm {
748        TimeSeriesAlgorithm::DTWKMedoids => {
749            let (_, assignments) = dtw_k_medoids(
750                time_series,
751                config.n_clusters,
752                config.max_iterations,
753                config.dtw_window,
754            )?;
755            Ok(assignments)
756        }
757        TimeSeriesAlgorithm::DTWKMeans => {
758            let tolerance = F::from(config.tolerance).expect("Failed to convert to float");
759            let (_, assignments) = dtw_k_means(
760                time_series,
761                config.n_clusters,
762                config.max_iterations,
763                tolerance,
764            )?;
765            Ok(assignments)
766        }
767        TimeSeriesAlgorithm::DTWHierarchical => {
768            // Build the DTW complete-linkage dendrogram, then cut it into exactly
769            // `n_clusters` flat clusters via the real `fcluster`/`cut_tree` routine
770            // (MaxClust criterion). This replaces the previous `i % n_clusters`
771            // placeholder with a genuine dendrogram cut driven by the merge tree.
772            let linkage = dtw_hierarchical_clustering(time_series, config.dtw_window)?;
773
774            let n_series = time_series.nrows();
775            let n_clusters = config.n_clusters.clamp(1, n_series.max(1));
776
777            // A single series (no merges => empty linkage) is trivially one cluster.
778            if linkage.nrows() == 0 {
779                return Ok(Array1::zeros(n_series));
780            }
781
782            fcluster(&linkage, n_clusters, Some(ClusterCriterion::MaxClust))
783        }
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use scirs2_core::ndarray::Array2;
791
792    #[test]
793    fn test_dtw_distance() {
794        let series1 = Array1::from_vec(vec![1.0, 2.0, 3.0, 2.0, 1.0]);
795        let series2 = Array1::from_vec(vec![1.0, 2.0, 2.0, 3.0, 2.0, 1.0]);
796
797        let distance =
798            dtw_distance(series1.view(), series2.view(), None).expect("Operation failed");
799        assert!(distance >= 0.0);
800    }
801
802    #[test]
803    fn test_dtw_identical_series() {
804        let series = Array1::from_vec(vec![1.0, 2.0, 3.0, 2.0, 1.0]);
805        let distance = dtw_distance(series.view(), series.view(), None).expect("Operation failed");
806        assert_eq!(distance, 0.0);
807    }
808
809    #[test]
810    fn test_dtw_hierarchical_real_dendrogram_cut() {
811        // Two clearly separated groups of two near-identical series each. A real
812        // dendrogram cut at k=2 must recover exactly that grouping; the old
813        // `i % n_clusters` placeholder could not.
814        let time_series = Array2::from_shape_vec(
815            (4, 5),
816            vec![
817                1.0, 2.0, 3.0, 2.0, 1.0, // group A
818                1.1, 2.1, 3.1, 2.1, 1.1, // group A
819                8.0, 9.0, 10.0, 9.0, 8.0, // group B
820                8.1, 9.1, 10.1, 9.1, 8.1, // group B
821            ],
822        )
823        .expect("Operation failed");
824
825        let config = TimeSeriesClusteringConfig {
826            algorithm: TimeSeriesAlgorithm::DTWHierarchical,
827            n_clusters: 2,
828            ..TimeSeriesClusteringConfig::default()
829        };
830
831        let assignments =
832            time_series_clustering(time_series.view(), &config).expect("Operation failed");
833
834        assert_eq!(assignments.len(), 4);
835        // The two members of each group share a label; groups differ.
836        assert_eq!(assignments[0], assignments[1]);
837        assert_eq!(assignments[2], assignments[3]);
838        assert_ne!(assignments[0], assignments[2]);
839        // Exactly two distinct clusters.
840        let mut distinct: Vec<usize> = assignments.to_vec();
841        distinct.sort_unstable();
842        distinct.dedup();
843        assert_eq!(distinct.len(), 2);
844    }
845
846    #[test]
847    fn test_dtw_k_medoids() {
848        let time_series = Array2::from_shape_vec(
849            (4, 5),
850            vec![
851                1.0, 2.0, 3.0, 2.0, 1.0, 1.1, 2.1, 3.1, 2.1, 1.1, 5.0, 6.0, 7.0, 6.0, 5.0, 5.1,
852                6.1, 7.1, 6.1, 5.1,
853            ],
854        )
855        .expect("Operation failed");
856
857        let (medoids, assignments) =
858            dtw_k_medoids(time_series.view(), 2, 10, None).expect("Operation failed");
859
860        assert_eq!(medoids.len(), 2);
861        assert_eq!(assignments.len(), 4);
862
863        // First two series should be in one cluster, last two in another
864        assert_eq!(assignments[0], assignments[1]);
865        assert_eq!(assignments[2], assignments[3]);
866        assert_ne!(assignments[0], assignments[2]);
867    }
868
869    #[test]
870    fn test_soft_dtw_distance() {
871        let series1 = Array1::from_vec(vec![1.0, 2.0, 3.0]);
872        let series2 = Array1::from_vec(vec![1.0, 2.5, 3.0]);
873
874        let distance =
875            soft_dtw_distance(series1.view(), series2.view(), 0.1).expect("Operation failed");
876        assert!(distance >= 0.0);
877    }
878
879    #[test]
880    fn test_dtw_barycenter_averaging() {
881        let time_series = Array2::from_shape_vec(
882            (3, 4),
883            vec![1.0, 2.0, 3.0, 2.0, 1.1, 2.1, 3.1, 2.1, 0.9, 1.9, 2.9, 1.9],
884        )
885        .expect("Operation failed");
886
887        let barycenter =
888            dtw_barycenter_averaging(&time_series, 10, 1e-3).expect("Operation failed");
889        assert_eq!(barycenter.len(), 4);
890
891        // Barycenter should be close to the mean
892        let mean_series = time_series.mean_axis(Axis(0)).expect("Operation failed");
893        for i in 0..4 {
894            assert!((barycenter[i] - mean_series[i]).abs() < 0.5);
895        }
896    }
897
898    #[test]
899    fn test_time_series_clustering_config() {
900        let config = TimeSeriesClusteringConfig::default();
901        assert_eq!(config.n_clusters, 3);
902        assert_eq!(config.max_iterations, 100);
903
904        let time_series = Array2::from_shape_vec(
905            (4, 5),
906            vec![
907                1.0, 2.0, 3.0, 2.0, 1.0, 1.1, 2.1, 3.1, 2.1, 1.1, 5.0, 6.0, 7.0, 6.0, 5.0, 5.1,
908                6.1, 7.1, 6.1, 5.1,
909            ],
910        )
911        .expect("Operation failed");
912
913        let assignments =
914            time_series_clustering(time_series.view(), &config).expect("Operation failed");
915        assert_eq!(assignments.len(), 4);
916    }
917}