Skip to main content

fdars_core/
simulation.rs

1//! Simulation functions for functional data.
2//!
3//! This module provides tools for generating synthetic functional data using
4//! the Karhunen-Loève expansion and various eigenfunction/eigenvalue configurations.
5//!
6//! ## Overview
7//!
8//! Functional data can be simulated using the truncated Karhunen-Loève representation:
9//! ```text
10//! f_i(t) = μ(t) + Σ_{k=1}^{M} ξ_{ik} φ_k(t)
11//! ```
12//! where:
13//! - μ(t) is the mean function
14//! - φ_k(t) are orthonormal eigenfunctions
15//! - ξ_{ik} ~ N(0, λ_k) are random scores with variances given by eigenvalues
16//!
17//! ## Eigenfunction Types
18//!
19//! - **Fourier**: sin/cos basis functions, suitable for periodic data
20//! - **Legendre**: Orthonormal Legendre polynomials on \[0,1\]
21//! - **Wiener**: Eigenfunctions of the Wiener process
22
23use crate::matrix::FdMatrix;
24use crate::maybe_par_chunks_mut_enumerate;
25use rand::prelude::*;
26use rand_distr::Normal;
27use std::f64::consts::PI;
28
29/// Eigenfunction type enum for simulation
30#[derive(Clone, Copy, Debug, PartialEq)]
31#[non_exhaustive]
32pub enum EFunType {
33    /// Fourier basis: 1, sqrt(2)*cos(2πkt), sqrt(2)*sin(2πkt)
34    Fourier = 0,
35    /// Orthonormal Legendre polynomials on \[0,1\]
36    Poly = 1,
37    /// Higher-order Legendre polynomials (starting at degree 2)
38    PolyHigh = 2,
39    /// Wiener process eigenfunctions: sqrt(2)*sin((k-0.5)πt)
40    Wiener = 3,
41}
42
43impl EFunType {
44    /// Create from integer (for FFI)
45    pub fn from_i32(value: i32) -> Result<Self, crate::FdarError> {
46        match value {
47            0 => Ok(EFunType::Fourier),
48            1 => Ok(EFunType::Poly),
49            2 => Ok(EFunType::PolyHigh),
50            3 => Ok(EFunType::Wiener),
51            _ => Err(crate::FdarError::InvalidEnumValue {
52                enum_name: "EFunType",
53                value,
54            }),
55        }
56    }
57}
58
59/// Eigenvalue decay type for simulation
60#[derive(Clone, Copy, Debug, PartialEq)]
61#[non_exhaustive]
62pub enum EValType {
63    /// Linear decay: λ_k = 1/k
64    Linear = 0,
65    /// Exponential decay: λ_k = exp(-k)
66    Exponential = 1,
67    /// Wiener eigenvalues: λ_k = 1/((k-0.5)π)²
68    Wiener = 2,
69}
70
71impl EValType {
72    /// Create from integer (for FFI)
73    pub fn from_i32(value: i32) -> Result<Self, crate::FdarError> {
74        match value {
75            0 => Ok(EValType::Linear),
76            1 => Ok(EValType::Exponential),
77            2 => Ok(EValType::Wiener),
78            _ => Err(crate::FdarError::InvalidEnumValue {
79                enum_name: "EValType",
80                value,
81            }),
82        }
83    }
84}
85
86// =============================================================================
87// Eigenfunction Computation
88// =============================================================================
89
90/// Compute Fourier eigenfunctions on \[0,1\].
91///
92/// The Fourier basis consists of:
93/// - φ_1(t) = 1
94/// - φ_{2k}(t) = √2 cos(2πkt) for k = 1, 2, ...
95/// - φ_{2k+1}(t) = √2 sin(2πkt) for k = 1, 2, ...
96///
97/// # Arguments
98/// * `t` - Evaluation points in \[0,1\]
99/// * `m` - Number of eigenfunctions
100///
101/// # Returns
102/// `FdMatrix` of size `len(t) × m`
103pub fn fourier_eigenfunctions(t: &[f64], m: usize) -> FdMatrix {
104    let n = t.len();
105    let mut phi = FdMatrix::zeros(n, m);
106    let sqrt2 = 2.0_f64.sqrt();
107
108    for (i, &ti) in t.iter().enumerate() {
109        // φ_1(t) = 1
110        phi[(i, 0)] = 1.0;
111
112        let mut k = 1; // current eigenfunction index
113        let mut freq = 1; // frequency index
114
115        while k < m {
116            // sin term: sqrt(2) * sin(2*pi*freq*t)
117            if k < m {
118                phi[(i, k)] = sqrt2 * (2.0 * PI * f64::from(freq) * ti).sin();
119                k += 1;
120            }
121            // cos term: sqrt(2) * cos(2*pi*freq*t)
122            if k < m {
123                phi[(i, k)] = sqrt2 * (2.0 * PI * f64::from(freq) * ti).cos();
124                k += 1;
125            }
126            freq += 1;
127        }
128    }
129    phi
130}
131
132/// Compute Legendre polynomial eigenfunctions on \[0,1\].
133///
134/// Uses orthonormalized Legendre polynomials. The normalization factor is
135/// √(2n+1) where n is the polynomial degree, which ensures unit L² norm on \[0,1\].
136///
137/// # Arguments
138/// * `t` - Evaluation points in \[0,1\]
139/// * `m` - Number of eigenfunctions
140/// * `high` - If true, start at degree 2 (PolyHigh), otherwise start at degree 0
141///
142/// # Returns
143/// `FdMatrix` of size `len(t) × m`
144pub fn legendre_eigenfunctions(t: &[f64], m: usize, high: bool) -> FdMatrix {
145    let n = t.len();
146    let mut phi = FdMatrix::zeros(n, m);
147    let start_deg = if high { 2 } else { 0 };
148
149    for (i, &ti) in t.iter().enumerate() {
150        // Transform from \[0,1\] to \[-1,1\]
151        let x = 2.0 * ti - 1.0;
152
153        for j in 0..m {
154            let deg = start_deg + j;
155            // Compute Legendre polynomial P_deg(x)
156            let p = legendre_p(x, deg);
157            // Normalize: ||P_n||² on \[-1,1\] = 2/(2n+1), on \[0,1\] = 1/(2n+1)
158            let norm = ((2 * deg + 1) as f64).sqrt();
159            phi[(i, j)] = p * norm;
160        }
161    }
162    phi
163}
164
165/// Compute Legendre polynomial P_n(x) using recurrence relation.
166///
167/// The three-term recurrence is:
168/// (n+1)P_{n+1}(x) = (2n+1)xP_n(x) - nP_{n-1}(x)
169fn legendre_p(x: f64, n: usize) -> f64 {
170    if n == 0 {
171        return 1.0;
172    }
173    if n == 1 {
174        return x;
175    }
176
177    let mut p_prev = 1.0;
178    let mut p_curr = x;
179
180    for k in 2..=n {
181        let p_next = ((2 * k - 1) as f64 * x * p_curr - (k - 1) as f64 * p_prev) / k as f64;
182        p_prev = p_curr;
183        p_curr = p_next;
184    }
185    p_curr
186}
187
188/// Compute Wiener process eigenfunctions on \[0,1\].
189///
190/// The Wiener (Brownian motion) eigenfunctions are:
191/// φ_k(t) = √2 sin((k - 0.5)πt)
192///
193/// These are the eigenfunctions of the covariance kernel K(s,t) = min(s,t).
194///
195/// # Arguments
196/// * `t` - Evaluation points in \[0,1\]
197/// * `m` - Number of eigenfunctions
198///
199/// # Returns
200/// `FdMatrix` of size `len(t) × m`
201pub fn wiener_eigenfunctions(t: &[f64], m: usize) -> FdMatrix {
202    let n = t.len();
203    let mut phi = FdMatrix::zeros(n, m);
204    let sqrt2 = 2.0_f64.sqrt();
205
206    for (i, &ti) in t.iter().enumerate() {
207        for j in 0..m {
208            let k = (j + 1) as f64;
209            // φ_k(t) = sqrt(2) * sin((k - 0.5) * pi * t)
210            phi[(i, j)] = sqrt2 * ((k - 0.5) * PI * ti).sin();
211        }
212    }
213    phi
214}
215
216/// Unified eigenfunction computation.
217///
218/// # Arguments
219/// * `t` - Evaluation points
220/// * `m` - Number of eigenfunctions
221/// * `efun_type` - Type of eigenfunction basis
222///
223/// # Returns
224/// `FdMatrix` of size `len(t) × m`
225pub fn eigenfunctions(t: &[f64], m: usize, efun_type: EFunType) -> FdMatrix {
226    match efun_type {
227        EFunType::Fourier => fourier_eigenfunctions(t, m),
228        EFunType::Poly => legendre_eigenfunctions(t, m, false),
229        EFunType::PolyHigh => legendre_eigenfunctions(t, m, true),
230        EFunType::Wiener => wiener_eigenfunctions(t, m),
231    }
232}
233
234// =============================================================================
235// Eigenvalue Computation
236// =============================================================================
237
238/// Generate eigenvalue sequence with linear decay.
239///
240/// λ_k = 1/k for k = 1, ..., m
241pub fn eigenvalues_linear(m: usize) -> Vec<f64> {
242    (1..=m).map(|k| 1.0 / k as f64).collect()
243}
244
245/// Generate eigenvalue sequence with exponential decay.
246///
247/// λ_k = exp(-k) for k = 1, ..., m
248pub fn eigenvalues_exponential(m: usize) -> Vec<f64> {
249    (1..=m).map(|k| (-(k as f64)).exp()).collect()
250}
251
252/// Generate Wiener process eigenvalues.
253///
254/// λ_k = 1/((k - 0.5)π)² for k = 1, ..., m
255///
256/// These are the eigenvalues of the covariance kernel K(s,t) = min(s,t).
257pub fn eigenvalues_wiener(m: usize) -> Vec<f64> {
258    (1..=m)
259        .map(|k| {
260            let denom = (k as f64 - 0.5) * PI;
261            1.0 / (denom * denom)
262        })
263        .collect()
264}
265
266/// Unified eigenvalue computation.
267///
268/// # Arguments
269/// * `m` - Number of eigenvalues
270/// * `eval_type` - Type of eigenvalue decay
271///
272/// # Returns
273/// Vector of m eigenvalues in decreasing order
274pub fn eigenvalues(m: usize, eval_type: EValType) -> Vec<f64> {
275    match eval_type {
276        EValType::Linear => eigenvalues_linear(m),
277        EValType::Exponential => eigenvalues_exponential(m),
278        EValType::Wiener => eigenvalues_wiener(m),
279    }
280}
281
282// =============================================================================
283// Karhunen-Loève Simulation
284// =============================================================================
285
286/// Simulate functional data via Karhunen-Loève expansion.
287///
288/// Generates n curves using the truncated KL representation:
289/// f_i(t) = Σ_{k=1}^{M} ξ_{ik} φ_k(t)
290/// where ξ_{ik} ~ N(0, λ_k)
291///
292/// # Arguments
293/// * `n` - Number of curves to generate
294/// * `phi` - Eigenfunctions matrix (m × big_m) as `FdMatrix`
295/// * `big_m` - Number of eigenfunctions
296/// * `lambda` - Eigenvalues (length big_m)
297/// * `seed` - Optional random seed for reproducibility
298///
299/// # Returns
300/// Data `FdMatrix` of size `n × m`
301pub fn sim_kl(
302    n: usize,
303    phi: &FdMatrix,
304    big_m: usize,
305    lambda: &[f64],
306    seed: Option<u64>,
307) -> FdMatrix {
308    let m = phi.nrows();
309
310    // Create RNG
311    let mut rng = match seed {
312        Some(s) => StdRng::seed_from_u64(s),
313        None => StdRng::from_entropy(),
314    };
315
316    let normal = Normal::new(0.0, 1.0).expect("valid distribution parameters");
317
318    // Generate scores ξ ~ N(0, λ) for all curves
319    // xi is n × big_m in column-major format
320    let mut xi = vec![0.0; n * big_m];
321    for k in 0..big_m {
322        let sd = lambda[k].sqrt();
323        for i in 0..n {
324            xi[i + k * n] = rng.sample::<f64, _>(normal) * sd;
325        }
326    }
327
328    // Compute data = xi * phi^T
329    // xi: n × big_m, phi: m × big_m -> data: n × m
330    let mut data = vec![0.0; n * m];
331
332    // Parallelize over columns (evaluation points)
333    maybe_par_chunks_mut_enumerate!(data, n, |(j, col)| {
334        for i in 0..n {
335            let mut sum = 0.0;
336            for k in 0..big_m {
337                // phi[(j, k)] is φ_k(t_j)
338                // xi[i + k*n] is ξ_{ik}
339                sum += xi[i + k * n] * phi[(j, k)];
340            }
341            col[i] = sum;
342        }
343    });
344
345    FdMatrix::from_column_major(data, n, m).expect("dimension invariant: data.len() == n * m")
346}
347
348/// Simulate functional data with specified eigenfunction and eigenvalue types.
349///
350/// Convenience function that combines eigenfunction and eigenvalue generation
351/// with KL simulation.
352///
353/// # Arguments
354/// * `n` - Number of curves to generate
355/// * `t` - Evaluation points
356/// * `big_m` - Number of eigenfunctions/eigenvalues to use
357/// * `efun_type` - Type of eigenfunction basis
358/// * `eval_type` - Type of eigenvalue decay
359/// * `seed` - Optional random seed
360///
361/// # Returns
362/// Data `FdMatrix` of size `n × len(t)`
363///
364/// # Examples
365///
366/// ```
367/// use fdars_core::simulation::{sim_fundata, EFunType, EValType};
368///
369/// let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
370/// let data = sim_fundata(5, &t, 4, EFunType::Fourier, EValType::Linear, Some(42));
371/// assert_eq!(data.shape(), (5, 20));
372/// assert!(data.as_slice().iter().all(|v| v.is_finite()));
373/// ```
374pub fn sim_fundata(
375    n: usize,
376    t: &[f64],
377    big_m: usize,
378    efun_type: EFunType,
379    eval_type: EValType,
380    seed: Option<u64>,
381) -> FdMatrix {
382    let phi = eigenfunctions(t, big_m, efun_type);
383    let lambda = eigenvalues(big_m, eval_type);
384    sim_kl(n, &phi, big_m, &lambda, seed)
385}
386
387// =============================================================================
388// Noise Addition
389// =============================================================================
390
391/// Add pointwise Gaussian noise to functional data.
392///
393/// Adds independent N(0, σ²) noise to each point.
394///
395/// # Arguments
396/// * `data` - Data `FdMatrix` (n × m)
397/// * `sd` - Standard deviation of noise
398/// * `seed` - Optional random seed
399///
400/// # Returns
401/// Noisy data `FdMatrix` (n × m)
402pub fn add_error_pointwise(data: &FdMatrix, sd: f64, seed: Option<u64>) -> FdMatrix {
403    let mut rng = match seed {
404        Some(s) => StdRng::seed_from_u64(s),
405        None => StdRng::from_entropy(),
406    };
407
408    let normal = Normal::new(0.0, sd).expect("valid distribution parameters: sd > 0");
409
410    let noisy: Vec<f64> = data
411        .as_slice()
412        .iter()
413        .map(|&x| x + rng.sample::<f64, _>(normal))
414        .collect();
415
416    FdMatrix::from_column_major(noisy, data.nrows(), data.ncols())
417        .expect("dimension invariant: data.len() == n * m")
418}
419
420/// Add curve-level Gaussian noise to functional data.
421///
422/// Adds a constant noise term per curve: each observation in curve i
423/// has the same noise value.
424///
425/// # Arguments
426/// * `data` - Data `FdMatrix` (n × m)
427/// * `sd` - Standard deviation of noise
428/// * `seed` - Optional random seed
429///
430/// # Returns
431/// Noisy data `FdMatrix` (n × m)
432pub fn add_error_curve(data: &FdMatrix, sd: f64, seed: Option<u64>) -> FdMatrix {
433    let n = data.nrows();
434    let m = data.ncols();
435
436    let mut rng = match seed {
437        Some(s) => StdRng::seed_from_u64(s),
438        None => StdRng::from_entropy(),
439    };
440
441    let normal = Normal::new(0.0, sd).expect("valid distribution parameters: sd > 0");
442
443    // Generate one noise value per curve
444    let curve_noise: Vec<f64> = (0..n).map(|_| rng.sample::<f64, _>(normal)).collect();
445
446    // Add to data
447    let mut result = data.as_slice().to_vec();
448    for j in 0..m {
449        for i in 0..n {
450            result[i + j * n] += curve_noise[i];
451        }
452    }
453    FdMatrix::from_column_major(result, n, m).expect("dimension invariant: data.len() == n * m")
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn test_fourier_eigenfunctions_dimensions() {
462        let t: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
463        let phi = fourier_eigenfunctions(&t, 5);
464        assert_eq!(phi.nrows(), 100);
465        assert_eq!(phi.ncols(), 5);
466        assert_eq!(phi.len(), 100 * 5);
467    }
468
469    #[test]
470    fn test_fourier_eigenfunctions_first_is_constant() {
471        let t: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
472        let phi = fourier_eigenfunctions(&t, 3);
473
474        // First eigenfunction should be constant 1
475        for i in 0..100 {
476            assert!((phi[(i, 0)] - 1.0).abs() < 1e-10);
477        }
478    }
479
480    #[test]
481    fn test_eigenvalues_linear() {
482        let lambda = eigenvalues_linear(5);
483        assert_eq!(lambda.len(), 5);
484        assert!((lambda[0] - 1.0).abs() < 1e-10);
485        assert!((lambda[1] - 0.5).abs() < 1e-10);
486        assert!((lambda[2] - 1.0 / 3.0).abs() < 1e-10);
487    }
488
489    #[test]
490    fn test_eigenvalues_exponential() {
491        let lambda = eigenvalues_exponential(3);
492        assert_eq!(lambda.len(), 3);
493        assert!((lambda[0] - (-1.0_f64).exp()).abs() < 1e-10);
494        assert!((lambda[1] - (-2.0_f64).exp()).abs() < 1e-10);
495    }
496
497    #[test]
498    fn test_sim_kl_dimensions() {
499        let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
500        let phi = fourier_eigenfunctions(&t, 5);
501        let lambda = eigenvalues_linear(5);
502
503        let data = sim_kl(10, &phi, 5, &lambda, Some(42));
504        assert_eq!(data.nrows(), 10);
505        assert_eq!(data.ncols(), 50);
506        assert_eq!(data.len(), 10 * 50);
507    }
508
509    #[test]
510    fn test_sim_fundata_dimensions() {
511        let t: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
512        let data = sim_fundata(20, &t, 5, EFunType::Fourier, EValType::Linear, Some(42));
513        assert_eq!(data.nrows(), 20);
514        assert_eq!(data.ncols(), 100);
515        assert_eq!(data.len(), 20 * 100);
516    }
517
518    #[test]
519    fn test_add_error_pointwise() {
520        let raw = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2 x 3 matrix
521        let data = FdMatrix::from_column_major(raw.clone(), 2, 3).unwrap();
522        let noisy = add_error_pointwise(&data, 0.1, Some(42));
523        assert_eq!(noisy.len(), 6);
524        // Check that values changed but not by too much
525        let noisy_slice = noisy.as_slice();
526        for i in 0..6 {
527            assert!((noisy_slice[i] - raw[i]).abs() < 1.0);
528        }
529    }
530
531    #[test]
532    fn test_legendre_orthonormality() {
533        // Test that Legendre eigenfunctions are approximately orthonormal
534        let n = 1000;
535        let t: Vec<f64> = (0..n).map(|i| i as f64 / (n - 1) as f64).collect();
536        let m = 5;
537        let phi = legendre_eigenfunctions(&t, m, false);
538        let dt = 1.0 / (n - 1) as f64;
539
540        // Check orthonormality
541        for j1 in 0..m {
542            for j2 in 0..m {
543                let mut integral = 0.0;
544                for i in 0..n {
545                    integral += phi[(i, j1)] * phi[(i, j2)] * dt;
546                }
547                let expected = if j1 == j2 { 1.0 } else { 0.0 };
548                assert!(
549                    (integral - expected).abs() < 0.05,
550                    "Orthonormality check failed for ({}, {}): {} vs {}",
551                    j1,
552                    j2,
553                    integral,
554                    expected
555                );
556            }
557        }
558    }
559
560    // ========================================================================
561    // Wiener eigenfunction tests
562    // ========================================================================
563
564    #[test]
565    fn test_wiener_eigenfunctions_dimensions() {
566        let t: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
567        let phi = wiener_eigenfunctions(&t, 7);
568        assert_eq!(phi.nrows(), 100);
569        assert_eq!(phi.ncols(), 7);
570        assert_eq!(phi.len(), 100 * 7);
571    }
572
573    #[test]
574    fn test_wiener_eigenfunctions_orthonormality() {
575        // Wiener eigenfunctions: sqrt(2)*sin((k-0.5)*pi*t)
576        // Should be orthonormal on [0,1]
577        let n = 1000;
578        let t: Vec<f64> = (0..n).map(|i| i as f64 / (n - 1) as f64).collect();
579        let m = 5;
580        let phi = wiener_eigenfunctions(&t, m);
581        let dt = 1.0 / (n - 1) as f64;
582
583        for j1 in 0..m {
584            for j2 in 0..m {
585                let mut integral = 0.0;
586                for i in 0..n {
587                    integral += phi[(i, j1)] * phi[(i, j2)] * dt;
588                }
589                let expected = if j1 == j2 { 1.0 } else { 0.0 };
590                assert!(
591                    (integral - expected).abs() < 0.05,
592                    "Wiener orthonormality failed for ({}, {}): {} vs {}",
593                    j1,
594                    j2,
595                    integral,
596                    expected
597                );
598            }
599        }
600    }
601
602    #[test]
603    fn test_wiener_eigenfunctions_analytical_form() {
604        // φ_k(t) = sqrt(2) * sin((k - 0.5) * pi * t)
605        let t = vec![0.0, 0.25, 0.5, 0.75, 1.0];
606        let phi = wiener_eigenfunctions(&t, 2);
607        let sqrt2 = 2.0_f64.sqrt();
608
609        // First eigenfunction: k=1, freq = 0.5*pi
610        for (i, &ti) in t.iter().enumerate() {
611            let expected = sqrt2 * (0.5 * PI * ti).sin();
612            assert!(
613                (phi[(i, 0)] - expected).abs() < 1e-10,
614                "k=1 at t={}: got {} expected {}",
615                ti,
616                phi[(i, 0)],
617                expected
618            );
619        }
620
621        // Second eigenfunction: k=2, freq = 1.5*pi
622        for (i, &ti) in t.iter().enumerate() {
623            let expected = sqrt2 * (1.5 * PI * ti).sin();
624            assert!(
625                (phi[(i, 1)] - expected).abs() < 1e-10,
626                "k=2 at t={}: got {} expected {}",
627                ti,
628                phi[(i, 1)],
629                expected
630            );
631        }
632    }
633
634    // ========================================================================
635    // Wiener eigenvalue tests
636    // ========================================================================
637
638    #[test]
639    fn test_eigenvalues_wiener_decay_rate() {
640        // λ_k = 1/((k - 0.5)*pi)^2
641        let lambda = eigenvalues_wiener(5);
642        assert_eq!(lambda.len(), 5);
643
644        for k in 1..=5 {
645            let denom = (k as f64 - 0.5) * PI;
646            let expected = 1.0 / (denom * denom);
647            assert!(
648                (lambda[k - 1] - expected).abs() < 1e-12,
649                "Wiener eigenvalue k={}: got {} expected {}",
650                k,
651                lambda[k - 1],
652                expected
653            );
654        }
655    }
656
657    #[test]
658    fn test_eigenvalues_wiener_decreasing() {
659        // Wiener eigenvalues should decrease monotonically
660        let lambda = eigenvalues_wiener(10);
661
662        for i in 1..lambda.len() {
663            assert!(
664                lambda[i] < lambda[i - 1],
665                "Eigenvalues not decreasing at {}: {} >= {}",
666                i,
667                lambda[i],
668                lambda[i - 1]
669            );
670        }
671    }
672
673    // ========================================================================
674    // add_error_curve tests
675    // ========================================================================
676
677    #[test]
678    fn test_add_error_curve_properties() {
679        // Curve-level noise: each observation in same curve gets same noise
680        let raw = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2 curves x 3 points
681        let n = 2;
682        let data = FdMatrix::from_column_major(raw.clone(), n, 3).unwrap();
683        let noisy = add_error_curve(&data, 0.5, Some(42));
684
685        assert_eq!(noisy.len(), 6);
686
687        // Compute the difference for curve 0 at each point
688        let diff0_j0 = noisy[(0, 0)] - raw[0]; // curve 0, point 0
689        let diff0_j1 = noisy[(0, 1)] - raw[n]; // curve 0, point 1
690        let diff0_j2 = noisy[(0, 2)] - raw[2 * n]; // curve 0, point 2
691
692        // All differences for same curve should be equal (same noise added)
693        assert!(
694            (diff0_j0 - diff0_j1).abs() < 1e-10,
695            "Curve 0 noise differs: {} vs {}",
696            diff0_j0,
697            diff0_j1
698        );
699        assert!(
700            (diff0_j0 - diff0_j2).abs() < 1e-10,
701            "Curve 0 noise differs: {} vs {}",
702            diff0_j0,
703            diff0_j2
704        );
705
706        // Curve 1 should have different noise
707        let diff1_j0 = noisy[(1, 0)] - raw[1];
708        // Different curves should (with high probability) have different noise
709        // We can't guarantee this, but with seed=42 they should differ
710        assert!(
711            (diff0_j0 - diff1_j0).abs() > 1e-10,
712            "Different curves got same noise"
713        );
714    }
715
716    #[test]
717    fn test_add_error_curve_reproducibility() {
718        let raw = vec![1.0, 2.0, 3.0, 4.0];
719        let data = FdMatrix::from_column_major(raw, 2, 2).unwrap();
720        let noisy1 = add_error_curve(&data, 1.0, Some(123));
721        let noisy2 = add_error_curve(&data, 1.0, Some(123));
722
723        let s1 = noisy1.as_slice();
724        let s2 = noisy2.as_slice();
725        for i in 0..4 {
726            assert!(
727                (s1[i] - s2[i]).abs() < 1e-10,
728                "Reproducibility failed at {}: {} vs {}",
729                i,
730                s1[i],
731                s2[i]
732            );
733        }
734    }
735
736    // ========================================================================
737    // Enum dispatcher tests
738    // ========================================================================
739
740    #[test]
741    fn test_efun_type_from_i32() {
742        assert_eq!(EFunType::from_i32(0), Ok(EFunType::Fourier));
743        assert_eq!(EFunType::from_i32(1), Ok(EFunType::Poly));
744        assert_eq!(EFunType::from_i32(2), Ok(EFunType::PolyHigh));
745        assert_eq!(EFunType::from_i32(3), Ok(EFunType::Wiener));
746        assert!(EFunType::from_i32(-1).is_err());
747        assert!(EFunType::from_i32(4).is_err());
748        assert!(EFunType::from_i32(100).is_err());
749    }
750
751    #[test]
752    fn test_eval_type_from_i32() {
753        assert_eq!(EValType::from_i32(0), Ok(EValType::Linear));
754        assert_eq!(EValType::from_i32(1), Ok(EValType::Exponential));
755        assert_eq!(EValType::from_i32(2), Ok(EValType::Wiener));
756        assert!(EValType::from_i32(-1).is_err());
757        assert!(EValType::from_i32(3).is_err());
758        assert!(EValType::from_i32(99).is_err());
759    }
760
761    #[test]
762    fn test_eigenfunctions_dispatcher() {
763        let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
764        let m = 4;
765
766        // Test that dispatcher returns correct results for each type
767        let phi_fourier = eigenfunctions(&t, m, EFunType::Fourier);
768        let phi_fourier_direct = fourier_eigenfunctions(&t, m);
769        assert_eq!(phi_fourier, phi_fourier_direct);
770
771        let phi_poly = eigenfunctions(&t, m, EFunType::Poly);
772        let phi_poly_direct = legendre_eigenfunctions(&t, m, false);
773        assert_eq!(phi_poly, phi_poly_direct);
774
775        let phi_poly_high = eigenfunctions(&t, m, EFunType::PolyHigh);
776        let phi_poly_high_direct = legendre_eigenfunctions(&t, m, true);
777        assert_eq!(phi_poly_high, phi_poly_high_direct);
778
779        let phi_wiener = eigenfunctions(&t, m, EFunType::Wiener);
780        let phi_wiener_direct = wiener_eigenfunctions(&t, m);
781        assert_eq!(phi_wiener, phi_wiener_direct);
782    }
783
784    #[test]
785    fn test_sigma_zero_error() {
786        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
787        let data = sim_fundata(5, &t, 3, EFunType::Fourier, EValType::Exponential, Some(42));
788        let noisy = add_error_pointwise(&data, 0.0, Some(42));
789        // sigma=0 → no noise added, should be identical
790        for i in 0..5 {
791            for j in 0..20 {
792                assert!(
793                    (noisy[(i, j)] - data[(i, j)]).abs() < 1e-12,
794                    "Zero-sigma error should not change data"
795                );
796            }
797        }
798    }
799
800    #[test]
801    fn test_ncomp1_eigenfunctions() {
802        let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
803        let phi = fourier_eigenfunctions(&t, 1);
804        assert_eq!(phi.nrows(), t.len());
805        assert_eq!(phi.ncols(), 1);
806        // First Fourier eigenfunction should be constant
807        let first_val = phi[(0, 0)];
808        for i in 1..t.len() {
809            assert!((phi[(i, 0)] - first_val).abs() < 1e-10);
810        }
811    }
812
813    #[test]
814    fn test_deterministic_seed() {
815        let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
816        let d1 = sim_fundata(10, &t, 3, EFunType::Fourier, EValType::Linear, Some(123));
817        let d2 = sim_fundata(10, &t, 3, EFunType::Fourier, EValType::Linear, Some(123));
818        for i in 0..10 {
819            for j in 0..30 {
820                assert!(
821                    (d1[(i, j)] - d2[(i, j)]).abs() < 1e-12,
822                    "Same seed should produce identical results"
823                );
824            }
825        }
826    }
827}