Skip to main content

oxiphysics_gpu/kernels/
sph.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! SPH (Smoothed Particle Hydrodynamics) compute kernels.
5
6#![allow(dead_code, missing_docs)]
7
8use crate::compute::ComputeKernel;
9use std::f64::consts::PI;
10
11// ─────────────────────────────────────────────────────────────────────────────
12// High-level SPH kernel API
13// ─────────────────────────────────────────────────────────────────────────────
14
15/// Selects the smoothing kernel function to use for SPH computations.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SphKernel {
18    /// Cubic B-spline kernel (C2 continuity, widely used in SPH).
19    CubicSpline,
20    /// Wendland C2 kernel (positive definite, no negative lobes).
21    Wendland,
22    /// Poly6 kernel (efficient for density, but poor gradient).
23    Poly6,
24    /// Spiky kernel (non-zero gradient at origin, good for pressure).
25    Spiky,
26}
27
28/// Pre-computed parameters for a smoothing kernel with smoothing length `h`.
29#[derive(Debug, Clone, Copy)]
30pub struct SphKernelParams {
31    /// Smoothing length.
32    pub h: f64,
33    /// 1 / h.
34    pub d_inv: f64,
35    /// 1 / h^3.
36    pub d3_inv: f64,
37}
38
39impl SphKernelParams {
40    /// Construct `SphKernelParams` from smoothing length `h`.
41    pub fn new(h: f64) -> Self {
42        Self {
43            h,
44            d_inv: 1.0 / h,
45            d3_inv: 1.0 / (h * h * h),
46        }
47    }
48}
49
50/// Evaluate the smoothing kernel W(r, h) for a given kernel type.
51///
52/// Returns 0 when `r >= h` (compact support).
53pub fn kernel_value(r: f64, params: &SphKernelParams, kernel: SphKernel) -> f64 {
54    let q = r * params.d_inv; // dimensionless distance
55    match kernel {
56        SphKernel::CubicSpline => {
57            // 3-D cubic spline, alpha = 8/(pi h^3)
58            let alpha = 8.0 / (PI * params.h.powi(3));
59            if q >= 1.0 {
60                0.0
61            } else if q >= 0.5 {
62                let t = 1.0 - q;
63                alpha * 2.0 * t.powi(3)
64            } else {
65                alpha * (6.0 * q.powi(3) - 6.0 * q * q + 1.0)
66            }
67        }
68        SphKernel::Wendland => {
69            // Wendland C2 in 3-D, alpha = 21/(2 pi h^3)
70            let alpha = 21.0 / (2.0 * PI * params.h.powi(3));
71            if q >= 1.0 {
72                0.0
73            } else {
74                let t = 1.0 - q;
75                alpha * t.powi(4) * (4.0 * q + 1.0)
76            }
77        }
78        SphKernel::Poly6 => {
79            let h2 = params.h * params.h;
80            let r2 = r * r;
81            if r2 >= h2 {
82                return 0.0;
83            }
84            let coeff = 315.0 / (64.0 * PI * params.h.powi(9));
85            coeff * (h2 - r2).powi(3)
86        }
87        SphKernel::Spiky => {
88            if q >= 1.0 {
89                return 0.0;
90            }
91            let coeff = 15.0 / (PI * params.h.powi(6));
92            coeff * (params.h - r).powi(3)
93        }
94    }
95}
96
97/// Evaluate the gradient nabla W(r_vec, h) of the smoothing kernel.
98///
99/// `r_vec` is the vector from particle j to particle i (r_i - r_j).
100/// `r` is its magnitude.  Returns the zero vector when `r < 1e-12` or `r >= h`.
101pub fn kernel_gradient(
102    r_vec: [f64; 3],
103    r: f64,
104    params: &SphKernelParams,
105    kernel: SphKernel,
106) -> [f64; 3] {
107    if r < 1e-12 || r * params.d_inv >= 1.0 {
108        return [0.0; 3];
109    }
110    let dw_dr = kernel_gradient_mag(r, params, kernel);
111    let scale = dw_dr / r;
112    [r_vec[0] * scale, r_vec[1] * scale, r_vec[2] * scale]
113}
114
115/// Scalar dW/dr (negative for most kernels beyond their core).
116fn kernel_gradient_mag(r: f64, params: &SphKernelParams, kernel: SphKernel) -> f64 {
117    let q = r * params.d_inv;
118    match kernel {
119        SphKernel::CubicSpline => {
120            let alpha = 8.0 / (PI * params.h.powi(3));
121            if q >= 1.0 {
122                0.0
123            } else if q >= 0.5 {
124                let t = 1.0 - q;
125                alpha * (-6.0 * t * t) * params.d_inv
126            } else {
127                alpha * (18.0 * q * q - 12.0 * q) * params.d_inv
128            }
129        }
130        SphKernel::Wendland => {
131            let alpha = 21.0 / (2.0 * PI * params.h.powi(3));
132            if q >= 1.0 {
133                0.0
134            } else {
135                let t = 1.0 - q;
136                // d/dr [(1-q)^4 (4q+1)] * alpha
137                // = alpha * d_inv * [-4(1-q)^3(4q+1) + (1-q)^4 * 4]
138                alpha * params.d_inv * t.powi(3) * (-4.0 * (4.0 * q + 1.0) + 4.0 * t)
139            }
140        }
141        SphKernel::Poly6 => {
142            let h2 = params.h * params.h;
143            let r2 = r * r;
144            if r2 >= h2 {
145                return 0.0;
146            }
147            let coeff = 315.0 / (64.0 * PI * params.h.powi(9));
148            // d/dr [(h^2-r^2)^3] = -6r(h^2-r^2)^2
149            coeff * (-6.0 * r) * (h2 - r2).powi(2)
150        }
151        SphKernel::Spiky => {
152            if q >= 1.0 {
153                return 0.0;
154            }
155            let coeff = 15.0 / (PI * params.h.powi(6));
156            // d/dr [(h-r)^3] = -3(h-r)^2
157            coeff * (-3.0) * (params.h - r).powi(2)
158        }
159    }
160}
161
162/// Compute per-particle densities using SPH summation.
163///
164/// `rho_i = sum_j m_j * W(|r_i - r_j|, h)`
165pub fn density_summation(positions: &[[f64; 3]], masses: &[f64], h: f64) -> Vec<f64> {
166    let params = SphKernelParams::new(h);
167    let n = positions.len();
168    let mut densities = vec![0.0f64; n];
169    for i in 0..n {
170        let mut rho = 0.0;
171        for j in 0..n {
172            let dx = positions[i][0] - positions[j][0];
173            let dy = positions[i][1] - positions[j][1];
174            let dz = positions[i][2] - positions[j][2];
175            let r = (dx * dx + dy * dy + dz * dz).sqrt();
176            rho += masses[j] * kernel_value(r, &params, SphKernel::CubicSpline);
177        }
178        densities[i] = rho;
179    }
180    densities
181}
182
183/// Compute per-particle densities with a specified kernel type.
184pub fn density_summation_kernel(
185    positions: &[[f64; 3]],
186    masses: &[f64],
187    h: f64,
188    kernel: SphKernel,
189) -> Vec<f64> {
190    let params = SphKernelParams::new(h);
191    let n = positions.len();
192    let mut densities = vec![0.0f64; n];
193    for i in 0..n {
194        let mut rho = 0.0;
195        for j in 0..n {
196            let dx = positions[i][0] - positions[j][0];
197            let dy = positions[i][1] - positions[j][1];
198            let dz = positions[i][2] - positions[j][2];
199            let r = (dx * dx + dy * dy + dz * dz).sqrt();
200            rho += masses[j] * kernel_value(r, &params, kernel);
201        }
202        densities[i] = rho;
203    }
204    densities
205}
206
207/// Compute pressure forces using the SPH symmetric pressure gradient formulation.
208///
209/// `F_i^pressure = -sum_j m_j (p_i/rho_i^2 + p_j/rho_j^2) nabla W(r_ij, h)`
210///
211/// Returns a `Vec<[f64;3]>` of forces, one per particle.
212pub fn pressure_force(
213    positions: &[[f64; 3]],
214    _velocities: &[[f64; 3]],
215    densities: &[f64],
216    pressures: &[f64],
217    masses: &[f64],
218    h: f64,
219) -> Vec<[f64; 3]> {
220    let params = SphKernelParams::new(h);
221    let n = positions.len();
222    let mut forces = vec![[0.0f64; 3]; n];
223    for i in 0..n {
224        let mut fx = 0.0f64;
225        let mut fy = 0.0f64;
226        let mut fz = 0.0f64;
227        let pi_over_rhoi2 = if densities[i].abs() > 1e-30 {
228            pressures[i] / (densities[i] * densities[i])
229        } else {
230            0.0
231        };
232        for j in 0..n {
233            if i == j {
234                continue;
235            }
236            let r_vec = [
237                positions[i][0] - positions[j][0],
238                positions[i][1] - positions[j][1],
239                positions[i][2] - positions[j][2],
240            ];
241            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
242            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
243            let pj_over_rhoj2 = if densities[j].abs() > 1e-30 {
244                pressures[j] / (densities[j] * densities[j])
245            } else {
246                0.0
247            };
248            let coeff = -masses[j] * (pi_over_rhoi2 + pj_over_rhoj2);
249            fx += coeff * grad[0];
250            fy += coeff * grad[1];
251            fz += coeff * grad[2];
252        }
253        forces[i] = [fx, fy, fz];
254    }
255    forces
256}
257
258/// Compute viscosity forces using the SPH viscosity formulation.
259///
260/// `F_i^visc = mu * sum_j m_j (v_j - v_i) / rho_j * laplacian_W(r_ij, h)`
261pub fn viscosity_force(
262    positions: &[[f64; 3]],
263    velocities: &[[f64; 3]],
264    densities: &[f64],
265    masses: &[f64],
266    h: f64,
267    mu: f64,
268) -> Vec<[f64; 3]> {
269    let n = positions.len();
270    let mut forces = vec![[0.0f64; 3]; n];
271    for i in 0..n {
272        let mut fx = 0.0f64;
273        let mut fy = 0.0f64;
274        let mut fz = 0.0f64;
275        for j in 0..n {
276            if i == j {
277                continue;
278            }
279            let dx = positions[i][0] - positions[j][0];
280            let dy = positions[i][1] - positions[j][1];
281            let dz = positions[i][2] - positions[j][2];
282            let r = (dx * dx + dy * dy + dz * dz).sqrt();
283            if r >= h || r < 1e-12 {
284                continue;
285            }
286            let lap = viscosity_laplacian(r, h);
287            let rho_j = if densities[j].abs() > 1e-30 {
288                densities[j]
289            } else {
290                1.0
291            };
292            fx += mu * masses[j] * (velocities[j][0] - velocities[i][0]) / rho_j * lap;
293            fy += mu * masses[j] * (velocities[j][1] - velocities[i][1]) / rho_j * lap;
294            fz += mu * masses[j] * (velocities[j][2] - velocities[i][2]) / rho_j * lap;
295        }
296        forces[i] = [fx, fy, fz];
297    }
298    forces
299}
300
301// ─────────────────────────────────────────────────────────────────────────────
302// Neighbor list
303// ─────────────────────────────────────────────────────────────────────────────
304
305/// A simple grid-based neighbor list for SPH.
306///
307/// Divides the domain into cells of size `h` and assigns each particle to a cell.
308/// Finding neighbors then only requires checking the local cell and its 26 neighbors.
309pub struct NeighborList {
310    /// Cell size (= smoothing length h).
311    cell_size: f64,
312    /// Number of cells in x, y, z.
313    grid_dims: [usize; 3],
314    /// Domain origin.
315    origin: [f64; 3],
316    /// Cell -> list of particle indices.
317    cells: Vec<Vec<usize>>,
318}
319
320impl NeighborList {
321    /// Create a new neighbor list for the given domain.
322    pub fn new(origin: [f64; 3], domain_size: [f64; 3], cell_size: f64) -> Self {
323        let nx = (domain_size[0] / cell_size).ceil() as usize;
324        let ny = (domain_size[1] / cell_size).ceil() as usize;
325        let nz = (domain_size[2] / cell_size).ceil() as usize;
326        let total = nx.max(1) * ny.max(1) * nz.max(1);
327        Self {
328            cell_size,
329            grid_dims: [nx.max(1), ny.max(1), nz.max(1)],
330            origin,
331            cells: vec![Vec::new(); total],
332        }
333    }
334
335    /// Clear all cells and re-assign particles.
336    pub fn build(&mut self, positions: &[[f64; 3]]) {
337        for cell in &mut self.cells {
338            cell.clear();
339        }
340        for (idx, pos) in positions.iter().enumerate() {
341            let ci = self.cell_index(pos);
342            self.cells[ci].push(idx);
343        }
344    }
345
346    /// Get the cell index for a position.
347    fn cell_index(&self, pos: &[f64; 3]) -> usize {
348        let ix = ((pos[0] - self.origin[0]) / self.cell_size).floor() as usize;
349        let iy = ((pos[1] - self.origin[1]) / self.cell_size).floor() as usize;
350        let iz = ((pos[2] - self.origin[2]) / self.cell_size).floor() as usize;
351        let ix = ix.min(self.grid_dims[0] - 1);
352        let iy = iy.min(self.grid_dims[1] - 1);
353        let iz = iz.min(self.grid_dims[2] - 1);
354        iz * self.grid_dims[1] * self.grid_dims[0] + iy * self.grid_dims[0] + ix
355    }
356
357    /// Return the neighbor particle indices for a given particle.
358    /// Checks the particle's cell and all 26 neighboring cells.
359    pub fn neighbors(&self, pos: &[f64; 3]) -> Vec<usize> {
360        let ix = ((pos[0] - self.origin[0]) / self.cell_size).floor() as i64;
361        let iy = ((pos[1] - self.origin[1]) / self.cell_size).floor() as i64;
362        let iz = ((pos[2] - self.origin[2]) / self.cell_size).floor() as i64;
363
364        let mut result = Vec::new();
365        let dims = self.grid_dims;
366
367        for dz in -1i64..=1 {
368            for dy in -1i64..=1 {
369                for dx in -1i64..=1 {
370                    let cx = ix + dx;
371                    let cy = iy + dy;
372                    let cz = iz + dz;
373                    if cx < 0 || cy < 0 || cz < 0 {
374                        continue;
375                    }
376                    let cx = cx as usize;
377                    let cy = cy as usize;
378                    let cz = cz as usize;
379                    if cx >= dims[0] || cy >= dims[1] || cz >= dims[2] {
380                        continue;
381                    }
382                    let ci = cz * dims[1] * dims[0] + cy * dims[0] + cx;
383                    result.extend_from_slice(&self.cells[ci]);
384                }
385            }
386        }
387        result
388    }
389
390    /// Total number of cells in the grid.
391    pub fn num_cells(&self) -> usize {
392        self.grid_dims[0] * self.grid_dims[1] * self.grid_dims[2]
393    }
394
395    /// Grid dimensions \[nx, ny, nz\].
396    pub fn grid_dims(&self) -> [usize; 3] {
397        self.grid_dims
398    }
399}
400
401// ─────────────────────────────────────────────────────────────────────────────
402// Kernel dispatch configuration
403// ─────────────────────────────────────────────────────────────────────────────
404
405/// Configuration for dispatching SPH compute kernels.
406pub struct SphDispatchConfig {
407    /// Number of particles.
408    pub n_particles: usize,
409    /// Smoothing length.
410    pub h: f64,
411    /// Viscosity coefficient.
412    pub mu: f64,
413    /// Equation of state stiffness (for pressure from density).
414    pub k_eos: f64,
415    /// Rest density.
416    pub rho0: f64,
417    /// Workgroup size for GPU dispatch.
418    pub workgroup_size: u32,
419}
420
421impl SphDispatchConfig {
422    /// Create a new dispatch config with default viscosity and EOS parameters.
423    pub fn new(n_particles: usize, h: f64) -> Self {
424        Self {
425            n_particles,
426            h,
427            mu: 0.1,
428            k_eos: 1000.0,
429            rho0: 1000.0,
430            workgroup_size: 64,
431        }
432    }
433
434    /// Compute pressure from density using a simple EOS: p = k * (rho - rho0).
435    pub fn pressure_from_density(&self, rho: f64) -> f64 {
436        self.k_eos * (rho - self.rho0).max(0.0)
437    }
438
439    /// Number of workgroups needed.
440    pub fn num_workgroups(&self) -> u32 {
441        (self.n_particles as u32).div_ceil(self.workgroup_size)
442    }
443}
444
445// ─────────────────────────────────────────────────────────────────────────────
446// SPH buffer layout descriptor
447// ─────────────────────────────────────────────────────────────────────────────
448
449/// Describes the buffer layout for SPH simulation data.
450pub struct SphBufferLayout {
451    /// Number of particles.
452    pub n_particles: usize,
453    /// Size of position buffer (3 * n_particles).
454    pub position_size: usize,
455    /// Size of velocity buffer (3 * n_particles).
456    pub velocity_size: usize,
457    /// Size of mass buffer (n_particles).
458    pub mass_size: usize,
459    /// Size of density buffer (n_particles).
460    pub density_size: usize,
461    /// Size of pressure buffer (n_particles).
462    pub pressure_size: usize,
463    /// Size of force buffer (3 * n_particles).
464    pub force_size: usize,
465}
466
467impl SphBufferLayout {
468    /// Create a buffer layout for the given number of particles.
469    pub fn new(n_particles: usize) -> Self {
470        Self {
471            n_particles,
472            position_size: 3 * n_particles,
473            velocity_size: 3 * n_particles,
474            mass_size: n_particles,
475            density_size: n_particles,
476            pressure_size: n_particles,
477            force_size: 3 * n_particles,
478        }
479    }
480
481    /// Total number of f64 elements needed across all buffers.
482    pub fn total_elements(&self) -> usize {
483        self.position_size
484            + self.velocity_size
485            + self.mass_size
486            + self.density_size
487            + self.pressure_size
488            + self.force_size
489    }
490
491    /// Total memory in bytes (f64 = 8 bytes).
492    pub fn total_bytes(&self) -> usize {
493        self.total_elements() * 8
494    }
495}
496
497/// Kernel that computes SPH particle densities.
498///
499/// **Inputs:**
500///   - `inputs[0]`: positions, flat `[x, y, z, ...]` (3 * n values)
501///   - `inputs[1]`: masses, `[m0, m1, ...]` (n values)
502///   - `inputs[2]`: `[smoothing_length]` (single value)
503///
504/// **Outputs:**
505///   - `outputs[0]`: densities, one per particle
506pub struct SphDensityKernel;
507
508/// Poly6 smoothing kernel value.
509#[inline]
510fn poly6(r2: f64, h: f64) -> f64 {
511    let h2 = h * h;
512    if r2 >= h2 {
513        return 0.0;
514    }
515    let coeff = 315.0 / (64.0 * PI * h.powi(9));
516    coeff * (h2 - r2).powi(3)
517}
518
519/// Spiky kernel gradient magnitude (scalar, multiply by direction).
520#[inline]
521fn spiky_grad(r: f64, h: f64) -> f64 {
522    if r >= h || r < 1e-12 {
523        return 0.0;
524    }
525    let coeff = -45.0 / (PI * h.powi(6));
526    coeff * (h - r).powi(2)
527}
528
529/// Viscosity Laplacian kernel.
530#[inline]
531fn viscosity_laplacian(r: f64, h: f64) -> f64 {
532    if r >= h || r < 1e-12 {
533        return 0.0;
534    }
535    45.0 / (PI * h.powi(6)) * (h - r)
536}
537
538#[allow(clippy::needless_range_loop)]
539impl ComputeKernel for SphDensityKernel {
540    fn name(&self) -> &str {
541        "SphDensityKernel"
542    }
543
544    fn execute(&self, inputs: &[&[f64]], outputs: &mut [Vec<f64>], work_size: usize) {
545        if inputs.len() < 3 || outputs.is_empty() {
546            return;
547        }
548        let positions = inputs[0];
549        let masses = inputs[1];
550        let h = inputs[2][0];
551        let n = work_size;
552
553        let mut densities = vec![0.0; n];
554        for i in 0..n {
555            let xi = [positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]];
556            let mut rho = 0.0;
557            for j in 0..n {
558                let xj = [positions[j * 3], positions[j * 3 + 1], positions[j * 3 + 2]];
559                let dx = xi[0] - xj[0];
560                let dy = xi[1] - xj[1];
561                let dz = xi[2] - xj[2];
562                let r2 = dx * dx + dy * dy + dz * dz;
563                rho += masses[j] * poly6(r2, h);
564            }
565            densities[i] = rho;
566        }
567        outputs[0] = densities;
568    }
569}
570
571/// Kernel that computes SPH pressure and viscosity forces.
572///
573/// **Inputs:**
574///   - `inputs[0]`: positions `[x, y, z, ...]` (3n)
575///   - `inputs[1]`: velocities `[vx, vy, vz, ...]` (3n)
576///   - `inputs[2]`: densities `[rho0, rho1, ...]` (n)
577///   - `inputs[3]`: pressures `[p0, p1, ...]` (n)
578///   - `inputs[4]`: masses `[m0, m1, ...]` (n)
579///   - `inputs[5]`: `[smoothing_length, viscosity_coeff]` (2 values)
580///
581/// **Outputs:**
582///   - `outputs[0]`: forces `[fx, fy, fz, ...]` (3n)
583pub struct SphForceKernel;
584
585#[allow(clippy::needless_range_loop)]
586impl ComputeKernel for SphForceKernel {
587    fn name(&self) -> &str {
588        "SphForceKernel"
589    }
590
591    fn execute(&self, inputs: &[&[f64]], outputs: &mut [Vec<f64>], work_size: usize) {
592        if inputs.len() < 6 || outputs.is_empty() {
593            return;
594        }
595        let pos = inputs[0];
596        let vel = inputs[1];
597        let density = inputs[2];
598        let pressure = inputs[3];
599        let mass = inputs[4];
600        let h = inputs[5][0];
601        let mu = inputs[5][1]; // viscosity coefficient
602        let n = work_size;
603
604        let mut forces = vec![0.0; n * 3];
605        for i in 0..n {
606            let xi = [pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2]];
607            let vi = [vel[i * 3], vel[i * 3 + 1], vel[i * 3 + 2]];
608            let mut fx = 0.0;
609            let mut fy = 0.0;
610            let mut fz = 0.0;
611            for j in 0..n {
612                if i == j {
613                    continue;
614                }
615                let xj = [pos[j * 3], pos[j * 3 + 1], pos[j * 3 + 2]];
616                let vj = [vel[j * 3], vel[j * 3 + 1], vel[j * 3 + 2]];
617                let dx = xi[0] - xj[0];
618                let dy = xi[1] - xj[1];
619                let dz = xi[2] - xj[2];
620                let r = (dx * dx + dy * dy + dz * dz).sqrt();
621                if r < 1e-12 || r >= h {
622                    continue;
623                }
624                // Pressure force (Navier-Stokes)
625                let p_term =
626                    -mass[j] * (pressure[i] + pressure[j]) / (2.0 * density[j]) * spiky_grad(r, h);
627                fx += p_term * dx / r;
628                fy += p_term * dy / r;
629                fz += p_term * dz / r;
630                // Viscosity force
631                let v_lap = viscosity_laplacian(r, h);
632                fx += mu * mass[j] * (vj[0] - vi[0]) / density[j] * v_lap;
633                fy += mu * mass[j] * (vj[1] - vi[1]) / density[j] * v_lap;
634                fz += mu * mass[j] * (vj[2] - vi[2]) / density[j] * v_lap;
635            }
636            forces[i * 3] = fx;
637            forces[i * 3 + 1] = fy;
638            forces[i * 3 + 2] = fz;
639        }
640        outputs[0] = forces;
641    }
642}
643
644/// Kernel that constructs a neighbor list on the CPU.
645///
646/// **Inputs:**
647///   - `inputs[0]`: positions `[x, y, z, ...]` (3n)
648///   - `inputs[1]`: `[h, origin_x, origin_y, origin_z, domain_x, domain_y, domain_z]`
649///
650/// **Outputs:**
651///   - `outputs[0]`: cell indices (one per particle)
652pub struct SphNeighborListKernel;
653
654#[allow(clippy::needless_range_loop)]
655impl ComputeKernel for SphNeighborListKernel {
656    fn name(&self) -> &str {
657        "SphNeighborListKernel"
658    }
659
660    fn execute(&self, inputs: &[&[f64]], outputs: &mut [Vec<f64>], work_size: usize) {
661        if inputs.len() < 2 || outputs.is_empty() {
662            return;
663        }
664        let positions = inputs[0];
665        let params = inputs[1];
666        if params.len() < 7 {
667            return;
668        }
669        let h = params[0];
670        let origin = [params[1], params[2], params[3]];
671        let _domain = [params[4], params[5], params[6]];
672        let n = work_size;
673
674        // Compute cell index for each particle
675        let nx = (_domain[0] / h).ceil() as usize;
676        let ny = (_domain[1] / h).ceil() as usize;
677        let nx = nx.max(1);
678        let ny = ny.max(1);
679
680        let mut cell_indices = vec![0.0f64; n];
681        for i in 0..n {
682            let px = positions[i * 3] - origin[0];
683            let py = positions[i * 3 + 1] - origin[1];
684            let pz = positions[i * 3 + 2] - origin[2];
685            let ix = (px / h).floor() as usize;
686            let iy = (py / h).floor() as usize;
687            let iz = (pz / h).floor() as usize;
688            cell_indices[i] = (iz * ny * nx + iy * nx + ix) as f64;
689        }
690        outputs[0] = cell_indices;
691    }
692}
693
694// ─────────────────────────────────────────────────────────────────────────────
695// Surface tension kernel (color function gradient method)
696// ─────────────────────────────────────────────────────────────────────────────
697
698/// Compute surface tension forces using the color function gradient (CSF) method.
699///
700/// The color function gradient approximates the interface normal.
701/// The curvature κ is estimated from the divergence of the unit normal.
702/// The surface tension force on particle `i` is:
703///
704/// `F_i^surf = σ · m_i / ρ_i · ∇(c_i)`
705///
706/// where `∇c_i = sum_j m_j/ρ_j * (c_j - c_i) * ∇W(r_ij, h)`.
707///
708/// # Arguments
709/// * `positions`  - Particle positions.
710/// * `color_fn`   - Color function value per particle (1 = fluid, 0 = void).
711/// * `masses`     - Per-particle masses.
712/// * `densities`  - Per-particle densities.
713/// * `h`          - Smoothing length.
714/// * `sigma`      - Surface tension coefficient.
715#[allow(clippy::too_many_arguments)]
716pub fn surface_tension_force(
717    positions: &[[f64; 3]],
718    color_fn: &[f64],
719    masses: &[f64],
720    densities: &[f64],
721    h: f64,
722    sigma: f64,
723) -> Vec<[f64; 3]> {
724    let params = SphKernelParams::new(h);
725    let n = positions.len();
726    let mut forces = vec![[0.0f64; 3]; n];
727
728    // Step 1: compute color function gradient ∇c_i for each particle
729    let mut grad_c = vec![[0.0f64; 3]; n];
730    for i in 0..n {
731        let rho_i = if densities[i].abs() > 1e-30 {
732            densities[i]
733        } else {
734            1.0
735        };
736        let mut gx = 0.0f64;
737        let mut gy = 0.0f64;
738        let mut gz = 0.0f64;
739        for j in 0..n {
740            if i == j {
741                continue;
742            }
743            let r_vec = [
744                positions[i][0] - positions[j][0],
745                positions[i][1] - positions[j][1],
746                positions[i][2] - positions[j][2],
747            ];
748            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
749            let grad_w = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
750            let rho_j = if densities[j].abs() > 1e-30 {
751                densities[j]
752            } else {
753                1.0
754            };
755            let dc = masses[j] / rho_j * (color_fn[j] - color_fn[i]);
756            gx += dc * grad_w[0];
757            gy += dc * grad_w[1];
758            gz += dc * grad_w[2];
759        }
760        grad_c[i] = [gx, gy, gz];
761
762        // Step 2: surface tension force F_i = sigma * m_i / rho_i * grad_c_i
763        let prefactor = sigma * masses[i] / rho_i;
764        forces[i] = [prefactor * gx, prefactor * gy, prefactor * gz];
765    }
766    forces
767}
768
769// ─────────────────────────────────────────────────────────────────────────────
770// CFL time step reduction
771// ─────────────────────────────────────────────────────────────────────────────
772
773/// Compute the CFL-limited time step for a particle system.
774///
775/// The CFL condition restricts the time step to:
776/// `Δt = cfl_factor · h / max(|v_i| + c_sound)`
777///
778/// where the maximum is taken over all particles.
779///
780/// # Arguments
781/// * `velocities` - Per-particle velocity vectors.
782/// * `h`          - Smoothing length (characteristic length scale).
783/// * `c_sound`    - Speed of sound (used as minimum wave speed).
784/// * `cfl_factor` - CFL safety factor (typically 0.1 – 0.4).
785pub fn cfl_timestep(velocities: &[[f64; 3]], h: f64, c_sound: f64, cfl_factor: f64) -> f64 {
786    let max_signal = velocities
787        .iter()
788        .map(|v| {
789            let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
790            speed + c_sound
791        })
792        .fold(0.0f64, f64::max);
793
794    let denominator = if max_signal > 1e-30 {
795        max_signal
796    } else {
797        c_sound.max(1e-30)
798    };
799    cfl_factor * h / denominator
800}
801
802// ─────────────────────────────────────────────────────────────────────────────
803// Radix sort by density (for load balancing)
804// ─────────────────────────────────────────────────────────────────────────────
805
806/// Sort particle indices in ascending order of density using a reference sort.
807///
808/// This CPU reference implementation mirrors what a GPU radix sort would do
809/// for load-balancing purposes: denser regions can be binned together for
810/// more uniform workloads.
811///
812/// Returns a permutation `indices` such that `densities[indices[0\]] <= densities[indices[1\]] ...`.
813pub fn radix_sort_by_density(densities: &[f64]) -> Vec<usize> {
814    let mut indices: Vec<usize> = (0..densities.len()).collect();
815    indices.sort_by(|&a, &b| {
816        densities[a]
817            .partial_cmp(&densities[b])
818            .unwrap_or(std::cmp::Ordering::Equal)
819    });
820    indices
821}
822
823// ─────────────────────────────────────────────────────────────────────────────
824// SPH density accumulation kernel (GPU-style parallel)
825// ─────────────────────────────────────────────────────────────────────────────
826
827/// Accumulate density contributions from a neighbour list.
828///
829/// For each particle `i`, sums contributions from its neighbours `j`:
830/// `rho_i += m_j * W(|r_i - r_j|, h)`
831///
832/// Only pairs within distance `h` contribute.  The neighbour list is expected
833/// to already be built for the given positions.
834#[allow(dead_code)]
835pub fn density_accumulation(
836    positions: &[[f64; 3]],
837    masses: &[f64],
838    h: f64,
839    neighbor_list: &NeighborList,
840) -> Vec<f64> {
841    let params = SphKernelParams::new(h);
842    let n = positions.len();
843    let mut densities = vec![0.0f64; n];
844    for i in 0..n {
845        let neighbors = neighbor_list.neighbors(&positions[i]);
846        let mut rho = 0.0;
847        for j in neighbors {
848            let dx = positions[i][0] - positions[j][0];
849            let dy = positions[i][1] - positions[j][1];
850            let dz = positions[i][2] - positions[j][2];
851            let r = (dx * dx + dy * dy + dz * dz).sqrt();
852            rho += masses[j] * kernel_value(r, &params, SphKernel::CubicSpline);
853        }
854        densities[i] = rho;
855    }
856    densities
857}
858
859// ─────────────────────────────────────────────────────────────────────────────
860// SPH pressure force kernel
861// ─────────────────────────────────────────────────────────────────────────────
862
863/// Symmetric SPH pressure force kernel using neighbour lists.
864///
865/// `F_i^press = -m_i sum_j m_j (p_i/rho_i^2 + p_j/rho_j^2) nabla W(r_ij, h)`
866#[allow(dead_code)]
867pub fn pressure_force_kernel(
868    positions: &[[f64; 3]],
869    densities: &[f64],
870    pressures: &[f64],
871    masses: &[f64],
872    h: f64,
873    neighbor_list: &NeighborList,
874) -> Vec<[f64; 3]> {
875    let params = SphKernelParams::new(h);
876    let n = positions.len();
877    let mut forces = vec![[0.0f64; 3]; n];
878    for i in 0..n {
879        let pi_over_rho2 = if densities[i].abs() > 1e-30 {
880            pressures[i] / (densities[i] * densities[i])
881        } else {
882            0.0
883        };
884        let neighbors = neighbor_list.neighbors(&positions[i]);
885        let mut fx = 0.0;
886        let mut fy = 0.0;
887        let mut fz = 0.0;
888        for j in neighbors {
889            if i == j {
890                continue;
891            }
892            let r_vec = [
893                positions[i][0] - positions[j][0],
894                positions[i][1] - positions[j][1],
895                positions[i][2] - positions[j][2],
896            ];
897            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
898            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
899            let pj_over_rho2 = if densities[j].abs() > 1e-30 {
900                pressures[j] / (densities[j] * densities[j])
901            } else {
902                0.0
903            };
904            let coeff = -masses[j] * (pi_over_rho2 + pj_over_rho2);
905            fx += coeff * grad[0];
906            fy += coeff * grad[1];
907            fz += coeff * grad[2];
908        }
909        forces[i] = [fx, fy, fz];
910    }
911    forces
912}
913
914// ─────────────────────────────────────────────────────────────────────────────
915// SPH viscosity kernel
916// ─────────────────────────────────────────────────────────────────────────────
917
918/// Artificial viscosity for SPH (Monaghan 1992 formulation).
919///
920/// Adds dissipation to prevent particle interpenetration.
921/// `F_i^visc = -sum_j m_j PI_ij nabla W(r_ij, h)`
922///
923/// where `PI_ij = (-alpha * mu_ij * c_s + beta * mu_ij^2) / rho_ij`
924/// and `mu_ij = h * v_ij . r_ij / (|r_ij|^2 + 0.01 h^2)`.
925#[allow(dead_code)]
926#[allow(clippy::too_many_arguments)]
927pub fn artificial_viscosity_force(
928    positions: &[[f64; 3]],
929    velocities: &[[f64; 3]],
930    densities: &[f64],
931    masses: &[f64],
932    h: f64,
933    c_s: f64,
934    alpha: f64,
935    beta: f64,
936) -> Vec<[f64; 3]> {
937    let params = SphKernelParams::new(h);
938    let n = positions.len();
939    let mut forces = vec![[0.0f64; 3]; n];
940    for i in 0..n {
941        let mut fx = 0.0;
942        let mut fy = 0.0;
943        let mut fz = 0.0;
944        for j in 0..n {
945            if i == j {
946                continue;
947            }
948            let r_vec = [
949                positions[i][0] - positions[j][0],
950                positions[i][1] - positions[j][1],
951                positions[i][2] - positions[j][2],
952            ];
953            let v_vec = [
954                velocities[i][0] - velocities[j][0],
955                velocities[i][1] - velocities[j][1],
956                velocities[i][2] - velocities[j][2],
957            ];
958            let r2 = r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2];
959            let r = r2.sqrt();
960            if r >= h || r < 1e-12 {
961                continue;
962            }
963            let vr = v_vec[0] * r_vec[0] + v_vec[1] * r_vec[1] + v_vec[2] * r_vec[2];
964            if vr >= 0.0 {
965                continue;
966            } // only when approaching
967            let mu_ij = h * vr / (r2 + 0.01 * h * h);
968            let rho_ij = 0.5 * (densities[i] + densities[j]).max(1e-30);
969            let pi_ij = (-alpha * c_s * mu_ij + beta * mu_ij * mu_ij) / rho_ij;
970            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
971            let coeff = -masses[j] * pi_ij;
972            fx += coeff * grad[0];
973            fy += coeff * grad[1];
974            fz += coeff * grad[2];
975        }
976        forces[i] = [fx, fy, fz];
977    }
978    forces
979}
980
981// ─────────────────────────────────────────────────────────────────────────────
982// WCSPH (Weakly Compressible SPH) step kernel
983// ─────────────────────────────────────────────────────────────────────────────
984
985/// Equation of state for WCSPH: `p = B * ((rho/rho0)^gamma - 1)`.
986///
987/// Typical values: `gamma = 7`, `B = rho0 * c_s^2 / gamma`.
988#[allow(dead_code)]
989pub fn wcsph_pressure(rho: f64, rho0: f64, b: f64, gamma: f64) -> f64 {
990    b * ((rho / rho0).powf(gamma) - 1.0)
991}
992
993/// Apply one explicit WCSPH Euler step.
994///
995/// Updates positions and velocities using the computed forces.
996/// Returns (new_positions, new_velocities).
997#[allow(dead_code)]
998pub fn wcsph_euler_step(
999    positions: &[[f64; 3]],
1000    velocities: &[[f64; 3]],
1001    forces: &[[f64; 3]],
1002    masses: &[f64],
1003    dt: f64,
1004) -> (Vec<[f64; 3]>, Vec<[f64; 3]>) {
1005    let n = positions.len();
1006    let mut new_pos = positions.to_vec();
1007    let mut new_vel = velocities.to_vec();
1008    for i in 0..n {
1009        let m = masses[i].max(1e-30);
1010        let ax = forces[i][0] / m;
1011        let ay = forces[i][1] / m;
1012        let az = forces[i][2] / m;
1013        new_vel[i] = [
1014            velocities[i][0] + dt * ax,
1015            velocities[i][1] + dt * ay,
1016            velocities[i][2] + dt * az,
1017        ];
1018        new_pos[i] = [
1019            positions[i][0] + dt * new_vel[i][0],
1020            positions[i][1] + dt * new_vel[i][1],
1021            positions[i][2] + dt * new_vel[i][2],
1022        ];
1023    }
1024    (new_pos, new_vel)
1025}
1026
1027/// Apply one leap-frog WCSPH half-step (velocity update only).
1028///
1029/// `v_{n+1/2} = v_{n-1/2} + a_n * dt`
1030#[allow(dead_code)]
1031pub fn wcsph_leapfrog_velocity_half(
1032    velocities: &[[f64; 3]],
1033    forces: &[[f64; 3]],
1034    masses: &[f64],
1035    dt: f64,
1036) -> Vec<[f64; 3]> {
1037    let n = velocities.len();
1038    let mut new_vel = velocities.to_vec();
1039    for i in 0..n {
1040        let m = masses[i].max(1e-30);
1041        new_vel[i][0] += dt * forces[i][0] / m;
1042        new_vel[i][1] += dt * forces[i][1] / m;
1043        new_vel[i][2] += dt * forces[i][2] / m;
1044    }
1045    new_vel
1046}
1047
1048// ─────────────────────────────────────────────────────────────────────────────
1049// SPH surface normal kernel
1050// ─────────────────────────────────────────────────────────────────────────────
1051
1052/// Compute approximate surface normals using the color function gradient method.
1053///
1054/// `n_i = sum_j (m_j / rho_j) * nabla W(r_ij, h)`
1055///
1056/// The magnitude of `n_i` is proportional to the interface curvature.
1057#[allow(dead_code)]
1058pub fn surface_normal_kernel(
1059    positions: &[[f64; 3]],
1060    densities: &[f64],
1061    masses: &[f64],
1062    h: f64,
1063) -> Vec<[f64; 3]> {
1064    let params = SphKernelParams::new(h);
1065    let n = positions.len();
1066    let mut normals = vec![[0.0f64; 3]; n];
1067    for i in 0..n {
1068        let mut nx = 0.0;
1069        let mut ny = 0.0;
1070        let mut nz = 0.0;
1071        for j in 0..n {
1072            if i == j {
1073                continue;
1074            }
1075            let r_vec = [
1076                positions[i][0] - positions[j][0],
1077                positions[i][1] - positions[j][1],
1078                positions[i][2] - positions[j][2],
1079            ];
1080            let r = (r_vec[0] * r_vec[0] + r_vec[1] * r_vec[1] + r_vec[2] * r_vec[2]).sqrt();
1081            let rho_j = densities[j].max(1e-30);
1082            let grad = kernel_gradient(r_vec, r, &params, SphKernel::CubicSpline);
1083            let coeff = masses[j] / rho_j;
1084            nx += coeff * grad[0];
1085            ny += coeff * grad[1];
1086            nz += coeff * grad[2];
1087        }
1088        normals[i] = [nx, ny, nz];
1089    }
1090    normals
1091}
1092
1093/// Normalize a surface normal vector.  Returns zero vector if magnitude is too small.
1094#[allow(dead_code)]
1095pub fn normalize_normal(n: [f64; 3]) -> [f64; 3] {
1096    let mag = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1097    if mag < 1e-30 {
1098        [0.0; 3]
1099    } else {
1100        [n[0] / mag, n[1] / mag, n[2] / mag]
1101    }
1102}
1103
1104// ─────────────────────────────────────────────────────────────────────────────
1105// SPH neighbour list build (explicit grid-based)
1106// ─────────────────────────────────────────────────────────────────────────────
1107
1108/// Build an explicit neighbour list: for each particle, store all particle
1109/// indices within distance `h`.
1110///
1111/// Returns `Vec<Vec`usize`>` where `neighbours[i]` is the list of neighbour indices for particle `i`.
1112#[allow(dead_code)]
1113pub fn build_neighbor_list_explicit(positions: &[[f64; 3]], h: f64) -> Vec<Vec<usize>> {
1114    let n = positions.len();
1115    let mut neighbors = vec![Vec::new(); n];
1116    for i in 0..n {
1117        for j in 0..n {
1118            let dx = positions[i][0] - positions[j][0];
1119            let dy = positions[i][1] - positions[j][1];
1120            let dz = positions[i][2] - positions[j][2];
1121            if dx * dx + dy * dy + dz * dz < h * h {
1122                neighbors[i].push(j);
1123            }
1124        }
1125    }
1126    neighbors
1127}
1128
1129/// Compute average number of neighbours per particle.
1130#[allow(dead_code)]
1131pub fn mean_neighbor_count(neighbors: &[Vec<usize>]) -> f64 {
1132    if neighbors.is_empty() {
1133        return 0.0;
1134    }
1135    let total: usize = neighbors.iter().map(|v| v.len()).sum();
1136    total as f64 / neighbors.len() as f64
1137}
1138
1139// ─────────────────────────────────────────────────────────────────────────────
1140// SPH kernel normalization check
1141// ─────────────────────────────────────────────────────────────────────────────
1142
1143/// Numerically integrate the kernel over a sphere of radius `h` using Monte Carlo.
1144///
1145/// Useful for verifying kernel normalization (should integrate to ~1 in 3D).
1146#[allow(dead_code)]
1147pub fn integrate_kernel_sphere(h: f64, kernel: SphKernel, n_samples: usize) -> f64 {
1148    let params = SphKernelParams::new(h);
1149    // Uniform sampling in [0, h] with spherical volume element 4*pi*r^2
1150    let dr = h / n_samples as f64;
1151    let mut integral = 0.0;
1152    for k in 0..n_samples {
1153        let r = (k as f64 + 0.5) * dr;
1154        let w = kernel_value(r, &params, kernel);
1155        integral += w * 4.0 * PI * r * r * dr;
1156    }
1157    integral
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163
1164    #[test]
1165    fn test_sph_kernel_density_sum() {
1166        // Central particle at origin surrounded by 8 neighbors at corners of
1167        // a unit cube with side 0.4.  All 9 particles have unit mass.
1168        // The smoothing length h = 1.0 covers all neighbors (max dist ~ 0.693 < 1).
1169        let h_val = 1.0_f64;
1170        let offsets: &[(f64, f64, f64)] = &[
1171            (0.4, 0.4, 0.4),
1172            (-0.4, 0.4, 0.4),
1173            (0.4, -0.4, 0.4),
1174            (0.4, 0.4, -0.4),
1175            (-0.4, -0.4, 0.4),
1176            (-0.4, 0.4, -0.4),
1177            (0.4, -0.4, -0.4),
1178            (-0.4, -0.4, -0.4),
1179        ];
1180        // First particle is the central one.
1181        let mut positions: Vec<f64> = vec![0.0, 0.0, 0.0];
1182        for &(x, y, z) in offsets {
1183            positions.extend_from_slice(&[x, y, z]);
1184        }
1185        let n = 9_usize;
1186        let masses = vec![1.0_f64; n];
1187        let h_slice = vec![h_val];
1188
1189        let mut outputs = vec![Vec::new()];
1190        SphDensityKernel.execute(&[&positions, &masses, &h_slice], &mut outputs, n);
1191
1192        assert_eq!(outputs[0].len(), n, "density output length should equal n");
1193        // The central particle should have a positive density (it sees all neighbors).
1194        let central_density = outputs[0][0];
1195        assert!(
1196            central_density > 0.0,
1197            "central particle density should be > 0, got {central_density}"
1198        );
1199    }
1200
1201    #[test]
1202    fn sph_density_uniform_distribution() {
1203        // 2 particles at the same position should give non-zero density
1204        let positions = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1205        let masses = vec![1.0, 1.0];
1206        let h = vec![1.0];
1207        let mut outputs = vec![Vec::new()];
1208        SphDensityKernel.execute(&[&positions, &masses, &h], &mut outputs, 2);
1209        assert_eq!(outputs[0].len(), 2);
1210        // Both particles see each other at r=0, so density should be positive
1211        assert!(outputs[0][0] > 0.0);
1212        assert!((outputs[0][0] - outputs[0][1]).abs() < 1e-12);
1213    }
1214
1215    #[test]
1216    fn sph_force_produces_finite_forces() {
1217        let n = 3;
1218        // Place particles in a line along x
1219        let positions = vec![0.0, 0.0, 0.0, 0.3, 0.0, 0.0, 0.6, 0.0, 0.0];
1220        let velocities = vec![0.0; 9];
1221        let densities = vec![1000.0; 3];
1222        let pressures = vec![100.0, 200.0, 100.0];
1223        let masses = vec![1.0; 3];
1224        let params = vec![1.0, 0.1]; // h=1.0, mu=0.1
1225
1226        let mut outputs = vec![Vec::new()];
1227        SphForceKernel.execute(
1228            &[
1229                &positions,
1230                &velocities,
1231                &densities,
1232                &pressures,
1233                &masses,
1234                &params,
1235            ],
1236            &mut outputs,
1237            n,
1238        );
1239        assert_eq!(outputs[0].len(), 9);
1240        // Forces should be finite
1241        for &f in &outputs[0] {
1242            assert!(f.is_finite(), "force component is not finite: {f}");
1243        }
1244    }
1245
1246    // ── High-level SPH kernel API tests ──────────────────────────────────────
1247
1248    /// Verify kernel_value integrates numerically to approximately 1 for each
1249    /// kernel type (compact support, unit normalization).
1250    #[test]
1251    fn kernel_value_positive_within_support() {
1252        let h = 1.0_f64;
1253        let params = SphKernelParams::new(h);
1254        for &k in &[
1255            SphKernel::CubicSpline,
1256            SphKernel::Wendland,
1257            SphKernel::Poly6,
1258            SphKernel::Spiky,
1259        ] {
1260            // W(0) should be positive (self-contribution)
1261            let w0 = kernel_value(0.0, &params, k);
1262            assert!(w0 > 0.0, "{k:?}: W(0) should be > 0, got {w0}");
1263            // W at r >= h should be exactly 0
1264            let wh = kernel_value(h, &params, k);
1265            assert_eq!(wh, 0.0, "{k:?}: W(h) should be 0, got {wh}");
1266        }
1267    }
1268
1269    /// Kernel must be symmetric: W(-r) == W(r) (evaluated via magnitude).
1270    #[test]
1271    fn kernel_value_symmetric() {
1272        let h = 2.0_f64;
1273        let params = SphKernelParams::new(h);
1274        let r = 0.7 * h;
1275        for &k in &[
1276            SphKernel::CubicSpline,
1277            SphKernel::Wendland,
1278            SphKernel::Poly6,
1279            SphKernel::Spiky,
1280        ] {
1281            let w_pos = kernel_value(r, &params, k);
1282            // Symmetry: W depends only on |r|, so same value at same magnitude.
1283            let w_same = kernel_value(r, &params, k);
1284            assert!(
1285                (w_pos - w_same).abs() < 1e-15,
1286                "{k:?}: kernel not symmetric at r={r}"
1287            );
1288        }
1289    }
1290
1291    /// density_summation: self-contribution gives positive density.
1292    #[test]
1293    fn density_summation_self_contribution() {
1294        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0]];
1295        let masses = vec![1.0, 1.0];
1296        let h = 1.0;
1297        let densities = density_summation(&positions, &masses, h);
1298        assert_eq!(densities.len(), 2);
1299        assert!(
1300            densities[0] > 0.0,
1301            "density[0] should be > 0, got {}",
1302            densities[0]
1303        );
1304        assert!(
1305            densities[1] > 0.0,
1306            "density[1] should be > 0, got {}",
1307            densities[1]
1308        );
1309    }
1310
1311    /// pressure_force: forces are finite and Newton's third law holds.
1312    #[test]
1313    fn pressure_force_finite_and_newtons_third_law() {
1314        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1315        let velocities = vec![[0.0; 3]; 3];
1316        let densities = vec![1000.0, 1000.0, 1000.0];
1317        let pressures = vec![100.0, 200.0, 100.0];
1318        let masses = vec![1.0, 1.0, 1.0];
1319        let h = 1.0;
1320
1321        let forces = pressure_force(&positions, &velocities, &densities, &pressures, &masses, h);
1322        assert_eq!(forces.len(), 3);
1323        for (i, f) in forces.iter().enumerate() {
1324            for &c in f.iter() {
1325                assert!(c.is_finite(), "force[{i}] component not finite: {c}");
1326            }
1327        }
1328        // Total momentum change should be near zero (action-reaction).
1329        let total_fx: f64 = forces.iter().map(|f| f[0]).sum();
1330        assert!(
1331            total_fx.abs() < 1e-8,
1332            "total x-force should be ~0 (Newton III), got {total_fx}"
1333        );
1334    }
1335
1336    // ── New tests ────────────────────────────────────────────────────────────
1337
1338    #[test]
1339    fn test_density_summation_kernel_poly6() {
1340        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1341        let masses = vec![1.0, 1.0];
1342        let h = 1.0;
1343        let densities = density_summation_kernel(&positions, &masses, h, SphKernel::Poly6);
1344        assert_eq!(densities.len(), 2);
1345        assert!(densities[0] > 0.0);
1346        assert!(densities[1] > 0.0);
1347    }
1348
1349    #[test]
1350    fn test_density_summation_kernel_wendland() {
1351        let positions = vec![[0.0, 0.0, 0.0]];
1352        let masses = vec![1.0];
1353        let h = 1.0;
1354        let densities = density_summation_kernel(&positions, &masses, h, SphKernel::Wendland);
1355        assert_eq!(densities.len(), 1);
1356        assert!(densities[0] > 0.0);
1357    }
1358
1359    #[test]
1360    fn test_density_summation_kernel_spiky() {
1361        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0]];
1362        let masses = vec![1.0, 1.0];
1363        let h = 1.0;
1364        let densities = density_summation_kernel(&positions, &masses, h, SphKernel::Spiky);
1365        assert!(densities[0] > 0.0);
1366        assert!(densities[1] > 0.0);
1367    }
1368
1369    #[test]
1370    fn test_viscosity_force_finite() {
1371        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1372        let velocities = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]];
1373        let densities = vec![1000.0, 1000.0];
1374        let masses = vec![1.0, 1.0];
1375        let h = 1.0;
1376        let mu = 0.1;
1377        let forces = viscosity_force(&positions, &velocities, &densities, &masses, h, mu);
1378        assert_eq!(forces.len(), 2);
1379        for f in &forces {
1380            for &c in f {
1381                assert!(c.is_finite(), "viscosity force not finite: {c}");
1382            }
1383        }
1384    }
1385
1386    #[test]
1387    fn test_viscosity_force_zero_for_same_velocity() {
1388        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1389        let velocities = vec![[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
1390        let densities = vec![1000.0, 1000.0];
1391        let masses = vec![1.0, 1.0];
1392        let h = 1.0;
1393        let mu = 0.1;
1394        let forces = viscosity_force(&positions, &velocities, &densities, &masses, h, mu);
1395        for f in &forces {
1396            for &c in f {
1397                assert!(
1398                    c.abs() < 1e-12,
1399                    "viscosity should be zero for uniform velocity"
1400                );
1401            }
1402        }
1403    }
1404
1405    #[test]
1406    fn test_neighbor_list_build_and_query() {
1407        let positions = vec![
1408            [0.5, 0.5, 0.5],
1409            [0.6, 0.5, 0.5],
1410            [5.0, 5.0, 5.0], // far away
1411        ];
1412        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [10.0, 10.0, 10.0], 1.0);
1413        nlist.build(&positions);
1414
1415        let neighbors = nlist.neighbors(&[0.5, 0.5, 0.5]);
1416        // Should find particles 0 and 1 (same or adjacent cells), not 2
1417        assert!(neighbors.contains(&0));
1418        assert!(neighbors.contains(&1));
1419        assert!(!neighbors.contains(&2));
1420    }
1421
1422    #[test]
1423    fn test_neighbor_list_num_cells() {
1424        let nlist = NeighborList::new([0.0, 0.0, 0.0], [10.0, 10.0, 10.0], 1.0);
1425        assert_eq!(nlist.num_cells(), 1000); // 10x10x10
1426        assert_eq!(nlist.grid_dims(), [10, 10, 10]);
1427    }
1428
1429    #[test]
1430    fn test_neighbor_list_single_particle() {
1431        let positions = vec![[0.5, 0.5, 0.5]];
1432        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], 1.0);
1433        nlist.build(&positions);
1434        let neighbors = nlist.neighbors(&[0.5, 0.5, 0.5]);
1435        assert!(neighbors.contains(&0));
1436    }
1437
1438    #[test]
1439    fn test_sph_dispatch_config() {
1440        let config = SphDispatchConfig::new(1000, 0.1);
1441        assert_eq!(config.n_particles, 1000);
1442        assert_eq!(config.num_workgroups(), 16); // ceil(1000/64) = 16
1443    }
1444
1445    #[test]
1446    fn test_sph_dispatch_config_pressure() {
1447        let config = SphDispatchConfig::new(100, 0.1);
1448        let p = config.pressure_from_density(1100.0);
1449        assert!((p - 100_000.0).abs() < 1e-6); // k=1000 * (1100-1000) = 100000
1450        let p_zero = config.pressure_from_density(500.0);
1451        assert!((p_zero).abs() < 1e-12); // below rest density -> 0
1452    }
1453
1454    #[test]
1455    fn test_sph_buffer_layout() {
1456        let layout = SphBufferLayout::new(1000);
1457        assert_eq!(layout.position_size, 3000);
1458        assert_eq!(layout.velocity_size, 3000);
1459        assert_eq!(layout.mass_size, 1000);
1460        assert_eq!(layout.density_size, 1000);
1461        assert_eq!(layout.pressure_size, 1000);
1462        assert_eq!(layout.force_size, 3000);
1463        assert_eq!(layout.total_elements(), 12000);
1464        assert_eq!(layout.total_bytes(), 96000);
1465    }
1466
1467    #[test]
1468    fn test_neighbor_list_kernel_executes() {
1469        let positions = vec![0.5, 0.5, 0.5, 1.5, 1.5, 1.5];
1470        let params = vec![1.0, 0.0, 0.0, 0.0, 5.0, 5.0, 5.0];
1471        let mut outputs = vec![Vec::new()];
1472        SphNeighborListKernel.execute(&[&positions, &params], &mut outputs, 2);
1473        assert_eq!(outputs[0].len(), 2);
1474        // cell indices should be non-negative
1475        assert!(outputs[0][0] >= 0.0);
1476        assert!(outputs[0][1] >= 0.0);
1477        // Different positions should generally give different cells
1478        assert!((outputs[0][0] - outputs[0][1]).abs() > 0.5);
1479    }
1480
1481    #[test]
1482    fn test_kernel_gradient_at_origin_is_zero() {
1483        let h = 1.0;
1484        let params = SphKernelParams::new(h);
1485        for &k in &[
1486            SphKernel::CubicSpline,
1487            SphKernel::Wendland,
1488            SphKernel::Poly6,
1489            SphKernel::Spiky,
1490        ] {
1491            let grad = kernel_gradient([0.0, 0.0, 0.0], 0.0, &params, k);
1492            assert_eq!(
1493                grad,
1494                [0.0, 0.0, 0.0],
1495                "{k:?}: gradient at origin should be zero"
1496            );
1497        }
1498    }
1499
1500    #[test]
1501    fn test_kernel_gradient_outside_support_is_zero() {
1502        let h = 1.0;
1503        let params = SphKernelParams::new(h);
1504        for &k in &[
1505            SphKernel::CubicSpline,
1506            SphKernel::Wendland,
1507            SphKernel::Poly6,
1508            SphKernel::Spiky,
1509        ] {
1510            let grad = kernel_gradient([2.0, 0.0, 0.0], 2.0, &params, k);
1511            assert_eq!(
1512                grad,
1513                [0.0, 0.0, 0.0],
1514                "{k:?}: gradient outside support should be zero"
1515            );
1516        }
1517    }
1518
1519    // ── Surface tension tests ─────────────────────────────────────────────
1520
1521    #[test]
1522    fn test_surface_tension_force_finite() {
1523        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1524        let color_fn = vec![1.0, 1.0, 0.0];
1525        let masses = vec![1.0, 1.0, 1.0];
1526        let densities = vec![1000.0, 1000.0, 1000.0];
1527        let h = 1.0;
1528        let sigma = 0.07;
1529        let forces = surface_tension_force(&positions, &color_fn, &masses, &densities, h, sigma);
1530        assert_eq!(forces.len(), 3);
1531        for f in &forces {
1532            for &c in f {
1533                assert!(c.is_finite(), "surface tension force not finite: {c}");
1534            }
1535        }
1536    }
1537
1538    #[test]
1539    fn test_surface_tension_zero_sigma() {
1540        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1541        let color_fn = vec![1.0, 0.0];
1542        let masses = vec![1.0, 1.0];
1543        let densities = vec![1000.0, 1000.0];
1544        let h = 1.0;
1545        let forces = surface_tension_force(&positions, &color_fn, &masses, &densities, h, 0.0);
1546        for f in &forces {
1547            for &c in f {
1548                assert!(
1549                    c.abs() < 1e-30,
1550                    "surface tension with sigma=0 should be zero"
1551                );
1552            }
1553        }
1554    }
1555
1556    // ── CFL time step tests ───────────────────────────────────────────────
1557
1558    #[test]
1559    fn test_cfl_timestep_basic() {
1560        let velocities = vec![[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 0.5]];
1561        let h = 1.0;
1562        let c_sound = 10.0;
1563        let dt = cfl_timestep(&velocities, h, c_sound, 0.3);
1564        assert!(dt > 0.0, "CFL dt should be positive");
1565        assert!(dt.is_finite(), "CFL dt should be finite");
1566        // max velocity magnitude = 2.0; CFL condition: dt <= 0.3*h/(v+c) = 0.3/12 ~ 0.025
1567        assert!(dt <= 0.3 * h / (2.0 + c_sound) + 1e-12);
1568    }
1569
1570    #[test]
1571    fn test_cfl_timestep_zero_velocity() {
1572        let velocities = vec![[0.0; 3]; 5];
1573        let h = 0.5;
1574        let c_sound = 5.0;
1575        let dt = cfl_timestep(&velocities, h, c_sound, 0.25);
1576        // With zero velocity: dt = cfl * h / c_sound
1577        let expected = 0.25 * h / c_sound;
1578        assert!(
1579            (dt - expected).abs() < 1e-10,
1580            "expected {expected}, got {dt}"
1581        );
1582    }
1583
1584    // ── Radix sort by density tests ───────────────────────────────────────
1585
1586    #[test]
1587    fn test_radix_sort_by_density_ordered() {
1588        let densities = vec![3.0, 1.0, 4.0, 1.5, 9.0, 2.6, 5.0, 3.5];
1589        let indices = radix_sort_by_density(&densities);
1590        assert_eq!(indices.len(), densities.len());
1591        // Verify sorted order
1592        for w in indices.windows(2) {
1593            assert!(
1594                densities[w[0]] <= densities[w[1]],
1595                "not sorted: {} > {}",
1596                densities[w[0]],
1597                densities[w[1]]
1598            );
1599        }
1600    }
1601
1602    #[test]
1603    fn test_radix_sort_by_density_single() {
1604        let densities = vec![42.0];
1605        let indices = radix_sort_by_density(&densities);
1606        assert_eq!(indices, vec![0]);
1607    }
1608
1609    #[test]
1610    fn test_radix_sort_by_density_empty() {
1611        let indices = radix_sort_by_density(&[]);
1612        assert!(indices.is_empty());
1613    }
1614
1615    #[test]
1616    fn test_radix_sort_is_permutation() {
1617        let densities = vec![5.0, 3.0, 8.0, 1.0, 2.0];
1618        let indices = radix_sort_by_density(&densities);
1619        let mut check = indices.clone();
1620        check.sort_unstable();
1621        assert_eq!(
1622            check,
1623            vec![0, 1, 2, 3, 4],
1624            "indices should be a permutation"
1625        );
1626    }
1627
1628    // ── density_accumulation tests ───────────────────────────────────────────
1629
1630    #[test]
1631    fn test_density_accumulation_matches_direct() {
1632        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1633        let masses = vec![1.0, 1.0, 1.0];
1634        let h = 1.0;
1635        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1636        nlist.build(&positions);
1637        let rho_nl = density_accumulation(&positions, &masses, h, &nlist);
1638        let rho_direct = density_summation(&positions, &masses, h);
1639        // Results should agree (neighbour list finds all within h)
1640        for i in 0..3 {
1641            assert!(
1642                (rho_nl[i] - rho_direct[i]).abs() < 1e-10,
1643                "density_accumulation vs direct at {i}: {} vs {}",
1644                rho_nl[i],
1645                rho_direct[i]
1646            );
1647        }
1648    }
1649
1650    #[test]
1651    fn test_density_accumulation_positive() {
1652        let positions = vec![[0.5, 0.5, 0.5], [0.6, 0.5, 0.5]];
1653        let masses = vec![1.0, 1.0];
1654        let h = 1.0;
1655        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1656        nlist.build(&positions);
1657        let rho = density_accumulation(&positions, &masses, h, &nlist);
1658        assert!(rho[0] > 0.0 && rho[1] > 0.0);
1659    }
1660
1661    // ── pressure_force_kernel tests ──────────────────────────────────────────
1662
1663    #[test]
1664    fn test_pressure_force_kernel_finite() {
1665        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1666        let densities = vec![1000.0, 1000.0];
1667        let pressures = vec![100.0, 200.0];
1668        let masses = vec![1.0, 1.0];
1669        let h = 1.0;
1670        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1671        nlist.build(&positions);
1672        let forces = pressure_force_kernel(&positions, &densities, &pressures, &masses, h, &nlist);
1673        assert_eq!(forces.len(), 2);
1674        for f in &forces {
1675            for &c in f {
1676                assert!(c.is_finite(), "pressure_force_kernel not finite: {c}");
1677            }
1678        }
1679    }
1680
1681    #[test]
1682    fn test_pressure_force_kernel_zero_gradient() {
1683        // Uniform pressure → no force
1684        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1685        let densities = vec![1000.0, 1000.0];
1686        let pressures = vec![100.0, 100.0];
1687        let masses = vec![1.0, 1.0];
1688        let h = 1.0;
1689        let mut nlist = NeighborList::new([0.0, 0.0, 0.0], [5.0, 5.0, 5.0], h);
1690        nlist.build(&positions);
1691        let forces = pressure_force_kernel(&positions, &densities, &pressures, &masses, h, &nlist);
1692        // For uniform pressure with symmetric formulation the net force should be near zero
1693        let total_fx: f64 = forces.iter().map(|f| f[0]).sum();
1694        assert!(
1695            total_fx.abs() < 1e-6,
1696            "total pressure force with uniform pressure = {total_fx}"
1697        );
1698    }
1699
1700    // ── artificial_viscosity_force tests ─────────────────────────────────────
1701
1702    #[test]
1703    fn test_artificial_viscosity_finite() {
1704        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1705        let velocities = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]]; // approaching
1706        let densities = vec![1000.0, 1000.0];
1707        let masses = vec![1.0, 1.0];
1708        let h = 1.0;
1709        let forces = artificial_viscosity_force(
1710            &positions,
1711            &velocities,
1712            &densities,
1713            &masses,
1714            h,
1715            100.0,
1716            1.0,
1717            2.0,
1718        );
1719        for f in &forces {
1720            for &c in f {
1721                assert!(c.is_finite(), "art. visc. force not finite: {c}");
1722            }
1723        }
1724    }
1725
1726    #[test]
1727    fn test_artificial_viscosity_zero_for_diverging() {
1728        // When particles are moving apart, PI_ij = 0
1729        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0]];
1730        let velocities = vec![[-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; // diverging
1731        let densities = vec![1000.0, 1000.0];
1732        let masses = vec![1.0, 1.0];
1733        let h = 1.0;
1734        let forces = artificial_viscosity_force(
1735            &positions,
1736            &velocities,
1737            &densities,
1738            &masses,
1739            h,
1740            100.0,
1741            1.0,
1742            2.0,
1743        );
1744        for f in &forces {
1745            for &c in f {
1746                assert!(
1747                    c.abs() < 1e-30,
1748                    "diverging particles: art visc should be 0, got {c}"
1749                );
1750            }
1751        }
1752    }
1753
1754    // ── WCSPH tests ───────────────────────────────────────────────────────────
1755
1756    #[test]
1757    fn test_wcsph_pressure_at_rest_density() {
1758        // At rho = rho0: pressure = 0
1759        let p = wcsph_pressure(1000.0, 1000.0, 100.0, 7.0);
1760        assert!(p.abs() < 1e-8, "pressure at rest density = {p}");
1761    }
1762
1763    #[test]
1764    fn test_wcsph_pressure_above_rest_density() {
1765        // At rho > rho0: pressure > 0
1766        let p = wcsph_pressure(1010.0, 1000.0, 100.0, 7.0);
1767        assert!(
1768            p > 0.0,
1769            "pressure above rest density should be positive: {p}"
1770        );
1771    }
1772
1773    #[test]
1774    fn test_wcsph_euler_step_positions_change() {
1775        let positions = vec![[0.0, 0.0, 0.0]];
1776        let velocities = vec![[1.0, 0.0, 0.0]];
1777        let forces = vec![[0.0, 0.0, 0.0]];
1778        let masses = vec![1.0];
1779        let dt = 0.01;
1780        let (new_pos, _) = wcsph_euler_step(&positions, &velocities, &forces, &masses, dt);
1781        assert!(
1782            (new_pos[0][0] - 0.01).abs() < 1e-12,
1783            "position should advance by v*dt"
1784        );
1785    }
1786
1787    #[test]
1788    fn test_wcsph_euler_step_velocity_changes() {
1789        let positions = vec![[0.0, 0.0, 0.0]];
1790        let velocities = vec![[0.0, 0.0, 0.0]];
1791        let forces = vec![[1.0, 0.0, 0.0]]; // acceleration = 1
1792        let masses = vec![1.0];
1793        let dt = 0.1;
1794        let (_, new_vel) = wcsph_euler_step(&positions, &velocities, &forces, &masses, dt);
1795        assert!(
1796            (new_vel[0][0] - 0.1).abs() < 1e-12,
1797            "velocity should increase by a*dt"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_wcsph_leapfrog_velocity_half() {
1803        let velocities = vec![[1.0, 0.0, 0.0]];
1804        let forces = vec![[2.0, 0.0, 0.0]];
1805        let masses = vec![1.0];
1806        let dt = 0.1;
1807        let new_vel = wcsph_leapfrog_velocity_half(&velocities, &forces, &masses, dt);
1808        // v += a * dt = 1 + 2*0.1 = 1.2
1809        assert!(
1810            (new_vel[0][0] - 1.2).abs() < 1e-12,
1811            "leapfrog v = {}",
1812            new_vel[0][0]
1813        );
1814    }
1815
1816    // ── surface_normal_kernel tests ───────────────────────────────────────────
1817
1818    #[test]
1819    fn test_surface_normal_kernel_finite() {
1820        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1821        let densities = vec![1000.0, 1000.0, 1000.0];
1822        let masses = vec![1.0, 1.0, 1.0];
1823        let h = 1.0;
1824        let normals = surface_normal_kernel(&positions, &densities, &masses, h);
1825        assert_eq!(normals.len(), 3);
1826        for n in &normals {
1827            for &c in n {
1828                assert!(c.is_finite(), "surface normal not finite: {c}");
1829            }
1830        }
1831    }
1832
1833    #[test]
1834    fn test_normalize_normal_unit_vector() {
1835        let n = normalize_normal([3.0, 0.0, 4.0]);
1836        let mag = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
1837        assert!((mag - 1.0).abs() < 1e-12, "normalized magnitude = {mag}");
1838    }
1839
1840    #[test]
1841    fn test_normalize_normal_zero_vector() {
1842        let n = normalize_normal([0.0, 0.0, 0.0]);
1843        assert_eq!(n, [0.0, 0.0, 0.0]);
1844    }
1845
1846    // ── build_neighbor_list_explicit tests ───────────────────────────────────
1847
1848    #[test]
1849    fn test_build_neighbor_list_explicit_finds_close() {
1850        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0], [5.0, 0.0, 0.0]];
1851        let h = 1.0;
1852        let neighbors = build_neighbor_list_explicit(&positions, h);
1853        assert!(
1854            neighbors[0].contains(&1),
1855            "particle 0 should find particle 1"
1856        );
1857        assert!(
1858            !neighbors[0].contains(&2),
1859            "particle 0 should not find particle 2"
1860        );
1861    }
1862
1863    #[test]
1864    fn test_build_neighbor_list_self_included() {
1865        let positions = vec![[0.0, 0.0, 0.0]];
1866        let h = 1.0;
1867        let neighbors = build_neighbor_list_explicit(&positions, h);
1868        assert!(neighbors[0].contains(&0), "particle should find itself");
1869    }
1870
1871    #[test]
1872    fn test_mean_neighbor_count() {
1873        let positions = vec![[0.0, 0.0, 0.0], [0.3, 0.0, 0.0], [0.6, 0.0, 0.0]];
1874        let h = 1.0;
1875        let neighbors = build_neighbor_list_explicit(&positions, h);
1876        let mean = mean_neighbor_count(&neighbors);
1877        assert!(mean >= 1.0, "mean neighbors should be >= 1: {mean}");
1878    }
1879
1880    #[test]
1881    fn test_mean_neighbor_count_empty() {
1882        let mean = mean_neighbor_count(&[]);
1883        assert_eq!(mean, 0.0);
1884    }
1885
1886    // ── integrate_kernel_sphere tests ────────────────────────────────────────
1887
1888    #[test]
1889    fn test_integrate_kernel_sphere_cubic_spline() {
1890        let h = 1.0;
1891        let integral = integrate_kernel_sphere(h, SphKernel::CubicSpline, 1000);
1892        // Should be approximately 1 for a normalized kernel
1893        assert!(
1894            integral > 0.5 && integral < 2.0,
1895            "CubicSpline integral = {integral} (expected ~1)"
1896        );
1897    }
1898
1899    #[test]
1900    fn test_integrate_kernel_sphere_wendland() {
1901        let h = 1.0;
1902        let integral = integrate_kernel_sphere(h, SphKernel::Wendland, 1000);
1903        assert!(
1904            integral > 0.5 && integral < 2.0,
1905            "Wendland integral = {integral} (expected ~1)"
1906        );
1907    }
1908
1909    #[test]
1910    fn test_integrate_kernel_sphere_zero_at_boundary() {
1911        // Kernel value at r=h should be 0
1912        let h = 1.0;
1913        let params = SphKernelParams::new(h);
1914        for &k in &[SphKernel::CubicSpline, SphKernel::Wendland] {
1915            let w = kernel_value(h, &params, k);
1916            assert_eq!(w, 0.0, "{k:?}: W(h) should be 0");
1917        }
1918    }
1919}