use numra_core::Scalar;
use numra_sde::wiener::{create_wiener, WienerProcess};
use rand::prelude::*;
use rand_distr::StandardNormal;
pub trait SpaceTimeNoise<S: Scalar> {
fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]);
fn reseed(&mut self, seed: u64);
}
pub struct WhiteNoise<S: Scalar> {
wiener: WienerProcess<StdRng>,
dx: S,
n_wiener: usize,
}
impl<S: Scalar> WhiteNoise<S> {
pub fn new(dx: S, n_points: usize, seed: Option<u64>) -> Self {
Self {
wiener: create_wiener(n_points, seed),
dx,
n_wiener: n_points,
}
}
}
impl<S: Scalar> SpaceTimeNoise<S> for WhiteNoise<S> {
fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]) {
if n_points != self.n_wiener {
self.wiener = create_wiener(n_points, None);
self.n_wiener = n_points;
}
let scale = S::ONE / self.dx.sqrt();
let inc = self.wiener.increment::<S>(dt);
for i in 0..n_points.min(inc.dw.len()) {
dw[i] = inc.dw[i] * scale;
}
}
fn reseed(&mut self, seed: u64) {
self.wiener = create_wiener(self.n_wiener, Some(seed));
}
}
pub struct ColoredNoise<S: Scalar> {
wiener: WienerProcess<StdRng>,
dx: S,
correlation_length: S,
cholesky: Vec<S>,
n_cached: usize,
}
impl<S: Scalar> ColoredNoise<S> {
pub fn new(
dx: S,
correlation_length: S,
n_points: usize,
seed: Option<u64>,
) -> Result<Self, String> {
let cholesky = Self::compute_cholesky(dx, correlation_length, n_points)?;
Ok(Self {
wiener: create_wiener(n_points, seed),
dx,
correlation_length,
cholesky,
n_cached: n_points,
})
}
fn compute_cholesky(dx: S, correlation_length: S, n: usize) -> Result<Vec<S>, String> {
let mut c = vec![S::ZERO; n * n];
for i in 0..n {
for j in 0..n {
let dist = dx * S::from_usize(if i > j { i - j } else { j - i });
c[i * n + j] = (-dist / correlation_length).exp();
}
}
cholesky_decompose(&c, n)
}
}
impl<S: Scalar> SpaceTimeNoise<S> for ColoredNoise<S> {
fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]) {
if n_points != self.n_cached {
self.cholesky = Self::compute_cholesky(self.dx, self.correlation_length, n_points)
.expect("Cholesky decomposition failed on grid resize; correlation matrix is ill-conditioned");
self.wiener = create_wiener(n_points, None);
self.n_cached = n_points;
}
let inc = self.wiener.increment::<S>(dt);
for i in 0..n_points {
dw[i] = S::ZERO;
for j in 0..=i {
dw[i] = dw[i] + self.cholesky[i * n_points + j] * inc.dw[j];
}
}
}
fn reseed(&mut self, seed: u64) {
self.wiener = create_wiener(self.n_cached, Some(seed));
}
}
#[allow(dead_code)]
pub struct TraceClassNoise<S: Scalar> {
wiener: WienerProcess<StdRng>,
n_modes: usize,
eigenvalues: Vec<S>,
domain_length: S,
}
#[allow(dead_code)]
impl<S: Scalar> TraceClassNoise<S> {
pub fn new(n_modes: usize, decay_rate: S, domain_length: S, seed: Option<u64>) -> Self {
let eigenvalues: Vec<S> = (1..=n_modes)
.map(|k| S::from_usize(k).powf(-decay_rate))
.collect();
Self {
wiener: create_wiener(n_modes, seed),
n_modes,
eigenvalues,
domain_length,
}
}
}
impl<S: Scalar> SpaceTimeNoise<S> for TraceClassNoise<S> {
fn increment(&mut self, dt: S, n_points: usize, dw: &mut [S]) {
for i in 0..n_points {
dw[i] = S::ZERO;
}
let pi = S::PI;
let inc = self.wiener.increment::<S>(dt);
for k in 0..self.n_modes {
let mode_idx = S::from_usize(k + 1);
let dw_k = inc.dw[k];
let sqrt_lambda = self.eigenvalues[k].sqrt();
let normalization = (S::TWO / self.domain_length).sqrt();
for i in 0..n_points {
let x = self.domain_length * S::from_usize(i) / S::from_usize(n_points - 1);
let basis = normalization * (mode_idx * pi * x / self.domain_length).sin();
dw[i] = dw[i] + sqrt_lambda * basis * dw_k;
}
}
}
fn reseed(&mut self, seed: u64) {
self.wiener = create_wiener(self.n_modes, Some(seed));
}
}
pub struct ColoredNoiseGenerator<S: Scalar> {
correlation_length: S,
rng: StdRng,
}
impl<S: Scalar> ColoredNoiseGenerator<S> {
pub fn new(correlation_length: S, seed: u64) -> Self {
Self {
correlation_length,
rng: StdRng::seed_from_u64(seed),
}
}
pub fn sample(&mut self, grid: &[S]) -> Result<Vec<S>, String> {
let n = grid.len();
let l = self.correlation_length;
let mut c = vec![S::ZERO; n * n];
for i in 0..n {
for j in 0..n {
let dx = grid[i] - grid[j];
c[i * n + j] = (-dx * dx / (S::from_f64(2.0) * l * l)).exp();
}
}
let chol = cholesky_decompose(&c, n)?;
let z: Vec<S> = (0..n)
.map(|_| S::from_f64(self.rng.sample(StandardNormal)))
.collect();
let mut result = vec![S::ZERO; n];
for i in 0..n {
for j in 0..=i {
result[i] = result[i] + chol[i * n + j] * z[j];
}
}
Ok(result)
}
}
pub(crate) fn cholesky_decompose<S: Scalar>(a: &[S], n: usize) -> Result<Vec<S>, String> {
let mut l = vec![S::ZERO; n * n];
let eps = S::from_f64(1e-12);
for j in 0..n {
let mut sum = S::ZERO;
for k in 0..j {
sum = sum + l[j * n + k] * l[j * n + k];
}
let diag = a[j * n + j] - sum;
if diag < S::ZERO {
return Err(format!(
"Correlation matrix not positive definite at index {}: \
diagonal element is {}",
j,
diag.to_f64()
));
}
let sqrt_diag = diag.sqrt();
if sqrt_diag < eps {
return Err(format!(
"Correlation matrix ill-conditioned at index {}: \
diagonal element {} is below threshold. \
Try increasing correlation_length.",
j,
diag.to_f64()
));
}
l[j * n + j] = sqrt_diag;
for i in (j + 1)..n {
let mut sum = S::ZERO;
for k in 0..j {
sum = sum + l[i * n + k] * l[j * n + k];
}
l[i * n + j] = (a[i * n + j] - sum) / l[j * n + j];
}
}
Ok(l)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_white_noise_variance() {
let dx = 0.1;
let dt = 0.01;
let n = 10;
let n_samples = 1000;
let mut noise = WhiteNoise::new(dx, n, Some(42));
let mut dw = vec![0.0; n];
let mut sum_sq = vec![0.0; n];
for _ in 0..n_samples {
noise.increment(dt, n, &mut dw);
for i in 0..n {
sum_sq[i] += dw[i] * dw[i];
}
}
let expected_var = dt / dx;
for i in 0..n {
let var = sum_sq[i] / n_samples as f64;
assert!(
(var - expected_var).abs() < 0.05,
"Variance at point {}: {}, expected: {}",
i,
var,
expected_var
);
}
}
#[test]
fn test_colored_noise_correlation() {
let dx = 0.1;
let correlation_length = 0.3;
let n = 20;
let dt = 0.01;
let n_samples = 5000;
let mut noise = ColoredNoise::new(dx, correlation_length, n, Some(42)).unwrap();
let mut dw = vec![0.0; n];
let mut cross_sum = 0.0;
let mut sum_sq = 0.0;
for _ in 0..n_samples {
noise.increment(dt, n, &mut dw);
cross_sum += dw[0] * dw[1];
sum_sq += dw[0] * dw[0];
}
let correlation = cross_sum / sum_sq;
let expected = (-dx / correlation_length).exp();
assert!(
correlation > 0.0,
"Adjacent points should be positively correlated"
);
assert!(
(correlation - expected).abs() < 0.2,
"Correlation: {}, expected: {}",
correlation,
expected
);
}
#[test]
fn test_trace_class_noise() {
let n_modes = 10;
let decay_rate = 2.0;
let domain_length = 1.0;
let n = 50;
let dt = 0.01;
let mut noise = TraceClassNoise::new(n_modes, decay_rate, domain_length, Some(42));
let mut dw = vec![0.0; n];
noise.increment(dt, n, &mut dw);
let mut total_variation = 0.0;
for i in 1..n {
total_variation += (dw[i] - dw[i - 1]).abs();
}
assert!(total_variation.is_finite());
}
#[test]
fn test_colored_noise_generation() {
use crate::noise::ColoredNoiseGenerator;
let correlation_length = 0.1_f64;
let grid: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
let n = grid.len();
let mut generator = ColoredNoiseGenerator::new(correlation_length, 42);
let n_samples = 2000;
let mut samples = Vec::with_capacity(n_samples);
for _ in 0..n_samples {
samples.push(generator.sample(&grid).unwrap());
}
let mean_0: f64 = samples.iter().map(|s| s[0]).sum::<f64>() / n_samples as f64;
let mean_1: f64 = samples.iter().map(|s| s[1]).sum::<f64>() / n_samples as f64;
let var_0: f64 =
samples.iter().map(|s| (s[0] - mean_0).powi(2)).sum::<f64>() / n_samples as f64;
let var_1: f64 =
samples.iter().map(|s| (s[1] - mean_1).powi(2)).sum::<f64>() / n_samples as f64;
let cov_01: f64 = samples
.iter()
.map(|s| (s[0] - mean_0) * (s[1] - mean_1))
.sum::<f64>()
/ n_samples as f64;
let corr_01 = cov_01 / (var_0.sqrt() * var_1.sqrt());
assert!(corr_01 > 0.5, "Adjacent correlation too low: {}", corr_01);
let mean_far: f64 = samples.iter().map(|s| s[n - 1]).sum::<f64>() / n_samples as f64;
let var_far: f64 = samples
.iter()
.map(|s| (s[n - 1] - mean_far).powi(2))
.sum::<f64>()
/ n_samples as f64;
let cov_far: f64 = samples
.iter()
.map(|s| (s[0] - mean_0) * (s[n - 1] - mean_far))
.sum::<f64>()
/ n_samples as f64;
let corr_far = cov_far / (var_0.sqrt() * var_far.sqrt());
assert!(
corr_01 > corr_far.abs(),
"Nearby correlation should be higher than far correlation"
);
}
#[test]
fn test_cholesky_rejects_non_positive_definite() {
let n = 2;
let matrix = vec![1.0_f64, 2.0, 2.0, 1.0];
let result = cholesky_decompose::<f64>(&matrix, n);
assert!(
result.is_err(),
"Should reject non-positive-definite matrix"
);
let msg = result.unwrap_err();
assert!(
msg.contains("not positive definite"),
"Error message: {}",
msg
);
}
#[test]
fn test_cholesky_rejects_ill_conditioned() {
let n = 3;
let mut matrix = vec![0.0_f64; n * n];
matrix[0] = 1.0;
matrix[4] = 1e-30; matrix[8] = 1.0;
let result = cholesky_decompose::<f64>(&matrix, n);
assert!(result.is_err(), "Should reject ill-conditioned matrix");
let msg = result.unwrap_err();
assert!(msg.contains("ill-conditioned"), "Error message: {}", msg);
}
#[test]
fn test_cholesky_accepts_valid_matrix() {
let n = 3;
let mut matrix = vec![0.0_f64; n * n];
matrix[0] = 1.0;
matrix[4] = 1.0;
matrix[8] = 1.0;
let result = cholesky_decompose::<f64>(&matrix, n);
assert!(result.is_ok(), "Should accept identity matrix");
let l = result.unwrap();
assert!((l[0] - 1.0).abs() < 1e-10);
assert!((l[4] - 1.0).abs() < 1e-10);
assert!((l[8] - 1.0).abs() < 1e-10);
}
#[test]
fn test_colored_noise_new_returns_ok_for_valid_params() {
let result = ColoredNoise::<f64>::new(0.1, 0.3, 10, Some(42));
assert!(
result.is_ok(),
"Should accept reasonable correlation parameters"
);
}
#[test]
fn test_cholesky_decompose_recovers_factor() {
let n = 3;
let a = vec![4.0_f64, 2.0, 1.0, 2.0, 5.0, 3.0, 1.0, 3.0, 6.0];
let l = cholesky_decompose::<f64>(&a, n).unwrap();
for i in 0..n {
for j in 0..n {
let mut sum = 0.0;
for k in 0..n {
sum += l[i * n + k] * l[j * n + k];
}
assert!(
(sum - a[i * n + j]).abs() < 1e-10,
"Reconstruction error at ({},{}): {} vs {}",
i,
j,
sum,
a[i * n + j]
);
}
}
}
}