Skip to main content

rustyqlib/core/fd_solvers/
adi.rs

1//! ADI (alternating direction implicit) time steppers for 1-D, 2-D and
2//! 3-D parabolic PDEs `u_t = sum_k A_k u + A_0 u`, where each `A_k` is a
3//! per-axis tridiagonal [`AxisOperator`] and `A_0` is an optional
4//! explicitly-treated part (typically the mixed derivatives of correlated
5//! factors, e.g. the `rho S v u_Sv` term of Heston).
6//!
7//! Two schemes, the standard choices in finance:
8//!
9//! - [`douglas_step`] (Do): one explicit predictor plus one implicit
10//!   correction per axis. First-order in time when a mixed term is
11//!   present, second-order (Crank-Nicolson-like at `theta = 1/2`)
12//!   without one. With a single axis and no mixed term it reduces
13//!   exactly to the 1-D theta scheme.
14//! - [`hundsdorfer_verwer_step`] (HV): Douglas plus a corrector sweep;
15//!   second-order in time including the mixed term, at roughly twice the
16//!   cost. The scheme of choice for Heston-type problems.
17//!
18//! Every stage's implicit solve is a line-by-line Thomas pass, so a step
19//! is O(nodes) regardless of dimension. Boundary rows of the operators
20//! encode the boundary conditions (all-zero row = value held fixed).
21
22use super::axis_operator::{AxisOperator, TensorGrid};
23
24/// `sum_k A_k u + A_0 u`: the full spatial operator applied explicitly.
25fn apply_full(
26    grid: &TensorGrid,
27    ops: &[AxisOperator],
28    mixed: Option<&dyn Fn(&[f64]) -> Vec<f64>>,
29    u: &[f64],
30) -> Vec<f64> {
31    let mut out = vec![0.0; u.len()];
32    for op in ops {
33        for (o, v) in out.iter_mut().zip(op.apply(grid, u)) {
34            *o += v;
35        }
36    }
37    if let Some(a0) = mixed {
38        for (o, v) in out.iter_mut().zip(a0(u)) {
39            *o += v;
40        }
41    }
42    out
43}
44
45/// One Douglas ADI step of size `dt` from `u`; `theta` is the implicit
46/// weight (1/2 is standard, 1 fully implicit stages).
47///
48/// ```text
49/// Y_0 = u + dt (A u + A_0 u)
50/// (I - theta dt A_k) Y_k = Y_{k-1} - theta dt A_k u      k = 1..d
51/// u_next = Y_d
52/// ```
53pub fn douglas_step(
54    grid: &TensorGrid,
55    ops: &[AxisOperator],
56    mixed: Option<&dyn Fn(&[f64]) -> Vec<f64>>,
57    u: &[f64],
58    dt: f64,
59    theta: f64,
60) -> Vec<f64> {
61    assert!(!ops.is_empty(), "at least one axis operator is required");
62    assert_eq!(u.len(), grid.len());
63    let f_u = apply_full(grid, ops, mixed, u);
64    let mut y: Vec<f64> = u.iter().zip(&f_u).map(|(ui, fi)| ui + dt * fi).collect();
65    for op in ops {
66        let a_u = op.apply(grid, u);
67        for (yi, ai) in y.iter_mut().zip(&a_u) {
68            *yi -= theta * dt * ai;
69        }
70        y = op.solve_shifted(grid, theta * dt, &y);
71    }
72    y
73}
74
75/// One Hundsdorfer-Verwer ADI step of size `dt` from `u`: a Douglas
76/// predictor followed by a corrector sweep with weight `mu` (1/2 is the
77/// standard choice giving second order with mixed terms).
78///
79/// ```text
80/// Y_0 = u + dt F(u)
81/// (I - theta dt A_k) Y_k     = Y_{k-1}     - theta dt A_k u      k = 1..d
82/// Yt_0 = Y_0 + mu dt (F(Y_d) - F(u))
83/// (I - theta dt A_k) Yt_k    = Yt_{k-1}    - theta dt A_k Y_d    k = 1..d
84/// u_next = Yt_d
85/// ```
86pub fn hundsdorfer_verwer_step(
87    grid: &TensorGrid,
88    ops: &[AxisOperator],
89    mixed: Option<&dyn Fn(&[f64]) -> Vec<f64>>,
90    u: &[f64],
91    dt: f64,
92    theta: f64,
93    mu: f64,
94) -> Vec<f64> {
95    assert!(!ops.is_empty(), "at least one axis operator is required");
96    assert_eq!(u.len(), grid.len());
97    let f_u = apply_full(grid, ops, mixed, u);
98    let y0: Vec<f64> = u.iter().zip(&f_u).map(|(ui, fi)| ui + dt * fi).collect();
99
100    // predictor (Douglas) sweep
101    let mut y = y0.clone();
102    for op in ops {
103        let a_u = op.apply(grid, u);
104        for (yi, ai) in y.iter_mut().zip(&a_u) {
105            *yi -= theta * dt * ai;
106        }
107        y = op.solve_shifted(grid, theta * dt, &y);
108    }
109
110    // corrector sweep around the predictor solution
111    let f_y = apply_full(grid, ops, mixed, &y);
112    let mut yt: Vec<f64> = y0
113        .iter()
114        .zip(f_y.iter().zip(&f_u))
115        .map(|(y0i, (fyi, fui))| y0i + mu * dt * (fyi - fui))
116        .collect();
117    for op in ops {
118        let a_y = op.apply(grid, &y);
119        for (yi, ai) in yt.iter_mut().zip(&a_y) {
120            *yi -= theta * dt * ai;
121        }
122        yt = op.solve_shifted(grid, theta * dt, &yt);
123    }
124    yt
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use std::f64::consts::PI;
131
132    /// Dirichlet-zero Laplacian along `axis` with spacing `h`.
133    fn laplacian(grid: &TensorGrid, axis: usize, h: f64) -> AxisOperator {
134        let mut op = AxisOperator::zero(grid, axis);
135        let stride = grid.strides()[axis];
136        let n = grid.dims()[axis];
137        for i in 0..grid.len() {
138            let j = (i / stride) % n;
139            if j > 0 && j < n - 1 {
140                op.sub[i] = 1.0 / (h * h);
141                op.diag[i] = -2.0 / (h * h);
142                op.sup[i] = 1.0 / (h * h);
143            }
144        }
145        op
146    }
147
148    /// Product-of-sines initial data, the heat equation's eigenfunction.
149    fn sine_product(grid: &TensorGrid, h: f64) -> Vec<f64> {
150        let dims = grid.dims().to_vec();
151        let strides = grid.strides();
152        (0..grid.len())
153            .map(|i| {
154                dims.iter()
155                    .zip(&strides)
156                    .map(|(&n, &s)| (PI * ((i / s) % n) as f64 * h).sin())
157                    .product()
158            })
159            .collect()
160    }
161
162    fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 {
163        a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f64::max)
164    }
165
166    /// Heat equation decay test in `ndim` dimensions: u0 = prod sin(pi x_k)
167    /// decays by e^{-ndim pi^2 t}.
168    fn heat_decay(ndim: usize, n: usize, steps: usize, dt: f64, hv: bool) -> f64 {
169        let h = 1.0 / (n - 1) as f64;
170        let grid = TensorGrid::new(&vec![n; ndim]);
171        let ops: Vec<AxisOperator> = (0..ndim).map(|k| laplacian(&grid, k, h)).collect();
172        let mut u = sine_product(&grid, h);
173        for _ in 0..steps {
174            u = if hv {
175                hundsdorfer_verwer_step(&grid, &ops, None, &u, dt, 0.5, 0.5)
176            } else {
177                douglas_step(&grid, &ops, None, &u, dt, 0.5)
178            };
179        }
180        let exact = (-(ndim as f64) * PI * PI * (steps as f64 * dt)).exp();
181        let u0 = sine_product(&grid, h);
182        // relative error at the grid maximum of the exact solution
183        let (imax, _) = u0
184            .iter()
185            .enumerate()
186            .fold((0, 0.0), |acc, (i, &v)| if v > acc.1 { (i, v) } else { acc });
187        (u[imax] / (exact * u0[imax]) - 1.0).abs()
188    }
189
190    #[test]
191    fn one_dimension_reduces_to_crank_nicolson_heat_solution() {
192        // 1 axis, no mixed term: Douglas = theta scheme
193        let err = heat_decay(1, 41, 200, 5e-4, false);
194        assert!(err < 5e-3, "1-D heat relative error {err}");
195    }
196
197    #[test]
198    fn douglas_solves_the_2d_heat_equation() {
199        let err = heat_decay(2, 21, 100, 1e-3, false);
200        assert!(err < 1e-2, "2-D heat relative error {err}");
201    }
202
203    #[test]
204    fn douglas_and_hv_solve_the_3d_heat_equation() {
205        let do_err = heat_decay(3, 11, 50, 1e-3, false);
206        let hv_err = heat_decay(3, 11, 50, 1e-3, true);
207        assert!(do_err < 3e-2, "3-D Douglas relative error {do_err}");
208        assert!(hv_err < 3e-2, "3-D HV relative error {hv_err}");
209    }
210
211    #[test]
212    fn mixed_derivative_term_matches_an_explicit_reference() {
213        // u_t = u_xx + u_yy + u_xy on a coarse grid: ADI with the mixed
214        // term explicit must track a tiny-step forward-Euler reference of
215        // the same semi-discrete system
216        let n = 9;
217        let h = 1.0 / (n - 1) as f64;
218        let grid = TensorGrid::new(&[n, n]);
219        let ops = [laplacian(&grid, 0, h), laplacian(&grid, 1, h)];
220        let (sx, sy) = (grid.strides()[0], grid.strides()[1]);
221        let dims = grid.dims().to_vec();
222        let mixed = move |u: &[f64]| -> Vec<f64> {
223            (0..u.len())
224                .map(|i| {
225                    let (jx, jy) = ((i / sx) % dims[0], (i / sy) % dims[1]);
226                    if jx == 0 || jx == dims[0] - 1 || jy == 0 || jy == dims[1] - 1 {
227                        0.0
228                    } else {
229                        (u[i + sx + sy] - u[i + sx - sy] - u[i - sx + sy] + u[i - sx - sy])
230                            / (4.0 * h * h)
231                    }
232                })
233                .collect()
234        };
235        let u0 = sine_product(&grid, h);
236        let t_end: f64 = 0.01;
237
238        // explicit reference with 1000x smaller steps
239        let mut reference = u0.clone();
240        let dt_ref = 1e-5;
241        for _ in 0..(t_end / dt_ref).round() as usize {
242            let f = apply_full(&grid, &ops, Some(&mixed), &reference);
243            for (r, fi) in reference.iter_mut().zip(&f) {
244                *r += dt_ref * fi;
245            }
246        }
247
248        let dt = 1e-3;
249        let steps = (t_end / dt).round() as usize;
250        let mut douglas = u0.clone();
251        let mut hv = u0;
252        for _ in 0..steps {
253            douglas = douglas_step(&grid, &ops, Some(&mixed), &douglas, dt, 0.5);
254            hv = hundsdorfer_verwer_step(&grid, &ops, Some(&mixed), &hv, dt, 0.5, 0.5);
255        }
256        assert!(max_abs_diff(&douglas, &reference) < 1e-2, "douglas vs reference");
257        assert!(max_abs_diff(&hv, &reference) < 1e-2, "hv vs reference");
258
259        // and the mixed term genuinely matters: dropping it moves the answer
260        let mut no_mixed = sine_product(&grid, h);
261        for _ in 0..steps {
262            no_mixed = douglas_step(&grid, &ops, None, &no_mixed, dt, 0.5);
263        }
264        assert!(max_abs_diff(&no_mixed, &reference) > 1e-3, "mixed term had no effect");
265    }
266}