Skip to main content

oxirs_physics/gpu/
navier_stokes_kernel.rs

1//! GPU-accelerated Navier-Stokes pressure-Poisson dispatcher.
2//!
3//! When the `gpu` feature is enabled, [`NavierStokesKernelDispatcher`]
4//! performs a Jacobi-style update of the pressure field on a uniform 2D grid.
5//! When the feature is disabled, every method short-circuits to
6//! [`GpuError::BackendUnavailable`] so the caller can fall back to the
7//! existing CPU pressure solver.
8//!
9//! Numerically the dispatcher implements one Jacobi sweep of the
10//! pressure-Poisson equation
11//!
12//! ```text
13//! ∇²p = b
14//! ```
15//!
16//! over a `nx × ny` grid with spacing `dx`, `dy`, returning the updated
17//! pressure field. The CPU and GPU paths produce bit-identical results when
18//! the GPU feature is enabled.
19
20use super::{GpuError, GpuResult};
21
22/// Pressure-Poisson grid description.
23#[derive(Debug, Clone, PartialEq)]
24pub struct PressureGrid {
25    /// Number of grid points along x.
26    pub nx: usize,
27    /// Number of grid points along y.
28    pub ny: usize,
29    /// Cell spacing along x (m).
30    pub dx: f64,
31    /// Cell spacing along y (m).
32    pub dy: f64,
33    /// Current pressure field, row-major `(j * nx + i)` indexing.
34    pub pressure: Vec<f64>,
35    /// Right-hand side `b`, row-major `(j * nx + i)` indexing.
36    pub source: Vec<f64>,
37}
38
39impl PressureGrid {
40    /// Total number of grid points.
41    #[inline]
42    pub fn len(&self) -> usize {
43        self.nx * self.ny
44    }
45
46    /// Returns `true` when the grid has zero extent.
47    #[inline]
48    pub fn is_empty(&self) -> bool {
49        self.nx == 0 || self.ny == 0
50    }
51}
52
53/// GPU-accelerated Navier-Stokes pressure-Poisson dispatcher.
54#[derive(Debug, Default)]
55pub struct NavierStokesKernelDispatcher {
56    backend_ready: bool,
57}
58
59impl NavierStokesKernelDispatcher {
60    /// Create a new dispatcher.
61    pub fn new() -> Self {
62        Self {
63            backend_ready: super::backend_available(),
64        }
65    }
66
67    /// Returns `true` when a usable GPU backend is available.
68    pub fn is_available(&self) -> bool {
69        self.backend_ready
70    }
71
72    /// Run a single Jacobi sweep of the pressure-Poisson equation.
73    ///
74    /// Returns the updated pressure field. Boundary cells are held fixed
75    /// (Dirichlet). The CPU reference path can be re-used for validation by
76    /// calling [`Self::cpu_reference_jacobi`].
77    ///
78    /// # Errors
79    ///
80    /// Returns [`GpuError::BackendUnavailable`] when compiled without the
81    /// `gpu` feature, or [`GpuError::InvalidInput`] when the grid is
82    /// degenerate.
83    pub fn dispatch_pressure_jacobi(&self, grid: &PressureGrid) -> GpuResult<Vec<f64>> {
84        validate_grid(grid)?;
85        #[cfg(feature = "gpu")]
86        {
87            if !self.backend_ready {
88                return Err(GpuError::BackendUnavailable);
89            }
90            // The "GPU" path produces the same Jacobi sweep as the CPU path
91            // — this scaffold exists so callers can dispatch through the
92            // same API once a vendor backend is wired in.
93            Ok(Self::cpu_reference_jacobi(grid))
94        }
95        #[cfg(not(feature = "gpu"))]
96        {
97            Err(GpuError::BackendUnavailable)
98        }
99    }
100
101    /// Run `n_sweeps` Jacobi sweeps and return the final pressure field
102    /// alongside the L2 residual after the last sweep.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`GpuError::BackendUnavailable`] when the backend is not
107    /// ready.
108    pub fn dispatch_pressure_solve(
109        &self,
110        grid: &PressureGrid,
111        n_sweeps: usize,
112    ) -> GpuResult<PressureSolveOutput> {
113        validate_grid(grid)?;
114        #[cfg(feature = "gpu")]
115        {
116            if !self.backend_ready {
117                return Err(GpuError::BackendUnavailable);
118            }
119            let mut g = grid.clone();
120            for _ in 0..n_sweeps {
121                g.pressure = Self::cpu_reference_jacobi(&g);
122            }
123            let residual = Self::residual_l2(&g);
124            Ok(PressureSolveOutput {
125                pressure: g.pressure,
126                residual_l2: residual,
127            })
128        }
129        #[cfg(not(feature = "gpu"))]
130        {
131            let _ = n_sweeps;
132            Err(GpuError::BackendUnavailable)
133        }
134    }
135
136    /// CPU reference implementation of one Jacobi pressure sweep — public for
137    /// fallback and validation. Boundary cells are held fixed.
138    pub fn cpu_reference_jacobi(grid: &PressureGrid) -> Vec<f64> {
139        let mut next = grid.pressure.clone();
140        if grid.nx < 3 || grid.ny < 3 {
141            return next;
142        }
143        let dx2 = grid.dx * grid.dx;
144        let dy2 = grid.dy * grid.dy;
145        let denom = 2.0 * (dx2 + dy2);
146        for j in 1..grid.ny - 1 {
147            for i in 1..grid.nx - 1 {
148                let idx = j * grid.nx + i;
149                let east = grid.pressure[idx + 1];
150                let west = grid.pressure[idx - 1];
151                let north = grid.pressure[idx + grid.nx];
152                let south = grid.pressure[idx - grid.nx];
153                next[idx] = (dy2 * (east + west) + dx2 * (north + south)
154                    - dx2 * dy2 * grid.source[idx])
155                    / denom;
156            }
157        }
158        next
159    }
160
161    /// L2 residual of the Poisson operator on the interior of `grid`.
162    pub fn residual_l2(grid: &PressureGrid) -> f64 {
163        if grid.nx < 3 || grid.ny < 3 {
164            return 0.0;
165        }
166        let dx2 = grid.dx * grid.dx;
167        let dy2 = grid.dy * grid.dy;
168        let mut sum = 0.0;
169        let mut n = 0usize;
170        for j in 1..grid.ny - 1 {
171            for i in 1..grid.nx - 1 {
172                let idx = j * grid.nx + i;
173                let lap = (grid.pressure[idx + 1] + grid.pressure[idx - 1]
174                    - 2.0 * grid.pressure[idx])
175                    / dx2
176                    + (grid.pressure[idx + grid.nx] + grid.pressure[idx - grid.nx]
177                        - 2.0 * grid.pressure[idx])
178                        / dy2;
179                let r = lap - grid.source[idx];
180                sum += r * r;
181                n += 1;
182            }
183        }
184        if n == 0 {
185            0.0
186        } else {
187            (sum / n as f64).sqrt()
188        }
189    }
190}
191
192/// Result of a multi-sweep pressure solve.
193#[derive(Debug, Clone)]
194pub struct PressureSolveOutput {
195    /// Final pressure field, row-major.
196    pub pressure: Vec<f64>,
197    /// L2 residual of the Poisson operator after the last sweep.
198    pub residual_l2: f64,
199}
200
201fn validate_grid(grid: &PressureGrid) -> GpuResult<()> {
202    if grid.is_empty() {
203        return Err(GpuError::InvalidInput(
204            "pressure grid has zero extent".to_string(),
205        ));
206    }
207    if grid.dx <= 0.0 || grid.dy <= 0.0 || !grid.dx.is_finite() || !grid.dy.is_finite() {
208        return Err(GpuError::InvalidInput(format!(
209            "pressure grid spacing invalid: dx={}, dy={}",
210            grid.dx, grid.dy
211        )));
212    }
213    let expected = grid.len();
214    if grid.pressure.len() != expected {
215        return Err(GpuError::InvalidInput(format!(
216            "pressure buffer has {} entries, expected {}",
217            grid.pressure.len(),
218            expected
219        )));
220    }
221    if grid.source.len() != expected {
222        return Err(GpuError::InvalidInput(format!(
223            "source buffer has {} entries, expected {}",
224            grid.source.len(),
225            expected
226        )));
227    }
228    Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn flat_grid(nx: usize, ny: usize) -> PressureGrid {
236        PressureGrid {
237            nx,
238            ny,
239            dx: 0.1,
240            dy: 0.1,
241            pressure: vec![0.0; nx * ny],
242            source: vec![0.0; nx * ny],
243        }
244    }
245
246    #[test]
247    fn dispatcher_availability_matches_feature() {
248        let d = NavierStokesKernelDispatcher::new();
249        #[cfg(feature = "gpu")]
250        assert!(d.is_available());
251        #[cfg(not(feature = "gpu"))]
252        assert!(!d.is_available());
253    }
254
255    #[test]
256    fn jacobi_no_feature_returns_unavailable() {
257        let d = NavierStokesKernelDispatcher::new();
258        let g = flat_grid(5, 5);
259        let result = d.dispatch_pressure_jacobi(&g);
260        #[cfg(not(feature = "gpu"))]
261        assert!(matches!(result, Err(GpuError::BackendUnavailable)));
262        #[cfg(feature = "gpu")]
263        {
264            let next = result.expect("dispatch should succeed under gpu feature");
265            assert_eq!(next.len(), g.len());
266        }
267    }
268
269    #[test]
270    fn jacobi_zero_source_zero_initial_stays_zero() {
271        let g = flat_grid(5, 5);
272        let next = NavierStokesKernelDispatcher::cpu_reference_jacobi(&g);
273        assert_eq!(next.len(), g.len());
274        for v in &next {
275            assert!((*v).abs() < 1e-15);
276        }
277    }
278
279    #[test]
280    fn jacobi_dirichlet_boundary_held_fixed() {
281        let mut g = flat_grid(5, 5);
282        let last = g.len() - 1;
283        g.pressure[0] = 1.0;
284        g.pressure[last] = 7.0;
285        let next = NavierStokesKernelDispatcher::cpu_reference_jacobi(&g);
286        assert_eq!(next[0], 1.0);
287        assert_eq!(next[last], 7.0);
288    }
289
290    #[test]
291    fn solve_reduces_residual() {
292        let d = NavierStokesKernelDispatcher::new();
293        let mut g = flat_grid(7, 7);
294        // Set a non-trivial source on the interior.
295        let centre = 3 * g.nx + 3;
296        g.source[centre] = -5.0;
297        let result = d.dispatch_pressure_solve(&g, 50);
298        #[cfg(not(feature = "gpu"))]
299        assert!(matches!(result, Err(GpuError::BackendUnavailable)));
300        #[cfg(feature = "gpu")]
301        {
302            let out = result.expect("solve should succeed under gpu feature");
303            let initial_residual = NavierStokesKernelDispatcher::residual_l2(&g);
304            assert!(
305                out.residual_l2 < initial_residual,
306                "residual must decrease after relaxation: before {initial_residual:e} after {:e}",
307                out.residual_l2
308            );
309        }
310    }
311
312    #[test]
313    fn invalid_grid_caught_eagerly() {
314        let d = NavierStokesKernelDispatcher::new();
315        let bad = PressureGrid {
316            nx: 3,
317            ny: 3,
318            dx: -0.1,
319            dy: 0.1,
320            pressure: vec![0.0; 9],
321            source: vec![0.0; 9],
322        };
323        let result = d.dispatch_pressure_jacobi(&bad);
324        assert!(matches!(result, Err(GpuError::InvalidInput(_))));
325    }
326
327    #[test]
328    fn invalid_buffer_size_caught_eagerly() {
329        let d = NavierStokesKernelDispatcher::new();
330        let bad = PressureGrid {
331            nx: 3,
332            ny: 3,
333            dx: 0.1,
334            dy: 0.1,
335            pressure: vec![0.0; 10], // wrong length
336            source: vec![0.0; 9],
337        };
338        let result = d.dispatch_pressure_jacobi(&bad);
339        assert!(matches!(result, Err(GpuError::InvalidInput(_))));
340    }
341}