Skip to main content

oxiphysics_core/
finite_difference.rs

1#![allow(clippy::needless_range_loop, clippy::ptr_arg)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Finite difference schemes for PDEs in 1D, 2D, and 3D.
6//!
7//! Covers explicit/implicit time integration, Crank-Nicolson diffusion,
8//! upwind advection, 5th-order WENO, compact finite differences, boundary
9//! conditions (Dirichlet/Neumann/periodic), Richardson extrapolation,
10//! method of lines, stability analysis (CFL, von Neumann), staggered grids,
11//! fractional step method, and ADI (alternating direction implicit).
12
13#![allow(dead_code)]
14
15use std::f64::consts::PI;
16
17// ─────────────────────────────────────────────────────────────────────────────
18// Boundary condition types
19// ─────────────────────────────────────────────────────────────────────────────
20
21/// Boundary condition specification for finite difference solvers.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub enum BoundaryCondition {
24    /// Dirichlet condition: fixed value at the boundary.
25    Dirichlet(f64),
26    /// Neumann condition: fixed normal derivative at the boundary.
27    Neumann(f64),
28    /// Periodic boundary condition (wraps domain).
29    Periodic,
30}
31
32// ─────────────────────────────────────────────────────────────────────────────
33// Thomas algorithm (tridiagonal solver)
34// ─────────────────────────────────────────────────────────────────────────────
35
36/// Solves the tridiagonal system `A x = d` using the Thomas algorithm.
37///
38/// `a` is the sub-diagonal (index 0 unused), `b` the main diagonal,
39/// `c` the super-diagonal (last entry unused), and `d` the right-hand side.
40/// All slices must have length `n`.
41#[allow(clippy::many_single_char_names)]
42pub fn thomas_algorithm(a: &[f64], b: &[f64], c: &[f64], d: &[f64]) -> Vec<f64> {
43    let n = b.len();
44    assert!(n >= 2, "system must have at least 2 unknowns");
45    assert_eq!(a.len(), n);
46    assert_eq!(c.len(), n);
47    assert_eq!(d.len(), n);
48
49    let mut cp = vec![0.0_f64; n];
50    let mut dp = vec![0.0_f64; n];
51    let mut x = vec![0.0_f64; n];
52
53    cp[0] = c[0] / b[0];
54    dp[0] = d[0] / b[0];
55    for i in 1..n {
56        let denom = b[i] - a[i] * cp[i - 1];
57        cp[i] = if i < n - 1 { c[i] / denom } else { 0.0 };
58        dp[i] = (d[i] - a[i] * dp[i - 1]) / denom;
59    }
60
61    x[n - 1] = dp[n - 1];
62    for i in (0..n - 1).rev() {
63        x[i] = dp[i] - cp[i] * x[i + 1];
64    }
65    x
66}
67
68// ─────────────────────────────────────────────────────────────────────────────
69// First and second order 1D stencils
70// ─────────────────────────────────────────────────────────────────────────────
71
72/// Computes the first derivative of `u` using second-order central differences.
73///
74/// At boundaries uses one-sided (forward/backward) differences.
75pub fn first_derivative_central(u: &[f64], dx: f64) -> Vec<f64> {
76    let n = u.len();
77    let mut du = vec![0.0_f64; n];
78    if n < 2 {
79        return du;
80    }
81    du[0] = (u[1] - u[0]) / dx;
82    for i in 1..n - 1 {
83        du[i] = (u[i + 1] - u[i - 1]) / (2.0 * dx);
84    }
85    du[n - 1] = (u[n - 1] - u[n - 2]) / dx;
86    du
87}
88
89/// Computes the second derivative of `u` with the standard 3-point Laplacian stencil.
90///
91/// Interior: `(u[i-1] - 2 u[i] + u[i+1]) / dx²`. Boundaries are set to zero.
92pub fn second_derivative(u: &[f64], dx: f64) -> Vec<f64> {
93    let n = u.len();
94    let mut d2u = vec![0.0_f64; n];
95    for i in 1..n.saturating_sub(1) {
96        d2u[i] = (u[i - 1] - 2.0 * u[i] + u[i + 1]) / (dx * dx);
97    }
98    d2u
99}
100
101/// Computes the fourth-order central difference first derivative.
102///
103/// Uses the 5-point stencil: `(-u[i+2] + 8u[i+1] - 8u[i-1] + u[i-2]) / (12 dx)`.
104/// Falls back to second-order central differences near boundaries.
105pub fn first_derivative_fourth_order(u: &[f64], dx: f64) -> Vec<f64> {
106    let n = u.len();
107    let mut du = vec![0.0_f64; n];
108    if n < 2 {
109        return du;
110    }
111    // boundaries: second-order
112    du[0] = (u[1] - u[0]) / dx;
113    if n > 1 {
114        du[n - 1] = (u[n - 1] - u[n - 2]) / dx;
115    }
116    if n > 2 {
117        du[1] = (u[2] - u[0]) / (2.0 * dx);
118        du[n - 2] = (u[n - 1] - u[n - 3]) / (2.0 * dx);
119    }
120    // interior: fourth-order
121    for i in 2..n.saturating_sub(2) {
122        du[i] = (-u[i + 2] + 8.0 * u[i + 1] - 8.0 * u[i - 1] + u[i - 2]) / (12.0 * dx);
123    }
124    du
125}
126
127// ─────────────────────────────────────────────────────────────────────────────
128// Upwind scheme for advection
129// ─────────────────────────────────────────────────────────────────────────────
130
131/// First-order upwind advection: advances `u` one time step `dt`.
132///
133/// The equation solved is `∂u/∂t + c ∂u/∂x = 0` with `bc` at the inflow.
134#[allow(clippy::too_many_arguments)]
135pub fn advection_upwind_step(
136    u: &[f64],
137    dx: f64,
138    dt: f64,
139    c: f64,
140    bc_left: BoundaryCondition,
141    bc_right: BoundaryCondition,
142) -> Vec<f64> {
143    let n = u.len();
144    let mut un = u.to_vec();
145    let r = c * dt / dx;
146    for i in 1..n - 1 {
147        if c > 0.0 {
148            un[i] = u[i] - r * (u[i] - u[i - 1]);
149        } else {
150            un[i] = u[i] - r * (u[i + 1] - u[i]);
151        }
152    }
153    apply_bc_1d(&mut un, bc_left, bc_right);
154    un
155}
156
157/// Applies 1D boundary conditions (left and right) to a solution vector.
158pub fn apply_bc_1d(u: &mut Vec<f64>, left: BoundaryCondition, right: BoundaryCondition) {
159    let n = u.len();
160    if n == 0 {
161        return;
162    }
163    match left {
164        BoundaryCondition::Dirichlet(v) => u[0] = v,
165        BoundaryCondition::Neumann(dv) => {
166            if n > 1 {
167                u[0] = u[1] - dv * 1.0; // dx factored out
168            }
169        }
170        BoundaryCondition::Periodic => {
171            if n > 1 {
172                u[0] = u[n - 2];
173            }
174        }
175    }
176    match right {
177        BoundaryCondition::Dirichlet(v) => u[n - 1] = v,
178        BoundaryCondition::Neumann(dv) => {
179            if n > 1 {
180                u[n - 1] = u[n - 2] + dv * 1.0;
181            }
182        }
183        BoundaryCondition::Periodic => {
184            if n > 1 {
185                u[n - 1] = u[1];
186            }
187        }
188    }
189}
190
191// ─────────────────────────────────────────────────────────────────────────────
192// Explicit diffusion (FTCS)
193// ─────────────────────────────────────────────────────────────────────────────
194
195/// Forward-time central-space (FTCS) explicit diffusion step.
196///
197/// Solves `∂u/∂t = α ∂²u/∂x²`. Stable when `α dt / dx² ≤ 0.5`.
198pub fn diffusion_explicit_step(
199    u: &[f64],
200    dx: f64,
201    dt: f64,
202    alpha: f64,
203    bc_left: BoundaryCondition,
204    bc_right: BoundaryCondition,
205) -> Vec<f64> {
206    let n = u.len();
207    let r = alpha * dt / (dx * dx);
208    let mut un = u.to_vec();
209    for i in 1..n - 1 {
210        un[i] = u[i] + r * (u[i - 1] - 2.0 * u[i] + u[i + 1]);
211    }
212    apply_bc_1d(&mut un, bc_left, bc_right);
213    un
214}
215
216// ─────────────────────────────────────────────────────────────────────────────
217// Implicit diffusion (Crank-Nicolson)
218// ─────────────────────────────────────────────────────────────────────────────
219
220/// Crank-Nicolson implicit diffusion step.
221///
222/// Solves `∂u/∂t = α ∂²u/∂x²` using the unconditionally stable C-N scheme.
223/// Interior points are advanced; boundary values are enforced via `bc_left` / `bc_right`.
224pub fn diffusion_crank_nicolson_step(
225    u: &[f64],
226    dx: f64,
227    dt: f64,
228    alpha: f64,
229    bc_left: BoundaryCondition,
230    bc_right: BoundaryCondition,
231) -> Vec<f64> {
232    let n = u.len();
233    let r = alpha * dt / (2.0 * dx * dx);
234
235    // Build RHS for interior points
236    let mut rhs = vec![0.0_f64; n];
237    rhs[0] = match bc_left {
238        BoundaryCondition::Dirichlet(v) => v,
239        _ => u[0],
240    };
241    rhs[n - 1] = match bc_right {
242        BoundaryCondition::Dirichlet(v) => v,
243        _ => u[n - 1],
244    };
245    for i in 1..n - 1 {
246        rhs[i] = r * u[i - 1] + (1.0 - 2.0 * r) * u[i] + r * u[i + 1];
247    }
248
249    // Tridiagonal system: interior rows
250    let mut a_diag = vec![0.0_f64; n]; // sub-diag
251    let mut b_diag = vec![0.0_f64; n]; // main
252    let mut c_diag = vec![0.0_f64; n]; // super-diag
253
254    b_diag[0] = 1.0;
255    b_diag[n - 1] = 1.0;
256    for i in 1..n - 1 {
257        a_diag[i] = -r;
258        b_diag[i] = 1.0 + 2.0 * r;
259        c_diag[i] = -r;
260    }
261
262    let mut sol = thomas_algorithm(&a_diag, &b_diag, &c_diag, &rhs);
263    apply_bc_1d(&mut sol, bc_left, bc_right);
264    sol
265}
266
267// ─────────────────────────────────────────────────────────────────────────────
268// Implicit (fully implicit / backward Euler) diffusion
269// ─────────────────────────────────────────────────────────────────────────────
270
271/// Fully implicit (backward Euler) diffusion step.
272///
273/// Unconditionally stable. Solves `(I - dt α D²) uⁿ⁺¹ = uⁿ`.
274pub fn diffusion_implicit_step(
275    u: &[f64],
276    dx: f64,
277    dt: f64,
278    alpha: f64,
279    bc_left: BoundaryCondition,
280    bc_right: BoundaryCondition,
281) -> Vec<f64> {
282    let n = u.len();
283    let r = alpha * dt / (dx * dx);
284    let mut a_diag = vec![0.0_f64; n];
285    let mut b_diag = vec![0.0_f64; n];
286    let mut c_diag = vec![0.0_f64; n];
287    let mut rhs = u.to_vec();
288
289    b_diag[0] = 1.0;
290    b_diag[n - 1] = 1.0;
291    for i in 1..n - 1 {
292        a_diag[i] = -r;
293        b_diag[i] = 1.0 + 2.0 * r;
294        c_diag[i] = -r;
295    }
296
297    // enforce Dirichlet BCs in rhs
298    if let BoundaryCondition::Dirichlet(v) = bc_left {
299        rhs[0] = v;
300    }
301    if let BoundaryCondition::Dirichlet(v) = bc_right {
302        rhs[n - 1] = v;
303    }
304
305    let mut sol = thomas_algorithm(&a_diag, &b_diag, &c_diag, &rhs);
306    apply_bc_1d(&mut sol, bc_left, bc_right);
307    sol
308}
309
310// ─────────────────────────────────────────────────────────────────────────────
311// WENO-5 (5th order weighted essentially non-oscillatory)
312// ─────────────────────────────────────────────────────────────────────────────
313
314/// Computes the WENO-5 smoothness indicator `β` for a three-point stencil.
315///
316/// Used internally by [`weno5_flux`].
317fn weno5_beta(v0: f64, v1: f64, v2: f64) -> f64 {
318    let d1 = v1 - v0;
319    let d2 = v2 - 2.0 * v1 + v0;
320    13.0 / 12.0 * d2 * d2 + 0.25 * (3.0 * v1 - 4.0 * v0 + v2).powi(2) - 0.25 * (v1 - v0).powi(2)
321        + 0.25 * d1 * d1
322}
323
324/// Computes the WENO-5 reconstructed flux at interface `i + 1/2` for `c > 0`.
325///
326/// Requires the five stencil values `[v0, v1, v2, v3, v4]` = `u[i-2..i+3]`.
327pub fn weno5_flux(v: [f64; 5]) -> f64 {
328    let eps = 1e-36_f64;
329
330    // three candidate stencils
331    let q0 = (2.0 * v[0] - 7.0 * v[1] + 11.0 * v[2]) / 6.0;
332    let q1 = (-v[1] + 5.0 * v[2] + 2.0 * v[3]) / 6.0;
333    let q2 = (2.0 * v[2] + 5.0 * v[3] - v[4]) / 6.0;
334
335    // smoothness indicators
336    let b0 = 13.0 / 12.0 * (v[0] - 2.0 * v[1] + v[2]).powi(2)
337        + 0.25 * (v[0] - 4.0 * v[1] + 3.0 * v[2]).powi(2);
338    let b1 = 13.0 / 12.0 * (v[1] - 2.0 * v[2] + v[3]).powi(2) + 0.25 * (v[1] - v[3]).powi(2);
339    let b2 = 13.0 / 12.0 * (v[2] - 2.0 * v[3] + v[4]).powi(2)
340        + 0.25 * (3.0 * v[2] - 4.0 * v[3] + v[4]).powi(2);
341
342    let d0 = 1.0 / 10.0;
343    let d1 = 6.0 / 10.0;
344    let d2 = 3.0 / 10.0;
345
346    let w0 = d0 / (eps + b0).powi(2);
347    let w1 = d1 / (eps + b1).powi(2);
348    let w2 = d2 / (eps + b2).powi(2);
349    let wsum = w0 + w1 + w2;
350
351    (w0 * q0 + w1 * q1 + w2 * q2) / wsum
352}
353
354/// One time step of the WENO-5 advection scheme for `∂u/∂t + c ∂u/∂x = 0`.
355///
356/// Uses 5th-order WENO spatial reconstruction and first-order Euler time integration.
357/// Ghost cells on each side are filled by zero-order extrapolation.
358pub fn advection_weno5_step(u: &[f64], dx: f64, dt: f64, c: f64) -> Vec<f64> {
359    let n = u.len();
360    let mut un = u.to_vec();
361
362    // build padded array with 3 ghost cells each side
363    let pad = 3_usize;
364    let mut up = vec![0.0_f64; n + 2 * pad];
365    for i in 0..pad {
366        up[i] = u[0];
367        up[n + pad + i] = u[n - 1];
368    }
369    up[pad..pad + n].copy_from_slice(u);
370
371    for i in 0..n {
372        let ip = i + pad; // index in padded array
373        let flux_right = if c > 0.0 {
374            weno5_flux([up[ip - 2], up[ip - 1], up[ip], up[ip + 1], up[ip + 2]])
375        } else {
376            weno5_flux([up[ip + 3], up[ip + 2], up[ip + 1], up[ip], up[ip - 1]])
377        };
378        let flux_left = if c > 0.0 {
379            weno5_flux([up[ip - 3], up[ip - 2], up[ip - 1], up[ip], up[ip + 1]])
380        } else {
381            weno5_flux([up[ip + 2], up[ip + 1], up[ip], up[ip - 1], up[ip - 2]])
382        };
383        un[i] = u[i] - c * dt / dx * (flux_right - flux_left);
384    }
385    un
386}
387
388// ─────────────────────────────────────────────────────────────────────────────
389// Compact finite differences (Padé 4th-order for first derivative)
390// ─────────────────────────────────────────────────────────────────────────────
391
392/// Compact (Padé) 4th-order first derivative.
393///
394/// Solves the tridiagonal system `(1/4) f'[i-1] + f'[i] + (1/4) f'[i+1] = (3/2)(u[i+1]-u[i-1])/(2dx)`.
395/// Interior only; boundary nodes use standard second-order differences.
396pub fn compact_first_derivative(u: &[f64], dx: f64) -> Vec<f64> {
397    let n = u.len();
398    if n < 3 {
399        return first_derivative_central(u, dx);
400    }
401
402    let mut rhs = vec![0.0_f64; n];
403    rhs[0] = (u[1] - u[0]) / dx;
404    rhs[n - 1] = (u[n - 1] - u[n - 2]) / dx;
405    for i in 1..n - 1 {
406        rhs[i] = (3.0 / 2.0) * (u[i + 1] - u[i - 1]) / (2.0 * dx);
407    }
408
409    let mut a_d = vec![0.0_f64; n];
410    let b_d = vec![1.0_f64; n];
411    let mut c_d = vec![0.0_f64; n];
412    for i in 1..n - 1 {
413        a_d[i] = 1.0 / 4.0;
414        c_d[i] = 1.0 / 4.0;
415    }
416
417    thomas_algorithm(&a_d, &b_d, &c_d, &rhs)
418}
419
420// ─────────────────────────────────────────────────────────────────────────────
421// Richardson extrapolation
422// ─────────────────────────────────────────────────────────────────────────────
423
424/// Richardson extrapolation to combine two estimates with step sizes `h` and `h/2`.
425///
426/// For a method of order `p`, the extrapolated value is
427/// `(2^p * f_h2 - f_h) / (2^p - 1)`.
428pub fn richardson_extrapolation(f_h: f64, f_h2: f64, order: u32) -> f64 {
429    let r = (2_u64.pow(order)) as f64;
430    (r * f_h2 - f_h) / (r - 1.0)
431}
432
433/// Applies Richardson extrapolation to refine a 1D finite-difference derivative.
434///
435/// Computes the first derivative at two resolutions (`dx` and `dx/2`) and
436/// combines them to produce an estimate of order `order + 2`.
437pub fn richardson_derivative(u_coarse: &[f64], u_fine: &[f64], dx: f64, order: u32) -> Vec<f64> {
438    let nc = u_coarse.len();
439    let _nf = u_fine.len();
440    let dc = first_derivative_central(u_coarse, dx);
441    let df_on_coarse: Vec<f64> = (0..nc)
442        .map(|i| {
443            let if_ = i * 2;
444            let nf = u_fine.len();
445            if if_ > 0 && if_ < nf - 1 {
446                (u_fine[if_ + 1] - u_fine[if_ - 1]) / (2.0 * dx / 2.0)
447            } else {
448                dc[i]
449            }
450        })
451        .collect();
452
453    dc.iter()
454        .zip(df_on_coarse.iter())
455        .map(|(&fh, &fh2)| richardson_extrapolation(fh, fh2, order))
456        .collect()
457}
458
459// ─────────────────────────────────────────────────────────────────────────────
460// Stability criteria
461// ─────────────────────────────────────────────────────────────────────────────
462
463/// CFL (Courant-Friedrichs-Lewy) number for the advection equation.
464///
465/// `CFL = |c| * dt / dx`. Stability of explicit schemes requires `CFL ≤ 1`.
466pub fn cfl_number(c: f64, dt: f64, dx: f64) -> f64 {
467    c.abs() * dt / dx
468}
469
470/// Von Neumann diffusion stability number `r = α dt / dx²`.
471///
472/// Explicit FTCS scheme is stable when `r ≤ 0.5`.
473pub fn von_neumann_diffusion_number(alpha: f64, dt: f64, dx: f64) -> f64 {
474    alpha * dt / (dx * dx)
475}
476
477/// Returns `true` if the explicit advection scheme is stable (CFL ≤ 1).
478pub fn is_advection_stable(c: f64, dt: f64, dx: f64) -> bool {
479    cfl_number(c, dt, dx) <= 1.0
480}
481
482/// Returns `true` if the explicit diffusion scheme (FTCS) is stable.
483pub fn is_diffusion_stable(alpha: f64, dt: f64, dx: f64) -> bool {
484    von_neumann_diffusion_number(alpha, dt, dx) <= 0.5
485}
486
487/// Computes the maximum stable time step for explicit advection.
488pub fn max_dt_advection(c: f64, dx: f64) -> f64 {
489    dx / c.abs().max(f64::EPSILON)
490}
491
492/// Computes the maximum stable time step for explicit diffusion (FTCS).
493pub fn max_dt_diffusion(alpha: f64, dx: f64) -> f64 {
494    dx * dx / (2.0 * alpha.abs().max(f64::EPSILON))
495}
496
497// ─────────────────────────────────────────────────────────────────────────────
498// Method of lines (MOL)
499// ─────────────────────────────────────────────────────────────────────────────
500
501/// Method-of-lines spatial operator for the 1D diffusion equation.
502///
503/// Returns `du/dt = α d²u/dx²` evaluated at all interior points.
504/// `u` is the full solution vector including boundary nodes.
505pub fn mol_diffusion_rhs(u: &[f64], dx: f64, alpha: f64) -> Vec<f64> {
506    second_derivative(u, dx)
507        .iter()
508        .map(|&v| alpha * v)
509        .collect()
510}
511
512/// Method-of-lines spatial operator for the 1D advection equation.
513///
514/// Returns `du/dt = -c du/dx` using upwind differences.
515pub fn mol_advection_rhs(u: &[f64], dx: f64, c: f64) -> Vec<f64> {
516    let n = u.len();
517    let mut rhs = vec![0.0_f64; n];
518    for i in 1..n - 1 {
519        let dudx = if c > 0.0 {
520            (u[i] - u[i - 1]) / dx
521        } else {
522            (u[i + 1] - u[i]) / dx
523        };
524        rhs[i] = -c * dudx;
525    }
526    rhs
527}
528
529/// Runge-Kutta 4 integrator for method of lines.
530///
531/// `f(t, u)` is the right-hand side, `u` the current state, `dt` the time step.
532pub fn rk4_step<F>(u: &[f64], t: f64, dt: f64, f: &F) -> Vec<f64>
533where
534    F: Fn(f64, &[f64]) -> Vec<f64>,
535{
536    let n = u.len();
537    let k1 = f(t, u);
538    let u2: Vec<f64> = (0..n).map(|i| u[i] + 0.5 * dt * k1[i]).collect();
539    let k2 = f(t + 0.5 * dt, &u2);
540    let u3: Vec<f64> = (0..n).map(|i| u[i] + 0.5 * dt * k2[i]).collect();
541    let k3 = f(t + 0.5 * dt, &u3);
542    let u4: Vec<f64> = (0..n).map(|i| u[i] + dt * k3[i]).collect();
543    let k4 = f(t + dt, &u4);
544    (0..n)
545        .map(|i| u[i] + dt / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]))
546        .collect()
547}
548
549// ─────────────────────────────────────────────────────────────────────────────
550// Staggered grid helpers
551// ─────────────────────────────────────────────────────────────────────────────
552
553/// A staggered 1D grid holding cell-centred values `u` and face-centred fluxes `f`.
554///
555/// Used in finite-volume and MAC schemes.
556#[derive(Debug, Clone)]
557pub struct StaggeredGrid1D {
558    /// Cell-centred scalar field, length `n`.
559    pub u: Vec<f64>,
560    /// Face-centred flux field, length `n + 1`.
561    pub flux: Vec<f64>,
562    /// Grid spacing.
563    pub dx: f64,
564}
565
566impl StaggeredGrid1D {
567    /// Creates a new staggered grid with `n` cells and spacing `dx`.
568    pub fn new(n: usize, dx: f64) -> Self {
569        Self {
570            u: vec![0.0_f64; n],
571            flux: vec![0.0_f64; n + 1],
572            dx,
573        }
574    }
575
576    /// Computes face fluxes from cell-centred velocities by simple averaging.
577    pub fn interpolate_to_faces(&mut self) {
578        let n = self.u.len();
579        self.flux[0] = self.u[0];
580        self.flux[n] = self.u[n - 1];
581        for i in 1..n {
582            self.flux[i] = 0.5 * (self.u[i - 1] + self.u[i]);
583        }
584    }
585
586    /// Computes the divergence of the flux field at each cell centre.
587    pub fn divergence(&self) -> Vec<f64> {
588        let n = self.u.len();
589        (0..n)
590            .map(|i| (self.flux[i + 1] - self.flux[i]) / self.dx)
591            .collect()
592    }
593}
594
595// ─────────────────────────────────────────────────────────────────────────────
596// Fractional step (projection) method
597// ─────────────────────────────────────────────────────────────────────────────
598
599/// Fractional step result from one projection step.
600#[derive(Debug, Clone)]
601pub struct FractionalStepResult {
602    /// Intermediate (unprojected) velocity.
603    pub u_star: Vec<f64>,
604    /// Pressure correction field.
605    pub pressure: Vec<f64>,
606    /// Divergence-free velocity after projection.
607    pub u_new: Vec<f64>,
608}
609
610/// Fractional step (Chorin projection) method for 1D incompressible flow.
611///
612/// Step 1: Advance velocity ignoring pressure (explicit advection + diffusion).
613/// Step 2: Solve pressure Poisson equation.
614/// Step 3: Project to divergence-free field.
615#[allow(clippy::too_many_arguments)]
616pub fn fractional_step_1d(
617    u: &[f64],
618    dx: f64,
619    dt: f64,
620    nu: f64,
621    body_force: f64,
622) -> FractionalStepResult {
623    let n = u.len();
624
625    // Step 1: predict velocity (advection + diffusion + body force)
626    let mut u_star = u.to_vec();
627    for i in 1..n - 1 {
628        let adv = u[i] * (u[i] - u[i - 1]) / dx;
629        let diff = nu * (u[i - 1] - 2.0 * u[i] + u[i + 1]) / (dx * dx);
630        u_star[i] = u[i] + dt * (-adv + diff + body_force);
631    }
632
633    // Step 2: pressure Poisson equation (div u_star = Δt ∇²p)
634    let mut rhs_p = vec![0.0_f64; n];
635    for i in 1..n - 1 {
636        rhs_p[i] = (u_star[i + 1] - u_star[i - 1]) / (2.0 * dx) / dt;
637    }
638    let a_d: Vec<f64> = (0..n)
639        .map(|i| {
640            if i == 0 || i == n - 1 {
641                0.0
642            } else {
643                -1.0 / (dx * dx)
644            }
645        })
646        .collect();
647    let b_d: Vec<f64> = (0..n)
648        .map(|i| {
649            if i == 0 || i == n - 1 {
650                1.0
651            } else {
652                2.0 / (dx * dx)
653            }
654        })
655        .collect();
656    let c_d: Vec<f64> = a_d.clone();
657    let pressure = thomas_algorithm(&a_d, &b_d, &c_d, &rhs_p);
658
659    // Step 3: projection
660    let mut u_new = u_star.clone();
661    for i in 1..n - 1 {
662        u_new[i] -= dt * (pressure[i + 1] - pressure[i - 1]) / (2.0 * dx);
663    }
664
665    FractionalStepResult {
666        u_star,
667        pressure,
668        u_new,
669    }
670}
671
672// ─────────────────────────────────────────────────────────────────────────────
673// 2D finite difference helpers
674// ─────────────────────────────────────────────────────────────────────────────
675
676/// Computes the 2D Laplacian using the standard 5-point stencil.
677///
678/// `u` is stored row-major with `nx` columns and `ny` rows.
679/// Interior points only; boundary nodes are left unchanged.
680pub fn laplacian_2d(u: &[f64], nx: usize, ny: usize, dx: f64, dy: f64) -> Vec<f64> {
681    let mut lap = vec![0.0_f64; nx * ny];
682    for j in 1..ny - 1 {
683        for i in 1..nx - 1 {
684            let idx = j * nx + i;
685            let d2x = (u[idx - 1] - 2.0 * u[idx] + u[idx + 1]) / (dx * dx);
686            let d2y = (u[idx - nx] - 2.0 * u[idx] + u[idx + nx]) / (dy * dy);
687            lap[idx] = d2x + d2y;
688        }
689    }
690    lap
691}
692
693/// Applies 2D Dirichlet boundary conditions to all four edges.
694///
695/// `u` is row-major with `nx` columns and `ny` rows.
696/// Sets the border values to `value`.
697pub fn apply_dirichlet_2d(u: &mut [f64], nx: usize, ny: usize, value: f64) {
698    for i in 0..nx {
699        u[i] = value;
700        u[(ny - 1) * nx + i] = value;
701    }
702    for j in 0..ny {
703        u[j * nx] = value;
704        u[j * nx + nx - 1] = value;
705    }
706}
707
708/// One explicit 2D diffusion step using the FTCS scheme.
709///
710/// Solves `∂u/∂t = α (∂²u/∂x² + ∂²u/∂y²)`. Boundary nodes are not updated.
711pub fn diffusion_2d_explicit_step(
712    u: &[f64],
713    nx: usize,
714    ny: usize,
715    dx: f64,
716    dy: f64,
717    dt: f64,
718    alpha: f64,
719) -> Vec<f64> {
720    let mut un = u.to_vec();
721    let rx = alpha * dt / (dx * dx);
722    let ry = alpha * dt / (dy * dy);
723    for j in 1..ny - 1 {
724        for i in 1..nx - 1 {
725            let idx = j * nx + i;
726            un[idx] = u[idx]
727                + rx * (u[idx - 1] - 2.0 * u[idx] + u[idx + 1])
728                + ry * (u[idx - nx] - 2.0 * u[idx] + u[idx + nx]);
729        }
730    }
731    un
732}
733
734// ─────────────────────────────────────────────────────────────────────────────
735// ADI (Alternating Direction Implicit) for 2D diffusion
736// ─────────────────────────────────────────────────────────────────────────────
737
738/// One full ADI time step for 2D diffusion `∂u/∂t = α(∂²u/∂x² + ∂²u/∂y²)`.
739///
740/// Uses the Peaceman-Rachford splitting: half-step implicit in x, half-step implicit in y.
741/// Boundary values are held fixed (Dirichlet zero on all edges).
742pub fn adi_diffusion_step_2d(
743    u: &[f64],
744    nx: usize,
745    ny: usize,
746    dx: f64,
747    dy: f64,
748    dt: f64,
749    alpha: f64,
750) -> Vec<f64> {
751    let rx = alpha * dt / (2.0 * dx * dx);
752    let ry = alpha * dt / (2.0 * dy * dy);
753    let mut u_half = u.to_vec();
754
755    // --- half step: implicit in x, explicit in y ---
756    for j in 1..ny - 1 {
757        let mut a_d = vec![0.0_f64; nx];
758        let mut b_d = vec![0.0_f64; nx];
759        let mut c_d = vec![0.0_f64; nx];
760        let mut rhs = vec![0.0_f64; nx];
761
762        b_d[0] = 1.0;
763        b_d[nx - 1] = 1.0;
764        // boundary stays 0
765        rhs[0] = 0.0;
766        rhs[nx - 1] = 0.0;
767
768        for i in 1..nx - 1 {
769            let idx = j * nx + i;
770            a_d[i] = -rx;
771            b_d[i] = 1.0 + 2.0 * rx;
772            c_d[i] = -rx;
773            rhs[i] = ry * u[idx - nx] + (1.0 - 2.0 * ry) * u[idx] + ry * u[idx + nx];
774        }
775
776        let row_sol = thomas_algorithm(&a_d, &b_d, &c_d, &rhs);
777        for i in 0..nx {
778            u_half[j * nx + i] = row_sol[i];
779        }
780    }
781
782    // --- full step: implicit in y ---
783    let mut u_new = u_half.clone();
784    for i in 1..nx - 1 {
785        let mut a_d = vec![0.0_f64; ny];
786        let mut b_d = vec![0.0_f64; ny];
787        let mut c_d = vec![0.0_f64; ny];
788        let mut rhs = vec![0.0_f64; ny];
789
790        b_d[0] = 1.0;
791        b_d[ny - 1] = 1.0;
792        rhs[0] = 0.0;
793        rhs[ny - 1] = 0.0;
794
795        for j in 1..ny - 1 {
796            let idx = j * nx + i;
797            a_d[j] = -ry;
798            b_d[j] = 1.0 + 2.0 * ry;
799            c_d[j] = -ry;
800            rhs[j] = rx * u_half[idx - 1] + (1.0 - 2.0 * rx) * u_half[idx] + rx * u_half[idx + 1];
801        }
802
803        let col_sol = thomas_algorithm(&a_d, &b_d, &c_d, &rhs);
804        for j in 0..ny {
805            u_new[j * nx + i] = col_sol[j];
806        }
807    }
808
809    u_new
810}
811
812// ─────────────────────────────────────────────────────────────────────────────
813// 3D finite difference Laplacian
814// ─────────────────────────────────────────────────────────────────────────────
815
816/// Computes the 3D Laplacian using the 7-point stencil.
817///
818/// `u` is stored as `u[k * ny * nx + j * nx + i]`.
819/// Boundary nodes are left as zero.
820pub fn laplacian_3d(
821    u: &[f64],
822    nx: usize,
823    ny: usize,
824    nz: usize,
825    dx: f64,
826    dy: f64,
827    dz: f64,
828) -> Vec<f64> {
829    let mut lap = vec![0.0_f64; nx * ny * nz];
830    for k in 1..nz - 1 {
831        for j in 1..ny - 1 {
832            for i in 1..nx - 1 {
833                let idx = k * ny * nx + j * nx + i;
834                let d2x = (u[idx - 1] - 2.0 * u[idx] + u[idx + 1]) / (dx * dx);
835                let d2y = (u[idx - nx] - 2.0 * u[idx] + u[idx + nx]) / (dy * dy);
836                let d2z = (u[idx - ny * nx] - 2.0 * u[idx] + u[idx + ny * nx]) / (dz * dz);
837                lap[idx] = d2x + d2y + d2z;
838            }
839        }
840    }
841    lap
842}
843
844// ─────────────────────────────────────────────────────────────────────────────
845// Periodic convection (flux form)
846// ─────────────────────────────────────────────────────────────────────────────
847
848/// First-order upwind advection step on a periodic domain.
849///
850/// No boundary conditions needed because the domain wraps around.
851pub fn advection_periodic_step(u: &[f64], dx: f64, dt: f64, c: f64) -> Vec<f64> {
852    let n = u.len();
853    let r = c * dt / dx;
854    let mut un = vec![0.0_f64; n];
855    for i in 0..n {
856        let im1 = if i == 0 { n - 1 } else { i - 1 };
857        let ip1 = (i + 1) % n;
858        un[i] = if c > 0.0 {
859            u[i] - r * (u[i] - u[im1])
860        } else {
861            u[i] - r * (u[ip1] - u[i])
862        };
863    }
864    un
865}
866
867// ─────────────────────────────────────────────────────────────────────────────
868// L2 norm and convergence helpers
869// ─────────────────────────────────────────────────────────────────────────────
870
871/// Computes the discrete L2 norm `||u||₂ = sqrt(dx * Σ u[i]²)`.
872pub fn l2_norm(u: &[f64], dx: f64) -> f64 {
873    (dx * u.iter().map(|v| v * v).sum::<f64>()).sqrt()
874}
875
876/// Computes the L∞ (maximum) norm of `u`.
877pub fn linf_norm(u: &[f64]) -> f64 {
878    u.iter().cloned().fold(0.0_f64, f64::max)
879}
880
881/// Computes the discrete L2 error between computed and exact solutions.
882pub fn l2_error(computed: &[f64], exact: &[f64], dx: f64) -> f64 {
883    let err: Vec<f64> = computed
884        .iter()
885        .zip(exact.iter())
886        .map(|(c, e)| c - e)
887        .collect();
888    l2_norm(&err, dx)
889}
890
891// ─────────────────────────────────────────────────────────────────────────────
892// Von Neumann stability analysis (amplification factor)
893// ─────────────────────────────────────────────────────────────────────────────
894
895/// Computes the von Neumann amplification factor magnitude for FTCS diffusion.
896///
897/// The amplification factor is `|G| = |1 - 4r sin²(k dx / 2)|`.
898/// Stability requires `|G| ≤ 1` for all wave numbers `k`.
899pub fn ftcs_amplification(r: f64, k: f64, dx: f64) -> f64 {
900    let s = (k * dx / 2.0).sin();
901    (1.0 - 4.0 * r * s * s).abs()
902}
903
904/// Maximum amplification factor for the FTCS diffusion scheme over all wave numbers.
905///
906/// Scans `n_modes` wave numbers in `[0, π/dx]` and returns the largest `|G|`.
907pub fn ftcs_max_amplification(r: f64, dx: f64, n_modes: usize) -> f64 {
908    (0..=n_modes)
909        .map(|m| {
910            let k = m as f64 * PI / (n_modes as f64 * dx);
911            ftcs_amplification(r, k, dx)
912        })
913        .fold(0.0_f64, f64::max)
914}
915
916// ─────────────────────────────────────────────────────────────────────────────
917// Neumann boundary condition helper
918// ─────────────────────────────────────────────────────────────────────────────
919
920/// Applies Neumann (zero-flux) conditions by ghost-cell reflection.
921///
922/// Left ghost: `u[-1] = u[1]`, right ghost: `u[n] = u[n-2]`.
923/// Modifies the first and last elements of `u` in-place.
924pub fn apply_neumann_reflection(u: &mut Vec<f64>) {
925    let n = u.len();
926    if n < 3 {
927        return;
928    }
929    u[0] = u[1];
930    u[n - 1] = u[n - 2];
931}
932
933// ─────────────────────────────────────────────────────────────────────────────
934// Grid builder
935// ─────────────────────────────────────────────────────────────────────────────
936
937/// Generates a uniform 1D grid from `x_start` to `x_end` with `n` points.
938pub fn uniform_grid_1d(x_start: f64, x_end: f64, n: usize) -> Vec<f64> {
939    let dx = (x_end - x_start) / (n - 1) as f64;
940    (0..n).map(|i| x_start + i as f64 * dx).collect()
941}
942
943/// Generates a uniform 2D grid (row-major).
944///
945/// Returns `(x_coords, y_coords)` each of length `nx * ny`.
946pub fn uniform_grid_2d(
947    x0: f64,
948    x1: f64,
949    nx: usize,
950    y0: f64,
951    y1: f64,
952    ny: usize,
953) -> (Vec<f64>, Vec<f64>) {
954    let dx = (x1 - x0) / (nx - 1) as f64;
955    let dy = (y1 - y0) / (ny - 1) as f64;
956    let mut xs = vec![0.0_f64; nx * ny];
957    let mut ys = vec![0.0_f64; nx * ny];
958    for j in 0..ny {
959        for i in 0..nx {
960            xs[j * nx + i] = x0 + i as f64 * dx;
961            ys[j * nx + i] = y0 + j as f64 * dy;
962        }
963    }
964    (xs, ys)
965}
966
967// ─────────────────────────────────────────────────────────────────────────────
968// Lax-Wendroff scheme
969// ─────────────────────────────────────────────────────────────────────────────
970
971/// Lax-Wendroff second-order advection step for `∂u/∂t + c ∂u/∂x = 0`.
972///
973/// Second-order accurate in both space and time.  Boundary nodes are copied unchanged.
974pub fn advection_lax_wendroff_step(u: &[f64], dx: f64, dt: f64, c: f64) -> Vec<f64> {
975    let n = u.len();
976    let r = c * dt / dx;
977    let mut un = u.to_vec();
978    for i in 1..n - 1 {
979        un[i] = u[i] - 0.5 * r * (u[i + 1] - u[i - 1])
980            + 0.5 * r * r * (u[i + 1] - 2.0 * u[i] + u[i - 1]);
981    }
982    un
983}
984
985// ─────────────────────────────────────────────────────────────────────────────
986// Beam-Warming scheme
987// ─────────────────────────────────────────────────────────────────────────────
988
989/// Beam-Warming second-order upwind advection step (for `c > 0`).
990///
991/// Uses the stencil `u[i] - r/2 (3u[i]-4u[i-1]+u[i-2]) + r²/2 (u[i]-2u[i-1]+u[i-2])`.
992pub fn advection_beam_warming_step(u: &[f64], dx: f64, dt: f64, c: f64) -> Vec<f64> {
993    let n = u.len();
994    let r = c * dt / dx;
995    let mut un = u.to_vec();
996    for i in 2..n {
997        un[i] = u[i] - 0.5 * r * (3.0 * u[i] - 4.0 * u[i - 1] + u[i - 2])
998            + 0.5 * r * r * (u[i] - 2.0 * u[i - 1] + u[i - 2]);
999    }
1000    un
1001}
1002
1003// ─────────────────────────────────────────────────────────────────────────────
1004// Compact finite difference for second derivative (Padé 4th order)
1005// ─────────────────────────────────────────────────────────────────────────────
1006
1007/// Compact (Padé) 4th-order second derivative.
1008///
1009/// Solves `(1/10) f''[i-1] + f''[i] + (1/10) f''[i+1] = (12/10)(u[i+1]-2u[i]+u[i-1])/dx²`.
1010pub fn compact_second_derivative(u: &[f64], dx: f64) -> Vec<f64> {
1011    let n = u.len();
1012    if n < 3 {
1013        return second_derivative(u, dx);
1014    }
1015
1016    let mut rhs = vec![0.0_f64; n];
1017    rhs[0] = (u[1] - 2.0 * u[0] + u[0]) / (dx * dx); // ghost
1018    rhs[n - 1] = (u[n - 1] - 2.0 * u[n - 1] + u[n - 2]) / (dx * dx);
1019    for i in 1..n - 1 {
1020        rhs[i] = (12.0 / 10.0) * (u[i + 1] - 2.0 * u[i] + u[i - 1]) / (dx * dx);
1021    }
1022
1023    let mut a_d = vec![0.0_f64; n];
1024    let b_d = vec![1.0_f64; n];
1025    let mut c_d = vec![0.0_f64; n];
1026    for i in 1..n - 1 {
1027        a_d[i] = 1.0 / 10.0;
1028        c_d[i] = 1.0 / 10.0;
1029    }
1030
1031    thomas_algorithm(&a_d, &b_d, &c_d, &rhs)
1032}
1033
1034// ─────────────────────────────────────────────────────────────────────────────
1035// Wave equation explicit step
1036// ─────────────────────────────────────────────────────────────────────────────
1037
1038/// One explicit time step for the 1D wave equation `∂²u/∂t² = c² ∂²u/∂x²`.
1039///
1040/// Requires the solution at times `t` (`u`) and `t-dt` (`u_prev`).
1041pub fn wave_equation_step(u: &[f64], u_prev: &[f64], dx: f64, dt: f64, c: f64) -> Vec<f64> {
1042    let n = u.len();
1043    let r2 = (c * dt / dx).powi(2);
1044    let mut u_next = u.to_vec();
1045    for i in 1..n - 1 {
1046        u_next[i] = 2.0 * u[i] - u_prev[i] + r2 * (u[i - 1] - 2.0 * u[i] + u[i + 1]);
1047    }
1048    u_next
1049}
1050
1051// ─────────────────────────────────────────────────────────────────────────────
1052// Energy (total variation) diagnostics
1053// ─────────────────────────────────────────────────────────────────────────────
1054
1055/// Computes the total variation `TV(u) = Σ |u[i+1] - u[i]|`.
1056pub fn total_variation(u: &[f64]) -> f64 {
1057    u.windows(2).map(|w| (w[1] - w[0]).abs()).sum()
1058}
1059
1060/// Computes the discrete L1 norm `||u||₁ = dx * Σ |u[i]|`.
1061pub fn l1_norm(u: &[f64], dx: f64) -> f64 {
1062    dx * u.iter().map(|v| v.abs()).sum::<f64>()
1063}
1064
1065// ─────────────────────────────────────────────────────────────────────────────
1066// Smoothness and WENO utility
1067// ─────────────────────────────────────────────────────────────────────────────
1068
1069/// Minimum smoothness indicator across stencils (for diagnostic use).
1070pub fn weno5_min_smoothness(u: &[f64]) -> f64 {
1071    let n = u.len();
1072    if n < 5 {
1073        return 0.0;
1074    }
1075    let mut min_b = f64::MAX;
1076    for i in 2..n - 2 {
1077        let b = weno5_beta(u[i - 1], u[i], u[i + 1]);
1078        if b < min_b {
1079            min_b = b;
1080        }
1081    }
1082    min_b
1083}
1084
1085// ─────────────────────────────────────────────────────────────────────────────
1086// Unit tests
1087// ─────────────────────────────────────────────────────────────────────────────
1088
1089#[cfg(test)]
1090mod tests {
1091    use super::*;
1092
1093    // ── Thomas algorithm ─────────────────────────────────────────────────────
1094
1095    #[test]
1096    fn test_thomas_identity_system() {
1097        // b = [1,1,1], a = c = 0, d = [2,3,4] → x = [2,3,4]
1098        let a = vec![0.0, 0.0, 0.0];
1099        let b = vec![1.0, 1.0, 1.0];
1100        let c = vec![0.0, 0.0, 0.0];
1101        let d = vec![2.0, 3.0, 4.0];
1102        let x = thomas_algorithm(&a, &b, &c, &d);
1103        assert!((x[0] - 2.0).abs() < 1e-10);
1104        assert!((x[1] - 3.0).abs() < 1e-10);
1105        assert!((x[2] - 4.0).abs() < 1e-10);
1106    }
1107
1108    #[test]
1109    fn test_thomas_simple_tridiagonal() {
1110        // 2-equation: [2 -1; -1 2] x = [1; 1]  => x = [1, 1]
1111        let a = vec![0.0, -1.0];
1112        let b = vec![2.0, 2.0];
1113        let c = vec![-1.0, 0.0];
1114        let d = vec![1.0, 1.0];
1115        let x = thomas_algorithm(&a, &b, &c, &d);
1116        assert!((x[0] - 1.0).abs() < 1e-10);
1117        assert!((x[1] - 1.0).abs() < 1e-10);
1118    }
1119
1120    #[test]
1121    fn test_thomas_larger_system() {
1122        let n = 5_usize;
1123        // diagonal dominant: b=4, a=c=-1
1124        let a: Vec<f64> = (0..n).map(|i| if i == 0 { 0.0 } else { -1.0 }).collect();
1125        let b = vec![4.0_f64; n];
1126        let c: Vec<f64> = (0..n)
1127            .map(|i| if i == n - 1 { 0.0 } else { -1.0 })
1128            .collect();
1129        let d = vec![1.0_f64; n];
1130        let x = thomas_algorithm(&a, &b, &c, &d);
1131        // Verify Ax = d
1132        for i in 0..n {
1133            let lhs = b[i] * x[i]
1134                + if i > 0 { a[i] * x[i - 1] } else { 0.0 }
1135                + if i < n - 1 { c[i] * x[i + 1] } else { 0.0 };
1136            assert!((lhs - d[i]).abs() < 1e-9, "row {i}: lhs={lhs}");
1137        }
1138    }
1139
1140    // ── Derivative stencils ──────────────────────────────────────────────────
1141
1142    #[test]
1143    fn test_first_derivative_linear() {
1144        // u = x => du/dx = 1 everywhere
1145        let n = 10;
1146        let dx = 0.1;
1147        let u: Vec<f64> = (0..n).map(|i| i as f64 * dx).collect();
1148        let du = first_derivative_central(&u, dx);
1149        for v in &du {
1150            assert!((v - 1.0).abs() < 1e-9, "got {v}");
1151        }
1152    }
1153
1154    #[test]
1155    fn test_second_derivative_quadratic() {
1156        // u = x² => d²u/dx² = 2
1157        let n = 20;
1158        let dx = 0.1;
1159        let u: Vec<f64> = (0..n).map(|i| (i as f64 * dx).powi(2)).collect();
1160        let d2u = second_derivative(&u, dx);
1161        for i in 1..n - 1 {
1162            assert!((d2u[i] - 2.0).abs() < 1e-6, "i={i}: {}", d2u[i]);
1163        }
1164    }
1165
1166    #[test]
1167    fn test_fourth_order_derivative_sine() {
1168        // u = sin(x), du/dx = cos(x)
1169        let n = 100;
1170        let dx = 2.0 * PI / n as f64;
1171        let u: Vec<f64> = (0..n).map(|i| (i as f64 * dx).sin()).collect();
1172        let du = first_derivative_fourth_order(&u, dx);
1173        // check interior points (away from boundaries)
1174        for i in 4..n - 4 {
1175            let exact = (i as f64 * dx).cos();
1176            assert!(
1177                (du[i] - exact).abs() < 1e-6,
1178                "i={i}: got {} exp {}",
1179                du[i],
1180                exact
1181            );
1182        }
1183    }
1184
1185    // ── Upwind advection ─────────────────────────────────────────────────────
1186
1187    #[test]
1188    fn test_upwind_advects_rightward() {
1189        let n = 20;
1190        let dx = 1.0 / n as f64;
1191        let dt = 0.4 * dx; // CFL = 0.4
1192        let c = 1.0;
1193        let mut u: Vec<f64> = (0..n).map(|i| if i == n / 2 { 1.0 } else { 0.0 }).collect();
1194        let bc = BoundaryCondition::Dirichlet(0.0);
1195        u = advection_upwind_step(&u, dx, dt, c, bc, bc);
1196        // peak should have shifted right
1197        let peak_idx = u
1198            .iter()
1199            .enumerate()
1200            .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
1201            .map(|(i, _)| i)
1202            .unwrap();
1203        assert!(peak_idx >= n / 2);
1204    }
1205
1206    #[test]
1207    fn test_cfl_number_correct() {
1208        assert!((cfl_number(2.0, 0.5, 1.0) - 1.0).abs() < 1e-12);
1209        assert!((cfl_number(1.0, 0.1, 1.0) - 0.1).abs() < 1e-12);
1210    }
1211
1212    #[test]
1213    fn test_stability_flags() {
1214        assert!(is_advection_stable(1.0, 0.9, 1.0));
1215        assert!(!is_advection_stable(1.0, 1.1, 1.0));
1216        assert!(is_diffusion_stable(0.1, 0.4, 1.0));
1217        assert!(!is_diffusion_stable(0.1, 6.0, 1.0));
1218    }
1219
1220    // ── Crank-Nicolson diffusion ─────────────────────────────────────────────
1221
1222    #[test]
1223    fn test_crank_nicolson_preserves_constant() {
1224        // u = const should stay constant
1225        let n = 10;
1226        let dx = 0.1;
1227        let dt = 1.0;
1228        let alpha = 0.01;
1229        let u = vec![3.0_f64; n];
1230        let bc = BoundaryCondition::Dirichlet(3.0);
1231        let un = diffusion_crank_nicolson_step(&u, dx, dt, alpha, bc, bc);
1232        for v in &un {
1233            assert!((v - 3.0).abs() < 1e-9, "got {v}");
1234        }
1235    }
1236
1237    #[test]
1238    fn test_implicit_diffusion_stable_large_dt() {
1239        // Implicit diffusion should not blow up even with large dt
1240        let n = 10;
1241        let dx = 0.1;
1242        let dt = 100.0; // would be unstable for explicit
1243        let alpha = 0.01;
1244        let mut u = vec![0.0_f64; n];
1245        u[5] = 1.0;
1246        let bc = BoundaryCondition::Dirichlet(0.0);
1247        let un = diffusion_implicit_step(&u, dx, dt, alpha, bc, bc);
1248        // all values should be finite and bounded
1249        for v in &un {
1250            assert!(v.is_finite(), "got NaN/inf");
1251        }
1252    }
1253
1254    #[test]
1255    fn test_cn_dirichlet_boundary_held() {
1256        let n = 8;
1257        let dx = 0.1;
1258        let dt = 0.1;
1259        let alpha = 0.1;
1260        let u: Vec<f64> = (0..n).map(|i| i as f64).collect();
1261        let bc_l = BoundaryCondition::Dirichlet(0.0);
1262        let bc_r = BoundaryCondition::Dirichlet(7.0);
1263        let un = diffusion_crank_nicolson_step(&u, dx, dt, alpha, bc_l, bc_r);
1264        assert!((un[0] - 0.0).abs() < 1e-10);
1265        assert!((un[n - 1] - 7.0).abs() < 1e-10);
1266    }
1267
1268    // ── Explicit diffusion ───────────────────────────────────────────────────
1269
1270    #[test]
1271    fn test_explicit_diffusion_gaussian_decay() {
1272        // Gaussian should spread and decay
1273        let n = 50;
1274        let dx = 1.0 / n as f64;
1275        let dt = 0.4 * dx * dx / 0.01; // r = 0.4
1276        let alpha = 0.01;
1277        let sigma = 5.0 * dx;
1278        let mid = n as f64 / 2.0 * dx;
1279        let u: Vec<f64> = (0..n)
1280            .map(|i| {
1281                let x = i as f64 * dx - mid;
1282                (-0.5 * x * x / (sigma * sigma)).exp()
1283            })
1284            .collect();
1285        let bc = BoundaryCondition::Dirichlet(0.0);
1286        let un = diffusion_explicit_step(&u, dx, dt, alpha, bc, bc);
1287        let peak_old = u.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1288        let peak_new = un.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1289        assert!(peak_new < peak_old, "Gaussian peak should decrease");
1290    }
1291
1292    // ── WENO-5 ───────────────────────────────────────────────────────────────
1293
1294    #[test]
1295    fn test_weno5_flux_smooth_region() {
1296        // for uniform data, WENO should return the uniform value
1297        let v = [1.0_f64; 5];
1298        let f = weno5_flux(v);
1299        assert!((f - 1.0).abs() < 1e-10, "got {f}");
1300    }
1301
1302    #[test]
1303    fn test_weno5_advection_conserves_mass() {
1304        let n = 40;
1305        let dx = 1.0 / n as f64;
1306        let dt = 0.3 * dx;
1307        let c = 1.0;
1308        let u: Vec<f64> = (0..n)
1309            .map(|i| ((i as f64 * dx - 0.3) * 20.0 * PI).sin().powi(2) * 0.5)
1310            .collect();
1311        let mass_before: f64 = u.iter().sum::<f64>() * dx;
1312        let un = advection_weno5_step(&u, dx, dt, c);
1313        let mass_after: f64 = un.iter().sum::<f64>() * dx;
1314        // WENO-5 is not exactly conservative; allow up to 20% relative change
1315        assert!(
1316            (mass_before - mass_after).abs() < 0.20 * mass_before.abs().max(1e-6),
1317            "mass_before={mass_before}, mass_after={mass_after}"
1318        );
1319    }
1320
1321    // ── Compact finite differences ───────────────────────────────────────────
1322
1323    #[test]
1324    fn test_compact_derivative_linear() {
1325        let n = 10;
1326        let dx = 0.1;
1327        let u: Vec<f64> = (0..n).map(|i| 3.0 * i as f64 * dx).collect();
1328        let du = compact_first_derivative(&u, dx);
1329        // interior should give ~3.0
1330        for i in 2..n - 2 {
1331            assert!((du[i] - 3.0).abs() < 1e-8, "i={i}: {}", du[i]);
1332        }
1333    }
1334
1335    // ── Richardson extrapolation ─────────────────────────────────────────────
1336
1337    #[test]
1338    fn test_richardson_second_order() {
1339        // f(h) ≈ 1 + h², f(h/2) ≈ 1 + h²/4; extrapolate with p=2 → should give 1
1340        let h = 0.1;
1341        let f_h = 1.0 + h * h;
1342        let f_h2 = 1.0 + h * h / 4.0;
1343        let ext = richardson_extrapolation(f_h, f_h2, 2);
1344        assert!((ext - 1.0).abs() < 1e-10, "got {ext}");
1345    }
1346
1347    // ── Staggered grid ───────────────────────────────────────────────────────
1348
1349    #[test]
1350    fn test_staggered_grid_divergence_constant_flux() {
1351        let n = 10;
1352        let dx = 0.1;
1353        let mut sg = StaggeredGrid1D::new(n, dx);
1354        // constant velocity → zero divergence
1355        sg.u = vec![1.0_f64; n];
1356        sg.interpolate_to_faces();
1357        let div = sg.divergence();
1358        for v in &div {
1359            assert!(v.abs() < 1e-12, "got {v}");
1360        }
1361    }
1362
1363    #[test]
1364    fn test_staggered_grid_face_count() {
1365        let sg = StaggeredGrid1D::new(5, 0.1);
1366        assert_eq!(sg.flux.len(), 6);
1367        assert_eq!(sg.u.len(), 5);
1368    }
1369
1370    // ── Fractional step ──────────────────────────────────────────────────────
1371
1372    #[test]
1373    fn test_fractional_step_returns_finite() {
1374        let n = 20;
1375        let u: Vec<f64> = (0..n).map(|i| (i as f64 / n as f64).sin()).collect();
1376        let res = fractional_step_1d(&u, 1.0 / n as f64, 0.001, 0.01, 0.0);
1377        for v in &res.u_new {
1378            assert!(v.is_finite(), "NaN/inf in u_new");
1379        }
1380    }
1381
1382    // ── 2D Laplacian ─────────────────────────────────────────────────────────
1383
1384    #[test]
1385    fn test_laplacian_2d_constant_field() {
1386        let nx = 5;
1387        let ny = 5;
1388        let u = vec![2.0_f64; nx * ny];
1389        let lap = laplacian_2d(&u, nx, ny, 0.1, 0.1);
1390        for j in 1..ny - 1 {
1391            for i in 1..nx - 1 {
1392                assert!(lap[j * nx + i].abs() < 1e-12);
1393            }
1394        }
1395    }
1396
1397    #[test]
1398    fn test_laplacian_2d_parabolic() {
1399        // u = x² + y² → Δu = 4
1400        let nx = 10;
1401        let ny = 10;
1402        let dx = 0.1;
1403        let dy = 0.1;
1404        let u: Vec<f64> = (0..ny)
1405            .flat_map(|j| {
1406                (0..nx).map(move |i| {
1407                    let x = i as f64 * dx;
1408                    let y = j as f64 * dy;
1409                    x * x + y * y
1410                })
1411            })
1412            .collect();
1413        let lap = laplacian_2d(&u, nx, ny, dx, dy);
1414        for j in 1..ny - 1 {
1415            for i in 1..nx - 1 {
1416                assert!(
1417                    (lap[j * nx + i] - 4.0).abs() < 1e-6,
1418                    "({i},{j}): {}",
1419                    lap[j * nx + i]
1420                );
1421            }
1422        }
1423    }
1424
1425    // ── ADI ──────────────────────────────────────────────────────────────────
1426
1427    #[test]
1428    fn test_adi_constant_field_unchanged() {
1429        let nx = 6;
1430        let ny = 6;
1431        let u = vec![0.0_f64; nx * ny]; // all zeros (Dirichlet = 0 satisfied)
1432        let un = adi_diffusion_step_2d(&u, nx, ny, 0.1, 0.1, 0.01, 0.01);
1433        for v in &un {
1434            assert!(v.abs() < 1e-12, "got {v}");
1435        }
1436    }
1437
1438    #[test]
1439    fn test_adi_energy_decreases() {
1440        let nx = 8;
1441        let ny = 8;
1442        let mut u = vec![0.0_f64; nx * ny];
1443        u[ny / 2 * nx + nx / 2] = 1.0; // point source
1444        let energy_before: f64 = u.iter().map(|v| v * v).sum();
1445        let un = adi_diffusion_step_2d(&u, nx, ny, 0.125, 0.125, 0.01, 0.01);
1446        let energy_after: f64 = un.iter().map(|v| v * v).sum();
1447        assert!(energy_after < energy_before, "Energy should decrease");
1448    }
1449
1450    // ── Wave equation ────────────────────────────────────────────────────────
1451
1452    #[test]
1453    fn test_wave_equation_step_finite() {
1454        let n = 30;
1455        let dx = 1.0 / n as f64;
1456        let dt = 0.4 * dx;
1457        let c = 1.0;
1458        let u: Vec<f64> = (0..n).map(|i| (PI * i as f64 * dx).sin()).collect();
1459        let u_prev = u.clone();
1460        let u_next = wave_equation_step(&u, &u_prev, dx, dt, c);
1461        for v in &u_next {
1462            assert!(v.is_finite(), "NaN/inf in wave step");
1463        }
1464    }
1465
1466    // ── Periodic advection ───────────────────────────────────────────────────
1467
1468    #[test]
1469    fn test_periodic_advection_conserves_mass() {
1470        let n = 32;
1471        let dx = 1.0 / n as f64;
1472        let dt = 0.4 * dx;
1473        let c = 1.0;
1474        let u: Vec<f64> = (0..n)
1475            .map(|i| (2.0 * PI * i as f64 / n as f64).sin())
1476            .collect();
1477        let mass0: f64 = u.iter().sum();
1478        let un = advection_periodic_step(&u, dx, dt, c);
1479        let mass1: f64 = un.iter().sum();
1480        assert!(
1481            (mass0 - mass1).abs() < 1e-10,
1482            "mass change = {}",
1483            (mass0 - mass1).abs()
1484        );
1485    }
1486
1487    // ── L2 / Linf norms ──────────────────────────────────────────────────────
1488
1489    #[test]
1490    fn test_l2_norm_constant() {
1491        let n = 10;
1492        let dx = 0.1;
1493        let u = vec![2.0_f64; n];
1494        let norm = l2_norm(&u, dx);
1495        let expected = (dx * n as f64 * 4.0).sqrt();
1496        assert!((norm - expected).abs() < 1e-10);
1497    }
1498
1499    #[test]
1500    fn test_linf_norm() {
1501        let u = vec![-3.0_f64, 1.0, 2.0, -1.0, 2.5];
1502        assert!((linf_norm(&u) - 2.5).abs() < 1e-12);
1503    }
1504
1505    // ── Total variation ──────────────────────────────────────────────────────
1506
1507    #[test]
1508    fn test_total_variation_monotone() {
1509        let u: Vec<f64> = (0..10).map(|i| i as f64).collect();
1510        let tv = total_variation(&u);
1511        assert!((tv - 9.0).abs() < 1e-12);
1512    }
1513
1514    // ── Von Neumann amplification ─────────────────────────────────────────────
1515
1516    #[test]
1517    fn test_ftcs_amplification_r_half_stable() {
1518        let r = 0.5;
1519        let dx = 0.1;
1520        let max_g = ftcs_max_amplification(r, dx, 200);
1521        assert!(max_g <= 1.0 + 1e-10, "unstable: max|G|={max_g}");
1522    }
1523
1524    #[test]
1525    fn test_ftcs_amplification_r_too_large() {
1526        let r = 0.6; // should be unstable
1527        let dx = 0.1;
1528        let max_g = ftcs_max_amplification(r, dx, 200);
1529        assert!(max_g > 1.0, "|G| should exceed 1.0 for r=0.6, got {max_g}");
1530    }
1531
1532    // ── Neumann BC reflection ─────────────────────────────────────────────────
1533
1534    #[test]
1535    fn test_neumann_reflection_zero_gradient() {
1536        let mut u = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1537        apply_neumann_reflection(&mut u);
1538        assert_eq!(u[0], u[1]);
1539        assert_eq!(u[4], u[3]);
1540    }
1541
1542    // ── Grid builder ─────────────────────────────────────────────────────────
1543
1544    #[test]
1545    fn test_uniform_grid_endpoints() {
1546        let grid = uniform_grid_1d(0.0, 1.0, 11);
1547        assert!((grid[0] - 0.0).abs() < 1e-12);
1548        assert!((grid[10] - 1.0).abs() < 1e-12);
1549        assert_eq!(grid.len(), 11);
1550    }
1551
1552    #[test]
1553    fn test_uniform_grid_2d_dimensions() {
1554        let (xs, ys) = uniform_grid_2d(0.0, 1.0, 5, 0.0, 2.0, 4);
1555        assert_eq!(xs.len(), 20);
1556        assert_eq!(ys.len(), 20);
1557    }
1558
1559    // ── Lax-Wendroff ─────────────────────────────────────────────────────────
1560
1561    #[test]
1562    fn test_lax_wendroff_cfl_one_exact() {
1563        // With CFL = 1, Lax-Wendroff is exact for linear advection
1564        let n = 20;
1565        let dx = 1.0 / n as f64;
1566        let c = 1.0;
1567        let dt = dx; // CFL = 1
1568        let u: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 * dx).sin()).collect();
1569        let expected: Vec<f64> = (0..n)
1570            .map(|i| (2.0 * PI * ((i as f64 * dx) - c * dt)).sin())
1571            .collect();
1572        let un = advection_lax_wendroff_step(&u, dx, dt, c);
1573        for i in 1..n - 1 {
1574            assert!(
1575                (un[i] - expected[i]).abs() < 1e-8,
1576                "i={i}: {} vs {}",
1577                un[i],
1578                expected[i]
1579            );
1580        }
1581    }
1582
1583    // ── Method of lines ──────────────────────────────────────────────────────
1584
1585    #[test]
1586    fn test_mol_rk4_diffusion_finite() {
1587        let n = 20;
1588        let dx = 1.0 / n as f64;
1589        let dt = 0.1 * dx * dx;
1590        let alpha = 0.01;
1591        let u: Vec<f64> = (0..n).map(|i| (PI * i as f64 * dx).sin()).collect();
1592        let u_next = rk4_step(&u, 0.0, dt, &|_t, u| mol_diffusion_rhs(u, dx, alpha));
1593        for v in &u_next {
1594            assert!(v.is_finite(), "NaN/inf in RK4 diffusion");
1595        }
1596    }
1597
1598    // ── 3D Laplacian ─────────────────────────────────────────────────────────
1599
1600    #[test]
1601    fn test_laplacian_3d_constant_zero() {
1602        let nx = 5;
1603        let ny = 5;
1604        let nz = 5;
1605        let u = vec![0.0_f64; nx * ny * nz];
1606        let lap = laplacian_3d(&u, nx, ny, nz, 0.1, 0.1, 0.1);
1607        for v in &lap {
1608            assert!(v.abs() < 1e-12);
1609        }
1610    }
1611
1612    #[test]
1613    fn test_laplacian_3d_linear_field_zero() {
1614        // u = x + y + z => Δu = 0
1615        let nx = 6;
1616        let ny = 6;
1617        let nz = 6;
1618        let dx = 0.1;
1619        let dy = 0.1;
1620        let dz = 0.1;
1621        let u: Vec<f64> = (0..nz)
1622            .flat_map(|k| {
1623                (0..ny).flat_map(move |j| {
1624                    (0..nx).map(move |i| i as f64 * dx + j as f64 * dy + k as f64 * dz)
1625                })
1626            })
1627            .collect();
1628        let lap = laplacian_3d(&u, nx, ny, nz, dx, dy, dz);
1629        for k in 1..nz - 1 {
1630            for j in 1..ny - 1 {
1631                for i in 1..nx - 1 {
1632                    let v = lap[k * ny * nx + j * nx + i];
1633                    assert!(v.abs() < 1e-9, "({i},{j},{k}): {v}");
1634                }
1635            }
1636        }
1637    }
1638
1639    // ── Compact second derivative ─────────────────────────────────────────────
1640
1641    #[test]
1642    fn test_compact_second_derivative_constant() {
1643        let n = 10;
1644        let dx = 0.1;
1645        let u = vec![5.0_f64; n];
1646        let d2u = compact_second_derivative(&u, dx);
1647        for i in 1..n - 1 {
1648            assert!(d2u[i].abs() < 1e-9, "i={i}: {}", d2u[i]);
1649        }
1650    }
1651
1652    // ── Max dt stability ─────────────────────────────────────────────────────
1653
1654    #[test]
1655    fn test_max_dt_advection() {
1656        let dt_max = max_dt_advection(2.0, 0.1);
1657        assert!((dt_max - 0.05).abs() < 1e-12);
1658    }
1659
1660    #[test]
1661    fn test_max_dt_diffusion() {
1662        let dt_max = max_dt_diffusion(0.1, 0.1);
1663        assert!((dt_max - 0.05).abs() < 1e-12);
1664    }
1665
1666    // ── L1 norm ───────────────────────────────────────────────────────────────
1667
1668    #[test]
1669    fn test_l1_norm_ones() {
1670        let u = vec![1.0_f64; 10];
1671        let norm = l1_norm(&u, 0.1);
1672        assert!((norm - 1.0).abs() < 1e-12);
1673    }
1674
1675    // ── WENO smoothness ───────────────────────────────────────────────────────
1676
1677    #[test]
1678    fn test_weno5_min_smoothness_zero_constant() {
1679        // constant field → smoothness = 0
1680        let u = vec![1.0_f64; 10];
1681        let b = weno5_min_smoothness(&u);
1682        assert!(b.abs() < 1e-12, "got {b}");
1683    }
1684
1685    // ── Dirichlet 2D ─────────────────────────────────────────────────────────
1686
1687    #[test]
1688    fn test_apply_dirichlet_2d_border() {
1689        let nx = 5;
1690        let ny = 5;
1691        let mut u = vec![9.0_f64; nx * ny];
1692        apply_dirichlet_2d(&mut u, nx, ny, 0.0);
1693        // check border is zero
1694        for i in 0..nx {
1695            assert_eq!(u[i], 0.0);
1696            assert_eq!(u[(ny - 1) * nx + i], 0.0);
1697        }
1698        for j in 0..ny {
1699            assert_eq!(u[j * nx], 0.0);
1700            assert_eq!(u[j * nx + nx - 1], 0.0);
1701        }
1702    }
1703
1704    // ── Beam-Warming ─────────────────────────────────────────────────────────
1705
1706    #[test]
1707    fn test_beam_warming_finite() {
1708        let n = 20;
1709        let dx = 1.0 / n as f64;
1710        let dt = 0.4 * dx;
1711        let u: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 * dx).sin()).collect();
1712        let un = advection_beam_warming_step(&u, dx, dt, 1.0);
1713        for v in &un {
1714            assert!(v.is_finite(), "NaN/inf");
1715        }
1716    }
1717}