Skip to main content

oxiphysics_gpu/
gpu_lbm.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-accelerated Lattice Boltzmann Method (D2Q9) — CPU mock backend.
5//!
6//! Implements the D2Q9 velocity set with BGK collision, periodic streaming,
7//! macroscopic quantity recovery, and diagnostic utilities for vorticity and
8//! divergence fields.
9
10// ── D2Q9 constants ────────────────────────────────────────────────────────────
11
12/// Number of discrete velocities in the D2Q9 velocity set.
13pub const D2Q9_Q: usize = 9;
14
15/// Standard D2Q9 lattice weights.
16///
17/// Ordering: rest (index 0), axis (1–4), diagonal (5–8).
18pub const D2Q9_WEIGHTS: [f64; D2Q9_Q] = [
19    4.0 / 9.0,  // 0: rest
20    1.0 / 9.0,  // 1: +x
21    1.0 / 9.0,  // 2: -x
22    1.0 / 9.0,  // 3: +y
23    1.0 / 9.0,  // 4: -y
24    1.0 / 36.0, // 5: +x+y
25    1.0 / 36.0, // 6: -x+y
26    1.0 / 36.0, // 7: -x-y
27    1.0 / 36.0, // 8: +x-y
28];
29
30/// D2Q9 discrete velocity directions `[cx, cy]`.
31pub const D2Q9_DIRS: [[i32; 2]; D2Q9_Q] = [
32    [0, 0],   // 0: rest
33    [1, 0],   // 1: +x
34    [-1, 0],  // 2: -x
35    [0, 1],   // 3: +y
36    [0, -1],  // 4: -y
37    [1, 1],   // 5: +x+y
38    [-1, 1],  // 6: -x+y
39    [-1, -1], // 7: -x-y
40    [1, -1],  // 8: +x-y
41];
42
43/// Opposite (bounce-back) indices for D2Q9.
44///
45/// `D2Q9_OPPOSITE[i]` is the index `j` such that `D2Q9_DIRS[j] == -D2Q9_DIRS[i]`.
46pub const D2Q9_OPPOSITE: [usize; D2Q9_Q] = [0, 2, 1, 4, 3, 7, 8, 5, 6];
47
48// ── D2Q9Weights ───────────────────────────────────────────────────────────────
49
50/// Lattice weight set and direction set for the D2Q9 velocity model.
51#[derive(Debug, Clone, PartialEq)]
52pub struct D2Q9Weights {
53    /// Nine lattice weights.
54    pub weights: [f64; 9],
55    /// Nine lattice directions `[cx, cy]`.
56    pub dirs: [[i32; 2]; 9],
57}
58
59impl D2Q9Weights {
60    /// Construct the standard D2Q9 weight/direction set.
61    pub fn standard() -> Self {
62        Self {
63            weights: D2Q9_WEIGHTS,
64            dirs: D2Q9_DIRS,
65        }
66    }
67
68    /// Speed of sound squared for this lattice (`cs² = 1/3`).
69    pub fn cs2() -> f64 {
70        1.0 / 3.0
71    }
72}
73
74impl Default for D2Q9Weights {
75    fn default() -> Self {
76        Self::standard()
77    }
78}
79
80// ── LbmCell ──────────────────────────────────────────────────────────────────
81
82/// A single D2Q9 LBM cell holding incoming and outgoing distribution functions.
83#[derive(Debug, Clone, PartialEq)]
84pub struct LbmCell {
85    /// Incoming distribution functions (post-stream).
86    pub f_in: [f64; 9],
87    /// Outgoing distribution functions (post-collision).
88    pub f_out: [f64; 9],
89}
90
91impl LbmCell {
92    /// Create a new cell with uniform rest-state distributions for density `rho`.
93    pub fn new_equilibrium(rho: f64) -> Self {
94        let mut f = [0.0f64; 9];
95        for (i, w) in D2Q9_WEIGHTS.iter().enumerate() {
96            f[i] = w * rho;
97        }
98        Self { f_in: f, f_out: f }
99    }
100
101    /// Macroscopic density: `ρ = Σ fᵢ`.
102    pub fn density(&self) -> f64 {
103        self.f_in.iter().sum()
104    }
105
106    /// Macroscopic velocity `[ux, uy]` from the incoming distributions.
107    pub fn velocity(&self) -> [f64; 2] {
108        let rho = self.density();
109        if rho < 1e-15 {
110            return [0.0, 0.0];
111        }
112        let mut ux = 0.0f64;
113        let mut uy = 0.0f64;
114        for (i, &fi) in self.f_in.iter().enumerate() {
115            ux += fi * D2Q9_DIRS[i][0] as f64;
116            uy += fi * D2Q9_DIRS[i][1] as f64;
117        }
118        [ux / rho, uy / rho]
119    }
120}
121
122impl Default for LbmCell {
123    fn default() -> Self {
124        Self::new_equilibrium(1.0)
125    }
126}
127
128// ── Free functions ────────────────────────────────────────────────────────────
129
130/// Compute the BGK equilibrium distribution for one velocity direction.
131///
132/// Formula:  `f_eq = w · ρ · (1 + (c·u)/cs² + (c·u)²/(2cs⁴) − u²/(2cs²))`
133///
134/// * `rho`  – macroscopic density
135/// * `ux`, `uy` – macroscopic velocity components
136/// * `w`    – lattice weight for this direction
137/// * `cx`, `cy` – discrete velocity components for this direction
138pub fn bgk_equilibrium(rho: f64, ux: f64, uy: f64, w: f64, cx: f64, cy: f64) -> f64 {
139    let cs2 = 1.0 / 3.0;
140    let cu = cx * ux + cy * uy;
141    let u2 = ux * ux + uy * uy;
142    w * rho * (1.0 + cu / cs2 + cu * cu / (2.0 * cs2 * cs2) - u2 / (2.0 * cs2))
143}
144
145/// Compute the BGK relaxation parameter `ω` from kinematic viscosity and `cs²`.
146///
147/// `ω = 1 / (ν/cs² + 0.5)`
148pub fn relaxation_from_viscosity(nu: f64, cs2: f64) -> f64 {
149    1.0 / (nu / cs2 + 0.5)
150}
151
152// ── GpuLbmGrid ───────────────────────────────────────────────────────────────
153
154/// 2-D D2Q9 LBM grid (CPU mock of a GPU backend).
155#[derive(Debug, Clone)]
156pub struct GpuLbmGrid {
157    /// Number of cells in the x-direction.
158    pub nx: usize,
159    /// Number of cells in the y-direction.
160    pub ny: usize,
161    /// Flat cell storage: row-major, `cells[y * nx + x]`.
162    pub cells: Vec<LbmCell>,
163}
164
165impl GpuLbmGrid {
166    /// Construct a new grid initialised to a uniform equilibrium state.
167    ///
168    /// * `nx`, `ny` – grid dimensions
169    /// * `rho`      – initial density (uniform)
170    pub fn new(nx: usize, ny: usize, rho: f64) -> Self {
171        let cells = vec![LbmCell::new_equilibrium(rho); nx * ny];
172        Self { nx, ny, cells }
173    }
174
175    /// Linear index for cell `(x, y)`.
176    #[inline]
177    pub fn idx(&self, x: usize, y: usize) -> usize {
178        y * self.nx + x
179    }
180
181    /// BGK collision step: compute `f_out` from current `f_in` using relaxation
182    /// parameter `omega`.
183    pub fn collision_bgk(&mut self, omega: f64) {
184        for cell in &mut self.cells {
185            let rho = cell.density();
186            let [ux, uy] = cell.velocity();
187            for i in 0..9 {
188                let cx = D2Q9_DIRS[i][0] as f64;
189                let cy = D2Q9_DIRS[i][1] as f64;
190                let feq = bgk_equilibrium(rho, ux, uy, D2Q9_WEIGHTS[i], cx, cy);
191                cell.f_out[i] = cell.f_in[i] - omega * (cell.f_in[i] - feq);
192            }
193        }
194    }
195
196    /// Streaming step with periodic boundary conditions.
197    ///
198    /// Each distribution `f_out[i]` from cell `(x, y)` is moved to
199    /// `f_in[i]` of the neighbouring cell in direction `D2Q9_DIRS[i]`.
200    pub fn stream_periodic(&mut self) {
201        let nx = self.nx;
202        let ny = self.ny;
203        // Snapshot of f_out before overwriting f_in
204        let snapshot: Vec<[f64; 9]> = self.cells.iter().map(|c| c.f_out).collect();
205
206        for y in 0..ny {
207            for x in 0..nx {
208                let src_idx = y * nx + x;
209                for i in 0..9 {
210                    let cx = D2Q9_DIRS[i][0];
211                    let cy = D2Q9_DIRS[i][1];
212                    let dest_x = ((x as i64 + cx as i64).rem_euclid(nx as i64)) as usize;
213                    let dest_y = ((y as i64 + cy as i64).rem_euclid(ny as i64)) as usize;
214                    let dest_idx = dest_y * nx + dest_x;
215                    self.cells[dest_idx].f_in[i] = snapshot[src_idx][i];
216                }
217            }
218        }
219    }
220
221    /// Perform one full LBM step: collision then streaming.
222    pub fn step(&mut self, omega: f64) {
223        self.collision_bgk(omega);
224        self.stream_periodic();
225    }
226}
227
228// ── LbmBoundary ──────────────────────────────────────────────────────────────
229
230/// Boundary condition type for a cell or face.
231#[derive(Debug, Clone, PartialEq)]
232pub enum LbmBoundary {
233    /// Periodic boundary (default — handled in `stream_periodic`).
234    Periodic,
235    /// No-slip bounce-back wall.
236    NoSlip,
237    /// Zou-He velocity/pressure boundary condition.
238    ZouHe {
239        /// Prescribed density (pressure BC: set ux=uy=0, or velocity BC: set rho=1).
240        rho: f64,
241        /// Prescribed x-velocity.
242        ux: f64,
243        /// Prescribed y-velocity.
244        uy: f64,
245    },
246}
247
248// ── GpuLbmDispatch ───────────────────────────────────────────────────────────
249
250/// High-level dispatcher that couples a `GpuLbmGrid` with per-cell boundary
251/// conditions and orchestrates time-stepping.
252#[derive(Debug, Clone)]
253pub struct GpuLbmDispatch {
254    /// The underlying LBM grid.
255    pub grid: GpuLbmGrid,
256    /// Per-cell boundary conditions (length == `grid.nx * grid.ny`).
257    pub boundary: Vec<LbmBoundary>,
258}
259
260impl GpuLbmDispatch {
261    /// Create a new dispatcher for a grid of size `nx × ny` with uniform
262    /// initial density `rho` and all boundaries set to `Periodic`.
263    pub fn new(nx: usize, ny: usize, rho: f64) -> Self {
264        let grid = GpuLbmGrid::new(nx, ny, rho);
265        let boundary = vec![LbmBoundary::Periodic; nx * ny];
266        Self { grid, boundary }
267    }
268
269    /// Set the boundary condition for cell `(x, y)`.
270    pub fn set_boundary(&mut self, x: usize, y: usize, bc: LbmBoundary) {
271        let idx = self.grid.idx(x, y);
272        self.boundary[idx] = bc;
273    }
274
275    /// Perform one dispatch step, applying boundary pre-conditioning before
276    /// collision + streaming.
277    pub fn dispatch_step(&mut self, omega: f64) {
278        self.apply_boundaries();
279        self.grid.step(omega);
280    }
281
282    /// Apply boundary conditions to cells before the collision step.
283    fn apply_boundaries(&mut self) {
284        let nx = self.grid.nx;
285        let ny = self.grid.ny;
286        for y in 0..ny {
287            for x in 0..nx {
288                let idx = y * nx + x;
289                match &self.boundary[idx] {
290                    LbmBoundary::Periodic => {}
291                    LbmBoundary::NoSlip => {
292                        // Bounce-back: swap f_in with its opposite
293                        let cell = &mut self.grid.cells[idx];
294                        let old = cell.f_in;
295                        for i in 0..9 {
296                            cell.f_in[i] = old[D2Q9_OPPOSITE[i]];
297                        }
298                    }
299                    LbmBoundary::ZouHe { rho, ux, uy } => {
300                        let rho = *rho;
301                        let ux = *ux;
302                        let uy = *uy;
303                        // Overwrite f_in with equilibrium at prescribed (rho, ux, uy)
304                        let cell = &mut self.grid.cells[idx];
305                        for i in 0..9 {
306                            let cx = D2Q9_DIRS[i][0] as f64;
307                            let cy = D2Q9_DIRS[i][1] as f64;
308                            cell.f_in[i] = bgk_equilibrium(rho, ux, uy, D2Q9_WEIGHTS[i], cx, cy);
309                        }
310                    }
311                }
312            }
313        }
314    }
315}
316
317// ── LbmDiagnostics ───────────────────────────────────────────────────────────
318
319/// Diagnostic utilities for LBM simulations.
320pub struct LbmDiagnostics;
321
322impl LbmDiagnostics {
323    /// Compute the vorticity field `∂uy/∂x − ∂ux/∂y` using central differences.
324    ///
325    /// Returns a flat `Vec`f64` in row-major order (same layout as `grid.cells`).
326    /// Periodic boundary wrapping is used at the domain edges.
327    pub fn compute_vorticity(grid: &GpuLbmGrid) -> Vec<f64> {
328        let nx = grid.nx;
329        let ny = grid.ny;
330        let mut vort = vec![0.0f64; nx * ny];
331
332        for y in 0..ny {
333            for x in 0..nx {
334                let xp = (x + 1) % nx;
335                let xm = (x + nx - 1) % nx;
336                let yp = (y + 1) % ny;
337                let ym = (y + ny - 1) % ny;
338
339                let uy_xp = grid.cells[grid.idx(xp, y)].velocity()[1];
340                let uy_xm = grid.cells[grid.idx(xm, y)].velocity()[1];
341                let ux_yp = grid.cells[grid.idx(x, yp)].velocity()[0];
342                let ux_ym = grid.cells[grid.idx(x, ym)].velocity()[0];
343
344                let duy_dx = (uy_xp - uy_xm) / 2.0;
345                let dux_dy = (ux_yp - ux_ym) / 2.0;
346                vort[grid.idx(x, y)] = duy_dx - dux_dy;
347            }
348        }
349        vort
350    }
351
352    /// Compute the velocity divergence field `∂ux/∂x + ∂uy/∂y` using central
353    /// differences with periodic wrapping.
354    pub fn compute_divergence(grid: &GpuLbmGrid) -> Vec<f64> {
355        let nx = grid.nx;
356        let ny = grid.ny;
357        let mut div = vec![0.0f64; nx * ny];
358
359        for y in 0..ny {
360            for x in 0..nx {
361                let xp = (x + 1) % nx;
362                let xm = (x + nx - 1) % nx;
363                let yp = (y + 1) % ny;
364                let ym = (y + ny - 1) % ny;
365
366                let ux_xp = grid.cells[grid.idx(xp, y)].velocity()[0];
367                let ux_xm = grid.cells[grid.idx(xm, y)].velocity()[0];
368                let uy_yp = grid.cells[grid.idx(x, yp)].velocity()[1];
369                let uy_ym = grid.cells[grid.idx(x, ym)].velocity()[1];
370
371                let dux_dx = (ux_xp - ux_xm) / 2.0;
372                let duy_dy = (uy_yp - uy_ym) / 2.0;
373                div[grid.idx(x, y)] = dux_dx + duy_dy;
374            }
375        }
376        div
377    }
378
379    /// Return the maximum velocity magnitude across all cells.
380    pub fn max_velocity(grid: &GpuLbmGrid) -> f64 {
381        grid.cells
382            .iter()
383            .map(|c| {
384                let [ux, uy] = c.velocity();
385                (ux * ux + uy * uy).sqrt()
386            })
387            .fold(0.0f64, f64::max)
388    }
389}
390
391// ── Tests ─────────────────────────────────────────────────────────────────────
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    const EPS: f64 = 1e-12;
398
399    // ── D2Q9Weights ──────────────────────────────────────────────────────────
400
401    #[test]
402    fn test_d2q9_weights_sum_to_one() {
403        let sum: f64 = D2Q9_WEIGHTS.iter().sum();
404        assert!((sum - 1.0).abs() < EPS, "weights sum = {sum}");
405    }
406
407    #[test]
408    fn test_d2q9_weights_standard_construction() {
409        let lw = D2Q9Weights::standard();
410        assert_eq!(lw.weights, D2Q9_WEIGHTS);
411        assert_eq!(lw.dirs, D2Q9_DIRS);
412    }
413
414    #[test]
415    fn test_d2q9_default_equals_standard() {
416        let a = D2Q9Weights::default();
417        let b = D2Q9Weights::standard();
418        assert_eq!(a, b);
419    }
420
421    #[test]
422    fn test_d2q9_cs2() {
423        assert!((D2Q9Weights::cs2() - 1.0 / 3.0).abs() < EPS);
424    }
425
426    #[test]
427    fn test_d2q9_dirs_rest_is_zero() {
428        assert_eq!(D2Q9_DIRS[0], [0, 0]);
429    }
430
431    #[test]
432    fn test_d2q9_opposite_involution() {
433        for i in 0..9 {
434            assert_eq!(D2Q9_OPPOSITE[D2Q9_OPPOSITE[i]], i);
435        }
436    }
437
438    #[test]
439    fn test_d2q9_opposite_directions_are_negatives() {
440        for i in 0..9 {
441            let j = D2Q9_OPPOSITE[i];
442            assert_eq!(D2Q9_DIRS[j][0], -D2Q9_DIRS[i][0]);
443            assert_eq!(D2Q9_DIRS[j][1], -D2Q9_DIRS[i][1]);
444        }
445    }
446
447    // ── bgk_equilibrium ──────────────────────────────────────────────────────
448
449    #[test]
450    fn test_bgk_equilibrium_rest_state() {
451        // At ux=uy=0, feq = w * rho
452        let rho = 1.0;
453        for i in 0..9 {
454            let cx = D2Q9_DIRS[i][0] as f64;
455            let cy = D2Q9_DIRS[i][1] as f64;
456            let feq = bgk_equilibrium(rho, 0.0, 0.0, D2Q9_WEIGHTS[i], cx, cy);
457            assert!(
458                (feq - D2Q9_WEIGHTS[i]).abs() < EPS,
459                "i={i}: feq={feq}, w={}",
460                D2Q9_WEIGHTS[i]
461            );
462        }
463    }
464
465    #[test]
466    fn test_bgk_equilibrium_sum_is_rho() {
467        let rho = 1.5;
468        let ux = 0.1;
469        let uy = -0.05;
470        let sum: f64 = (0..9)
471            .map(|i| {
472                bgk_equilibrium(
473                    rho,
474                    ux,
475                    uy,
476                    D2Q9_WEIGHTS[i],
477                    D2Q9_DIRS[i][0] as f64,
478                    D2Q9_DIRS[i][1] as f64,
479                )
480            })
481            .sum();
482        assert!((sum - rho).abs() < 1e-10, "sum={sum}, rho={rho}");
483    }
484
485    #[test]
486    fn test_bgk_equilibrium_momentum_x_is_rho_ux() {
487        let rho = 1.2;
488        let ux = 0.08;
489        let uy = 0.0;
490        let mom_x: f64 = (0..9)
491            .map(|i| {
492                bgk_equilibrium(
493                    rho,
494                    ux,
495                    uy,
496                    D2Q9_WEIGHTS[i],
497                    D2Q9_DIRS[i][0] as f64,
498                    D2Q9_DIRS[i][1] as f64,
499                ) * D2Q9_DIRS[i][0] as f64
500            })
501            .sum();
502        assert!((mom_x - rho * ux).abs() < 1e-10, "mom_x={mom_x}");
503    }
504
505    #[test]
506    fn test_bgk_equilibrium_momentum_y_is_rho_uy() {
507        let rho = 1.0;
508        let ux = 0.0;
509        let uy = 0.05;
510        let mom_y: f64 = (0..9)
511            .map(|i| {
512                bgk_equilibrium(
513                    rho,
514                    ux,
515                    uy,
516                    D2Q9_WEIGHTS[i],
517                    D2Q9_DIRS[i][0] as f64,
518                    D2Q9_DIRS[i][1] as f64,
519                ) * D2Q9_DIRS[i][1] as f64
520            })
521            .sum();
522        assert!((mom_y - rho * uy).abs() < 1e-10, "mom_y={mom_y}");
523    }
524
525    #[test]
526    fn test_bgk_equilibrium_positive_for_small_u() {
527        for i in 0..9 {
528            let val = bgk_equilibrium(
529                1.0,
530                0.05,
531                0.05,
532                D2Q9_WEIGHTS[i],
533                D2Q9_DIRS[i][0] as f64,
534                D2Q9_DIRS[i][1] as f64,
535            );
536            assert!(val >= 0.0, "i={i}: feq={val}");
537        }
538    }
539
540    // ── relaxation_from_viscosity ────────────────────────────────────────────
541
542    #[test]
543    fn test_relaxation_from_viscosity_nu0() {
544        // nu=0 => omega = 1/(0+0.5) = 2.0
545        let omega = relaxation_from_viscosity(0.0, 1.0 / 3.0);
546        assert!((omega - 2.0).abs() < EPS);
547    }
548
549    #[test]
550    fn test_relaxation_from_viscosity_typical() {
551        let cs2 = 1.0 / 3.0;
552        let nu = 1.0 / 6.0;
553        // omega = 1 / (nu/cs2 + 0.5) = 1/(0.5 + 0.5) = 1.0
554        let omega = relaxation_from_viscosity(nu, cs2);
555        assert!((omega - 1.0).abs() < EPS);
556    }
557
558    #[test]
559    fn test_relaxation_from_viscosity_range() {
560        let cs2 = 1.0 / 3.0;
561        for nu_times_10 in 1..20usize {
562            let nu = nu_times_10 as f64 * 0.01;
563            let omega = relaxation_from_viscosity(nu, cs2);
564            // omega must be in (0, 2) for stable BGK
565            assert!(omega > 0.0 && omega < 2.0, "nu={nu}, omega={omega}");
566        }
567    }
568
569    // ── LbmCell ──────────────────────────────────────────────────────────────
570
571    #[test]
572    fn test_lbm_cell_density_equilibrium() {
573        let rho = 1.3;
574        let cell = LbmCell::new_equilibrium(rho);
575        assert!((cell.density() - rho).abs() < EPS);
576    }
577
578    #[test]
579    fn test_lbm_cell_velocity_rest() {
580        let cell = LbmCell::new_equilibrium(1.0);
581        let [ux, uy] = cell.velocity();
582        assert!(ux.abs() < EPS);
583        assert!(uy.abs() < EPS);
584    }
585
586    #[test]
587    fn test_lbm_cell_default_density_one() {
588        let cell = LbmCell::default();
589        assert!((cell.density() - 1.0).abs() < EPS);
590    }
591
592    #[test]
593    fn test_lbm_cell_velocity_zero_density() {
594        let cell = LbmCell {
595            f_in: [0.0; 9],
596            f_out: [0.0; 9],
597        };
598        let [ux, uy] = cell.velocity();
599        assert_eq!([ux, uy], [0.0, 0.0]);
600    }
601
602    // ── GpuLbmGrid ───────────────────────────────────────────────────────────
603
604    #[test]
605    fn test_gpu_lbm_grid_creation() {
606        let grid = GpuLbmGrid::new(8, 8, 1.0);
607        assert_eq!(grid.nx, 8);
608        assert_eq!(grid.ny, 8);
609        assert_eq!(grid.cells.len(), 64);
610    }
611
612    #[test]
613    fn test_gpu_lbm_grid_idx() {
614        let grid = GpuLbmGrid::new(4, 4, 1.0);
615        assert_eq!(grid.idx(0, 0), 0);
616        assert_eq!(grid.idx(3, 3), 15);
617        assert_eq!(grid.idx(1, 2), 9);
618    }
619
620    #[test]
621    fn test_collision_bgk_conserves_mass() {
622        let mut grid = GpuLbmGrid::new(4, 4, 1.0);
623        let mass_before: f64 = grid.cells.iter().map(|c| c.density()).sum();
624        grid.collision_bgk(1.0);
625        let mass_after: f64 = grid.cells.iter().map(|c| c.f_out.iter().sum::<f64>()).sum();
626        assert!((mass_before - mass_after).abs() < 1e-10);
627    }
628
629    #[test]
630    fn test_collision_bgk_equilibrium_state_unchanged() {
631        // If cell is already at equilibrium, f_out == f_in after collision
632        let mut grid = GpuLbmGrid::new(4, 4, 1.0);
633        let omega = 1.0;
634        grid.collision_bgk(omega);
635        for cell in &grid.cells {
636            for i in 0..9 {
637                assert!((cell.f_in[i] - cell.f_out[i]).abs() < EPS);
638            }
639        }
640    }
641
642    #[test]
643    fn test_stream_periodic_mass_conservation() {
644        let mut grid = GpuLbmGrid::new(6, 6, 1.0);
645        let mass_before: f64 = grid.cells.iter().map(|c| c.density()).sum();
646        // Prepare f_out = f_in before streaming
647        for cell in &mut grid.cells {
648            cell.f_out = cell.f_in;
649        }
650        grid.stream_periodic();
651        let mass_after: f64 = grid.cells.iter().map(|c| c.density()).sum();
652        assert!((mass_before - mass_after).abs() < 1e-10);
653    }
654
655    #[test]
656    fn test_step_mass_conservation() {
657        let mut grid = GpuLbmGrid::new(8, 8, 1.0);
658        let mass_before: f64 = grid.cells.iter().map(|c| c.density()).sum();
659        grid.step(1.0);
660        let mass_after: f64 = grid.cells.iter().map(|c| c.density()).sum();
661        assert!((mass_before - mass_after).abs() < 1e-9);
662    }
663
664    #[test]
665    fn test_step_multiple_iterations() {
666        let mut grid = GpuLbmGrid::new(4, 4, 1.0);
667        let mass_before: f64 = grid.cells.iter().map(|c| c.density()).sum();
668        for _ in 0..10 {
669            grid.step(1.0);
670        }
671        let mass_after: f64 = grid.cells.iter().map(|c| c.density()).sum();
672        assert!((mass_before - mass_after).abs() < 1e-8);
673    }
674
675    // ── GpuLbmDispatch ───────────────────────────────────────────────────────
676
677    #[test]
678    fn test_gpu_lbm_dispatch_creation() {
679        let dispatch = GpuLbmDispatch::new(4, 4, 1.0);
680        assert_eq!(dispatch.boundary.len(), 16);
681        assert!(
682            dispatch
683                .boundary
684                .iter()
685                .all(|b| *b == LbmBoundary::Periodic)
686        );
687    }
688
689    #[test]
690    fn test_dispatch_set_boundary() {
691        let mut d = GpuLbmDispatch::new(4, 4, 1.0);
692        d.set_boundary(1, 2, LbmBoundary::NoSlip);
693        assert_eq!(d.boundary[d.grid.idx(1, 2)], LbmBoundary::NoSlip);
694    }
695
696    #[test]
697    fn test_dispatch_zou_he_boundary() {
698        let mut d = GpuLbmDispatch::new(4, 4, 1.0);
699        d.set_boundary(
700            0,
701            0,
702            LbmBoundary::ZouHe {
703                rho: 1.05,
704                ux: 0.1,
705                uy: 0.0,
706            },
707        );
708        d.dispatch_step(1.0);
709        // After step the cell should have density close to 1.05
710        // (it's been overwritten by ZouHe then run through collision/stream)
711        // Just check no panic and mass is finite
712        let total_mass: f64 = d.grid.cells.iter().map(|c| c.density()).sum();
713        assert!(total_mass.is_finite());
714    }
715
716    #[test]
717    fn test_dispatch_step_mass_finite() {
718        let mut d = GpuLbmDispatch::new(6, 6, 1.0);
719        d.set_boundary(0, 0, LbmBoundary::NoSlip);
720        d.dispatch_step(1.0);
721        for cell in &d.grid.cells {
722            assert!(cell.density().is_finite());
723        }
724    }
725
726    // ── LbmDiagnostics ───────────────────────────────────────────────────────
727
728    #[test]
729    fn test_vorticity_uniform_flow_is_zero() {
730        // Uniform flow: all cells identical => gradients are zero
731        let grid = GpuLbmGrid::new(6, 6, 1.0);
732        let vort = LbmDiagnostics::compute_vorticity(&grid);
733        for v in &vort {
734            assert!(v.abs() < EPS, "vorticity={v}");
735        }
736    }
737
738    #[test]
739    fn test_vorticity_field_length() {
740        let grid = GpuLbmGrid::new(5, 7, 1.0);
741        let vort = LbmDiagnostics::compute_vorticity(&grid);
742        assert_eq!(vort.len(), 35);
743    }
744
745    #[test]
746    fn test_divergence_uniform_flow_is_zero() {
747        let grid = GpuLbmGrid::new(6, 6, 1.0);
748        let div = LbmDiagnostics::compute_divergence(&grid);
749        for d in &div {
750            assert!(d.abs() < EPS, "div={d}");
751        }
752    }
753
754    #[test]
755    fn test_divergence_field_length() {
756        let grid = GpuLbmGrid::new(3, 4, 1.0);
757        let div = LbmDiagnostics::compute_divergence(&grid);
758        assert_eq!(div.len(), 12);
759    }
760
761    #[test]
762    fn test_max_velocity_rest_state() {
763        let grid = GpuLbmGrid::new(4, 4, 1.0);
764        let mv = LbmDiagnostics::max_velocity(&grid);
765        assert!(mv.abs() < EPS, "max_vel={mv}");
766    }
767
768    #[test]
769    fn test_max_velocity_after_step() {
770        let mut grid = GpuLbmGrid::new(4, 4, 1.0);
771        // Perturb one cell
772        grid.cells[0].f_in[1] += 0.1;
773        grid.step(1.0);
774        let mv = LbmDiagnostics::max_velocity(&grid);
775        assert!(mv.is_finite());
776    }
777
778    // ── Macroscopic recovery ─────────────────────────────────────────────────
779
780    #[test]
781    fn test_macroscopic_recovery_rho() {
782        // Set f_in explicitly and verify density()
783        let mut cell = LbmCell {
784            f_in: [0.0; 9],
785            f_out: [0.0; 9],
786        };
787        cell.f_in[0] = 0.5;
788        cell.f_in[1] = 0.25;
789        cell.f_in[2] = 0.25;
790        assert!((cell.density() - 1.0).abs() < EPS);
791    }
792
793    #[test]
794    fn test_macroscopic_recovery_ux() {
795        // Manually set f_in so ux = sum(fi * cx) / rho is predictable
796        let rho = 1.0;
797        let ux = 0.1;
798        let uy = 0.0;
799        let mut cell = LbmCell {
800            f_in: [0.0; 9],
801            f_out: [0.0; 9],
802        };
803        for i in 0..9 {
804            let cx = D2Q9_DIRS[i][0] as f64;
805            let cy = D2Q9_DIRS[i][1] as f64;
806            cell.f_in[i] = bgk_equilibrium(rho, ux, uy, D2Q9_WEIGHTS[i], cx, cy);
807        }
808        let [vx, vy] = cell.velocity();
809        assert!((vx - ux).abs() < 1e-10, "vx={vx}");
810        assert!(vy.abs() < 1e-10, "vy={vy}");
811    }
812
813    #[test]
814    fn test_macroscopic_recovery_uy() {
815        let rho = 1.0;
816        let ux = 0.0;
817        let uy = -0.07;
818        let mut cell = LbmCell {
819            f_in: [0.0; 9],
820            f_out: [0.0; 9],
821        };
822        for i in 0..9 {
823            let cx = D2Q9_DIRS[i][0] as f64;
824            let cy = D2Q9_DIRS[i][1] as f64;
825            cell.f_in[i] = bgk_equilibrium(rho, ux, uy, D2Q9_WEIGHTS[i], cx, cy);
826        }
827        let [vx, vy] = cell.velocity();
828        assert!(vx.abs() < 1e-10, "vx={vx}");
829        assert!((vy - uy).abs() < 1e-10, "vy={vy}");
830    }
831
832    // ── Streaming correctness ────────────────────────────────────────────────
833
834    #[test]
835    fn test_stream_periodic_translation() {
836        // Put a pulse in the x-direction distribution (index 1 = +x)
837        // and verify it moves one cell to the right after streaming.
838        let nx = 4;
839        let ny = 1;
840        let mut grid = GpuLbmGrid::new(nx, ny, 1.0);
841
842        // Reset all f_in to 0, place 1.0 in direction 1 (cx=+1) at x=0
843        for cell in &mut grid.cells {
844            cell.f_in = [0.0; 9];
845            cell.f_out = [0.0; 9];
846        }
847        grid.cells[0].f_in[1] = 1.0; // direction +x, cell (0,0)
848        // Copy f_in → f_out (skip collision)
849        for cell in &mut grid.cells {
850            cell.f_out = cell.f_in;
851        }
852        grid.stream_periodic();
853
854        // The value should now be at cell (1,0) direction 1
855        assert!((grid.cells[1].f_in[1] - 1.0).abs() < EPS);
856        assert!(grid.cells[0].f_in[1].abs() < EPS);
857    }
858
859    #[test]
860    fn test_stream_periodic_wrap() {
861        // Direction +x from the last cell should wrap to cell 0.
862        let nx = 4;
863        let ny = 1;
864        let mut grid = GpuLbmGrid::new(nx, ny, 1.0);
865        for cell in &mut grid.cells {
866            cell.f_in = [0.0; 9];
867            cell.f_out = [0.0; 9];
868        }
869        grid.cells[nx - 1].f_in[1] = 1.0;
870        for cell in &mut grid.cells {
871            cell.f_out = cell.f_in;
872        }
873        grid.stream_periodic();
874        assert!((grid.cells[0].f_in[1] - 1.0).abs() < EPS);
875    }
876}