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