Skip to main content

oxiphysics_core/
pde_solvers.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Finite-difference PDE solvers for common one-dimensional equations.
6//!
7//! All solvers operate on simple `Vec`f64` grids and accept closure-based
8//! boundary conditions or source terms for flexibility.  The emphasis is on
9//! correctness and clarity rather than maximum performance.
10
11// ──────────────────────────────────────────────────────────────────────────────
12// CflCondition
13// ──────────────────────────────────────────────────────────────────────────────
14
15/// Stability criterion checker for explicit finite-difference schemes.
16///
17/// The Courant–Friedrichs–Lewy (CFL) number must satisfy `ν ≤ 1` (advection)
18/// or `α ≤ 0.5` (diffusion) for stability of explicit schemes.
19#[allow(dead_code)]
20#[derive(Debug, Clone)]
21pub struct CflCondition {
22    /// Grid spacing Δx.
23    pub dx: f64,
24    /// Time step Δt.
25    pub dt: f64,
26    /// Wave / advection speed (m/s).
27    pub speed: f64,
28    /// Diffusion coefficient (m²/s).
29    pub diffusivity: f64,
30}
31
32impl CflCondition {
33    /// Construct a new `CflCondition`.
34    pub fn new(dx: f64, dt: f64, speed: f64, diffusivity: f64) -> Self {
35        Self {
36            dx,
37            dt,
38            speed,
39            diffusivity,
40        }
41    }
42
43    /// Advective CFL number `c·Δt/Δx`. Must be ≤ 1 for explicit upwind/leapfrog.
44    pub fn advective_cfl(&self) -> f64 {
45        self.speed * self.dt / self.dx
46    }
47
48    /// Diffusive CFL number `α·Δt/Δx²`. Must be ≤ 0.5 for explicit FTCS.
49    pub fn diffusive_cfl(&self) -> f64 {
50        self.diffusivity * self.dt / (self.dx * self.dx)
51    }
52
53    /// Returns `true` if the advective CFL condition is satisfied (`≤ 1`).
54    pub fn is_advection_stable(&self) -> bool {
55        self.advective_cfl() <= 1.0
56    }
57
58    /// Returns `true` if the diffusive CFL condition is satisfied (`≤ 0.5`).
59    pub fn is_diffusion_stable(&self) -> bool {
60        self.diffusive_cfl() <= 0.5
61    }
62}
63
64// ──────────────────────────────────────────────────────────────────────────────
65// 1-D Heat equation  (explicit FTCS)
66// ──────────────────────────────────────────────────────────────────────────────
67
68/// Solve the 1-D heat equation  ∂u/∂t = α ∂²u/∂x²  for `n_steps` time steps
69/// using the explicit Forward-Time Centred-Space (FTCS) scheme.
70///
71/// # Parameters
72/// - `u0` — initial temperature field (length N).
73/// - `alpha` — thermal diffusivity (m²/s).
74/// - `dx` — grid spacing (m).
75/// - `dt` — time step (s).  Stability requires `α·Δt/Δx² ≤ 0.5`.
76/// - `n_steps` — number of time steps to advance.
77///
78/// Dirichlet boundary conditions are enforced by keeping the first and last
79/// elements fixed.
80///
81/// Returns the temperature field after `n_steps`.
82#[allow(dead_code)]
83pub fn heat_equation_1d(u0: &[f64], alpha: f64, dx: f64, dt: f64, n_steps: usize) -> Vec<f64> {
84    let n = u0.len();
85    let mut u = u0.to_vec();
86    let mut u_new = u.clone();
87    let r = alpha * dt / (dx * dx);
88
89    for _ in 0..n_steps {
90        // Interior nodes
91        for i in 1..n - 1 {
92            u_new[i] = u[i] + r * (u[i + 1] - 2.0 * u[i] + u[i - 1]);
93        }
94        // Dirichlet BCs: endpoints unchanged
95        u_new[0] = u[0];
96        u_new[n - 1] = u[n - 1];
97
98        std::mem::swap(&mut u, &mut u_new);
99    }
100    u
101}
102
103// ──────────────────────────────────────────────────────────────────────────────
104// 1-D Wave equation  (explicit leapfrog)
105// ──────────────────────────────────────────────────────────────────────────────
106
107/// Solve the 1-D wave equation  ∂²u/∂t² = c² ∂²u/∂x²  using the second-order
108/// leapfrog (Störmer–Verlet) explicit scheme.
109///
110/// # Parameters
111/// - `u_prev` — solution at time t−Δt (length N).
112/// - `u_curr` — solution at time t (length N).
113/// - `c` — wave speed (m/s).
114/// - `dx` — grid spacing (m).
115/// - `dt` — time step (s).  Stability requires `c·Δt/Δx ≤ 1`.
116/// - `n_steps` — number of time steps to advance.
117///
118/// Dirichlet zero boundary conditions are applied.
119///
120/// Returns the solution at time t + n_steps·Δt.
121#[allow(dead_code)]
122pub fn wave_equation_1d(
123    u_prev: &[f64],
124    u_curr: &[f64],
125    c: f64,
126    dx: f64,
127    dt: f64,
128    n_steps: usize,
129) -> Vec<f64> {
130    let n = u_curr.len();
131    let mut prev = u_prev.to_vec();
132    let mut curr = u_curr.to_vec();
133    let mut next = vec![0.0_f64; n];
134    let r2 = (c * dt / dx).powi(2);
135
136    for _ in 0..n_steps {
137        for i in 1..n - 1 {
138            next[i] = 2.0 * curr[i] - prev[i] + r2 * (curr[i + 1] - 2.0 * curr[i] + curr[i - 1]);
139        }
140        // Dirichlet zero BCs
141        next[0] = 0.0;
142        next[n - 1] = 0.0;
143
144        prev.copy_from_slice(&curr);
145        curr.copy_from_slice(&next);
146    }
147    curr
148}
149
150// ──────────────────────────────────────────────────────────────────────────────
151// 1-D Poisson equation  (Thomas algorithm)
152// ──────────────────────────────────────────────────────────────────────────────
153
154/// Solve the 1-D Poisson equation  −u″ = f  on a uniform grid using the
155/// Thomas algorithm (tridiagonal matrix algorithm, TDMA).
156///
157/// The system is  −(u\[i+1\] − 2u\[i\] + u\[i−1\]) / Δx² = f\[i\].
158///
159/// Dirichlet boundary conditions `u\[0\] = bc_left` and `u\[n-1\] = bc_right`
160/// are applied.
161///
162/// Returns the solution vector `u` of the same length as `f`.
163#[allow(dead_code)]
164pub fn poisson_1d(f: &[f64], dx: f64, bc_left: f64, bc_right: f64) -> Vec<f64> {
165    let n = f.len();
166    if n < 2 {
167        return vec![bc_left; n];
168    }
169    let dx2 = dx * dx;
170
171    // Build RHS: rhs[i] = f[i] * dx^2 for interior nodes
172    // Interior indices: 1 .. n-2
173    let m = n - 2; // number of interior unknowns
174    if m == 0 {
175        return vec![bc_left, bc_right];
176    }
177
178    let mut rhs = vec![0.0_f64; m];
179    for i in 0..m {
180        rhs[i] = f[i + 1] * dx2;
181    }
182    // Apply BCs to RHS
183    rhs[0] += bc_left;
184    rhs[m - 1] += bc_right;
185
186    // Tridiagonal: a_i = c_i = -1, b_i = 2
187    let mut c_star = vec![0.0_f64; m];
188    let mut d_star = vec![0.0_f64; m];
189
190    // Forward sweep
191    c_star[0] = -1.0 / 2.0;
192    d_star[0] = rhs[0] / 2.0;
193    for i in 1..m {
194        let denom = 2.0 + c_star[i - 1];
195        c_star[i] = -1.0 / denom;
196        d_star[i] = (rhs[i] + d_star[i - 1]) / denom;
197    }
198
199    // Back substitution
200    let mut x = vec![0.0_f64; m];
201    x[m - 1] = d_star[m - 1];
202    for i in (0..m - 1).rev() {
203        x[i] = d_star[i] - c_star[i] * x[i + 1];
204    }
205
206    // Assemble full solution
207    let mut u = vec![0.0_f64; n];
208    u[0] = bc_left;
209    u[n - 1] = bc_right;
210    u[1..m + 1].copy_from_slice(&x[..m]);
211    u
212}
213
214// ──────────────────────────────────────────────────────────────────────────────
215// 1-D Advection  (first-order upwind)
216// ──────────────────────────────────────────────────────────────────────────────
217
218/// Solve the 1-D linear advection equation  ∂u/∂t + c ∂u/∂x = 0  for
219/// `n_steps` time steps using a first-order upwind scheme.
220///
221/// # Parameters
222/// - `u0` — initial field (length N).
223/// - `c` — advection speed. Positive → right-moving.
224/// - `dx` — grid spacing.
225/// - `dt` — time step.  Stability requires `|c|·Δt/Δx ≤ 1`.
226/// - `n_steps` — number of time steps.
227///
228/// Periodic boundary conditions are applied.
229///
230/// Returns the advected field after `n_steps`.
231#[allow(dead_code)]
232pub fn advection_1d_upwind(u0: &[f64], c: f64, dx: f64, dt: f64, n_steps: usize) -> Vec<f64> {
233    let n = u0.len();
234    let mut u = u0.to_vec();
235    let mut u_new = u.clone();
236    let nu = c * dt / dx; // CFL number
237
238    for _ in 0..n_steps {
239        for i in 0..n {
240            if c >= 0.0 {
241                // Left-biased stencil
242                let im1 = if i == 0 { n - 1 } else { i - 1 };
243                u_new[i] = u[i] - nu * (u[i] - u[im1]);
244            } else {
245                // Right-biased stencil
246                let ip1 = if i == n - 1 { 0 } else { i + 1 };
247                u_new[i] = u[i] - nu * (u[ip1] - u[i]);
248            }
249        }
250        std::mem::swap(&mut u, &mut u_new);
251    }
252    u
253}
254
255// ──────────────────────────────────────────────────────────────────────────────
256// 1-D Inviscid Burgers equation  (Godunov flux)
257// ──────────────────────────────────────────────────────────────────────────────
258
259/// Solve the inviscid Burgers equation  ∂u/∂t + ∂(u²/2)/∂x = 0  for
260/// `n_steps` time steps using the Godunov (Rusanov) flux.
261///
262/// # Parameters
263/// - `u0` — initial field (length N).
264/// - `dx` — grid spacing.
265/// - `dt` — time step.
266/// - `n_steps` — number of time steps.
267///
268/// Periodic boundary conditions are applied.
269#[allow(dead_code)]
270pub fn burger_equation_1d(u0: &[f64], dx: f64, dt: f64, n_steps: usize) -> Vec<f64> {
271    let n = u0.len();
272    let mut u = u0.to_vec();
273    let mut u_new = vec![0.0_f64; n];
274
275    for _ in 0..n_steps {
276        for i in 0..n {
277            let ip1 = (i + 1) % n;
278            let im1 = if i == 0 { n - 1 } else { i - 1 };
279
280            // Godunov flux at right interface i+1/2
281            let f_right = godunov_flux_burgers(u[i], u[ip1]);
282            // Godunov flux at left interface i-1/2
283            let f_left = godunov_flux_burgers(u[im1], u[i]);
284
285            u_new[i] = u[i] - (dt / dx) * (f_right - f_left);
286        }
287        std::mem::swap(&mut u, &mut u_new);
288    }
289    u
290}
291
292/// Godunov (entropy-consistent) flux for Burgers equation, F(u) = u²/2.
293fn godunov_flux_burgers(u_l: f64, u_r: f64) -> f64 {
294    if u_l >= u_r {
295        // Shock region: use Rankine-Hugoniot
296        let s = (u_l + u_r) / 2.0; // shock speed
297        if s >= 0.0 {
298            u_l * u_l / 2.0
299        } else {
300            u_r * u_r / 2.0
301        }
302    } else {
303        // Rarefaction region
304        if u_l >= 0.0 {
305            u_l * u_l / 2.0
306        } else if u_r <= 0.0 {
307            u_r * u_r / 2.0
308        } else {
309            0.0 // sonic point
310        }
311    }
312}
313
314// ──────────────────────────────────────────────────────────────────────────────
315// 1-D Diffusion-Reaction  (FTCS)
316// ──────────────────────────────────────────────────────────────────────────────
317
318/// Solve the 1-D diffusion-reaction equation
319///   ∂u/∂t = D ∂²u/∂x² + R(u, x, t)
320/// for `n_steps` time steps using the explicit FTCS scheme.
321///
322/// # Parameters
323/// - `u0` — initial field (length N).
324/// - `d` — diffusion coefficient (m²/s).
325/// - `dx` — grid spacing.
326/// - `dt` — time step.  Stability requires `D·Δt/Δx² ≤ 0.5`.
327/// - `n_steps` — number of time steps.
328/// - `reaction` — closure `R(u, i, t)` returning the reaction rate at node `i`,
329///   value `u`, and time `t`.
330///
331/// Dirichlet boundary conditions are preserved from `u0`.
332#[allow(dead_code)]
333pub fn diffusion_reaction_1d<F>(
334    u0: &[f64],
335    d: f64,
336    dx: f64,
337    dt: f64,
338    n_steps: usize,
339    reaction: F,
340) -> Vec<f64>
341where
342    F: Fn(f64, usize, f64) -> f64,
343{
344    let n = u0.len();
345    let mut u = u0.to_vec();
346    let mut u_new = u.clone();
347    let r = d * dt / (dx * dx);
348    let mut t = 0.0_f64;
349
350    for _ in 0..n_steps {
351        for i in 1..n - 1 {
352            let diffusion = r * (u[i + 1] - 2.0 * u[i] + u[i - 1]);
353            let react = reaction(u[i], i, t) * dt;
354            u_new[i] = u[i] + diffusion + react;
355        }
356        u_new[0] = u[0];
357        u_new[n - 1] = u[n - 1];
358
359        t += dt;
360        std::mem::swap(&mut u, &mut u_new);
361    }
362    u
363}
364
365// ──────────────────────────────────────────────────────────────────────────────
366// Tests
367// ──────────────────────────────────────────────────────────────────────────────
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    const EPS: f64 = 1e-10;
374
375    // ── CflCondition ────────────────────────────────────────────────────────
376
377    #[test]
378    fn test_cfl_advective_stable() {
379        let cfl = CflCondition::new(0.1, 0.05, 1.0, 0.0);
380        // ν = 1.0 * 0.05 / 0.1 = 0.5 → stable
381        assert!(cfl.is_advection_stable());
382        assert!((cfl.advective_cfl() - 0.5).abs() < EPS);
383    }
384
385    #[test]
386    fn test_cfl_advective_unstable() {
387        let cfl = CflCondition::new(0.1, 0.2, 1.0, 0.0);
388        // ν = 2.0 → unstable
389        assert!(!cfl.is_advection_stable());
390    }
391
392    #[test]
393    fn test_cfl_diffusive_stable() {
394        // α·Δt/Δx² = 0.1 * 0.001 / 0.01 = 0.01 → stable
395        let cfl = CflCondition::new(0.1, 0.001, 0.0, 0.1);
396        assert!(cfl.is_diffusion_stable());
397    }
398
399    #[test]
400    fn test_cfl_diffusive_unstable() {
401        // α·Δt/Δx² = 1.0 * 1.0 / 0.01 = 100 → unstable
402        let cfl = CflCondition::new(0.1, 1.0, 0.0, 1.0);
403        assert!(!cfl.is_diffusion_stable());
404    }
405
406    // ── Heat equation ───────────────────────────────────────────────────────
407
408    #[test]
409    fn test_heat_bc_fixed() {
410        // BCs (u[0] and u[n-1]) should remain fixed.
411        let n = 20;
412        let u0: Vec<f64> = (0..n)
413            .map(|i| {
414                if i == 0 {
415                    0.0
416                } else if i == n - 1 {
417                    1.0
418                } else {
419                    0.5
420                }
421            })
422            .collect();
423        let u = heat_equation_1d(&u0, 0.1, 0.1, 0.001, 100);
424        assert!((u[0] - u0[0]).abs() < EPS);
425        assert!((u[n - 1] - u0[n - 1]).abs() < EPS);
426    }
427
428    #[test]
429    fn test_heat_steady_state_linear() {
430        // Steady state of heat equation with BCs u(0)=0, u(1)=1 is linear: u(x)=x.
431        let n = 11;
432        let dx = 1.0 / (n - 1) as f64;
433        // Start from all-zeros interior; BCs enforced inside solver.
434        let mut u0 = vec![0.5_f64; n];
435        u0[0] = 0.0;
436        u0[n - 1] = 1.0;
437
438        let alpha = 0.5;
439        let dt = 0.4 * dx * dx / alpha; // just below stability limit
440        let u = heat_equation_1d(&u0, alpha, dx, dt, 5000);
441
442        for i in 1..n - 1 {
443            let x = i as f64 * dx;
444            assert!(
445                (u[i] - x).abs() < 1e-3,
446                "expected {x:.3} at node {i}, got {:.6}",
447                u[i]
448            );
449        }
450    }
451
452    #[test]
453    fn test_heat_conservation_insulated() {
454        // With zero temperature at both ends, total "heat" decreases but stays ≥ 0.
455        let n = 50;
456        let u0: Vec<f64> = (0..n)
457            .map(|i| {
458                let x = i as f64 / (n - 1) as f64;
459                (std::f64::consts::PI * x).sin()
460            })
461            .collect();
462        let dx = 1.0 / (n - 1) as f64;
463        let alpha = 0.01;
464        let dt = 0.4 * dx * dx / alpha;
465        let u = heat_equation_1d(&u0, alpha, dx, dt, 100);
466        // All values should remain finite
467        for val in &u {
468            assert!(val.is_finite());
469        }
470    }
471
472    // ── Wave equation ───────────────────────────────────────────────────────
473
474    #[test]
475    fn test_wave_zero_field_stays_zero() {
476        let n = 30;
477        let u_prev = vec![0.0_f64; n];
478        let u_curr = vec![0.0_f64; n];
479        let u = wave_equation_1d(&u_prev, &u_curr, 1.0, 0.1, 0.05, 10);
480        for v in &u {
481            assert!(v.abs() < EPS);
482        }
483    }
484
485    #[test]
486    fn test_wave_bc_fixed_zero() {
487        let n = 20;
488        let u_prev: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
489        let u_curr = u_prev.clone();
490        let u = wave_equation_1d(&u_prev, &u_curr, 1.0, 0.1, 0.05, 5);
491        assert!(u[0].abs() < EPS);
492        assert!(u[n - 1].abs() < EPS);
493    }
494
495    #[test]
496    fn test_wave_finite_values() {
497        let n = 40;
498        let dx = 1.0 / (n - 1) as f64;
499        let c = 1.0;
500        let dt = 0.9 * dx / c;
501        let u0: Vec<f64> = (0..n)
502            .map(|i| {
503                let x = i as f64 * dx;
504                (2.0 * std::f64::consts::PI * x).sin()
505            })
506            .collect();
507        let u = wave_equation_1d(&u0, &u0, c, dx, dt, 50);
508        for v in &u {
509            assert!(v.is_finite());
510        }
511    }
512
513    // ── Poisson equation ────────────────────────────────────────────────────
514
515    #[test]
516    fn test_poisson_bc_satisfied() {
517        let n = 10;
518        let f = vec![1.0_f64; n];
519        let u = poisson_1d(&f, 0.1, 0.0, 0.0);
520        assert!((u[0] - 0.0).abs() < EPS);
521        assert!((u[n - 1] - 0.0).abs() < EPS);
522    }
523
524    #[test]
525    fn test_poisson_zero_rhs_linear_solution() {
526        // -u'' = 0 with u(0)=0, u(1)=1 → u(x) = x
527        let n = 11;
528        let dx = 1.0 / (n - 1) as f64;
529        let f = vec![0.0_f64; n];
530        let u = poisson_1d(&f, dx, 0.0, 1.0);
531        for i in 0..n {
532            let x = i as f64 * dx;
533            assert!(
534                (u[i] - x).abs() < 1e-10,
535                "node {i}: expected {x}, got {}",
536                u[i]
537            );
538        }
539    }
540
541    #[test]
542    fn test_poisson_nonzero_rhs_symmetry() {
543        // -u'' = 2 with u(0)=u(1)=0 → u(x) = x(1-x)
544        let n = 11;
545        let dx = 1.0 / (n - 1) as f64;
546        let f = vec![2.0_f64; n];
547        let u = poisson_1d(&f, dx, 0.0, 0.0);
548        for i in 0..n {
549            let x = i as f64 * dx;
550            let expected = x * (1.0 - x);
551            assert!(
552                (u[i] - expected).abs() < 1e-10,
553                "node {i}: expected {expected:.6}, got {:.6}",
554                u[i]
555            );
556        }
557    }
558
559    #[test]
560    fn test_poisson_two_node_trivial() {
561        let u = poisson_1d(&[0.0, 0.0], 1.0, 1.0, 2.0);
562        assert_eq!(u.len(), 2);
563        assert!((u[0] - 1.0).abs() < EPS);
564        assert!((u[1] - 2.0).abs() < EPS);
565    }
566
567    // ── Advection ───────────────────────────────────────────────────────────
568
569    #[test]
570    fn test_advection_zero_field() {
571        let u0 = vec![0.0_f64; 20];
572        let u = advection_1d_upwind(&u0, 1.0, 0.1, 0.05, 10);
573        for v in &u {
574            assert!(v.abs() < EPS);
575        }
576    }
577
578    #[test]
579    fn test_advection_positive_speed_shifts_right() {
580        // Step function at index 5; after enough steps it should shift right.
581        let n = 20;
582        let dx = 1.0 / n as f64;
583        let c = 1.0;
584        let dt = 0.5 * dx / c; // CFL = 0.5
585        let mut u0 = vec![0.0_f64; n];
586        u0[5] = 1.0;
587        let u = advection_1d_upwind(&u0, c, dx, dt, 4);
588        // After 4 steps the mass should have diffused rightward; sum preserved approx
589        let sum_orig: f64 = u0.iter().sum();
590        let sum_adv: f64 = u.iter().sum();
591        assert!((sum_adv - sum_orig).abs() < 1e-10);
592    }
593
594    #[test]
595    fn test_advection_negative_speed_shifts_left() {
596        let n = 20;
597        let dx = 1.0 / n as f64;
598        let c = -1.0_f64;
599        let dt = 0.5 * dx / c.abs();
600        let mut u0 = vec![0.0_f64; n];
601        u0[15] = 1.0;
602        let u = advection_1d_upwind(&u0, c, dx, dt, 4);
603        // With negative speed, peak should shift toward lower indices
604        let sum_orig: f64 = u0.iter().sum();
605        let sum_adv: f64 = u.iter().sum();
606        assert!((sum_adv - sum_orig).abs() < 1e-10);
607    }
608
609    #[test]
610    fn test_advection_finite_values() {
611        let n = 50;
612        let dx = 1.0 / n as f64;
613        let c = 2.0;
614        let dt = 0.4 * dx / c;
615        let u0: Vec<f64> = (0..n)
616            .map(|i| (i as f64 * dx * std::f64::consts::PI * 2.0).sin())
617            .collect();
618        let u = advection_1d_upwind(&u0, c, dx, dt, 20);
619        for v in &u {
620            assert!(v.is_finite());
621        }
622    }
623
624    // ── Burgers equation ─────────────────────────────────────────────────────
625
626    #[test]
627    fn test_burgers_zero_field() {
628        let u0 = vec![0.0_f64; 20];
629        let u = burger_equation_1d(&u0, 0.1, 0.01, 10);
630        for v in &u {
631            assert!(v.abs() < EPS);
632        }
633    }
634
635    #[test]
636    fn test_burgers_finite_values() {
637        let n = 40;
638        let dx = 1.0 / n as f64;
639        let dt = 0.3 * dx; // CFL < 1 for u ∈ [-1,1]
640        let u0: Vec<f64> = (0..n)
641            .map(|i| {
642                let x = i as f64 * dx;
643                (std::f64::consts::PI * 2.0 * x).sin()
644            })
645            .collect();
646        let u = burger_equation_1d(&u0, dx, dt, 30);
647        for v in &u {
648            assert!(v.is_finite());
649        }
650    }
651
652    #[test]
653    fn test_godunov_flux_shock() {
654        // u_L > u_R, shock speed s = (u_L + u_R)/2
655        // u_L=2, u_R=0, s=1>0 → flux = u_L²/2 = 2
656        let f = super::godunov_flux_burgers(2.0, 0.0);
657        assert!((f - 2.0).abs() < EPS);
658    }
659
660    #[test]
661    fn test_godunov_flux_rarefaction_positive() {
662        // u_L=1, u_R=2, both positive → flux = u_L²/2 = 0.5
663        let f = super::godunov_flux_burgers(1.0, 2.0);
664        assert!((f - 0.5).abs() < EPS);
665    }
666
667    #[test]
668    fn test_godunov_flux_sonic() {
669        // u_L < 0 < u_R: sonic point → flux = 0
670        let f = super::godunov_flux_burgers(-1.0, 1.0);
671        assert!(f.abs() < EPS);
672    }
673
674    // ── Diffusion-Reaction ──────────────────────────────────────────────────
675
676    #[test]
677    fn test_diffusion_reaction_no_reaction_matches_heat() {
678        let n = 20;
679        let dx = 0.05;
680        let d = 0.1;
681        let dt = 0.4 * dx * dx / d;
682        let u0: Vec<f64> = (0..n)
683            .map(|i| (i as f64 / (n - 1) as f64 * std::f64::consts::PI).sin())
684            .collect();
685
686        let u_heat = heat_equation_1d(&u0, d, dx, dt, 50);
687        let u_dr = diffusion_reaction_1d(&u0, d, dx, dt, 50, |_u, _i, _t| 0.0);
688
689        for (a, b) in u_heat.iter().zip(u_dr.iter()) {
690            assert!(
691                (a - b).abs() < 1e-12,
692                "heat and DR (no reaction) differ: {a} vs {b}"
693            );
694        }
695    }
696
697    #[test]
698    fn test_diffusion_reaction_linear_decay() {
699        // Reaction R = -k*u (linear decay), D → 0, should decay exponentially.
700        let n = 20;
701        let dx = 0.05;
702        let d = 0.0; // no diffusion
703        let dt = 0.01;
704        let k = 0.5;
705        let steps = 50;
706        let mut u0 = vec![0.5_f64; n];
707        u0[0] = 0.0;
708        u0[n - 1] = 0.0;
709
710        let u = diffusion_reaction_1d(&u0, d, dx, dt, steps, |val, _i, _t| -k * val);
711
712        // Interior values should decay: u ≈ 0.5 * (1 - k*dt)^steps
713        let expected = 0.5 * (1.0 - k * dt).powi(steps as i32);
714        for i in 1..n - 1 {
715            assert!(
716                (u[i] - expected).abs() < 1e-6,
717                "node {i}: expected {expected:.8}, got {:.8}",
718                u[i]
719            );
720        }
721    }
722
723    #[test]
724    fn test_diffusion_reaction_bc_preserved() {
725        let n = 15;
726        let u0: Vec<f64> = (0..n).map(|i| i as f64).collect();
727        let dx = 0.1;
728        let d = 0.01;
729        let dt = 0.001;
730        let u = diffusion_reaction_1d(&u0, d, dx, dt, 10, |_u, _i, _t| 0.0);
731        assert!((u[0] - u0[0]).abs() < EPS);
732        assert!((u[n - 1] - u0[n - 1]).abs() < EPS);
733    }
734
735    #[test]
736    fn test_diffusion_reaction_finite_values() {
737        let n = 30;
738        let dx = 0.05;
739        let d = 0.05;
740        let dt = 0.4 * dx * dx / d;
741        let u0: Vec<f64> = (0..n)
742            .map(|i| (i as f64 / (n - 1) as f64).powi(2))
743            .collect();
744        let u = diffusion_reaction_1d(&u0, d, dx, dt, 20, |val, _i, _t| val * 0.1);
745        for v in &u {
746            assert!(v.is_finite());
747        }
748    }
749}