Skip to main content

numra_spde/
noise.rs

1//! Space-time noise generation for SPDEs.
2//!
3//! This module provides noise generators for the spatial component of SPDE
4//! solvers, ranging from simple i.i.d. white noise to spatially correlated
5//! colored noise.
6//!
7//! ## Choosing `correlation_length`
8//!
9//! For [`ColoredNoiseGenerator`] with spatial correlation:
10//! - **`correlation_length` ~ grid spacing:** Noise is nearly white; correlation
11//!   has minimal effect. Use white noise instead for efficiency.
12//! - **`correlation_length` ~ domain size:** Noise is nearly uniform across
13//!   the domain (long-range correlation). May cause Cholesky ill-conditioning
14//!   if the grid is fine relative to the correlation length.
15//! - **`correlation_length` ~ 5-20 × grid spacing:** Typical choice that
16//!   produces smooth spatial noise patterns without numerical issues.
17//!
18//! If the Cholesky decomposition fails with an ill-conditioning error, try:
19//! 1. Increasing `correlation_length` (less extreme correlations)
20//! 2. Coarsening the spatial grid (fewer points to correlate)
21//! 3. Using trace-class noise instead (eigenvalue decay provides regularization)
22//!
23//! Author: Moussa Leblouba
24//! Date: 4 February 2026
25//! Modified: 2 May 2026
26
27use numra_core::Scalar;
28use numra_sde::wiener::{create_wiener, WienerProcess};
29use rand::prelude::*;
30use rand_distr::StandardNormal;
31
32/// Trait for space-time noise processes.
33pub trait SpaceTimeNoise<S: Scalar> {
34    /// Generate noise increments at all spatial points.
35    ///
36    /// # Arguments
37    /// * `dt` - Time step
38    /// * `n_points` - Number of spatial grid points
39    /// * `dw` - Output: noise increments at each point
40    fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]);
41
42    /// Reset the noise generator with a new seed.
43    fn reseed(&mut self, seed: u64);
44}
45
46/// Spatially white (uncorrelated) space-time noise.
47///
48/// Each grid point receives an independent Wiener increment scaled by dx^(-1/2).
49pub struct WhiteNoise<S: Scalar> {
50    wiener: WienerProcess<StdRng>,
51    dx: S,
52    n_wiener: usize,
53}
54
55impl<S: Scalar> WhiteNoise<S> {
56    /// Create white noise generator.
57    ///
58    /// # Arguments
59    /// * `dx` - Spatial grid spacing
60    /// * `n_points` - Number of spatial grid points
61    /// * `seed` - Optional random seed
62    pub fn new(dx: S, n_points: usize, seed: Option<u64>) -> Self {
63        Self {
64            wiener: create_wiener(n_points, seed),
65            dx,
66            n_wiener: n_points,
67        }
68    }
69}
70
71impl<S: Scalar> SpaceTimeNoise<S> for WhiteNoise<S> {
72    fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]) {
73        // Resize if needed
74        if n_points != self.n_wiener {
75            self.wiener = create_wiener(n_points, None);
76            self.n_wiener = n_points;
77        }
78
79        // For space-time white noise, the covariance is:
80        // E[dW(x,t) dW(x',t')] = δ(x-x') δ(t-t') dx dt
81        //
82        // In discrete form with grid spacing dx:
83        // Var[ΔW_i] = dt / dx
84        //
85        // So we scale by sqrt(dt / dx) to get variance dt/dx
86        // But WienerProcess already gives variance dt, so we scale by 1/sqrt(dx)
87        let scale = S::ONE / self.dx.sqrt();
88
89        let inc = self.wiener.increment::<S>(dt);
90        for i in 0..n_points.min(inc.dw.len()) {
91            dw[i] = inc.dw[i] * scale;
92        }
93    }
94
95    fn reseed(&mut self, seed: u64) {
96        self.wiener = create_wiener(self.n_wiener, Some(seed));
97    }
98}
99
100/// Spatially colored (correlated) noise.
101///
102/// Noise at nearby points is correlated with correlation function:
103/// C(|x - x'|) = exp(-|x - x'| / λ)
104///
105/// where λ is the correlation length.
106pub struct ColoredNoise<S: Scalar> {
107    wiener: WienerProcess<StdRng>,
108    dx: S,
109    correlation_length: S,
110    /// Pre-computed correlation matrix (Cholesky factor)
111    cholesky: Vec<S>,
112    n_cached: usize,
113}
114
115impl<S: Scalar> ColoredNoise<S> {
116    /// Create colored noise generator.
117    ///
118    /// # Arguments
119    /// * `dx` - Spatial grid spacing
120    /// * `correlation_length` - Noise correlation length
121    /// * `n_points` - Number of spatial grid points
122    /// * `seed` - Optional random seed
123    ///
124    /// Returns an error if the resulting correlation matrix is not positive
125    /// definite or is ill-conditioned for the given parameters.
126    pub fn new(
127        dx: S,
128        correlation_length: S,
129        n_points: usize,
130        seed: Option<u64>,
131    ) -> Result<Self, String> {
132        // Build correlation matrix and compute Cholesky factorization
133        let cholesky = Self::compute_cholesky(dx, correlation_length, n_points)?;
134
135        Ok(Self {
136            wiener: create_wiener(n_points, seed),
137            dx,
138            correlation_length,
139            cholesky,
140            n_cached: n_points,
141        })
142    }
143
144    fn compute_cholesky(dx: S, correlation_length: S, n: usize) -> Result<Vec<S>, String> {
145        // Build correlation matrix: C_ij = exp(-|x_i - x_j| / λ)
146        let mut c = vec![S::ZERO; n * n];
147
148        for i in 0..n {
149            for j in 0..n {
150                let dist = dx * S::from_usize(if i > j { i - j } else { j - i });
151                c[i * n + j] = (-dist / correlation_length).exp();
152            }
153        }
154
155        // Cholesky factorization: C = L * L^T
156        cholesky_decompose(&c, n)
157    }
158}
159
160impl<S: Scalar> SpaceTimeNoise<S> for ColoredNoise<S> {
161    fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]) {
162        // Recompute Cholesky if grid size changed
163        if n_points != self.n_cached {
164            self.cholesky = Self::compute_cholesky(self.dx, self.correlation_length, n_points)
165                .expect("Cholesky decomposition failed on grid resize; correlation matrix is ill-conditioned");
166            self.wiener = create_wiener(n_points, None);
167            self.n_cached = n_points;
168        }
169
170        // Generate independent increments
171        let inc = self.wiener.increment::<S>(dt);
172
173        // Apply Cholesky factor to correlate: dW = L * z
174        for i in 0..n_points {
175            dw[i] = S::ZERO;
176            for j in 0..=i {
177                dw[i] = dw[i] + self.cholesky[i * n_points + j] * inc.dw[j];
178            }
179        }
180    }
181
182    fn reseed(&mut self, seed: u64) {
183        self.wiener = create_wiener(self.n_cached, Some(seed));
184    }
185}
186
187/// Trace-class noise using spectral representation.
188///
189/// Expands the noise in eigenfunctions of the Laplacian:
190/// W(x,t) = Σ_k sqrt(λ_k) e_k(x) W_k(t)
191///
192/// where λ_k are eigenvalues and e_k are eigenfunctions.
193#[allow(dead_code)]
194pub struct TraceClassNoise<S: Scalar> {
195    wiener: WienerProcess<StdRng>,
196    n_modes: usize,
197    eigenvalues: Vec<S>,
198    domain_length: S,
199}
200
201#[allow(dead_code)]
202impl<S: Scalar> TraceClassNoise<S> {
203    /// Create trace-class noise generator.
204    ///
205    /// # Arguments
206    /// * `n_modes` - Number of eigenmodes to include
207    /// * `decay_rate` - Eigenvalue decay rate (λ_k ~ k^(-decay_rate))
208    /// * `domain_length` - Length of spatial domain
209    /// * `seed` - Optional random seed
210    pub fn new(n_modes: usize, decay_rate: S, domain_length: S, seed: Option<u64>) -> Self {
211        // Eigenvalues decay as k^(-decay_rate)
212        let eigenvalues: Vec<S> = (1..=n_modes)
213            .map(|k| S::from_usize(k).powf(-decay_rate))
214            .collect();
215
216        Self {
217            wiener: create_wiener(n_modes, seed),
218            n_modes,
219            eigenvalues,
220            domain_length,
221        }
222    }
223}
224
225impl<S: Scalar> SpaceTimeNoise<S> for TraceClassNoise<S> {
226    fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]) {
227        // Initialize output
228        for i in 0..n_points {
229            dw[i] = S::ZERO;
230        }
231
232        let pi = S::PI;
233
234        // Generate mode amplitudes
235        let inc = self.wiener.increment::<S>(dt);
236
237        // Sum over modes
238        for k in 0..self.n_modes {
239            let mode_idx = S::from_usize(k + 1);
240            let dw_k = inc.dw[k];
241            let sqrt_lambda = self.eigenvalues[k].sqrt();
242
243            // Eigenfunction: e_k(x) = sqrt(2/L) * sin(k*π*x/L)
244            let normalization = (S::TWO / self.domain_length).sqrt();
245
246            for i in 0..n_points {
247                let x = self.domain_length * S::from_usize(i) / S::from_usize(n_points - 1);
248                let basis = normalization * (mode_idx * pi * x / self.domain_length).sin();
249                dw[i] = dw[i] + sqrt_lambda * basis * dw_k;
250            }
251        }
252    }
253
254    fn reseed(&mut self, seed: u64) {
255        self.wiener = create_wiener(self.n_modes, Some(seed));
256    }
257}
258
259/// Spatially correlated Gaussian noise generator.
260///
261/// Generates samples of Gaussian random fields with exponential (squared)
262/// correlation function:
263///
264/// ```text
265/// C(x, y) = exp(-|x - y|^2 / (2 * L^2))
266/// ```
267///
268/// where `L` is the correlation length. Uses Cholesky decomposition of the
269/// correlation matrix to transform independent standard normal samples into
270/// correlated samples.
271pub struct ColoredNoiseGenerator<S: Scalar> {
272    correlation_length: S,
273    rng: StdRng,
274}
275
276impl<S: Scalar> ColoredNoiseGenerator<S> {
277    /// Create a new colored noise generator.
278    ///
279    /// # Arguments
280    /// * `correlation_length` - The spatial correlation length `L`
281    /// * `seed` - Random seed for reproducibility
282    pub fn new(correlation_length: S, seed: u64) -> Self {
283        Self {
284            correlation_length,
285            rng: StdRng::seed_from_u64(seed),
286        }
287    }
288
289    /// Generate a sample of spatially correlated noise on the given grid.
290    ///
291    /// Returns a vector of correlated Gaussian values, one per grid point.
292    /// Adjacent grid points within the correlation length will have similar values.
293    ///
294    /// Returns an error if the correlation matrix is not positive definite
295    /// or is ill-conditioned for the given grid/correlation_length combination.
296    pub fn sample(&mut self, grid: &[S]) -> Result<Vec<S>, String> {
297        let n = grid.len();
298        let l = self.correlation_length;
299
300        // Build correlation matrix: C_ij = exp(-|x_i - x_j|^2 / (2*L^2))
301        let mut c = vec![S::ZERO; n * n];
302        for i in 0..n {
303            for j in 0..n {
304                let dx = grid[i] - grid[j];
305                c[i * n + j] = (-dx * dx / (S::from_f64(2.0) * l * l)).exp();
306            }
307        }
308
309        // Cholesky factorization: C = L_chol * L_chol^T
310        let chol = cholesky_decompose(&c, n)?;
311
312        // Generate iid standard normal samples
313        let z: Vec<S> = (0..n)
314            .map(|_| S::from_f64(self.rng.sample(StandardNormal)))
315            .collect();
316
317        // Correlate: y = L_chol * z
318        let mut result = vec![S::ZERO; n];
319        for i in 0..n {
320            for j in 0..=i {
321                result[i] = result[i] + chol[i * n + j] * z[j];
322            }
323        }
324
325        Ok(result)
326    }
327}
328
329/// Cholesky decomposition of a symmetric positive-definite matrix.
330///
331/// Given an n x n SPD matrix `a` stored in row-major order, returns
332/// the lower-triangular Cholesky factor `L` such that `A = L * L^T`.
333///
334/// Returns an error if the matrix is not positive definite or is
335/// ill-conditioned (diagonal element below threshold during factorization).
336pub(crate) fn cholesky_decompose<S: Scalar>(a: &[S], n: usize) -> Result<Vec<S>, String> {
337    let mut l = vec![S::ZERO; n * n];
338    let eps = S::from_f64(1e-12);
339
340    for j in 0..n {
341        let mut sum = S::ZERO;
342        for k in 0..j {
343            sum = sum + l[j * n + k] * l[j * n + k];
344        }
345        let diag = a[j * n + j] - sum;
346        if diag < S::ZERO {
347            return Err(format!(
348                "Correlation matrix not positive definite at index {}: \
349                 diagonal element is {}",
350                j,
351                diag.to_f64()
352            ));
353        }
354        let sqrt_diag = diag.sqrt();
355        if sqrt_diag < eps {
356            return Err(format!(
357                "Correlation matrix ill-conditioned at index {}: \
358                 diagonal element {} is below threshold. \
359                 Try increasing correlation_length.",
360                j,
361                diag.to_f64()
362            ));
363        }
364        l[j * n + j] = sqrt_diag;
365
366        for i in (j + 1)..n {
367            let mut sum = S::ZERO;
368            for k in 0..j {
369                sum = sum + l[i * n + k] * l[j * n + k];
370            }
371            l[i * n + j] = (a[i * n + j] - sum) / l[j * n + j];
372        }
373    }
374
375    Ok(l)
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_white_noise_variance() {
384        let dx = 0.1;
385        let dt = 0.01;
386        let n = 10;
387        let n_samples = 1000;
388
389        let mut noise = WhiteNoise::new(dx, n, Some(42));
390        let mut dw = vec![0.0; n];
391        let mut sum_sq = vec![0.0; n];
392
393        for _ in 0..n_samples {
394            noise.increment(dt, n, &mut dw);
395            for i in 0..n {
396                sum_sq[i] += dw[i] * dw[i];
397            }
398        }
399
400        // Expected variance: dt / dx = 0.01 / 0.1 = 0.1
401        let expected_var = dt / dx;
402        for i in 0..n {
403            let var = sum_sq[i] / n_samples as f64;
404            assert!(
405                (var - expected_var).abs() < 0.05,
406                "Variance at point {}: {}, expected: {}",
407                i,
408                var,
409                expected_var
410            );
411        }
412    }
413
414    #[test]
415    fn test_colored_noise_correlation() {
416        let dx = 0.1;
417        let correlation_length = 0.3;
418        let n = 20;
419        let dt = 0.01;
420        let n_samples = 5000;
421
422        let mut noise = ColoredNoise::new(dx, correlation_length, n, Some(42)).unwrap();
423        let mut dw = vec![0.0; n];
424        let mut cross_sum = 0.0;
425        let mut sum_sq = 0.0;
426
427        // Check correlation between adjacent points
428        for _ in 0..n_samples {
429            noise.increment(dt, n, &mut dw);
430            cross_sum += dw[0] * dw[1];
431            sum_sq += dw[0] * dw[0];
432        }
433
434        let correlation = cross_sum / sum_sq;
435        let expected = (-dx / correlation_length).exp();
436
437        // Colored noise should have positive correlation between nearby points
438        assert!(
439            correlation > 0.0,
440            "Adjacent points should be positively correlated"
441        );
442        assert!(
443            (correlation - expected).abs() < 0.2,
444            "Correlation: {}, expected: {}",
445            correlation,
446            expected
447        );
448    }
449
450    #[test]
451    fn test_trace_class_noise() {
452        let n_modes = 10;
453        let decay_rate = 2.0;
454        let domain_length = 1.0;
455        let n = 50;
456        let dt = 0.01;
457
458        let mut noise = TraceClassNoise::new(n_modes, decay_rate, domain_length, Some(42));
459        let mut dw = vec![0.0; n];
460
461        noise.increment(dt, n, &mut dw);
462
463        // Trace-class noise should be smoother than white noise
464        // Check that the noise has spatial structure
465        let mut total_variation = 0.0;
466        for i in 1..n {
467            total_variation += (dw[i] - dw[i - 1]).abs();
468        }
469
470        // Should be finite and not too wild
471        assert!(total_variation.is_finite());
472    }
473
474    #[test]
475    fn test_colored_noise_generation() {
476        use crate::noise::ColoredNoiseGenerator;
477
478        let correlation_length = 0.1_f64;
479        let grid: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
480        let n = grid.len();
481
482        let mut generator = ColoredNoiseGenerator::new(correlation_length, 42);
483
484        // Generate many samples and compute sample correlations
485        let n_samples = 2000;
486        let mut samples = Vec::with_capacity(n_samples);
487        for _ in 0..n_samples {
488            samples.push(generator.sample(&grid).unwrap());
489        }
490
491        // Compute sample correlation between adjacent points
492        // For exponential correlation C(d) = exp(-d^2/(2*L^2)),
493        // adjacent points (d = 1/19 ~ 0.053) with L=0.1 should have
494        // correlation ~ exp(-0.053^2/0.02) ~ 0.87
495        let mean_0: f64 = samples.iter().map(|s| s[0]).sum::<f64>() / n_samples as f64;
496        let mean_1: f64 = samples.iter().map(|s| s[1]).sum::<f64>() / n_samples as f64;
497        let var_0: f64 =
498            samples.iter().map(|s| (s[0] - mean_0).powi(2)).sum::<f64>() / n_samples as f64;
499        let var_1: f64 =
500            samples.iter().map(|s| (s[1] - mean_1).powi(2)).sum::<f64>() / n_samples as f64;
501        let cov_01: f64 = samples
502            .iter()
503            .map(|s| (s[0] - mean_0) * (s[1] - mean_1))
504            .sum::<f64>()
505            / n_samples as f64;
506        let corr_01 = cov_01 / (var_0.sqrt() * var_1.sqrt());
507
508        // Correlation should be high for adjacent points with this L
509        assert!(corr_01 > 0.5, "Adjacent correlation too low: {}", corr_01);
510
511        // Check correlation decays with distance
512        let mean_far: f64 = samples.iter().map(|s| s[n - 1]).sum::<f64>() / n_samples as f64;
513        let var_far: f64 = samples
514            .iter()
515            .map(|s| (s[n - 1] - mean_far).powi(2))
516            .sum::<f64>()
517            / n_samples as f64;
518        let cov_far: f64 = samples
519            .iter()
520            .map(|s| (s[0] - mean_0) * (s[n - 1] - mean_far))
521            .sum::<f64>()
522            / n_samples as f64;
523        let corr_far = cov_far / (var_0.sqrt() * var_far.sqrt());
524
525        assert!(
526            corr_01 > corr_far.abs(),
527            "Nearby correlation should be higher than far correlation"
528        );
529    }
530
531    #[test]
532    fn test_cholesky_rejects_non_positive_definite() {
533        // Matrix [[1, 2], [2, 1]] has eigenvalues {3, -1}, not positive definite
534        let n = 2;
535        let matrix = vec![1.0_f64, 2.0, 2.0, 1.0];
536        let result = cholesky_decompose::<f64>(&matrix, n);
537        assert!(
538            result.is_err(),
539            "Should reject non-positive-definite matrix"
540        );
541        let msg = result.unwrap_err();
542        assert!(
543            msg.contains("not positive definite"),
544            "Error message: {}",
545            msg
546        );
547    }
548
549    #[test]
550    fn test_cholesky_rejects_ill_conditioned() {
551        // Identity with one near-zero diagonal => ill-conditioned
552        let n = 3;
553        let mut matrix = vec![0.0_f64; n * n];
554        matrix[0] = 1.0;
555        matrix[4] = 1e-30; // nearly singular
556        matrix[8] = 1.0;
557        let result = cholesky_decompose::<f64>(&matrix, n);
558        assert!(result.is_err(), "Should reject ill-conditioned matrix");
559        let msg = result.unwrap_err();
560        assert!(msg.contains("ill-conditioned"), "Error message: {}", msg);
561    }
562
563    #[test]
564    fn test_cholesky_accepts_valid_matrix() {
565        // Valid positive-definite matrix: identity
566        let n = 3;
567        let mut matrix = vec![0.0_f64; n * n];
568        matrix[0] = 1.0;
569        matrix[4] = 1.0;
570        matrix[8] = 1.0;
571        let result = cholesky_decompose::<f64>(&matrix, n);
572        assert!(result.is_ok(), "Should accept identity matrix");
573        let l = result.unwrap();
574        // L should be identity for identity input
575        assert!((l[0] - 1.0).abs() < 1e-10);
576        assert!((l[4] - 1.0).abs() < 1e-10);
577        assert!((l[8] - 1.0).abs() < 1e-10);
578    }
579
580    #[test]
581    fn test_colored_noise_new_returns_ok_for_valid_params() {
582        let result = ColoredNoise::<f64>::new(0.1, 0.3, 10, Some(42));
583        assert!(
584            result.is_ok(),
585            "Should accept reasonable correlation parameters"
586        );
587    }
588
589    #[test]
590    fn test_cholesky_decompose_recovers_factor() {
591        // Verify L * L^T = A for a known SPD matrix
592        let n = 3;
593        let a = vec![4.0_f64, 2.0, 1.0, 2.0, 5.0, 3.0, 1.0, 3.0, 6.0];
594        let l = cholesky_decompose::<f64>(&a, n).unwrap();
595
596        // Reconstruct A = L * L^T
597        for i in 0..n {
598            for j in 0..n {
599                let mut sum = 0.0;
600                for k in 0..n {
601                    sum += l[i * n + k] * l[j * n + k];
602                }
603                assert!(
604                    (sum - a[i * n + j]).abs() < 1e-10,
605                    "Reconstruction error at ({},{}): {} vs {}",
606                    i,
607                    j,
608                    sum,
609                    a[i * n + j]
610                );
611            }
612        }
613    }
614}