Skip to main content

oxiphysics_gpu/
flux_compute.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! CPU-side parallel flux and advection compute kernels.
5//!
6//! Provides scalar and vector flux computation on structured 3-D Cartesian
7//! grids: upwind advection, central-difference divergence, Lax-Friedrichs
8//! flux splitting, Godunov interface reconstruction, limiter functions
9//! (minmod, superbee, van Leer), and a simple finite-volume time-stepping
10//! helper.  All kernels use Rayon for data-parallel execution.
11
12#![allow(dead_code)]
13#![allow(non_snake_case)]
14
15use rayon::prelude::*;
16
17// ---------------------------------------------------------------------------
18// 1. FluxGrid3D — a 3-D scalar field on a Cartesian grid
19// ---------------------------------------------------------------------------
20
21/// A 3-D scalar field stored in row-major order on a uniform Cartesian grid.
22///
23/// Index layout: `values[i*(ny*nz) + j*nz + k]` for cell `(i, j, k)`.
24#[derive(Debug, Clone)]
25pub struct FluxGrid3D {
26    /// Cells in the x-direction.
27    pub nx: usize,
28    /// Cells in the y-direction.
29    pub ny: usize,
30    /// Cells in the z-direction.
31    pub nz: usize,
32    /// Uniform cell spacing.
33    pub dx: f64,
34    /// Scalar field values.
35    pub values: Vec<f64>,
36}
37
38impl FluxGrid3D {
39    /// Create a grid filled with `fill`.
40    pub fn new(nx: usize, ny: usize, nz: usize, dx: f64, fill: f64) -> Self {
41        Self {
42            nx,
43            ny,
44            nz,
45            dx,
46            values: vec![fill; nx * ny * nz],
47        }
48    }
49
50    /// Flat index for cell `(i, j, k)`.
51    #[inline]
52    pub fn idx(&self, i: usize, j: usize, k: usize) -> usize {
53        i * self.ny * self.nz + j * self.nz + k
54    }
55
56    /// Read cell `(i, j, k)`.
57    #[inline]
58    pub fn get(&self, i: usize, j: usize, k: usize) -> f64 {
59        self.values[self.idx(i, j, k)]
60    }
61
62    /// Write cell `(i, j, k)`.
63    #[inline]
64    pub fn set(&mut self, i: usize, j: usize, k: usize, v: f64) {
65        let idx = self.idx(i, j, k);
66        self.values[idx] = v;
67    }
68
69    /// Total number of cells.
70    pub fn len(&self) -> usize {
71        self.nx * self.ny * self.nz
72    }
73
74    /// True when the grid has no cells.
75    pub fn is_empty(&self) -> bool {
76        self.len() == 0
77    }
78
79    /// Clamp-to-boundary accessor: returns the value at `(i, j, k)` with
80    /// zero-gradient boundary conditions.
81    pub fn get_clamped(&self, i: isize, j: isize, k: isize) -> f64 {
82        let ci = i.clamp(0, self.nx as isize - 1) as usize;
83        let cj = j.clamp(0, self.ny as isize - 1) as usize;
84        let ck = k.clamp(0, self.nz as isize - 1) as usize;
85        self.get(ci, cj, ck)
86    }
87
88    /// L2 norm of all values.
89    pub fn l2_norm(&self) -> f64 {
90        let sum_sq: f64 = self.values.iter().map(|&v| v * v).sum();
91        sum_sq.sqrt()
92    }
93
94    /// Element-wise maximum value.
95    pub fn max_val(&self) -> f64 {
96        self.values
97            .iter()
98            .copied()
99            .fold(f64::NEG_INFINITY, f64::max)
100    }
101
102    /// Element-wise minimum value.
103    pub fn min_val(&self) -> f64 {
104        self.values.iter().copied().fold(f64::INFINITY, f64::min)
105    }
106}
107
108// ---------------------------------------------------------------------------
109// 2. VectorFluxGrid3D — three-component (u, v, w) velocity field
110// ---------------------------------------------------------------------------
111
112/// Three-component 3-D velocity / flux field.
113///
114/// Components `u` (x), `v` (y), `w` (z) are stored as separate [`FluxGrid3D`]
115/// instances sharing the same dimensions and spacing.
116#[derive(Debug, Clone)]
117pub struct VectorFluxGrid3D {
118    /// x-component.
119    pub u: FluxGrid3D,
120    /// y-component.
121    pub v: FluxGrid3D,
122    /// z-component.
123    pub w: FluxGrid3D,
124}
125
126impl VectorFluxGrid3D {
127    /// Create a zero-filled vector field.
128    pub fn zeros(nx: usize, ny: usize, nz: usize, dx: f64) -> Self {
129        Self {
130            u: FluxGrid3D::new(nx, ny, nz, dx, 0.0),
131            v: FluxGrid3D::new(nx, ny, nz, dx, 0.0),
132            w: FluxGrid3D::new(nx, ny, nz, dx, 0.0),
133        }
134    }
135
136    /// Set velocity at cell `(i, j, k)`.
137    pub fn set_vel(&mut self, i: usize, j: usize, k: usize, vel: [f64; 3]) {
138        self.u.set(i, j, k, vel[0]);
139        self.v.set(i, j, k, vel[1]);
140        self.w.set(i, j, k, vel[2]);
141    }
142
143    /// Get velocity at cell `(i, j, k)`.
144    pub fn get_vel(&self, i: usize, j: usize, k: usize) -> [f64; 3] {
145        [
146            self.u.get(i, j, k),
147            self.v.get(i, j, k),
148            self.w.get(i, j, k),
149        ]
150    }
151}
152
153// ---------------------------------------------------------------------------
154// 3. Slope limiter functions
155// ---------------------------------------------------------------------------
156
157/// Minmod slope limiter: smallest slope among `a` and `b` with same sign.
158///
159/// Returns zero when `a` and `b` have opposite signs.
160#[inline]
161pub fn minmod(a: f64, b: f64) -> f64 {
162    if a * b <= 0.0 {
163        0.0
164    } else if a.abs() < b.abs() {
165        a
166    } else {
167        b
168    }
169}
170
171/// Superbee limiter: most compressive TVD limiter.
172pub fn superbee(a: f64, b: f64) -> f64 {
173    if a * b <= 0.0 {
174        return 0.0;
175    }
176    let s = if a >= 0.0 { 1.0 } else { -1.0 };
177    s * a.abs().max(b.abs()).min(2.0 * a.abs().min(b.abs()))
178}
179
180/// Van Leer limiter: smooth TVD limiter.
181pub fn van_leer(a: f64, b: f64) -> f64 {
182    if a * b <= 0.0 {
183        return 0.0;
184    }
185    2.0 * a * b / (a + b)
186}
187
188/// MC (Monotonised Central) limiter.
189pub fn mc_limiter(a: f64, b: f64) -> f64 {
190    if a * b <= 0.0 {
191        return 0.0;
192    }
193    let centered = 0.5 * (a + b);
194    let s = if a >= 0.0 { 1.0 } else { -1.0 };
195    s * centered.abs().min(2.0 * a.abs()).min(2.0 * b.abs())
196}
197
198// ---------------------------------------------------------------------------
199// 4. Upwind advection (1-D per axis, assembled into 3-D)
200// ---------------------------------------------------------------------------
201
202/// First-order upwind advection flux for a scalar `phi` transported by
203/// uniform velocity `vel` in one dimension.
204///
205/// Returns `F_right - F_left` (net flux into cell `i`) for each cell.
206///
207/// Uses zero-gradient boundary conditions.
208pub fn upwind_flux_1d(phi: &[f64], vel: f64, dx: f64, dt: f64) -> Vec<f64> {
209    let n = phi.len();
210    let mut flux = vec![0.0; n];
211    let cfl = vel * dt / dx;
212    for i in 0..n {
213        let phi_l = if i == 0 { phi[0] } else { phi[i - 1] };
214        let phi_r = if i + 1 >= n { phi[n - 1] } else { phi[i + 1] };
215        if vel >= 0.0 {
216            // Upwind from left
217            flux[i] = -cfl * (phi[i] - phi_l);
218        } else {
219            // Upwind from right
220            flux[i] = -cfl * (phi_r - phi[i]);
221        }
222    }
223    flux
224}
225
226/// Apply first-order upwind advection to a [`FluxGrid3D`] for one time step.
227///
228/// Returns a new grid with updated values.
229pub fn advect_upwind_3d(phi: &FluxGrid3D, vel: &VectorFluxGrid3D, dt: f64) -> FluxGrid3D {
230    let _nx = phi.nx;
231    let ny = phi.ny;
232    let nz = phi.nz;
233    let dx = phi.dx;
234
235    let mut out = phi.clone();
236
237    out.values.par_iter_mut().enumerate().for_each(|(idx, v)| {
238        let i = idx / (ny * nz);
239        let j = (idx / nz) % ny;
240        let k = idx % nz;
241
242        let ux = vel.u.get(i, j, k);
243        let uy = vel.v.get(i, j, k);
244        let uz = vel.w.get(i, j, k);
245
246        let phi_xm = phi.get_clamped(i as isize - 1, j as isize, k as isize);
247        let phi_xp = phi.get_clamped(i as isize + 1, j as isize, k as isize);
248        let phi_ym = phi.get_clamped(i as isize, j as isize - 1, k as isize);
249        let phi_yp = phi.get_clamped(i as isize, j as isize + 1, k as isize);
250        let phi_zm = phi.get_clamped(i as isize, j as isize, k as isize - 1);
251        let phi_zp = phi.get_clamped(i as isize, j as isize, k as isize + 1);
252        let phi_c = phi.get(i, j, k);
253
254        let flux_x = if ux >= 0.0 {
255            ux * (phi_c - phi_xm) / dx
256        } else {
257            ux * (phi_xp - phi_c) / dx
258        };
259        let flux_y = if uy >= 0.0 {
260            uy * (phi_c - phi_ym) / dx
261        } else {
262            uy * (phi_yp - phi_c) / dx
263        };
264        let flux_z = if uz >= 0.0 {
265            uz * (phi_c - phi_zm) / dx
266        } else {
267            uz * (phi_zp - phi_c) / dx
268        };
269
270        *v = phi_c - dt * (flux_x + flux_y + flux_z);
271    });
272
273    out
274}
275
276// ---------------------------------------------------------------------------
277// 5. Lax-Friedrichs flux splitting
278// ---------------------------------------------------------------------------
279
280/// Lax-Friedrichs numerical flux at the interface between cells `i` and `i+1`.
281///
282/// `f(u)` is the physical flux function, `alpha` is the maximum wave speed.
283#[inline]
284pub fn lax_friedrichs_flux(u_l: f64, u_r: f64, f_l: f64, f_r: f64, alpha: f64) -> f64 {
285    0.5 * (f_l + f_r) - 0.5 * alpha * (u_r - u_l)
286}
287
288/// Lax-Friedrichs advection update for a 1-D scalar array.
289///
290/// Physical flux: `f(u) = vel * u`.
291/// Returns the updated scalar array after one time step.
292pub fn lax_friedrichs_advect_1d(u: &[f64], vel: f64, dx: f64, dt: f64) -> Vec<f64> {
293    let n = u.len();
294    if n == 0 {
295        return Vec::new();
296    }
297    let alpha = vel.abs();
298    let mut u_new = vec![0.0; n];
299    for i in 0..n {
300        let u_l = if i == 0 { u[0] } else { u[i - 1] };
301        let u_r = if i + 1 >= n { u[n - 1] } else { u[i + 1] };
302        let f_l = vel * u_l;
303        let f_r = vel * u_r;
304        let f_left = lax_friedrichs_flux(u_l, u[i], f_l, vel * u[i], alpha);
305        let f_right = lax_friedrichs_flux(u[i], u_r, vel * u[i], f_r, alpha);
306        u_new[i] = u[i] - dt / dx * (f_right - f_left);
307    }
308    u_new
309}
310
311// ---------------------------------------------------------------------------
312// 6. Divergence and gradient on the 3-D grid
313// ---------------------------------------------------------------------------
314
315/// Compute the divergence of a vector field at every interior cell.
316///
317/// Uses central differences: `div(F) ≈ (Fx_{i+1} - Fx_{i-1}) / (2dx) + ...`
318pub fn divergence_3d(vel: &VectorFluxGrid3D) -> FluxGrid3D {
319    let nx = vel.u.nx;
320    let ny = vel.u.ny;
321    let nz = vel.u.nz;
322    let dx = vel.u.dx;
323
324    let mut div = FluxGrid3D::new(nx, ny, nz, dx, 0.0);
325    div.values.par_iter_mut().enumerate().for_each(|(idx, d)| {
326        let i = idx / (ny * nz);
327        let j = (idx / nz) % ny;
328        let k = idx % nz;
329        let ii = i as isize;
330        let jj = j as isize;
331        let kk = k as isize;
332        let du_dx =
333            (vel.u.get_clamped(ii + 1, jj, kk) - vel.u.get_clamped(ii - 1, jj, kk)) / (2.0 * dx);
334        let dv_dy =
335            (vel.v.get_clamped(ii, jj + 1, kk) - vel.v.get_clamped(ii, jj - 1, kk)) / (2.0 * dx);
336        let dw_dz =
337            (vel.w.get_clamped(ii, jj, kk + 1) - vel.w.get_clamped(ii, jj, kk - 1)) / (2.0 * dx);
338        *d = du_dx + dv_dy + dw_dz;
339    });
340    div
341}
342
343/// Compute the gradient of a scalar field at every cell.
344///
345/// Returns a [`VectorFluxGrid3D`] where each component is a partial derivative.
346pub fn gradient_3d(phi: &FluxGrid3D) -> VectorFluxGrid3D {
347    let nx = phi.nx;
348    let ny = phi.ny;
349    let nz = phi.nz;
350    let dx = phi.dx;
351
352    let mut grad = VectorFluxGrid3D::zeros(nx, ny, nz, dx);
353
354    let u_vals: Vec<f64> = (0..nx * ny * nz)
355        .into_par_iter()
356        .map(|idx| {
357            let i = idx / (ny * nz);
358            let j = (idx / nz) % ny;
359            let k = idx % nz;
360            let ii = i as isize;
361            let jj = j as isize;
362            let kk = k as isize;
363            (phi.get_clamped(ii + 1, jj, kk) - phi.get_clamped(ii - 1, jj, kk)) / (2.0 * dx)
364        })
365        .collect();
366
367    let v_vals: Vec<f64> = (0..nx * ny * nz)
368        .into_par_iter()
369        .map(|idx| {
370            let i = idx / (ny * nz);
371            let j = (idx / nz) % ny;
372            let k = idx % nz;
373            let ii = i as isize;
374            let jj = j as isize;
375            let kk = k as isize;
376            (phi.get_clamped(ii, jj + 1, kk) - phi.get_clamped(ii, jj - 1, kk)) / (2.0 * dx)
377        })
378        .collect();
379
380    let w_vals: Vec<f64> = (0..nx * ny * nz)
381        .into_par_iter()
382        .map(|idx| {
383            let i = idx / (ny * nz);
384            let j = (idx / nz) % ny;
385            let k = idx % nz;
386            let ii = i as isize;
387            let jj = j as isize;
388            let kk = k as isize;
389            (phi.get_clamped(ii, jj, kk + 1) - phi.get_clamped(ii, jj, kk - 1)) / (2.0 * dx)
390        })
391        .collect();
392
393    grad.u.values = u_vals;
394    grad.v.values = v_vals;
395    grad.w.values = w_vals;
396    grad
397}
398
399// ---------------------------------------------------------------------------
400// 7. Finite-volume Euler step helper
401// ---------------------------------------------------------------------------
402
403/// Advance `phi` by one Euler time step using explicit upwind advection.
404///
405/// Convenience wrapper: `phi_{n+1} = advect_upwind_3d(phi_n, vel, dt)`.
406pub fn euler_step_advect(phi: &FluxGrid3D, vel: &VectorFluxGrid3D, dt: f64) -> FluxGrid3D {
407    advect_upwind_3d(phi, vel, dt)
408}
409
410/// Compute the CFL-limited maximum stable time step.
411///
412/// `dt ≤ CFL_factor * dx / max(|u|, |v|, |w|)`.
413pub fn cfl_dt(vel: &VectorFluxGrid3D, cfl_factor: f64) -> f64 {
414    let max_u = vel
415        .u
416        .values
417        .iter()
418        .copied()
419        .map(f64::abs)
420        .fold(0.0_f64, f64::max);
421    let max_v = vel
422        .v
423        .values
424        .iter()
425        .copied()
426        .map(f64::abs)
427        .fold(0.0_f64, f64::max);
428    let max_w = vel
429        .w
430        .values
431        .iter()
432        .copied()
433        .map(f64::abs)
434        .fold(0.0_f64, f64::max);
435    let max_vel = max_u.max(max_v).max(max_w);
436    let dx = vel.u.dx;
437    if max_vel < 1e-300 {
438        f64::INFINITY
439    } else {
440        cfl_factor * dx / max_vel
441    }
442}
443
444// ---------------------------------------------------------------------------
445// 8. Conservative state vector for Euler equations
446// ---------------------------------------------------------------------------
447
448/// Conservative variable vector for the Euler equations: \[rho, rho*u, rho*v, rho*w, E\].
449#[allow(dead_code)]
450#[derive(Debug, Clone, Copy)]
451pub struct EulerState {
452    /// Density.
453    pub rho: f64,
454    /// x-momentum.
455    pub rho_u: f64,
456    /// y-momentum.
457    pub rho_v: f64,
458    /// z-momentum.
459    pub rho_w: f64,
460    /// Total energy density.
461    pub e: f64,
462}
463
464impl EulerState {
465    /// Create from primitives `(rho, u, v, w, p)` with ratio of specific heats `gamma`.
466    #[allow(dead_code)]
467    pub fn from_primitives(rho: f64, u: f64, v: f64, w: f64, p: f64, gamma: f64) -> Self {
468        let ke = 0.5 * rho * (u * u + v * v + w * w);
469        let e = p / (gamma - 1.0) + ke;
470        Self {
471            rho,
472            rho_u: rho * u,
473            rho_v: rho * v,
474            rho_w: rho * w,
475            e,
476        }
477    }
478
479    /// Reconstruct velocity components.
480    #[allow(dead_code)]
481    pub fn velocity(&self) -> [f64; 3] {
482        let rho = if self.rho.abs() > 1e-30 {
483            self.rho
484        } else {
485            1e-30
486        };
487        [self.rho_u / rho, self.rho_v / rho, self.rho_w / rho]
488    }
489
490    /// Reconstruct pressure from conserved variables.
491    #[allow(dead_code)]
492    pub fn pressure(&self, gamma: f64) -> f64 {
493        let u = self.velocity();
494        let ke = 0.5 * self.rho * (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]);
495        (gamma - 1.0) * (self.e - ke)
496    }
497
498    /// Sound speed.
499    #[allow(dead_code)]
500    pub fn sound_speed(&self, gamma: f64) -> f64 {
501        let p = self.pressure(gamma);
502        let rho = if self.rho.abs() > 1e-30 {
503            self.rho
504        } else {
505            1e-30
506        };
507        (gamma * p / rho).sqrt().max(0.0)
508    }
509
510    /// x-direction Euler flux vector.
511    #[allow(dead_code)]
512    pub fn flux_x(&self, gamma: f64) -> [f64; 5] {
513        let u = self.velocity();
514        let p = self.pressure(gamma);
515        [
516            self.rho_u,
517            self.rho_u * u[0] + p,
518            self.rho_v * u[0],
519            self.rho_w * u[0],
520            (self.e + p) * u[0],
521        ]
522    }
523
524    /// Linear interpolation between two states.
525    #[allow(dead_code)]
526    pub fn lerp(&self, other: &EulerState, t: f64) -> EulerState {
527        EulerState {
528            rho: self.rho + t * (other.rho - self.rho),
529            rho_u: self.rho_u + t * (other.rho_u - self.rho_u),
530            rho_v: self.rho_v + t * (other.rho_v - self.rho_v),
531            rho_w: self.rho_w + t * (other.rho_w - self.rho_w),
532            e: self.e + t * (other.e - self.e),
533        }
534    }
535}
536
537// ---------------------------------------------------------------------------
538// 9. Van Albada slope limiter
539// ---------------------------------------------------------------------------
540
541/// Van Albada slope limiter: smooth TVD limiter with quadratic shape.
542///
543/// `φ(r) = (r² + r) / (r² + 1)` for `r = a/b`.
544#[allow(dead_code)]
545pub fn van_albada(a: f64, b: f64) -> f64 {
546    if a * b <= 0.0 {
547        return 0.0;
548    }
549    let r = a / b;
550    b * (r * r + r) / (r * r + 1.0)
551}
552
553// ---------------------------------------------------------------------------
554// 10. MUSCL reconstruction (piecewise-linear, limited)
555// ---------------------------------------------------------------------------
556
557/// Reconstruct left and right interface values from cell-centred values
558/// using the MUSCL scheme with a given limiter.
559///
560/// Returns `(u_L, u_R)` at the interface between cells `i` and `i+1`.
561///
562/// Uses: `u_L = u[i]   + 0.5 * phi(delta_m, delta_p) * delta_m`
563///       `u_R = u[i+1] - 0.5 * phi(delta_p, delta_m) * delta_p`
564/// where `delta_m = u[i] - u[i-1]`, `delta_p = u[i+1] - u[i]`.
565#[allow(dead_code)]
566pub fn muscl_reconstruct(u: &[f64], i: usize, limiter: fn(f64, f64) -> f64) -> (f64, f64) {
567    let n = u.len();
568    let u_im = if i == 0 { u[0] } else { u[i - 1] };
569    let u_i = u[i];
570    let u_ip = if i + 1 < n { u[i + 1] } else { u[n - 1] };
571    let u_ipp = if i + 2 < n { u[i + 2] } else { u[n - 1] };
572
573    let delta_m = u_i - u_im;
574    let delta_p = u_ip - u_i;
575    let delta_pp = u_ipp - u_ip;
576
577    let sigma_l = limiter(delta_m, delta_p);
578    let sigma_r = limiter(delta_p, delta_pp);
579
580    let u_l = u_i + 0.5 * sigma_l;
581    let u_r = u_ip - 0.5 * sigma_r;
582    (u_l, u_r)
583}
584
585/// Apply MUSCL reconstruction across all interfaces in a 1-D array.
586///
587/// Returns a `Vec<(f64, f64)>` of `(u_L, u_R)` at interfaces `0..n-1`.
588#[allow(dead_code)]
589pub fn muscl_reconstruct_all(u: &[f64], limiter: fn(f64, f64) -> f64) -> Vec<(f64, f64)> {
590    let n = u.len();
591    if n < 2 {
592        return Vec::new();
593    }
594    (0..n - 1)
595        .map(|i| muscl_reconstruct(u, i, limiter))
596        .collect()
597}
598
599// ---------------------------------------------------------------------------
600// 11. Godunov flux (exact Riemann solver for linear advection)
601// ---------------------------------------------------------------------------
602
603/// Godunov flux for a scalar advection equation `u_t + a * u_x = 0`.
604///
605/// Uses upwinding: the Godunov flux is `F = a * u_L` if `a >= 0`, else `a * u_R`.
606#[allow(dead_code)]
607pub fn godunov_flux_advection(u_l: f64, u_r: f64, wave_speed: f64) -> f64 {
608    if wave_speed >= 0.0 {
609        wave_speed * u_l
610    } else {
611        wave_speed * u_r
612    }
613}
614
615/// Godunov-type flux for Burgers' equation `u_t + (u²/2)_x = 0`.
616///
617/// Handles the sonic point (where the characteristic speed changes sign).
618#[allow(dead_code)]
619pub fn godunov_flux_burgers(u_l: f64, u_r: f64) -> f64 {
620    // Rankine-Hugoniot shock speed: s = (u_l + u_r) / 2
621    // Entropy fix: rarefaction fans may straddle u=0
622    if u_l >= u_r {
623        // Shock
624        let s = 0.5 * (u_l + u_r);
625        if s >= 0.0 {
626            0.5 * u_l * u_l
627        } else {
628            0.5 * u_r * u_r
629        }
630    } else {
631        // Rarefaction
632        if u_l >= 0.0 {
633            0.5 * u_l * u_l
634        } else if u_r <= 0.0 {
635            0.5 * u_r * u_r
636        } else {
637            0.0 // entropy-satisfying: sonic point at u=0
638        }
639    }
640}
641
642// ---------------------------------------------------------------------------
643// 12. Roe flux for the linear-advection system
644// ---------------------------------------------------------------------------
645
646/// Roe-averaged scalar flux for a 1-D scalar conservation law.
647///
648/// For `u_t + f(u)_x = 0` with `f(u) = a * u`:
649/// `F_Roe = 0.5*(f_L + f_R) - 0.5*|a_Roe|*(u_R - u_L)`
650#[allow(dead_code)]
651pub fn roe_flux_scalar(u_l: f64, u_r: f64, a_roe: f64) -> f64 {
652    let f_l = a_roe * u_l;
653    let f_r = a_roe * u_r;
654    0.5 * (f_l + f_r) - 0.5 * a_roe.abs() * (u_r - u_l)
655}
656
657/// Roe flux for 1-D Euler equations (x-direction).
658///
659/// Returns the interface flux `[F_rho, F_rhou, F_e]` using the Roe-average state.
660/// `gamma` is the ratio of specific heats (1.4 for air).
661#[allow(dead_code)]
662pub fn roe_flux_euler_1d(
663    rho_l: f64,
664    u_l: f64,
665    p_l: f64,
666    rho_r: f64,
667    u_r: f64,
668    p_r: f64,
669    gamma: f64,
670) -> [f64; 3] {
671    // Roe averages
672    let sqrt_rl = rho_l.sqrt();
673    let sqrt_rr = rho_r.sqrt();
674    let denom = sqrt_rl + sqrt_rr;
675
676    let u_roe = (sqrt_rl * u_l + sqrt_rr * u_r) / denom;
677    let h_l = (p_l / (rho_l * (gamma - 1.0)) + p_l / rho_l) + 0.5 * u_l * u_l;
678    let h_r = (p_r / (rho_r * (gamma - 1.0)) + p_r / rho_r) + 0.5 * u_r * u_r;
679    let h_roe = (sqrt_rl * h_l + sqrt_rr * h_r) / denom;
680
681    let a2 = (gamma - 1.0) * (h_roe - 0.5 * u_roe * u_roe);
682    let a_roe = if a2 > 0.0 { a2.sqrt() } else { 0.0 };
683
684    // Conservative variables
685    let e_l = p_l / (gamma - 1.0) + 0.5 * rho_l * u_l * u_l;
686    let e_r = p_r / (gamma - 1.0) + 0.5 * rho_r * u_r * u_r;
687
688    // Physical fluxes
689    let fl = [rho_l * u_l, rho_l * u_l * u_l + p_l, (e_l + p_l) * u_l];
690    let fr = [rho_r * u_r, rho_r * u_r * u_r + p_r, (e_r + p_r) * u_r];
691
692    // Delta conserved variables
693    let du = [rho_r - rho_l, rho_r * u_r - rho_l * u_l, e_r - e_l];
694
695    // Eigenvalues
696    let lam = [u_roe - a_roe, u_roe, u_roe + a_roe];
697
698    // Roe dissipation matrix applied to du (simplified diagonal form)
699    let r_inv_du = [
700        (du[0] * u_roe - du[1]) / (2.0 * a_roe.max(1e-30)),
701        du[0] - (du[0] * u_roe - du[1]) / (a_roe.max(1e-30)),
702        (du[0] * u_roe + du[1]) / (2.0 * a_roe.max(1e-30)),
703    ];
704
705    // Right eigenvectors (simplified form)
706    let r_mat = [
707        [1.0, 1.0, 1.0],
708        [u_roe - a_roe, u_roe, u_roe + a_roe],
709        [
710            h_roe - u_roe * a_roe,
711            0.5 * u_roe * u_roe,
712            h_roe + u_roe * a_roe,
713        ],
714    ];
715
716    let mut dissipation = [0.0f64; 3];
717    for i in 0..3 {
718        for k in 0..3 {
719            dissipation[i] += lam[k].abs() * r_inv_du[k] * r_mat[i][k];
720        }
721    }
722
723    [
724        0.5 * (fl[0] + fr[0]) - 0.5 * dissipation[0],
725        0.5 * (fl[1] + fr[1]) - 0.5 * dissipation[1],
726        0.5 * (fl[2] + fr[2]) - 0.5 * dissipation[2],
727    ]
728}
729
730// ---------------------------------------------------------------------------
731// 13. HLL flux
732// ---------------------------------------------------------------------------
733
734/// HLL (Harten-Lax-van Leer) numerical flux for a scalar conservation law.
735///
736/// Wave speed estimates `s_l` and `s_r` are the left and right running speeds.
737/// `f_l = f(u_l)`, `f_r = f(u_r)`.
738#[allow(dead_code)]
739pub fn hll_flux(u_l: f64, u_r: f64, f_l: f64, f_r: f64, s_l: f64, s_r: f64) -> f64 {
740    if s_l >= 0.0 {
741        f_l
742    } else if s_r <= 0.0 {
743        f_r
744    } else {
745        (s_r * f_l - s_l * f_r + s_l * s_r * (u_r - u_l)) / (s_r - s_l)
746    }
747}
748
749/// Estimate HLL wave speeds for the 1-D Euler equations.
750///
751/// Uses Davis' estimates: `s_l = min(u_l - a_l, u_r - a_r)`, `s_r = max(u_l + a_l, u_r + a_r)`.
752#[allow(dead_code)]
753pub fn hll_wave_speeds(u_l: f64, a_l: f64, u_r: f64, a_r: f64) -> (f64, f64) {
754    let s_l = (u_l - a_l).min(u_r - a_r);
755    let s_r = (u_l + a_l).max(u_r + a_r);
756    (s_l, s_r)
757}
758
759/// HLL flux for 1-D Euler equations.
760///
761/// Returns `[F_rho, F_rhou, F_e]`.
762#[allow(dead_code)]
763pub fn hll_flux_euler_1d(
764    rho_l: f64,
765    u_l: f64,
766    p_l: f64,
767    rho_r: f64,
768    u_r: f64,
769    p_r: f64,
770    gamma: f64,
771) -> [f64; 3] {
772    let a_l = (gamma * p_l / rho_l).sqrt().max(0.0);
773    let a_r = (gamma * p_r / rho_r).sqrt().max(0.0);
774    let (s_l, s_r) = hll_wave_speeds(u_l, a_l, u_r, a_r);
775
776    let e_l = p_l / (gamma - 1.0) + 0.5 * rho_l * u_l * u_l;
777    let e_r = p_r / (gamma - 1.0) + 0.5 * rho_r * u_r * u_r;
778
779    let fl = [rho_l * u_l, rho_l * u_l * u_l + p_l, (e_l + p_l) * u_l];
780    let fr = [rho_r * u_r, rho_r * u_r * u_r + p_r, (e_r + p_r) * u_r];
781    let ul = [rho_l, rho_l * u_l, e_l];
782    let ur = [rho_r, rho_r * u_r, e_r];
783
784    let mut f = [0.0f64; 3];
785    for i in 0..3 {
786        f[i] = hll_flux(ul[i], ur[i], fl[i], fr[i], s_l, s_r);
787    }
788    f
789}
790
791// ---------------------------------------------------------------------------
792// 14. HLLC flux for 1-D Euler equations
793// ---------------------------------------------------------------------------
794
795/// HLLC (Harten-Lax-van Leer-Contact) flux for 1-D Euler equations.
796///
797/// The HLLC flux restores the contact wave missing in HLL.
798/// Returns `[F_rho, F_rhou, F_e]`.
799#[allow(dead_code)]
800pub fn hllc_flux_euler_1d(
801    rho_l: f64,
802    u_l: f64,
803    p_l: f64,
804    rho_r: f64,
805    u_r: f64,
806    p_r: f64,
807    gamma: f64,
808) -> [f64; 3] {
809    let a_l = (gamma * p_l / rho_l.max(1e-30)).sqrt();
810    let a_r = (gamma * p_r / rho_r.max(1e-30)).sqrt();
811
812    // Davis wave speed estimates
813    let (s_l, s_r) = hll_wave_speeds(u_l, a_l, u_r, a_r);
814
815    // Contact wave speed (HLLC star state)
816    let s_star = (p_r - p_l + rho_l * u_l * (s_l - u_l) - rho_r * u_r * (s_r - u_r))
817        / (rho_l * (s_l - u_l) - rho_r * (s_r - u_r) + 1e-200);
818
819    let e_l = p_l / (gamma - 1.0) + 0.5 * rho_l * u_l * u_l;
820    let e_r = p_r / (gamma - 1.0) + 0.5 * rho_r * u_r * u_r;
821
822    let fl = [rho_l * u_l, rho_l * u_l * u_l + p_l, (e_l + p_l) * u_l];
823    let fr = [rho_r * u_r, rho_r * u_r * u_r + p_r, (e_r + p_r) * u_r];
824
825    let hllc_flux_side = |rho: f64, u: f64, e: f64, p: f64, f: &[f64; 3], s: f64| -> [f64; 3] {
826        let coeff = rho * (s - u) / (s - s_star + 1e-200);
827        let u_star = [
828            coeff,
829            coeff * s_star,
830            coeff * (e / rho + (s_star - u) * (s_star + p / (rho * (s - u) + 1e-200))),
831        ];
832        [
833            f[0] + s * (u_star[0] - rho),
834            f[1] + s * (u_star[1] - rho * u),
835            f[2] + s * (u_star[2] - e),
836        ]
837    };
838
839    if s_l >= 0.0 {
840        fl
841    } else if s_star >= 0.0 {
842        hllc_flux_side(rho_l, u_l, e_l, p_l, &fl, s_l)
843    } else if s_r >= 0.0 {
844        hllc_flux_side(rho_r, u_r, e_r, p_r, &fr, s_r)
845    } else {
846        fr
847    }
848}
849
850// ---------------------------------------------------------------------------
851// 15. Characteristic decomposition for 1-D advection systems
852// ---------------------------------------------------------------------------
853
854/// Project a vector of conservative variables onto the characteristic variables
855/// for a simple 2-component advection system.
856///
857/// Eigenvalues `lambda = [a, -a]` (rightward and leftward waves).
858/// Returns `(w_plus, w_minus)` — the characteristic amplitudes.
859#[allow(dead_code)]
860pub fn characteristic_decompose_2wave(u0: f64, u1: f64, a: f64) -> (f64, f64) {
861    let w_plus = 0.5 * (u0 + u1 / a);
862    let w_minus = 0.5 * (u0 - u1 / a);
863    (w_plus, w_minus)
864}
865
866/// Reconstruct conservative variables from characteristic variables.
867#[allow(dead_code)]
868pub fn characteristic_recompose_2wave(w_plus: f64, w_minus: f64, a: f64) -> (f64, f64) {
869    let u0 = w_plus + w_minus;
870    let u1 = a * (w_plus - w_minus);
871    (u0, u1)
872}
873
874/// Apply a characteristic-based limiter to 1-D reconstruction.
875///
876/// Projects to characteristic variables, limits each wave separately, then
877/// reconstructs in physical space.  This is important for multi-component
878/// systems to prevent spurious oscillations across contact waves.
879#[allow(dead_code)]
880pub fn characteristic_limited_reconstruct(
881    u0: &[f64],
882    u1: &[f64],
883    i: usize,
884    a: f64,
885    limiter: fn(f64, f64) -> f64,
886) -> ([f64; 2], [f64; 2]) {
887    let n = u0.len();
888    let get = |arr: &[f64], j: usize| -> f64 { arr[j.min(n - 1)] };
889
890    // Project cell values around i to characteristic space
891    let (wm_im, _) = characteristic_decompose_2wave(
892        get(u0, i.saturating_sub(1)),
893        get(u1, i.saturating_sub(1)),
894        a,
895    );
896    let (wm_i, wp_i) = characteristic_decompose_2wave(get(u0, i), get(u1, i), a);
897    let (wm_ip, wp_ip) = characteristic_decompose_2wave(get(u0, i + 1), get(u1, i + 1), a);
898    let (wm_ipp, wp_ipp) = if i + 2 < n {
899        characteristic_decompose_2wave(get(u0, i + 2), get(u1, i + 2), a)
900    } else {
901        (wm_ip, wp_ip)
902    };
903    let (_, wp_im) = characteristic_decompose_2wave(
904        get(u0, i.saturating_sub(1)),
905        get(u1, i.saturating_sub(1)),
906        a,
907    );
908
909    // Limit each characteristic wave
910    let sig_p_l = limiter(wp_i - wp_im, wp_ip - wp_i);
911    let sig_p_r = limiter(wp_ip - wp_i, wp_ipp - wp_ip);
912    let sig_m_l = limiter(wm_i - wm_im, wm_ip - wm_i);
913    let sig_m_r = limiter(wm_ip - wm_i, wm_ipp - wm_ip);
914
915    let wp_l = wp_i + 0.5 * sig_p_l;
916    let wp_r = wp_ip - 0.5 * sig_p_r;
917    let wm_l = wm_i + 0.5 * sig_m_l;
918    let wm_r = wm_ip - 0.5 * sig_m_r;
919
920    let (u0_l, u1_l) = characteristic_recompose_2wave(wp_l, wm_l, a);
921    let (u0_r, u1_r) = characteristic_recompose_2wave(wp_r, wm_r, a);
922
923    ([u0_l, u1_l], [u0_r, u1_r])
924}
925
926// ---------------------------------------------------------------------------
927// 16. Second-order Runge-Kutta (TVD RK2) time stepping
928// ---------------------------------------------------------------------------
929
930/// Advance `phi` using the TVD Runge-Kutta 2 (Heun) scheme.
931///
932/// `phi_{n+1} = 0.5 * (phi_n + phi_n^(2))`
933/// `phi_n^(1) = phi_n + dt * L(phi_n)` (Euler predictor)
934/// `phi_n^(2) = phi_n^(1) + dt * L(phi_n^(1))` (corrector)
935///
936/// where `L` is the upwind advection operator.
937#[allow(dead_code)]
938pub fn tvd_rk2_advect(phi: &FluxGrid3D, vel: &VectorFluxGrid3D, dt: f64) -> FluxGrid3D {
939    // Stage 1: phi^(1) = phi + dt * L(phi)
940    let phi_1 = advect_upwind_3d(phi, vel, dt);
941    // Stage 2: phi^(2) = phi^(1) + dt * L(phi^(1))
942    let phi_2 = advect_upwind_3d(&phi_1, vel, dt);
943    // Combine: phi_{n+1} = 0.5*(phi + phi^(2))
944    let mut result = phi.clone();
945    result
946        .values
947        .iter_mut()
948        .zip(phi.values.iter().zip(phi_2.values.iter()))
949        .for_each(|(r, (&a, &b))| *r = 0.5 * (a + b));
950    result
951}
952
953// ---------------------------------------------------------------------------
954// 17. Flux limiter in ratio form
955// ---------------------------------------------------------------------------
956
957/// Compute the minmod limiter in ratio form: `phi(r) = max(0, min(1, r))`.
958///
959/// Input `r = delta_{i-1/2} / delta_{i+1/2}` (ratio of consecutive differences).
960#[allow(dead_code)]
961pub fn minmod_ratio(r: f64) -> f64 {
962    if r <= 0.0 {
963        0.0
964    } else if r <= 1.0 {
965        r
966    } else {
967        1.0
968    }
969}
970
971/// Superbee limiter in ratio form: `phi(r) = max(0, max(min(2r,1), min(r,2)))`.
972#[allow(dead_code)]
973pub fn superbee_ratio(r: f64) -> f64 {
974    if r <= 0.0 {
975        0.0
976    } else {
977        (2.0 * r).min(1.0_f64).max(r.min(2.0_f64)).max(0.0_f64)
978    }
979}
980
981/// Van Leer limiter in ratio form: `phi(r) = (r + |r|) / (1 + |r|)`.
982#[allow(dead_code)]
983pub fn van_leer_ratio(r: f64) -> f64 {
984    (r + r.abs()) / (1.0 + r.abs())
985}
986
987// ---------------------------------------------------------------------------
988// Tests
989// ---------------------------------------------------------------------------
990
991#[cfg(test)]
992mod flux_compute_tests {
993    use super::*;
994
995    #[test]
996    fn test_flux_grid3d_get_set() {
997        let mut g = FluxGrid3D::new(4, 4, 4, 0.1, 0.0);
998        g.set(1, 2, 3, 7.5);
999        assert!((g.get(1, 2, 3) - 7.5).abs() < 1e-12);
1000    }
1001
1002    #[test]
1003    fn test_flux_grid3d_clamped_boundary() {
1004        let g = FluxGrid3D::new(5, 5, 5, 0.1, 3.0);
1005        // Negative indices and out-of-bounds indices should clamp.
1006        assert!((g.get_clamped(-1, 0, 0) - 3.0).abs() < 1e-12);
1007        assert!((g.get_clamped(100, 0, 0) - 3.0).abs() < 1e-12);
1008    }
1009
1010    #[test]
1011    fn test_l2_norm_uniform() {
1012        // A 2×2×2 grid filled with 1.0: l2 = sqrt(8).
1013        let g = FluxGrid3D::new(2, 2, 2, 0.5, 1.0);
1014        let expected = (8.0_f64).sqrt();
1015        assert!((g.l2_norm() - expected).abs() < 1e-10);
1016    }
1017
1018    #[test]
1019    fn test_minmod_same_sign() {
1020        assert!(
1021            (minmod(2.0, 3.0) - 2.0).abs() < 1e-12,
1022            "minmod picks smaller"
1023        );
1024        assert!((minmod(-1.0, -4.0) - (-1.0)).abs() < 1e-12);
1025    }
1026
1027    #[test]
1028    fn test_minmod_opposite_sign() {
1029        assert!(
1030            (minmod(2.0, -1.0)).abs() < 1e-12,
1031            "minmod returns 0 for opposite signs"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_van_leer_smooth() {
1037        // van_leer(a, b) = 2ab/(a+b) for same-sign inputs.
1038        let vl = van_leer(2.0, 2.0);
1039        assert!(
1040            (vl - 2.0).abs() < 1e-12,
1041            "symmetric inputs: van_leer = value"
1042        );
1043        let vl2 = van_leer(1.0, 3.0);
1044        let expected = 2.0 * 1.0 * 3.0 / (1.0 + 3.0);
1045        assert!((vl2 - expected).abs() < 1e-12);
1046    }
1047
1048    #[test]
1049    fn test_upwind_flux_1d_positive_velocity() {
1050        // Uniform phi = 1.0, positive velocity: no flux.
1051        let phi = vec![1.0; 10];
1052        let flux = upwind_flux_1d(&phi, 1.0, 0.1, 0.05);
1053        for f in &flux {
1054            assert!(
1055                f.abs() < 1e-12,
1056                "uniform field with positive vel: zero flux"
1057            );
1058        }
1059    }
1060
1061    #[test]
1062    fn test_lax_friedrichs_1d_preserves_mass() {
1063        // Total mass ≈ integral of u dx. Advection should conserve mass
1064        // (up to boundary corrections). Check sum is approximately preserved.
1065        let u: Vec<f64> = (0..20).map(|i| (i as f64 * 0.1 - 1.0).powi(2)).collect();
1066        let sum_before: f64 = u.iter().sum();
1067        let u_new = lax_friedrichs_advect_1d(&u, 0.5, 0.1, 0.01);
1068        let sum_after: f64 = u_new.iter().sum();
1069        // Small boundary effect allowed.
1070        assert!(
1071            (sum_after - sum_before).abs() < sum_before * 0.05 + 1.0,
1072            "mass should be approximately conserved"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_divergence_constant_field_is_zero() {
1078        // Uniform velocity field → divergence = 0 everywhere.
1079        let n = 6;
1080        let mut vel = VectorFluxGrid3D::zeros(n, n, n, 0.1);
1081        for i in 0..n {
1082            for j in 0..n {
1083                for k in 0..n {
1084                    vel.set_vel(i, j, k, [1.0, 2.0, 3.0]);
1085                }
1086            }
1087        }
1088        let div = divergence_3d(&vel);
1089        for &d in &div.values {
1090            assert!(
1091                d.abs() < 1e-10,
1092                "divergence of uniform field must be 0, got {d}"
1093            );
1094        }
1095    }
1096
1097    #[test]
1098    fn test_gradient_linear_field() {
1099        // phi(i,j,k) = x = i*dx. Gradient_x should be ~1.0 everywhere.
1100        let n = 8;
1101        let dx = 0.25;
1102        let mut phi = FluxGrid3D::new(n, n, n, dx, 0.0);
1103        for i in 0..n {
1104            for j in 0..n {
1105                for k in 0..n {
1106                    phi.set(i, j, k, i as f64 * dx);
1107                }
1108            }
1109        }
1110        let grad = gradient_3d(&phi);
1111        // Check interior cells.
1112        for i in 1..n - 1 {
1113            for j in 1..n - 1 {
1114                for k in 1..n - 1 {
1115                    let gx = grad.u.get(i, j, k);
1116                    assert!(
1117                        (gx - 1.0).abs() < 1e-10,
1118                        "gradient_x of linear field should be 1, got {gx} at ({i},{j},{k})"
1119                    );
1120                }
1121            }
1122        }
1123    }
1124
1125    #[test]
1126    fn test_cfl_dt_uniform_velocity() {
1127        let mut vel = VectorFluxGrid3D::zeros(4, 4, 4, 0.1);
1128        for i in 0..4 {
1129            for j in 0..4 {
1130                for k in 0..4 {
1131                    vel.set_vel(i, j, k, [2.0, 0.0, 0.0]);
1132                }
1133            }
1134        }
1135        // CFL dt = 0.5 * dx / 2.0 = 0.025
1136        let dt = cfl_dt(&vel, 0.5);
1137        assert!(
1138            (dt - 0.025).abs() < 1e-10,
1139            "cfl_dt should be 0.025, got {dt}"
1140        );
1141    }
1142
1143    #[test]
1144    fn test_cfl_dt_zero_velocity() {
1145        let vel = VectorFluxGrid3D::zeros(4, 4, 4, 0.1);
1146        let dt = cfl_dt(&vel, 0.5);
1147        assert!(dt.is_infinite(), "zero velocity → infinite dt");
1148    }
1149
1150    // ── Additional FluxGrid3D tests ──────────────────────────────────────────
1151
1152    #[test]
1153    fn test_flux_grid3d_len_empty() {
1154        let g = FluxGrid3D::new(0, 5, 5, 0.1, 0.0);
1155        assert_eq!(g.len(), 0);
1156        assert!(g.is_empty());
1157    }
1158
1159    #[test]
1160    fn test_flux_grid3d_len_nonempty() {
1161        let g = FluxGrid3D::new(3, 4, 5, 0.1, 0.0);
1162        assert_eq!(g.len(), 60);
1163        assert!(!g.is_empty());
1164    }
1165
1166    #[test]
1167    fn test_flux_grid3d_max_val() {
1168        let mut g = FluxGrid3D::new(2, 2, 2, 0.1, 0.0);
1169        g.set(0, 0, 0, 5.0);
1170        g.set(1, 1, 1, -3.0);
1171        assert!((g.max_val() - 5.0).abs() < 1e-12);
1172    }
1173
1174    #[test]
1175    fn test_flux_grid3d_min_val() {
1176        let mut g = FluxGrid3D::new(2, 2, 2, 0.1, 0.0);
1177        g.set(0, 0, 0, 5.0);
1178        g.set(1, 1, 1, -3.0);
1179        assert!((g.min_val() - (-3.0)).abs() < 1e-12);
1180    }
1181
1182    #[test]
1183    fn test_flux_grid3d_l2_norm_zeros() {
1184        let g = FluxGrid3D::new(3, 3, 3, 0.1, 0.0);
1185        assert!(g.l2_norm() < 1e-15);
1186    }
1187
1188    #[test]
1189    fn test_flux_grid3d_index_layout() {
1190        let g = FluxGrid3D::new(3, 4, 5, 0.1, 0.0);
1191        // Check index formula: i*ny*nz + j*nz + k
1192        assert_eq!(g.idx(0, 0, 0), 0);
1193        assert_eq!(g.idx(1, 0, 0), 20); // 1 * 4 * 5
1194        assert_eq!(g.idx(0, 1, 0), 5); // 0 * 20 + 1 * 5
1195        assert_eq!(g.idx(0, 0, 1), 1);
1196    }
1197
1198    #[test]
1199    fn test_flux_grid3d_clamped_low_indices() {
1200        let mut g = FluxGrid3D::new(5, 5, 5, 0.1, 0.0);
1201        g.set(0, 0, 0, 99.0);
1202        // Clamping -1 → 0
1203        assert!((g.get_clamped(-1, 0, 0) - 99.0).abs() < 1e-12);
1204        assert!((g.get_clamped(0, -5, 0) - 99.0).abs() < 1e-12);
1205    }
1206
1207    // ── VectorFluxGrid3D tests ───────────────────────────────────────────────
1208
1209    #[test]
1210    fn test_vector_flux_grid_zeros() {
1211        let v = VectorFluxGrid3D::zeros(3, 3, 3, 0.1);
1212        for i in 0..3 {
1213            for j in 0..3 {
1214                for k in 0..3 {
1215                    let vel = v.get_vel(i, j, k);
1216                    assert_eq!(vel, [0.0, 0.0, 0.0]);
1217                }
1218            }
1219        }
1220    }
1221
1222    #[test]
1223    fn test_vector_flux_grid_set_get_vel() {
1224        let mut v = VectorFluxGrid3D::zeros(4, 4, 4, 0.1);
1225        v.set_vel(1, 2, 3, [1.5, 2.5, 3.5]);
1226        let vel = v.get_vel(1, 2, 3);
1227        assert!((vel[0] - 1.5).abs() < 1e-12);
1228        assert!((vel[1] - 2.5).abs() < 1e-12);
1229        assert!((vel[2] - 3.5).abs() < 1e-12);
1230    }
1231
1232    // ── Limiter tests ────────────────────────────────────────────────────────
1233
1234    #[test]
1235    fn test_superbee_same_sign_positive() {
1236        // superbee(a, b) = max(min(2a, b), min(a, 2b)) for same-sign
1237        let s = superbee(1.0, 2.0);
1238        assert!(s > 0.0, "superbee of positives should be positive, got {s}");
1239    }
1240
1241    #[test]
1242    fn test_superbee_opposite_signs() {
1243        assert_eq!(superbee(1.0, -1.0), 0.0);
1244        assert_eq!(superbee(-2.0, 3.0), 0.0);
1245    }
1246
1247    #[test]
1248    fn test_superbee_both_zero() {
1249        assert_eq!(superbee(0.0, 0.0), 0.0);
1250    }
1251
1252    #[test]
1253    fn test_mc_limiter_same_sign() {
1254        let mc = mc_limiter(2.0, 4.0);
1255        // centered = 3, limited by 2*min(2,4) = 4, min(2*2, 2*4) = 4, answer ≤ 3
1256        assert!(mc > 0.0 && mc <= 3.0, "mc_limiter = {mc}");
1257    }
1258
1259    #[test]
1260    fn test_mc_limiter_opposite_sign() {
1261        assert_eq!(mc_limiter(1.0, -1.0), 0.0);
1262    }
1263
1264    #[test]
1265    fn test_van_leer_zero_sum() {
1266        // When a + b ≈ 0 this should handle gracefully
1267        // van_leer(a, -a) = 0 because a * -a < 0
1268        let vl = van_leer(2.0, -2.0);
1269        assert_eq!(vl, 0.0);
1270    }
1271
1272    #[test]
1273    fn test_minmod_equal_values() {
1274        // minmod(a, a) = a
1275        assert!((minmod(3.0, 3.0) - 3.0).abs() < 1e-12);
1276        assert!((minmod(-2.0, -2.0) - (-2.0)).abs() < 1e-12);
1277    }
1278
1279    // ── Upwind flux tests ────────────────────────────────────────────────────
1280
1281    #[test]
1282    fn test_upwind_flux_1d_negative_velocity() {
1283        // Uniform phi = 1.0, negative velocity: no flux.
1284        let phi = vec![1.0; 8];
1285        let flux = upwind_flux_1d(&phi, -1.0, 0.1, 0.05);
1286        for f in &flux {
1287            assert!(
1288                f.abs() < 1e-12,
1289                "uniform field with negative vel: zero flux"
1290            );
1291        }
1292    }
1293
1294    #[test]
1295    fn test_upwind_flux_1d_step_function() {
1296        // Step function: 0 left, 1 right. Positive velocity should transport left.
1297        let phi = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
1298        let flux = upwind_flux_1d(&phi, 1.0, 0.1, 0.01);
1299        // At boundary cell (index 3), phi_l = 0, phi_c = 1 → flux negative
1300        assert!(
1301            flux[3] < 0.0,
1302            "step up with positive vel: negative flux at boundary"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_upwind_flux_1d_length() {
1308        let phi = vec![1.0; 7];
1309        let flux = upwind_flux_1d(&phi, 1.0, 0.1, 0.01);
1310        assert_eq!(flux.len(), 7);
1311    }
1312
1313    // ── Lax-Friedrichs tests ─────────────────────────────────────────────────
1314
1315    #[test]
1316    fn test_lax_friedrichs_flux_zero_difference() {
1317        // u_l == u_r, f_l == f_r: LF flux = f_l = f_r
1318        let f = lax_friedrichs_flux(1.0, 1.0, 2.0, 2.0, 1.0);
1319        assert!((f - 2.0).abs() < 1e-12, "LF flux = {f}");
1320    }
1321
1322    #[test]
1323    fn test_lax_friedrichs_advect_1d_empty() {
1324        let result = lax_friedrichs_advect_1d(&[], 1.0, 0.1, 0.01);
1325        assert!(result.is_empty());
1326    }
1327
1328    #[test]
1329    fn test_lax_friedrichs_advect_1d_length() {
1330        let u = vec![1.0; 6];
1331        let u_new = lax_friedrichs_advect_1d(&u, 0.5, 0.1, 0.01);
1332        assert_eq!(u_new.len(), 6);
1333    }
1334
1335    #[test]
1336    fn test_lax_friedrichs_uniform_field_stable() {
1337        // Uniform field should not change much.
1338        let u = vec![1.0; 10];
1339        let u_new = lax_friedrichs_advect_1d(&u, 1.0, 0.1, 0.05);
1340        let diff: f64 = u_new.iter().zip(u.iter()).map(|(a, b)| (a - b).abs()).sum();
1341        assert!(
1342            diff < 1e-10,
1343            "uniform field should not change under LF advection"
1344        );
1345    }
1346
1347    // ── Divergence / gradient tests ──────────────────────────────────────────
1348
1349    #[test]
1350    fn test_divergence_size_matches_input() {
1351        let vel = VectorFluxGrid3D::zeros(3, 4, 5, 0.1);
1352        let div = divergence_3d(&vel);
1353        assert_eq!(div.nx, 3);
1354        assert_eq!(div.ny, 4);
1355        assert_eq!(div.nz, 5);
1356    }
1357
1358    #[test]
1359    fn test_gradient_size_matches_input() {
1360        let phi = FluxGrid3D::new(3, 4, 5, 0.1, 0.0);
1361        let grad = gradient_3d(&phi);
1362        assert_eq!(grad.u.nx, 3);
1363        assert_eq!(grad.v.ny, 4);
1364        assert_eq!(grad.w.nz, 5);
1365    }
1366
1367    #[test]
1368    fn test_gradient_constant_field_is_zero() {
1369        let phi = FluxGrid3D::new(5, 5, 5, 0.1, 7.0);
1370        let grad = gradient_3d(&phi);
1371        for &g in &grad.u.values {
1372            assert!(g.abs() < 1e-10, "gradient of constant = 0");
1373        }
1374    }
1375
1376    #[test]
1377    fn test_divergence_linear_vx_field() {
1378        // u(i,j,k) = i*dx, v=w=0 → div = ∂u/∂x = 1 at interior cells
1379        let n = 8;
1380        let dx = 0.5;
1381        let mut vel = VectorFluxGrid3D::zeros(n, n, n, dx);
1382        for i in 0..n {
1383            for j in 0..n {
1384                for k in 0..n {
1385                    vel.u.set(i, j, k, i as f64 * dx);
1386                }
1387            }
1388        }
1389        let div = divergence_3d(&vel);
1390        // Interior cells: central diff of i*dx → 1
1391        for i in 1..n - 1 {
1392            for j in 1..n - 1 {
1393                for k in 1..n - 1 {
1394                    let d = div.get(i, j, k);
1395                    assert!(
1396                        (d - 1.0).abs() < 1e-10,
1397                        "div at ({i},{j},{k}) = {d}, expected 1"
1398                    );
1399                }
1400            }
1401        }
1402    }
1403
1404    // ── Euler step test ──────────────────────────────────────────────────────
1405
1406    #[test]
1407    fn test_euler_step_advect_preserves_size() {
1408        let phi = FluxGrid3D::new(4, 4, 4, 0.1, 1.0);
1409        let mut vel = VectorFluxGrid3D::zeros(4, 4, 4, 0.1);
1410        for i in 0..4 {
1411            for j in 0..4 {
1412                for k in 0..4 {
1413                    vel.set_vel(i, j, k, [1.0, 0.0, 0.0]);
1414                }
1415            }
1416        }
1417        let phi_new = euler_step_advect(&phi, &vel, 0.01);
1418        assert_eq!(phi_new.nx, phi.nx);
1419        assert_eq!(phi_new.ny, phi.ny);
1420        assert_eq!(phi_new.nz, phi.nz);
1421    }
1422
1423    #[test]
1424    fn test_euler_step_constant_phi_no_change() {
1425        // Uniform phi, uniform velocity → upwind flux is zero → phi unchanged
1426        let phi = FluxGrid3D::new(5, 5, 5, 0.1, 3.0);
1427        let mut vel = VectorFluxGrid3D::zeros(5, 5, 5, 0.1);
1428        for i in 0..5 {
1429            for j in 0..5 {
1430                for k in 0..5 {
1431                    vel.set_vel(i, j, k, [1.0, 1.0, 1.0]);
1432                }
1433            }
1434        }
1435        let phi_new = euler_step_advect(&phi, &vel, 0.01);
1436        for (&a, &b) in phi.values.iter().zip(phi_new.values.iter()) {
1437            assert!(
1438                (a - b).abs() < 1e-10,
1439                "constant phi should not change: {a} vs {b}"
1440            );
1441        }
1442    }
1443
1444    // ── CFL dt tests ─────────────────────────────────────────────────────────
1445
1446    #[test]
1447    fn test_cfl_dt_negative_velocity() {
1448        // Negative velocity magnitude should still work.
1449        let mut vel = VectorFluxGrid3D::zeros(4, 4, 4, 0.2);
1450        vel.set_vel(0, 0, 0, [0.0, -4.0, 0.0]);
1451        let dt = cfl_dt(&vel, 1.0);
1452        assert!((dt - 0.05).abs() < 1e-10, "cfl_dt = {dt}, expected 0.05");
1453    }
1454
1455    #[test]
1456    fn test_cfl_dt_multiple_components() {
1457        let mut vel = VectorFluxGrid3D::zeros(3, 3, 3, 0.1);
1458        // Set u=1, v=2, w=3 somewhere — max is 3
1459        vel.set_vel(1, 1, 1, [1.0, 2.0, 3.0]);
1460        let dt = cfl_dt(&vel, 1.0);
1461        assert!((dt - 0.1 / 3.0).abs() < 1e-10, "cfl_dt = {dt}");
1462    }
1463
1464    // ── EulerState tests ─────────────────────────────────────────────────────
1465
1466    #[test]
1467    fn test_euler_state_from_primitives() {
1468        let gamma = 1.4;
1469        let s = EulerState::from_primitives(1.0, 0.1, 0.0, 0.0, 1.0, gamma);
1470        assert!((s.rho - 1.0).abs() < 1e-12);
1471        let p_back = s.pressure(gamma);
1472        assert!((p_back - 1.0).abs() < 1e-10, "pressure roundtrip: {p_back}");
1473    }
1474
1475    #[test]
1476    fn test_euler_state_velocity() {
1477        let gamma = 1.4;
1478        let s = EulerState::from_primitives(2.0, 3.0, 4.0, 5.0, 1.0, gamma);
1479        let u = s.velocity();
1480        assert!((u[0] - 3.0).abs() < 1e-12);
1481        assert!((u[1] - 4.0).abs() < 1e-12);
1482        assert!((u[2] - 5.0).abs() < 1e-12);
1483    }
1484
1485    #[test]
1486    fn test_euler_state_sound_speed() {
1487        let gamma = 1.4;
1488        // c = sqrt(gamma*p/rho) = sqrt(1.4 * 1.0 / 1.0) ≈ 1.1832
1489        let s = EulerState::from_primitives(1.0, 0.0, 0.0, 0.0, 1.0, gamma);
1490        let c = s.sound_speed(gamma);
1491        let expected = (gamma).sqrt();
1492        assert!(
1493            (c - expected).abs() < 1e-10,
1494            "sound speed = {c}, expected {expected}"
1495        );
1496    }
1497
1498    #[test]
1499    fn test_euler_state_lerp() {
1500        let gamma = 1.4;
1501        let s0 = EulerState::from_primitives(1.0, 0.0, 0.0, 0.0, 1.0, gamma);
1502        let s1 = EulerState::from_primitives(2.0, 0.0, 0.0, 0.0, 2.0, gamma);
1503        let s05 = s0.lerp(&s1, 0.5);
1504        assert!((s05.rho - 1.5).abs() < 1e-12, "lerp rho = {}", s05.rho);
1505    }
1506
1507    #[test]
1508    fn test_euler_state_flux_x() {
1509        let gamma = 1.4;
1510        // At rest: F = [0, p, 0, 0, 0]
1511        let s = EulerState::from_primitives(1.0, 0.0, 0.0, 0.0, 1.0, gamma);
1512        let f = s.flux_x(gamma);
1513        assert!(f[0].abs() < 1e-12, "mass flux at rest = {}", f[0]);
1514        assert!((f[1] - 1.0).abs() < 1e-10, "pressure flux = {}", f[1]);
1515    }
1516
1517    // ── Van Albada tests ──────────────────────────────────────────────────────
1518
1519    #[test]
1520    fn test_van_albada_same_sign() {
1521        // van_albada should return positive for same-sign inputs
1522        let v = van_albada(2.0, 4.0);
1523        assert!(v > 0.0, "van_albada(2,4) = {v}");
1524        // Should be TVD: |result| <= min(2|a|, 2|b|, |a+b|/2...)
1525        assert!(v <= 4.0);
1526    }
1527
1528    #[test]
1529    fn test_van_albada_opposite_signs() {
1530        assert_eq!(van_albada(1.0, -1.0), 0.0);
1531        assert_eq!(van_albada(-2.0, 3.0), 0.0);
1532    }
1533
1534    #[test]
1535    fn test_van_albada_symmetry() {
1536        // van_albada(a, b) vs van_albada(b, a): not strictly symmetric but both > 0
1537        let v1 = van_albada(1.0, 3.0);
1538        let v2 = van_albada(3.0, 1.0);
1539        assert!(v1 > 0.0 && v2 > 0.0);
1540    }
1541
1542    // ── MUSCL tests ──────────────────────────────────────────────────────────
1543
1544    #[test]
1545    fn test_muscl_reconstruct_uniform() {
1546        // Uniform field: reconstruction should give exact values
1547        let u = vec![1.0_f64; 10];
1548        let (u_l, u_r) = muscl_reconstruct(&u, 3, minmod);
1549        assert!((u_l - 1.0).abs() < 1e-12, "uniform MUSCL u_l = {u_l}");
1550        assert!((u_r - 1.0).abs() < 1e-12, "uniform MUSCL u_r = {u_r}");
1551    }
1552
1553    #[test]
1554    fn test_muscl_reconstruct_all_length() {
1555        let u = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1556        let pairs = muscl_reconstruct_all(&u, minmod);
1557        assert_eq!(pairs.len(), 4, "MUSCL all: expected 4 interfaces");
1558    }
1559
1560    #[test]
1561    fn test_muscl_reconstruct_all_monotone() {
1562        // Linear field: reconstruction should preserve ordering
1563        let u: Vec<f64> = (0..8).map(|i| i as f64).collect();
1564        let pairs = muscl_reconstruct_all(&u, minmod);
1565        for (i, &(u_l, u_r)) in pairs.iter().enumerate() {
1566            assert!(u_l <= u_r + 1e-10, "interface {i}: u_l={u_l} > u_r={u_r}");
1567        }
1568    }
1569
1570    // ── Godunov flux tests ────────────────────────────────────────────────────
1571
1572    #[test]
1573    fn test_godunov_flux_advection_positive_speed() {
1574        // With a > 0, flux = a * u_l
1575        let f = godunov_flux_advection(2.0, 3.0, 1.0);
1576        assert!((f - 2.0).abs() < 1e-12, "Godunov advection +speed: {f}");
1577    }
1578
1579    #[test]
1580    fn test_godunov_flux_advection_negative_speed() {
1581        let f = godunov_flux_advection(2.0, 3.0, -1.0);
1582        assert!((f - (-3.0)).abs() < 1e-12, "Godunov advection -speed: {f}");
1583    }
1584
1585    #[test]
1586    fn test_godunov_flux_burgers_shock() {
1587        // Shock: u_l > u_r, s > 0 → f = 0.5 * u_l^2
1588        let f = godunov_flux_burgers(2.0, 1.0);
1589        assert!((f - 0.5 * 4.0).abs() < 1e-12, "Burgers shock: {f}");
1590    }
1591
1592    #[test]
1593    fn test_godunov_flux_burgers_rarefaction_sonic() {
1594        // Rarefaction spanning u=0: u_l < 0 < u_r → entropy-satisfying flux = 0
1595        let f = godunov_flux_burgers(-1.0, 1.0);
1596        assert!(f.abs() < 1e-12, "Burgers sonic: {f}");
1597    }
1598
1599    #[test]
1600    fn test_godunov_flux_burgers_rarefaction_positive() {
1601        // Rarefaction, all positive: u_l > 0, u_r > u_l → f = 0.5*u_l^2
1602        let f = godunov_flux_burgers(1.0, 2.0);
1603        assert!((f - 0.5).abs() < 1e-12, "Burgers pos rarefaction: {f}");
1604    }
1605
1606    // ── Roe flux tests ────────────────────────────────────────────────────────
1607
1608    #[test]
1609    fn test_roe_flux_scalar_symmetry() {
1610        // For uniform state, Roe flux = physical flux
1611        let f = roe_flux_scalar(2.0, 2.0, 1.5);
1612        assert!((f - 1.5 * 2.0).abs() < 1e-12, "Roe scalar uniform: {f}");
1613    }
1614
1615    #[test]
1616    fn test_roe_flux_scalar_upwind() {
1617        // For a > 0 and jump, Roe flux should propagate from left
1618        let f = roe_flux_scalar(1.0, 2.0, 1.0);
1619        // f = 0.5*(1+2) - 0.5*1*(2-1) = 1.5 - 0.5 = 1.0
1620        assert!((f - 1.0).abs() < 1e-12, "Roe scalar upwind: {f}");
1621    }
1622
1623    #[test]
1624    fn test_roe_flux_euler_1d_finite() {
1625        let f = roe_flux_euler_1d(1.0, 0.1, 1.0, 1.0, 0.1, 1.0, 1.4);
1626        for (i, &fi) in f.iter().enumerate() {
1627            assert!(fi.is_finite(), "Roe Euler flux[{i}] not finite: {fi}");
1628        }
1629    }
1630
1631    // ── HLL flux tests ────────────────────────────────────────────────────────
1632
1633    #[test]
1634    fn test_hll_flux_subsonic() {
1635        // s_l < 0 < s_r: active HLL formula
1636        let f = hll_flux(1.0, 2.0, 1.0, 2.0, -1.0, 1.0);
1637        assert!(f.is_finite(), "HLL subsonic: {f}");
1638    }
1639
1640    #[test]
1641    fn test_hll_flux_supersonic_right() {
1642        // s_l > 0: use f_l
1643        let f = hll_flux(1.0, 2.0, 3.0, 4.0, 1.0, 2.0);
1644        assert!((f - 3.0).abs() < 1e-12, "HLL supersonic right: {f}");
1645    }
1646
1647    #[test]
1648    fn test_hll_flux_supersonic_left() {
1649        // s_r < 0: use f_r
1650        let f = hll_flux(1.0, 2.0, 3.0, 4.0, -2.0, -1.0);
1651        assert!((f - 4.0).abs() < 1e-12, "HLL supersonic left: {f}");
1652    }
1653
1654    #[test]
1655    fn test_hll_flux_euler_1d_finite() {
1656        let f = hll_flux_euler_1d(1.0, 0.1, 1.0, 1.2, 0.05, 1.1, 1.4);
1657        for &fi in &f {
1658            assert!(fi.is_finite(), "HLL Euler flux not finite");
1659        }
1660    }
1661
1662    #[test]
1663    fn test_hll_wave_speeds() {
1664        let (s_l, s_r) = hll_wave_speeds(0.5, 1.0, -0.5, 1.0);
1665        assert!(s_l < 0.0 && s_r > 0.0, "wave speeds s_l={s_l} s_r={s_r}");
1666        assert!(s_l <= s_r);
1667    }
1668
1669    // ── HLLC flux tests ───────────────────────────────────────────────────────
1670
1671    #[test]
1672    fn test_hllc_flux_euler_1d_finite() {
1673        let f = hllc_flux_euler_1d(1.0, 0.1, 1.0, 1.2, 0.05, 1.1, 1.4);
1674        for &fi in &f {
1675            assert!(fi.is_finite(), "HLLC Euler flux not finite: {fi}");
1676        }
1677    }
1678
1679    #[test]
1680    fn test_hllc_agrees_with_hll_at_uniform_state() {
1681        // For uniform state (no jump), HLL and HLLC should give similar answers
1682        let f_hll = hll_flux_euler_1d(1.0, 0.1, 1.0, 1.0, 0.1, 1.0, 1.4);
1683        let f_hllc = hllc_flux_euler_1d(1.0, 0.1, 1.0, 1.0, 0.1, 1.0, 1.4);
1684        for i in 0..3 {
1685            assert!(
1686                (f_hll[i] - f_hllc[i]).abs() < 1e-6,
1687                "HLL vs HLLC at [{}]: {} vs {}",
1688                i,
1689                f_hll[i],
1690                f_hllc[i]
1691            );
1692        }
1693    }
1694
1695    // ── Characteristic decomposition tests ───────────────────────────────────
1696
1697    #[test]
1698    fn test_characteristic_roundtrip() {
1699        let u0 = 1.5;
1700        let u1 = 0.7;
1701        let a = 2.0;
1702        let (wp, wm) = characteristic_decompose_2wave(u0, u1, a);
1703        let (u0_back, u1_back) = characteristic_recompose_2wave(wp, wm, a);
1704        assert!((u0_back - u0).abs() < 1e-12, "roundtrip u0: {u0_back}");
1705        assert!((u1_back - u1).abs() < 1e-12, "roundtrip u1: {u1_back}");
1706    }
1707
1708    #[test]
1709    fn test_characteristic_limited_reconstruct_finite() {
1710        let u0: Vec<f64> = (0..8).map(|i| i as f64 * 0.1).collect();
1711        let u1: Vec<f64> = vec![0.5; 8];
1712        let (left, right) = characteristic_limited_reconstruct(&u0, &u1, 3, 1.0, minmod);
1713        for &v in left.iter().chain(right.iter()) {
1714            assert!(v.is_finite(), "characteristic reconstruct not finite: {v}");
1715        }
1716    }
1717
1718    // ── TVD RK2 tests ─────────────────────────────────────────────────────────
1719
1720    #[test]
1721    fn test_tvd_rk2_advect_uniform_unchanged() {
1722        let phi = FluxGrid3D::new(5, 5, 5, 0.1, 2.0);
1723        let mut vel = VectorFluxGrid3D::zeros(5, 5, 5, 0.1);
1724        for i in 0..5 {
1725            for j in 0..5 {
1726                for k in 0..5 {
1727                    vel.set_vel(i, j, k, [1.0, 0.0, 0.0]);
1728                }
1729            }
1730        }
1731        let phi_new = tvd_rk2_advect(&phi, &vel, 0.01);
1732        for (&a, &b) in phi.values.iter().zip(phi_new.values.iter()) {
1733            assert!((a - b).abs() < 1e-10, "TVD RK2 uniform: {a} vs {b}");
1734        }
1735    }
1736
1737    #[test]
1738    fn test_tvd_rk2_advect_size_preserved() {
1739        let phi = FluxGrid3D::new(4, 3, 2, 0.1, 1.0);
1740        let vel = VectorFluxGrid3D::zeros(4, 3, 2, 0.1);
1741        let phi_new = tvd_rk2_advect(&phi, &vel, 0.01);
1742        assert_eq!(phi_new.nx, phi.nx);
1743        assert_eq!(phi_new.ny, phi.ny);
1744        assert_eq!(phi_new.nz, phi.nz);
1745    }
1746
1747    // ── Limiter ratio-form tests ──────────────────────────────────────────────
1748
1749    #[test]
1750    fn test_minmod_ratio_clamp() {
1751        assert_eq!(minmod_ratio(-0.5), 0.0);
1752        assert!((minmod_ratio(0.5) - 0.5).abs() < 1e-12);
1753        assert!((minmod_ratio(2.0) - 1.0).abs() < 1e-12);
1754    }
1755
1756    #[test]
1757    fn test_superbee_ratio_basic() {
1758        assert_eq!(superbee_ratio(-1.0), 0.0);
1759        let v = superbee_ratio(1.5);
1760        assert!(v > 0.0 && v <= 2.0, "superbee_ratio(1.5) = {v}");
1761    }
1762
1763    #[test]
1764    fn test_van_leer_ratio_basic() {
1765        // van_leer_ratio(1.0) = 2*1/(1+1) = 1.0
1766        let v = van_leer_ratio(1.0);
1767        assert!((v - 1.0).abs() < 1e-12, "van_leer_ratio(1) = {v}");
1768        // Negative r → 0
1769        assert_eq!(van_leer_ratio(-0.5), 0.0);
1770    }
1771
1772    #[test]
1773    fn test_van_leer_ratio_monotone() {
1774        // phi(r) should be monotone increasing for r > 0
1775        let v1 = van_leer_ratio(0.5);
1776        let v2 = van_leer_ratio(1.0);
1777        let v3 = van_leer_ratio(2.0);
1778        assert!(v1 <= v2, "van_leer not monotone: {v1} > {v2}");
1779        assert!(v2 <= v3 + 1e-12, "van_leer not monotone: {v2} > {v3}");
1780    }
1781}