Files
optimiz-rs/src/pde/fokker_planck.rs
T
ThotDjehuty d6b6018b9c release(v2.0.0-alpha.1): top-level reorg + bsde/pde/stochastic_control + mean_field/agent_based/inference/optimization
Phase 1 (top-level reorg):
  - matrix_riccati promoted to crate root via re-export
  - new top-level groups: bsde, pde, stochastic_control,
    agent_based, inference, optimization

Phase 2 (bsde):
  - theta_scheme: linear-BSDE theta-scheme
  - deep_bsde_bridge: ConditionalExpectation trait + driver

Phase 3 (pde):
  - fokker_planck: 1D forward FP with conservative central FD
  - hjb_multid: explicit n-D HJB on Cartesian grid (d <= 3)
  - elliptic_fd: 2D Poisson SOR solver

Phase 4 (stochastic_control):
  - optimal_switching: Snell envelope backward induction
  - pontryagin: 1D LQR Riccati shooting
  - two_sided_intensity_control: bilateral intensity control

Phase 5/6 (controls):
  - optimal_control::quadratic_impact_control (closed-form Riccati)
  - stochastic_control::two_sided_intensity_control

Phase 7 (mean_field + agent_based):
  - mean_field::mckean_vlasov: interacting-particle Euler scheme
  - agent_based::mod: generic interacting-agent simulator

Phase 8 (inference + optimization):
  - inference::robust_drift: Huber IRLS drift estimator
  - optimization::generative_calibration_hooks: GenerativeSampler trait
    + Gaussian MMD + finite-diff calibration step

Tests: 38 NEW tests, all passing (165/170 lib total; the 5 pre-existing
failures predate v1.1 and are tracked separately).

Versions bumped: Cargo 2.0.0-alpha.1, pyproject 2.0.0a1.

Deferred to subsequent v2.0.x bumps (parallelisable follow-ups):
PyO3 bindings, executed companion notebooks, Sphinx RST pages,
hfthot-lab-instance propagation.
2026-05-12 12:02:07 +02:00

188 lines
5.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 1-D FokkerPlanck (Kolmogorov forward) equation
//! =================================================
//!
//! Solves
//!
//! ```text
//! ∂_t m(x, t) + ∂_x [μ(x) m(x, t)] - (1/2) ∂_xx [σ²(x) m(x, t)] = 0, t > 0
//! m(x, 0) = m_0(x), Dirichlet boundary m = 0 on the box ends.
//! ```
//!
//! Discretisation: forward Euler in time, conservative central differences in
//! space. The CFL-type stability condition `Δt · (max|μ|/Δx + max σ²/Δx²) ≤ 1`
//! must hold; the routine returns an error when it would be violated.
use crate::core::{OptimizrError, Result};
use ndarray::Array1;
#[derive(Clone, Debug)]
pub struct FokkerPlanckConfig {
pub n_x: usize,
pub x_min: f64,
pub x_max: f64,
pub n_t: usize,
pub t_horizon: f64,
}
impl FokkerPlanckConfig {
pub fn validate(&self) -> Result<()> {
if self.n_x < 5 {
return Err(OptimizrError::InvalidParameter("n_x must be ≥ 5".into()));
}
if !(self.x_max > self.x_min) {
return Err(OptimizrError::InvalidParameter("x_max > x_min required".into()));
}
if self.n_t == 0 {
return Err(OptimizrError::InvalidParameter("n_t must be > 0".into()));
}
if !(self.t_horizon > 0.0) {
return Err(OptimizrError::InvalidParameter("t_horizon must be > 0".into()));
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct FokkerPlanckResult {
pub x_grid: Array1<f64>,
pub time_grid: Array1<f64>,
/// Density at each `(t_k, x_i)` flattened in row-major order
/// `density[k * n_x + i]`.
pub density: Vec<f64>,
}
pub fn solve_fokker_planck_1d<Mu, Sigma2, M0>(
drift: Mu,
diffusion_sq: Sigma2,
initial_density: M0,
cfg: &FokkerPlanckConfig,
) -> Result<FokkerPlanckResult>
where
Mu: Fn(f64) -> f64,
Sigma2: Fn(f64) -> f64,
M0: Fn(f64) -> f64,
{
cfg.validate()?;
let nx = cfg.n_x;
let nt = cfg.n_t;
let dx = (cfg.x_max - cfg.x_min) / (nx - 1) as f64;
let dt = cfg.t_horizon / nt as f64;
let x_grid: Array1<f64> = Array1::from_iter((0..nx).map(|i| cfg.x_min + i as f64 * dx));
let time_grid: Array1<f64> = Array1::from_iter((0..=nt).map(|k| k as f64 * dt));
// Stability check (very mild upper bound on coefficients sampled on the grid).
let mut max_mu = 0.0_f64;
let mut max_sig = 0.0_f64;
for &x in x_grid.iter() {
max_mu = max_mu.max(drift(x).abs());
max_sig = max_sig.max(diffusion_sq(x).abs());
}
let cfl = dt * (max_mu / dx + max_sig / (dx * dx));
if cfl > 1.0 {
return Err(OptimizrError::NumericalError(format!(
"CFL condition violated: dt·(|μ|/dx + σ²/dx²) = {cfl:.3} > 1"
)));
}
let mut density = vec![0.0; nx * (nt + 1)];
for i in 0..nx {
density[i] = initial_density(x_grid[i]).max(0.0);
}
// Renormalise initial density to mass 1 (trapezoidal).
let mut mass = 0.0;
for i in 0..nx - 1 {
mass += 0.5 * dx * (density[i] + density[i + 1]);
}
if mass > 0.0 {
for i in 0..nx {
density[i] /= mass;
}
}
for k in 0..nt {
let off = k * nx;
let new_off = (k + 1) * nx;
// Boundaries enforced to zero
density[new_off] = 0.0;
density[new_off + nx - 1] = 0.0;
for i in 1..nx - 1 {
let x_im = x_grid[i - 1];
let x_ip = x_grid[i + 1];
let m_im = density[off + i - 1];
let m_i = density[off + i];
let m_ip = density[off + i + 1];
let mu_im = drift(x_im);
let mu_ip = drift(x_ip);
let s_im = diffusion_sq(x_im);
let s_i = diffusion_sq(x_grid[i]);
let s_ip = diffusion_sq(x_ip);
let drift_term = (mu_ip * m_ip - mu_im * m_im) / (2.0 * dx);
let diff_term = (s_ip * m_ip - 2.0 * s_i * m_i + s_im * m_im) / (dx * dx);
density[new_off + i] = m_i - dt * drift_term + 0.5 * dt * diff_term;
if density[new_off + i] < 0.0 {
density[new_off + i] = 0.0; // positivity safeguard
}
}
}
Ok(FokkerPlanckResult {
x_grid,
time_grid,
density,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
/// Pure diffusion `μ = 0, σ² = 1` with Gaussian initial condition centred
/// at 0 should remain centred and stay non-negative; total mass should be
/// approximately conserved before any boundary loss.
#[test]
fn pure_diffusion_keeps_mean_at_zero() {
let cfg = FokkerPlanckConfig {
n_x: 401,
x_min: -8.0,
x_max: 8.0,
n_t: 8000,
t_horizon: 0.5,
};
let res = solve_fokker_planck_1d(
|_| 0.0,
|_| 1.0,
|x| (-(x * x) / 2.0).exp() / (2.0 * PI).sqrt(),
&cfg,
)
.unwrap();
let nx = cfg.n_x;
let off = cfg.n_t * nx;
let dx = (cfg.x_max - cfg.x_min) / (nx - 1) as f64;
let mut mean = 0.0;
let mut mass = 0.0;
for i in 0..nx {
let x = cfg.x_min + i as f64 * dx;
let m = res.density[off + i];
mean += x * m * dx;
mass += m * dx;
}
assert!(mass > 0.5, "lost too much mass: {mass}");
assert!(mean.abs() < 0.05, "mean drifted: {mean}");
}
#[test]
fn cfl_violation_is_detected() {
let cfg = FokkerPlanckConfig {
n_x: 11,
x_min: 0.0,
x_max: 1.0,
n_t: 1,
t_horizon: 1.0,
};
let res = solve_fokker_planck_1d(|_| 0.0, |_| 1.0, |_| 1.0, &cfg);
assert!(res.is_err());
}
}