Skip to main content

oxiphysics_gpu/
gpu_sph_pressure.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU SPH pressure solver (CPU mock backend).
5//!
6//! Implements the Smoothed Particle Hydrodynamics pressure and viscosity
7//! force computation pipeline using Poly6, Spiky, and Viscosity kernels.
8//! The GPU dispatch is mocked by plain Rust loops for portability.
9
10use std::f64::consts::PI;
11
12// ── Data structures ──────────────────────────────────────────────────────────
13
14/// GPU SPH pressure solver holding all per-particle state.
15#[allow(dead_code)]
16#[derive(Debug, Clone)]
17pub struct GpuSphPressureSolver {
18    /// Total number of simulated particles.
19    pub n_particles: usize,
20    /// Per-particle pressure values (Pa).
21    pub pressure: Vec<f64>,
22    /// Per-particle density values (kg/m³).
23    pub density: Vec<f64>,
24    /// Per-particle positions `[x, y, z]` in metres.
25    pub positions: Vec<[f64; 3]>,
26    /// Per-particle masses (kg).
27    pub masses: Vec<f64>,
28    /// Smoothing length h (m).
29    pub smoothing_h: f64,
30    /// Rest density ρ₀ (kg/m³).
31    pub rest_density: f64,
32    /// Stiffness constant k for the linearised EOS.
33    pub stiffness: f64,
34}
35
36impl GpuSphPressureSolver {
37    /// Create a new solver with `n` particles, smoothing length `h`,
38    /// rest density `rho0`, and stiffness `k`.
39    pub fn new(n: usize, h: f64, rho0: f64, k: f64) -> Self {
40        Self {
41            n_particles: n,
42            pressure: vec![0.0; n],
43            density: vec![0.0; n],
44            positions: vec![[0.0; 3]; n],
45            masses: vec![1.0; n],
46            smoothing_h: h,
47            rest_density: rho0,
48            stiffness: k,
49        }
50    }
51
52    /// Return the number of particles in this solver.
53    pub fn particle_count(&self) -> usize {
54        self.n_particles
55    }
56
57    /// Set the position of particle `i`.
58    pub fn set_position(&mut self, i: usize, pos: [f64; 3]) {
59        self.positions[i] = pos;
60    }
61
62    /// Set the mass of particle `i`.
63    pub fn set_mass(&mut self, i: usize, mass: f64) {
64        self.masses[i] = mass;
65    }
66
67    /// Compute total mass of all particles.
68    pub fn total_mass(&self) -> f64 {
69        self.masses.iter().sum()
70    }
71
72    /// Compute the maximum relative density error: max |ρᵢ − ρ₀| / ρ₀.
73    pub fn density_error(&self) -> f64 {
74        if self.rest_density <= 0.0 {
75            return 0.0;
76        }
77        self.density
78            .iter()
79            .map(|&rho| (rho - self.rest_density).abs() / self.rest_density)
80            .fold(0.0_f64, f64::max)
81    }
82
83    /// Compute density statistics.
84    pub fn compute_stats(&self) -> GpuSphStats {
85        let max_density = self
86            .density
87            .iter()
88            .cloned()
89            .fold(f64::NEG_INFINITY, f64::max);
90        let min_density = self.density.iter().cloned().fold(f64::INFINITY, f64::min);
91        let mean_pressure = if self.n_particles == 0 {
92            0.0
93        } else {
94            self.pressure.iter().sum::<f64>() / self.n_particles as f64
95        };
96        let compression_error = self.density_error();
97        GpuSphStats {
98            max_density,
99            min_density,
100            mean_pressure,
101            compression_error,
102        }
103    }
104
105    /// Mock GPU density computation: ρᵢ = Σⱼ mⱼ · W_poly6(|rᵢ − rⱼ|, h).
106    pub fn gpu_compute_density(&mut self) {
107        let n = self.n_particles;
108        let h = self.smoothing_h;
109        for i in 0..n {
110            let mut rho = 0.0f64;
111            for j in 0..n {
112                let dx = self.positions[i][0] - self.positions[j][0];
113                let dy = self.positions[i][1] - self.positions[j][1];
114                let dz = self.positions[i][2] - self.positions[j][2];
115                let r = (dx * dx + dy * dy + dz * dz).sqrt();
116                rho += self.masses[j] * kernel_poly6(r, h);
117            }
118            self.density[i] = rho;
119        }
120    }
121
122    /// Mock GPU pressure computation: pᵢ = k · (ρᵢ − ρ₀).
123    pub fn gpu_compute_pressure(&mut self) {
124        let k = self.stiffness;
125        let rho0 = self.rest_density;
126        for i in 0..self.n_particles {
127            self.pressure[i] = k * (self.density[i] - rho0);
128        }
129    }
130
131    /// Compute pressure force on particle `i`:
132    /// F_press_i = −Σⱼ mⱼ (pᵢ/ρᵢ² + pⱼ/ρⱼ²) ∇W_spiky.
133    pub fn gpu_pressure_force(&self, i: usize) -> [f64; 3] {
134        let h = self.smoothing_h;
135        let rhoi = self.density[i];
136        let pi = self.pressure[i];
137        let mut force = [0.0f64; 3];
138        if rhoi < 1e-15 {
139            return force;
140        }
141        for j in 0..self.n_particles {
142            if i == j {
143                continue;
144            }
145            let rhoj = self.density[j];
146            if rhoj < 1e-15 {
147                continue;
148            }
149            let r_vec = [
150                self.positions[i][0] - self.positions[j][0],
151                self.positions[i][1] - self.positions[j][1],
152                self.positions[i][2] - self.positions[j][2],
153            ];
154            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
155            let grad = kernel_spiky_grad(r_vec, r, h);
156            let coeff = -self.masses[j] * (pi / (rhoi * rhoi) + self.pressure[j] / (rhoj * rhoj));
157            force[0] += coeff * grad[0];
158            force[1] += coeff * grad[1];
159            force[2] += coeff * grad[2];
160        }
161        force
162    }
163
164    /// Compute viscosity force on particle `i`:
165    /// F_visc_i = μ Σⱼ mⱼ/ρⱼ (vⱼ − vᵢ) ∇²W_visc.
166    #[allow(clippy::too_many_arguments)]
167    pub fn gpu_viscosity_force(&self, i: usize, velocities: &[[f64; 3]], mu: f64) -> [f64; 3] {
168        let h = self.smoothing_h;
169        let mut force = [0.0f64; 3];
170        for j in 0..self.n_particles {
171            if i == j {
172                continue;
173            }
174            let rhoj = self.density[j];
175            if rhoj < 1e-15 {
176                continue;
177            }
178            let r_vec = [
179                self.positions[i][0] - self.positions[j][0],
180                self.positions[i][1] - self.positions[j][1],
181                self.positions[i][2] - self.positions[j][2],
182            ];
183            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
184            let lap = kernel_viscosity_laplacian(r, h);
185            let dv = [
186                velocities[j][0] - velocities[i][0],
187                velocities[j][1] - velocities[i][1],
188                velocities[j][2] - velocities[i][2],
189            ];
190            let coeff = mu * self.masses[j] / rhoj * lap;
191            force[0] += coeff * dv[0];
192            force[1] += coeff * dv[1];
193            force[2] += coeff * dv[2];
194        }
195        force
196    }
197}
198
199/// Statistics computed from the pressure solver state.
200#[allow(dead_code)]
201#[derive(Debug, Clone)]
202pub struct GpuSphStats {
203    /// Maximum density across all particles.
204    pub max_density: f64,
205    /// Minimum density across all particles.
206    pub min_density: f64,
207    /// Mean pressure across all particles.
208    pub mean_pressure: f64,
209    /// Maximum relative density deviation from rest density.
210    pub compression_error: f64,
211}
212
213// ── Kernel functions ─────────────────────────────────────────────────────────
214
215/// Poly6 SPH kernel: W_poly6(r, h).
216///
217/// Returns `315/(64·π·h⁹) · (h²−r²)³` for `r < h`, else `0`.
218pub fn kernel_poly6(r: f64, h: f64) -> f64 {
219    if h <= 0.0 || r >= h {
220        return 0.0;
221    }
222    let h2 = h * h;
223    let diff = h2 - r * r;
224    315.0 / (64.0 * PI * h.powi(9)) * diff.powi(3)
225}
226
227/// Gradient of the Spiky kernel: ∇W_spiky(r_vec, r, h).
228///
229/// Returns `−45/(π·h⁶) · (h−r)² · r̂` for `r < h` and `r > 0`, else zero vector.
230pub fn kernel_spiky_grad(r_vec: [f64; 3], r: f64, h: f64) -> [f64; 3] {
231    if h <= 0.0 || r >= h || r < 1e-15 {
232        return [0.0; 3];
233    }
234    let coeff = -45.0 / (PI * h.powi(6)) * (h - r).powi(2) / r;
235    [coeff * r_vec[0], coeff * r_vec[1], coeff * r_vec[2]]
236}
237
238/// Viscosity kernel Laplacian: ∇²W_visc(r, h).
239///
240/// Returns `45/(π·h⁶) · (h−r)` for `r < h`, else `0`.
241pub fn kernel_viscosity_laplacian(r: f64, h: f64) -> f64 {
242    if h <= 0.0 || r >= h {
243        return 0.0;
244    }
245    45.0 / (PI * h.powi(6)) * (h - r)
246}
247
248// ── Standalone utilities ─────────────────────────────────────────────────────
249
250/// PCISPH pressure correction loop.
251///
252/// Iteratively updates pressure until the density error drops below `tol`
253/// or `max_iter` iterations are reached. Returns the number of iterations.
254pub fn pcisph_gpu_correction(
255    solver: &mut GpuSphPressureSolver,
256    max_iter: usize,
257    tol: f64,
258) -> usize {
259    for iter in 0..max_iter {
260        solver.gpu_compute_density();
261        solver.gpu_compute_pressure();
262        if solver.density_error() < tol {
263            return iter + 1;
264        }
265    }
266    max_iter
267}
268
269/// WCSPH Tait equation of state.
270///
271/// Returns `ρ₀·cs²/γ · ((ρ/ρ₀)^γ − 1)`.
272pub fn wcsph_tait_eos(rho: f64, rho0: f64, cs: f64, gamma: f64) -> f64 {
273    if rho0 <= 0.0 || gamma <= 0.0 {
274        return 0.0;
275    }
276    rho0 * cs * cs / gamma * ((rho / rho0).powf(gamma) - 1.0)
277}
278
279// ── Tests ────────────────────────────────────────────────────────────────────
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    // ── kernel tests ─────────────────────────────────────────────────────
286
287    #[test]
288    fn test_poly6_positive_within_h() {
289        let w = kernel_poly6(0.5, 1.0);
290        assert!(w > 0.0, "poly6 should be positive for r < h, got {w}");
291    }
292
293    #[test]
294    fn test_poly6_zero_at_h() {
295        let w = kernel_poly6(1.0, 1.0);
296        assert_eq!(w, 0.0, "poly6 should be zero at r == h");
297    }
298
299    #[test]
300    fn test_poly6_zero_beyond_h() {
301        let w = kernel_poly6(1.5, 1.0);
302        assert_eq!(w, 0.0, "poly6 should be zero for r > h");
303    }
304
305    #[test]
306    fn test_poly6_at_origin_positive() {
307        let w = kernel_poly6(0.0, 1.0);
308        assert!(w > 0.0, "poly6 at r=0 should be positive");
309    }
310
311    #[test]
312    fn test_poly6_decreasing() {
313        let w0 = kernel_poly6(0.0, 1.0);
314        let w1 = kernel_poly6(0.4, 1.0);
315        let w2 = kernel_poly6(0.8, 1.0);
316        assert!(w0 > w1 && w1 > w2);
317    }
318
319    #[test]
320    fn test_poly6_zero_h() {
321        assert_eq!(kernel_poly6(0.0, 0.0), 0.0);
322    }
323
324    #[test]
325    fn test_spiky_grad_zero_outside_h() {
326        let g = kernel_spiky_grad([1.0, 0.0, 0.0], 1.0, 0.5);
327        assert_eq!(g, [0.0; 3]);
328    }
329
330    #[test]
331    fn test_spiky_grad_nonzero_within_h() {
332        let r_vec = [0.3, 0.0, 0.0];
333        let r = 0.3_f64;
334        let g = kernel_spiky_grad(r_vec, r, 1.0);
335        let mag = (g[0] * g[0] + g[1] * g[1] + g[2] * g[2]).sqrt();
336        assert!(mag > 0.0, "spiky grad should be nonzero within h");
337    }
338
339    #[test]
340    fn test_spiky_grad_points_radially() {
341        // For r_vec along x axis, gradient should be along x only
342        let r_vec = [0.4, 0.0, 0.0];
343        let g = kernel_spiky_grad(r_vec, 0.4, 1.0);
344        assert!(g[1].abs() < 1e-15 && g[2].abs() < 1e-15);
345    }
346
347    #[test]
348    fn test_viscosity_laplacian_positive_within_h() {
349        let lap = kernel_viscosity_laplacian(0.5, 1.0);
350        assert!(lap > 0.0);
351    }
352
353    #[test]
354    fn test_viscosity_laplacian_zero_at_h() {
355        let lap = kernel_viscosity_laplacian(1.0, 1.0);
356        assert_eq!(lap, 0.0);
357    }
358
359    #[test]
360    fn test_viscosity_laplacian_zero_beyond_h() {
361        let lap = kernel_viscosity_laplacian(1.5, 1.0);
362        assert_eq!(lap, 0.0);
363    }
364
365    // ── wcsph_tait_eos tests ──────────────────────────────────────────────
366
367    #[test]
368    fn test_wcsph_zero_at_rest_density() {
369        let p = wcsph_tait_eos(1000.0, 1000.0, 100.0, 7.0);
370        assert!(
371            p.abs() < 1e-6,
372            "WCSPH pressure at rest density should be zero, got {p}"
373        );
374    }
375
376    #[test]
377    fn test_wcsph_positive_above_rest() {
378        let p = wcsph_tait_eos(1100.0, 1000.0, 100.0, 7.0);
379        assert!(p > 0.0, "WCSPH pressure should be positive for rho > rho0");
380    }
381
382    #[test]
383    fn test_wcsph_negative_below_rest() {
384        let p = wcsph_tait_eos(900.0, 1000.0, 100.0, 7.0);
385        assert!(p < 0.0, "WCSPH pressure should be negative for rho < rho0");
386    }
387
388    #[test]
389    fn test_wcsph_zero_rho0() {
390        assert_eq!(wcsph_tait_eos(1000.0, 0.0, 100.0, 7.0), 0.0);
391    }
392
393    // ── solver constructor tests ──────────────────────────────────────────
394
395    #[test]
396    fn test_solver_new_particle_count() {
397        let s = GpuSphPressureSolver::new(5, 1.0, 1000.0, 1.0);
398        assert_eq!(s.particle_count(), 5);
399    }
400
401    #[test]
402    fn test_solver_new_initial_pressure_zero() {
403        let s = GpuSphPressureSolver::new(3, 1.0, 1000.0, 1.0);
404        assert!(s.pressure.iter().all(|&p| p == 0.0));
405    }
406
407    #[test]
408    fn test_solver_total_mass() {
409        let mut s = GpuSphPressureSolver::new(3, 1.0, 1000.0, 1.0);
410        s.masses = vec![1.0, 2.0, 3.0];
411        assert!((s.total_mass() - 6.0).abs() < 1e-12);
412    }
413
414    #[test]
415    fn test_solver_set_position() {
416        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 1.0);
417        s.set_position(0, [1.0, 2.0, 3.0]);
418        assert_eq!(s.positions[0], [1.0, 2.0, 3.0]);
419    }
420
421    #[test]
422    fn test_solver_set_mass() {
423        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 1.0);
424        s.set_mass(1, 5.0);
425        assert!((s.masses[1] - 5.0).abs() < 1e-12);
426    }
427
428    // ── density / pressure compute tests ─────────────────────────────────
429
430    #[test]
431    fn test_gpu_compute_density_positive() {
432        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 1.0);
433        s.set_position(0, [0.0, 0.0, 0.0]);
434        s.set_position(1, [0.3, 0.0, 0.0]);
435        s.gpu_compute_density();
436        assert!(s.density[0] > 0.0 && s.density[1] > 0.0);
437    }
438
439    #[test]
440    fn test_gpu_compute_density_self_contribution() {
441        // A single particle should self-contribute via W_poly6(0, h) > 0
442        let mut s = GpuSphPressureSolver::new(1, 1.0, 1000.0, 1.0);
443        s.gpu_compute_density();
444        assert!(s.density[0] > 0.0);
445    }
446
447    #[test]
448    fn test_gpu_compute_pressure_nonneg_above_rho0() {
449        let mut s = GpuSphPressureSolver::new(1, 1.0, 1000.0, 200.0);
450        s.density[0] = 1100.0; // above rest
451        s.gpu_compute_pressure();
452        assert!(s.pressure[0] > 0.0);
453    }
454
455    #[test]
456    fn test_gpu_compute_pressure_zero_at_rest() {
457        let mut s = GpuSphPressureSolver::new(1, 1.0, 1000.0, 200.0);
458        s.density[0] = 1000.0; // exactly at rest
459        s.gpu_compute_pressure();
460        assert!(s.pressure[0].abs() < 1e-10);
461    }
462
463    // ── stats tests ───────────────────────────────────────────────────────
464
465    #[test]
466    fn test_stats_max_density_ge_min() {
467        let mut s = GpuSphPressureSolver::new(3, 1.0, 1000.0, 1.0);
468        s.density = vec![900.0, 1000.0, 1100.0];
469        let stats = s.compute_stats();
470        assert!(stats.max_density >= stats.min_density);
471    }
472
473    #[test]
474    fn test_stats_mean_pressure() {
475        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 1.0);
476        s.pressure = vec![10.0, 20.0];
477        let stats = s.compute_stats();
478        assert!((stats.mean_pressure - 15.0).abs() < 1e-10);
479    }
480
481    #[test]
482    fn test_density_error_zero_at_rest() {
483        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 1.0);
484        s.density = vec![1000.0, 1000.0];
485        assert!(s.density_error().abs() < 1e-12);
486    }
487
488    // ── pcisph correction tests ───────────────────────────────────────────
489
490    #[test]
491    fn test_pcisph_returns_iterations_le_max() {
492        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 1.0);
493        s.set_position(0, [0.0, 0.0, 0.0]);
494        s.set_position(1, [0.3, 0.0, 0.0]);
495        let iters = pcisph_gpu_correction(&mut s, 10, 1e-3);
496        assert!(iters <= 10);
497    }
498
499    #[test]
500    fn test_pcisph_updates_density() {
501        let mut s = GpuSphPressureSolver::new(1, 1.0, 1000.0, 1.0);
502        pcisph_gpu_correction(&mut s, 1, 1.0);
503        assert!(s.density[0] > 0.0);
504    }
505
506    // ── pressure / viscosity force tests ─────────────────────────────────
507
508    #[test]
509    fn test_pressure_force_finite() {
510        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 200.0);
511        s.set_position(0, [0.0, 0.0, 0.0]);
512        s.set_position(1, [0.3, 0.0, 0.0]);
513        s.gpu_compute_density();
514        s.gpu_compute_pressure();
515        let f = s.gpu_pressure_force(0);
516        assert!(f.iter().all(|v| v.is_finite()));
517    }
518
519    #[test]
520    fn test_viscosity_force_finite() {
521        let mut s = GpuSphPressureSolver::new(2, 1.0, 1000.0, 200.0);
522        s.set_position(0, [0.0, 0.0, 0.0]);
523        s.set_position(1, [0.3, 0.0, 0.0]);
524        s.gpu_compute_density();
525        let vels = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
526        let f = s.gpu_viscosity_force(0, &vels, 0.001);
527        assert!(f.iter().all(|v| v.is_finite()));
528    }
529
530    #[test]
531    fn test_total_mass_empty() {
532        let s = GpuSphPressureSolver::new(0, 1.0, 1000.0, 1.0);
533        assert_eq!(s.total_mass(), 0.0);
534    }
535
536    #[test]
537    fn test_solver_clone() {
538        let s = GpuSphPressureSolver::new(3, 1.0, 1000.0, 1.0);
539        let s2 = s.clone();
540        assert_eq!(s2.particle_count(), 3);
541    }
542
543    #[test]
544    fn test_stats_compression_error_nonzero() {
545        let mut s = GpuSphPressureSolver::new(1, 1.0, 1000.0, 1.0);
546        s.density[0] = 1100.0;
547        let stats = s.compute_stats();
548        assert!(stats.compression_error > 0.0);
549    }
550}