use crate::error::{IntegrateError, IntegrateResult};
use crate::spde::random_fields::{CorrelationFunction, RandomField};
use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
use scirs2_core::random::prelude::{Normal, Rng, StdRng};
use scirs2_core::Distribution;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum NoiseType {
Additive,
Multiplicative,
}
#[derive(Debug, Clone)]
pub struct StochasticHeatConfig {
pub diffusion: f64,
pub sigma: f64,
pub noise_type: NoiseType,
pub spatial_covariance: Option<CorrelationFunction>,
pub kl_terms: usize,
pub dt: f64,
}
impl Default for StochasticHeatConfig {
fn default() -> Self {
Self {
diffusion: 1.0,
sigma: 0.1,
noise_type: NoiseType::Additive,
spatial_covariance: None,
kl_terms: 20,
dt: 1e-4,
}
}
}
#[derive(Debug, Clone)]
pub struct HeatSnapshot {
pub t: f64,
pub u: Array1<f64>,
}
#[derive(Debug, Clone)]
pub struct StochasticHeatSolution {
pub times: Vec<f64>,
pub snapshots: Vec<Array1<f64>>,
pub grid: Array1<f64>,
pub dim: usize,
pub shape: Vec<usize>,
}
impl StochasticHeatSolution {
pub fn len(&self) -> usize {
self.times.len()
}
pub fn is_empty(&self) -> bool {
self.times.is_empty()
}
pub fn mean_field(&self) -> Option<Array1<f64>> {
if self.snapshots.is_empty() {
return None;
}
let n = self.snapshots[0].len();
let mut mean = Array1::zeros(n);
for snap in &self.snapshots {
mean = mean + snap;
}
let count = self.snapshots.len() as f64;
Some(mean / count)
}
pub fn variance_field(&self) -> Option<Array1<f64>> {
let mean = self.mean_field()?;
let n = mean.len();
let mut var = Array1::zeros(n);
for snap in &self.snapshots {
let diff = snap - &mean;
var = var + diff.mapv(|v| v * v);
}
let count = (self.snapshots.len() as f64 - 1.0).max(1.0);
Some(var / count)
}
}
pub struct StochasticHeatSolver1D {
cfg: StochasticHeatConfig,
n_nodes: usize,
dx: f64,
domain_length: f64,
save_every: usize,
x_coords: Array1<f64>,
}
impl StochasticHeatSolver1D {
pub fn new(
config: StochasticHeatConfig,
domain_length: f64,
n_nodes: usize,
save_every: usize,
) -> IntegrateResult<Self> {
if n_nodes == 0 {
return Err(IntegrateError::InvalidInput(
"n_nodes must be at least 1".to_string(),
));
}
if domain_length <= 0.0 {
return Err(IntegrateError::InvalidInput(
"domain_length must be positive".to_string(),
));
}
if config.dt <= 0.0 {
return Err(IntegrateError::InvalidInput("dt must be positive".to_string()));
}
if config.diffusion <= 0.0 {
return Err(IntegrateError::InvalidInput(
"diffusion coefficient must be positive".to_string(),
));
}
let dx = domain_length / (n_nodes + 1) as f64;
let cfl = config.diffusion * config.dt / (dx * dx);
if cfl > 0.5 {
return Err(IntegrateError::InvalidInput(format!(
"CFL condition violated: D*dt/dx^2 = {cfl:.4} > 0.5. \
Reduce dt or increase n_nodes.",
)));
}
let x_coords = Array1::linspace(dx, domain_length - dx, n_nodes);
Ok(Self {
cfg: config,
n_nodes,
dx,
domain_length,
save_every: save_every.max(1),
x_coords,
})
}
pub fn solve(
&self,
u0: ArrayView1<f64>,
t0: f64,
t_end: f64,
rng: &mut StdRng,
) -> IntegrateResult<StochasticHeatSolution> {
if u0.len() != self.n_nodes {
return Err(IntegrateError::DimensionMismatch(format!(
"u0 length {} ≠ n_nodes {}",
u0.len(),
self.n_nodes
)));
}
if t_end <= t0 {
return Err(IntegrateError::InvalidInput(
"t_end must be greater than t0".to_string(),
));
}
let normal = Normal::new(0.0_f64, 1.0).map_err(|e| {
IntegrateError::ComputationError(format!("Normal distribution: {e}"))
})?;
let dt = self.cfg.dt;
let d = self.cfg.diffusion;
let sigma = self.cfg.sigma;
let r = d * dt / (self.dx * self.dx); let noise_scale = (dt / self.dx).sqrt();
let n_steps = ((t_end - t0) / dt).ceil() as usize;
let capacity = (n_steps / self.save_every + 2).max(2);
let mut times = Vec::with_capacity(capacity);
let mut snapshots = Vec::with_capacity(capacity);
let mut u = u0.to_owned();
let mut u_new = Array1::<f64>::zeros(self.n_nodes);
times.push(t0);
snapshots.push(u.clone());
let mut t = t0;
for step in 0..n_steps {
let actual_dt = dt.min(t_end - t);
if actual_dt <= 0.0 {
break;
}
let r_act = d * actual_dt / (self.dx * self.dx);
let ns_act = (actual_dt / self.dx).sqrt();
let xi: Array1<f64> = if self.cfg.spatial_covariance.is_some() {
self.sample_kl_noise(rng, actual_dt)?
} else {
Array1::from_vec(
(0..self.n_nodes)
.map(|_| rng.sample(&normal))
.collect(),
)
};
for i in 0..self.n_nodes {
let u_left = if i == 0 { 0.0 } else { u[i - 1] }; let u_right = if i == self.n_nodes - 1 { 0.0 } else { u[i + 1] };
let laplacian = u_left - 2.0 * u[i] + u_right;
let noise_amp = match self.cfg.noise_type {
NoiseType::Additive => sigma * ns_act,
NoiseType::Multiplicative => sigma * u[i] * ns_act,
};
u_new[i] = u[i] + r_act * laplacian + noise_amp * xi[i];
}
u.assign(&u_new);
t += actual_dt;
if (step + 1) % self.save_every == 0 || t >= t_end - 1e-14 {
times.push(t);
snapshots.push(u.clone());
}
}
Ok(StochasticHeatSolution {
times,
snapshots,
grid: self.x_coords.clone(),
dim: 1,
shape: vec![self.n_nodes],
})
}
fn sample_kl_noise(&self, rng: &mut StdRng, dt: f64) -> IntegrateResult<Array1<f64>> {
let cov = self
.cfg
.spatial_covariance
.as_ref()
.ok_or_else(|| IntegrateError::ComputationError("No covariance set".to_string()))?
.clone();
let gy = Array1::from_vec(vec![0.0_f64]); let field = RandomField::sample_kl_expansion(
self.x_coords.view(),
gy.view(),
cov,
self.cfg.kl_terms,
rng,
)?;
let noise = field.column(0).to_owned() * dt.sqrt();
Ok(noise)
}
pub fn grid(&self) -> &Array1<f64> {
&self.x_coords
}
}
pub struct StochasticHeatSolver2D {
cfg: StochasticHeatConfig,
nx: usize,
ny: usize,
dx: f64,
dy: f64,
save_every: usize,
x_coords: Vec<f64>,
y_coords: Vec<f64>,
}
impl StochasticHeatSolver2D {
pub fn new(
config: StochasticHeatConfig,
lx: f64,
ly: f64,
nx: usize,
ny: usize,
save_every: usize,
) -> IntegrateResult<Self> {
if nx == 0 || ny == 0 {
return Err(IntegrateError::InvalidInput(
"nx and ny must be at least 1".to_string(),
));
}
if lx <= 0.0 || ly <= 0.0 {
return Err(IntegrateError::InvalidInput(
"Domain dimensions must be positive".to_string(),
));
}
if config.dt <= 0.0 {
return Err(IntegrateError::InvalidInput("dt must be positive".to_string()));
}
let dx = lx / (nx + 1) as f64;
let dy = ly / (ny + 1) as f64;
let cfl = config.diffusion * config.dt * (1.0 / (dx * dx) + 1.0 / (dy * dy));
if cfl > 0.5 {
return Err(IntegrateError::InvalidInput(format!(
"2-D CFL condition violated: {cfl:.4} > 0.5. \
Reduce dt or increase nx/ny.",
)));
}
let n = nx * ny;
let mut x_coords = vec![0.0_f64; n];
let mut y_coords = vec![0.0_f64; n];
for i in 0..nx {
for j in 0..ny {
let xi = (i + 1) as f64 * dx;
let yj = (j + 1) as f64 * dy;
x_coords[i * ny + j] = xi;
y_coords[i * ny + j] = yj;
}
}
Ok(Self {
cfg: config,
nx,
ny,
dx,
dy,
save_every: save_every.max(1),
x_coords,
y_coords,
})
}
pub fn n_nodes(&self) -> usize {
self.nx * self.ny
}
pub fn solve(
&self,
u0: &[f64],
t0: f64,
t_end: f64,
rng: &mut StdRng,
) -> IntegrateResult<StochasticHeatSolution> {
let n = self.n_nodes();
if u0.len() != n {
return Err(IntegrateError::DimensionMismatch(format!(
"u0 length {} ≠ n_nodes {}",
u0.len(),
n
)));
}
if t_end <= t0 {
return Err(IntegrateError::InvalidInput(
"t_end must be greater than t0".to_string(),
));
}
let normal = Normal::new(0.0_f64, 1.0).map_err(|e| {
IntegrateError::ComputationError(format!("Normal distribution: {e}"))
})?;
let dt = self.cfg.dt;
let d = self.cfg.diffusion;
let sigma = self.cfg.sigma;
let rx = d * dt / (self.dx * self.dx);
let ry = d * dt / (self.dy * self.dy);
let noise_scale = (dt / (self.dx * self.dy)).sqrt();
let n_steps = ((t_end - t0) / dt).ceil() as usize;
let capacity = (n_steps / self.save_every + 2).max(2);
let mut times = Vec::with_capacity(capacity);
let mut snapshots = Vec::with_capacity(capacity);
let mut u = u0.to_vec();
let mut u_new = vec![0.0_f64; n];
times.push(t0);
snapshots.push(Array1::from_vec(u.clone()));
let mut t = t0;
for step in 0..n_steps {
let actual_dt = dt.min(t_end - t);
if actual_dt <= 0.0 {
break;
}
let rx_act = d * actual_dt / (self.dx * self.dx);
let ry_act = d * actual_dt / (self.dy * self.dy);
let ns_act = (actual_dt / (self.dx * self.dy)).sqrt();
for idx in 0..n {
let i = idx / self.ny;
let j = idx % self.ny;
let u_left = if i > 0 { u[(i - 1) * self.ny + j] } else { 0.0 };
let u_right = if i < self.nx - 1 {
u[(i + 1) * self.ny + j]
} else {
0.0
};
let u_down = if j > 0 { u[i * self.ny + j - 1] } else { 0.0 };
let u_up = if j < self.ny - 1 {
u[i * self.ny + j + 1]
} else {
0.0
};
let lap = (u_left - 2.0 * u[idx] + u_right) / (self.dx * self.dx)
+ (u_down - 2.0 * u[idx] + u_up) / (self.dy * self.dy);
let xi = rng.sample(&normal);
let noise_amp = match self.cfg.noise_type {
NoiseType::Additive => sigma * ns_act,
NoiseType::Multiplicative => sigma * u[idx] * ns_act,
};
u_new[idx] = u[idx] + d * actual_dt * lap + noise_amp * xi;
}
u.copy_from_slice(&u_new);
t += actual_dt;
if (step + 1) % self.save_every == 0 || t >= t_end - 1e-14 {
times.push(t);
snapshots.push(Array1::from_vec(u.clone()));
}
}
let grid = Array1::from_vec(self.x_coords.clone()); Ok(StochasticHeatSolution {
times,
snapshots,
grid,
dim: 2,
shape: vec![self.nx, self.ny],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array1;
use scirs2_core::random::prelude::*;
fn make_rng() -> StdRng {
seeded_rng(12345)
}
#[test]
fn test_1d_additive_runs() {
let config = StochasticHeatConfig {
diffusion: 0.1,
sigma: 0.05,
noise_type: NoiseType::Additive,
dt: 1e-4,
..Default::default()
};
let solver = StochasticHeatSolver1D::new(config, 1.0, 20, 10).expect("StochasticHeatSolver1D::new should succeed");
let u0 = Array1::from_vec(
(0..20)
.map(|i| ((i as f64 + 1.0) * std::f64::consts::PI / 21.0).sin())
.collect(),
);
let mut rng = make_rng();
let sol = solver.solve(u0.view(), 0.0, 0.01, &mut rng).expect("solver.solve should succeed");
assert!(!sol.is_empty());
for snap in &sol.snapshots {
assert!(snap.iter().all(|v| v.is_finite()), "Non-finite value in snapshot");
}
}
#[test]
fn test_1d_multiplicative_runs() {
let config = StochasticHeatConfig {
diffusion: 0.1,
sigma: 0.1,
noise_type: NoiseType::Multiplicative,
dt: 1e-4,
..Default::default()
};
let solver = StochasticHeatSolver1D::new(config, 1.0, 10, 5).expect("StochasticHeatSolver1D::new should succeed");
let u0 = Array1::from_vec(vec![0.5_f64; 10]);
let mut rng = make_rng();
let sol = solver.solve(u0.view(), 0.0, 0.005, &mut rng).expect("solver.solve should succeed");
assert!(!sol.is_empty());
}
#[test]
fn test_cfl_violation_returns_error() {
let config = StochasticHeatConfig {
diffusion: 1.0,
sigma: 0.1,
dt: 0.1,
..Default::default()
};
let result = StochasticHeatSolver1D::new(config, 1.0, 20, 1);
assert!(result.is_err(), "Should fail CFL check");
}
#[test]
fn test_2d_additive_runs() {
let config = StochasticHeatConfig {
diffusion: 0.05,
sigma: 0.02,
noise_type: NoiseType::Additive,
dt: 5e-5,
..Default::default()
};
let solver = StochasticHeatSolver2D::new(config, 1.0, 1.0, 8, 8, 5).expect("StochasticHeatSolver2D::new should succeed");
let u0 = vec![0.1_f64; 64];
let mut rng = make_rng();
let sol = solver.solve(&u0, 0.0, 0.001, &mut rng).expect("solver.solve should succeed");
assert!(!sol.is_empty());
for snap in &sol.snapshots {
assert!(snap.iter().all(|v| v.is_finite()));
}
}
#[test]
fn test_mean_variance_field() {
let config = StochasticHeatConfig {
diffusion: 0.1,
sigma: 0.05,
dt: 1e-4,
..Default::default()
};
let solver = StochasticHeatSolver1D::new(config, 1.0, 10, 5).expect("StochasticHeatSolver1D::new should succeed");
let u0 = Array1::from_vec(vec![0.0_f64; 10]);
let mut rng = make_rng();
let sol = solver.solve(u0.view(), 0.0, 0.01, &mut rng).expect("solver.solve should succeed");
let mean = sol.mean_field().expect("mean_field should succeed");
let var = sol.variance_field().expect("variance_field should succeed");
assert_eq!(mean.len(), 10);
assert_eq!(var.len(), 10);
assert!(var.iter().all(|v| *v >= 0.0));
}
}