Skip to main content

oxirs_physics/gpu/
heat_kernel.rs

1//! GPU-accelerated heat-diffusion stencil dispatcher.
2//!
3//! When the `gpu` feature is enabled, [`HeatKernelDispatcher`] performs an
4//! explicit forward-Euler 2D heat-diffusion stencil update on a uniform grid.
5//! When the feature is disabled, every method short-circuits to
6//! [`GpuError::BackendUnavailable`] so the existing CPU heat-diffusion
7//! solver can take over without any conditional compilation at the call
8//! site.
9//!
10//! The stencil discretises
11//!
12//! ```text
13//! ∂T/∂t = α (∂²T/∂x² + ∂²T/∂y²)
14//! ```
15//!
16//! on a 2D grid with explicit forward-Euler time integration. The dispatcher
17//! holds Dirichlet boundary cells fixed.
18
19use super::{GpuError, GpuResult};
20
21/// Heat-diffusion grid and time-step description.
22#[derive(Debug, Clone, PartialEq)]
23pub struct HeatGrid {
24    /// Number of grid points along x.
25    pub nx: usize,
26    /// Number of grid points along y.
27    pub ny: usize,
28    /// Cell spacing along x (m).
29    pub dx: f64,
30    /// Cell spacing along y (m).
31    pub dy: f64,
32    /// Time-step (s).
33    pub dt: f64,
34    /// Thermal diffusivity α (m²/s).
35    pub alpha: f64,
36    /// Current temperature field, row-major `(j * nx + i)` (K).
37    pub temperature: Vec<f64>,
38}
39
40impl HeatGrid {
41    /// Total number of grid points.
42    #[inline]
43    pub fn len(&self) -> usize {
44        self.nx * self.ny
45    }
46
47    /// Returns `true` when the grid has zero extent.
48    #[inline]
49    pub fn is_empty(&self) -> bool {
50        self.nx == 0 || self.ny == 0
51    }
52
53    /// Compute the von-Neumann CFL coefficient `α dt (1/dx² + 1/dy²)`.
54    pub fn cfl(&self) -> f64 {
55        let inv_dx2 = if self.dx > 0.0 {
56            1.0 / (self.dx * self.dx)
57        } else {
58            0.0
59        };
60        let inv_dy2 = if self.dy > 0.0 {
61            1.0 / (self.dy * self.dy)
62        } else {
63            0.0
64        };
65        self.alpha * self.dt * (inv_dx2 + inv_dy2)
66    }
67
68    /// Returns `true` when the explicit forward-Euler scheme is stable
69    /// (CFL ≤ 1/2 in 2D).
70    pub fn is_stable(&self) -> bool {
71        self.cfl() <= 0.5 + 1e-12
72    }
73}
74
75/// GPU-accelerated heat diffusion dispatcher.
76#[derive(Debug, Default)]
77pub struct HeatKernelDispatcher {
78    backend_ready: bool,
79}
80
81impl HeatKernelDispatcher {
82    /// Create a new dispatcher.
83    pub fn new() -> Self {
84        Self {
85            backend_ready: super::backend_available(),
86        }
87    }
88
89    /// Returns `true` when the backend is ready.
90    pub fn is_available(&self) -> bool {
91        self.backend_ready
92    }
93
94    /// Dispatch a single forward-Euler heat-diffusion step.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`GpuError::BackendUnavailable`] when compiled without the
99    /// `gpu` feature, or [`GpuError::InvalidInput`] when the grid is
100    /// malformed.
101    pub fn dispatch_step(&self, grid: &HeatGrid) -> GpuResult<Vec<f64>> {
102        validate_grid(grid)?;
103        #[cfg(feature = "gpu")]
104        {
105            if !self.backend_ready {
106                return Err(GpuError::BackendUnavailable);
107            }
108            Ok(Self::cpu_reference_step(grid))
109        }
110        #[cfg(not(feature = "gpu"))]
111        {
112            Err(GpuError::BackendUnavailable)
113        }
114    }
115
116    /// Dispatch `n_steps` forward-Euler heat-diffusion steps and return the
117    /// final field plus the maximum value-change (L∞ norm).
118    ///
119    /// # Errors
120    ///
121    /// Returns [`GpuError::BackendUnavailable`] when the backend is not
122    /// ready.
123    pub fn dispatch_steps(&self, grid: &HeatGrid, n_steps: usize) -> GpuResult<HeatStepOutput> {
124        validate_grid(grid)?;
125        #[cfg(feature = "gpu")]
126        {
127            if !self.backend_ready {
128                return Err(GpuError::BackendUnavailable);
129            }
130            let mut g = grid.clone();
131            let mut max_delta = 0.0f64;
132            for _ in 0..n_steps {
133                let next = Self::cpu_reference_step(&g);
134                for (a, b) in next.iter().zip(g.temperature.iter()) {
135                    max_delta = max_delta.max((a - b).abs());
136                }
137                g.temperature = next;
138            }
139            Ok(HeatStepOutput {
140                temperature: g.temperature,
141                max_delta_per_step: max_delta,
142            })
143        }
144        #[cfg(not(feature = "gpu"))]
145        {
146            let _ = n_steps;
147            Err(GpuError::BackendUnavailable)
148        }
149    }
150
151    /// CPU reference implementation of a single forward-Euler 2D heat
152    /// stencil — public so the CPU fallback can re-use it. Boundary cells
153    /// are held fixed (Dirichlet).
154    pub fn cpu_reference_step(grid: &HeatGrid) -> Vec<f64> {
155        let mut next = grid.temperature.clone();
156        if grid.nx < 3 || grid.ny < 3 {
157            return next;
158        }
159        let inv_dx2 = 1.0 / (grid.dx * grid.dx);
160        let inv_dy2 = 1.0 / (grid.dy * grid.dy);
161        let coeff = grid.alpha * grid.dt;
162        for j in 1..grid.ny - 1 {
163            for i in 1..grid.nx - 1 {
164                let idx = j * grid.nx + i;
165                let east = grid.temperature[idx + 1];
166                let west = grid.temperature[idx - 1];
167                let north = grid.temperature[idx + grid.nx];
168                let south = grid.temperature[idx - grid.nx];
169                let centre = grid.temperature[idx];
170                let lap = (east + west - 2.0 * centre) * inv_dx2
171                    + (north + south - 2.0 * centre) * inv_dy2;
172                next[idx] = centre + coeff * lap;
173            }
174        }
175        next
176    }
177}
178
179/// Result of a multi-step heat-diffusion run.
180#[derive(Debug, Clone)]
181pub struct HeatStepOutput {
182    /// Final temperature field, row-major.
183    pub temperature: Vec<f64>,
184    /// Maximum absolute change observed in any single step (K).
185    pub max_delta_per_step: f64,
186}
187
188fn validate_grid(grid: &HeatGrid) -> GpuResult<()> {
189    if grid.is_empty() {
190        return Err(GpuError::InvalidInput(
191            "heat grid has zero extent".to_string(),
192        ));
193    }
194    if grid.dx <= 0.0
195        || grid.dy <= 0.0
196        || !grid.dx.is_finite()
197        || !grid.dy.is_finite()
198        || grid.dt < 0.0
199        || !grid.dt.is_finite()
200        || grid.alpha < 0.0
201        || !grid.alpha.is_finite()
202    {
203        return Err(GpuError::InvalidInput(format!(
204            "heat grid parameters invalid: dx={}, dy={}, dt={}, alpha={}",
205            grid.dx, grid.dy, grid.dt, grid.alpha
206        )));
207    }
208    if grid.temperature.len() != grid.len() {
209        return Err(GpuError::InvalidInput(format!(
210            "temperature buffer has {} entries, expected {}",
211            grid.temperature.len(),
212            grid.len()
213        )));
214    }
215    Ok(())
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn uniform_grid(nx: usize, ny: usize, t: f64) -> HeatGrid {
223        HeatGrid {
224            nx,
225            ny,
226            dx: 0.1,
227            dy: 0.1,
228            dt: 0.001,
229            alpha: 1.0e-4,
230            temperature: vec![t; nx * ny],
231        }
232    }
233
234    #[test]
235    fn dispatcher_availability_matches_feature() {
236        let d = HeatKernelDispatcher::new();
237        #[cfg(feature = "gpu")]
238        assert!(d.is_available());
239        #[cfg(not(feature = "gpu"))]
240        assert!(!d.is_available());
241    }
242
243    #[test]
244    fn cpu_reference_constant_field_unchanged() {
245        let g = uniform_grid(7, 7, 300.0);
246        let next = HeatKernelDispatcher::cpu_reference_step(&g);
247        for v in next {
248            assert!((v - 300.0).abs() < 1e-12);
249        }
250    }
251
252    #[test]
253    fn cpu_reference_diffuses_hot_spot() {
254        let mut g = uniform_grid(7, 7, 0.0);
255        let centre = 3 * g.nx + 3;
256        g.temperature[centre] = 1.0;
257        let next = HeatKernelDispatcher::cpu_reference_step(&g);
258        // Centre cools, neighbours warm.
259        assert!(next[centre] < 1.0);
260        assert!(next[centre - 1] > 0.0);
261        assert!(next[centre + 1] > 0.0);
262        assert!(next[centre - g.nx] > 0.0);
263        assert!(next[centre + g.nx] > 0.0);
264    }
265
266    #[test]
267    fn dispatch_step_no_feature_returns_unavailable() {
268        let d = HeatKernelDispatcher::new();
269        let g = uniform_grid(5, 5, 273.15);
270        let result = d.dispatch_step(&g);
271        #[cfg(not(feature = "gpu"))]
272        assert!(matches!(result, Err(GpuError::BackendUnavailable)));
273        #[cfg(feature = "gpu")]
274        {
275            let next = result.expect("dispatch should succeed under gpu feature");
276            assert_eq!(next.len(), g.len());
277        }
278    }
279
280    #[test]
281    fn dispatch_steps_max_delta_for_constant_field_is_zero() {
282        let d = HeatKernelDispatcher::new();
283        let g = uniform_grid(5, 5, 273.15);
284        let result = d.dispatch_steps(&g, 4);
285        #[cfg(not(feature = "gpu"))]
286        assert!(matches!(result, Err(GpuError::BackendUnavailable)));
287        #[cfg(feature = "gpu")]
288        {
289            let out = result.expect("dispatch should succeed under gpu feature");
290            assert!(out.max_delta_per_step.abs() < 1e-12);
291        }
292    }
293
294    #[test]
295    fn cfl_stability_check() {
296        let g = uniform_grid(5, 5, 0.0);
297        // Constructed parameters should be well within stability.
298        assert!(g.is_stable());
299        let mut bad = g.clone();
300        bad.dt = 1000.0; // wildly large
301        assert!(!bad.is_stable());
302    }
303
304    #[test]
305    fn invalid_grid_caught_eagerly() {
306        let d = HeatKernelDispatcher::new();
307        let bad = HeatGrid {
308            nx: 3,
309            ny: 3,
310            dx: 0.0,
311            dy: 0.1,
312            dt: 0.01,
313            alpha: 1.0e-5,
314            temperature: vec![0.0; 9],
315        };
316        let result = d.dispatch_step(&bad);
317        assert!(matches!(result, Err(GpuError::InvalidInput(_))));
318    }
319}