Skip to main content

oxiphysics_python/
sph_api.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Smoothed Particle Hydrodynamics (SPH) simulation API for Python interop.
6//!
7//! Provides `PySphSimulation`: a standalone 3-D weakly compressible SPH (WCSPH)
8//! solver with poly6/spiky kernels, pressure and viscosity forces, configurable
9//! gravity, and query helpers.
10
11#![allow(missing_docs)]
12
13use serde::{Deserialize, Serialize};
14
15// ---------------------------------------------------------------------------
16// Configuration
17// ---------------------------------------------------------------------------
18
19/// Configuration for the SPH simulation.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PySphConfig {
22    /// Smoothing length (kernel radius h).
23    pub h: f64,
24    /// Reference (rest) density ρ₀ in kg/m³.
25    pub rest_density: f64,
26    /// Pressure stiffness constant k (state-equation coefficient).
27    pub stiffness: f64,
28    /// Dynamic viscosity μ.
29    pub viscosity: f64,
30    /// Gravity vector `[gx, gy, gz]`.
31    pub gravity: [f64; 3],
32    /// Mass of each particle in kg.
33    pub particle_mass: f64,
34    /// Whether to enforce a floor boundary at y=0 (floor_y).
35    pub floor_enabled: bool,
36    /// Y-coordinate of the floor plane (only used when `floor_enabled`).
37    pub floor_y: f64,
38    /// Coefficient of restitution for floor bounces (0=inelastic, 1=elastic).
39    pub floor_restitution: f64,
40}
41
42impl PySphConfig {
43    /// Water-like default configuration.
44    pub fn water() -> Self {
45        Self {
46            h: 0.1,
47            rest_density: 1000.0,
48            stiffness: 200.0,
49            viscosity: 0.01,
50            gravity: [0.0, -9.81, 0.0],
51            particle_mass: 0.02,
52            floor_enabled: false,
53            floor_y: 0.0,
54            floor_restitution: 0.3,
55        }
56    }
57
58    /// Gas-like configuration with low density and stiffness.
59    pub fn gas() -> Self {
60        Self {
61            h: 0.2,
62            rest_density: 1.2,
63            stiffness: 50.0,
64            viscosity: 1.81e-5,
65            gravity: [0.0, 0.0, 0.0],
66            particle_mass: 0.001,
67            floor_enabled: false,
68            floor_y: 0.0,
69            floor_restitution: 1.0,
70        }
71    }
72}
73
74impl Default for PySphConfig {
75    fn default() -> Self {
76        Self::water()
77    }
78}
79
80// ---------------------------------------------------------------------------
81// PySphSimulation
82// ---------------------------------------------------------------------------
83
84/// A 3-D weakly-compressible SPH fluid simulation.
85///
86/// Particles are integrated with forward Euler and use Tait's equation of
87/// state (p = k * (ρ/ρ₀ - 1)) for pressure. Supports an optional flat floor
88/// boundary.
89#[derive(Debug, Clone)]
90pub struct PySphSimulation {
91    /// Particle positions `[x, y, z]`.
92    positions: Vec<[f64; 3]>,
93    /// Particle velocities `[vx, vy, vz]`.
94    velocities: Vec<[f64; 3]>,
95    /// Per-particle densities.
96    densities: Vec<f64>,
97    /// Per-particle pressures.
98    pressures: Vec<f64>,
99    /// Simulation configuration.
100    config: PySphConfig,
101    /// Accumulated simulation time.
102    time: f64,
103    /// Number of steps taken.
104    step_count: u64,
105}
106
107impl PySphSimulation {
108    /// Create an empty SPH simulation with the given configuration.
109    pub fn new(config: PySphConfig) -> Self {
110        Self {
111            positions: Vec::new(),
112            velocities: Vec::new(),
113            densities: Vec::new(),
114            pressures: Vec::new(),
115            config,
116            time: 0.0,
117            step_count: 0,
118        }
119    }
120
121    /// Add a particle at `pos` with zero velocity. Returns the particle index.
122    pub fn add_particle(&mut self, pos: [f64; 3]) -> usize {
123        let idx = self.positions.len();
124        self.positions.push(pos);
125        self.velocities.push([0.0; 3]);
126        self.densities.push(self.config.rest_density);
127        self.pressures.push(0.0);
128        idx
129    }
130
131    /// Add a particle at `pos` with the given initial velocity.
132    pub fn add_particle_with_velocity(&mut self, pos: [f64; 3], vel: [f64; 3]) -> usize {
133        let idx = self.add_particle(pos);
134        self.velocities[idx] = vel;
135        idx
136    }
137
138    /// Add `nx * ny * nz` particles in a 3-D lattice starting at `origin`,
139    /// with inter-particle spacing `dx`.
140    pub fn add_particle_block(
141        &mut self,
142        origin: [f64; 3],
143        nx: usize,
144        ny: usize,
145        nz: usize,
146        dx: f64,
147    ) {
148        for iz in 0..nz {
149            for iy in 0..ny {
150                for ix in 0..nx {
151                    let pos = [
152                        origin[0] + ix as f64 * dx,
153                        origin[1] + iy as f64 * dx,
154                        origin[2] + iz as f64 * dx,
155                    ];
156                    self.add_particle(pos);
157                }
158            }
159        }
160    }
161
162    /// Number of particles.
163    pub fn particle_count(&self) -> usize {
164        self.positions.len()
165    }
166
167    /// Position of particle `i`, or `None` if out of bounds.
168    pub fn position(&self, i: usize) -> Option<[f64; 3]> {
169        self.positions.get(i).copied()
170    }
171
172    /// Velocity of particle `i`, or `None` if out of bounds.
173    pub fn velocity(&self, i: usize) -> Option<[f64; 3]> {
174        self.velocities.get(i).copied()
175    }
176
177    /// Density of particle `i`, or `None` if out of bounds.
178    pub fn density(&self, i: usize) -> Option<f64> {
179        self.densities.get(i).copied()
180    }
181
182    /// Accumulated simulation time.
183    pub fn time(&self) -> f64 {
184        self.time
185    }
186
187    /// Number of completed steps.
188    pub fn step_count(&self) -> u64 {
189        self.step_count
190    }
191
192    /// Return all positions as a flat `Vec`f64` (length = 3 * particle_count).
193    pub fn all_positions(&self) -> Vec<f64> {
194        self.positions
195            .iter()
196            .flat_map(|p| p.iter().copied())
197            .collect()
198    }
199
200    /// Return all velocities as a flat `Vec`f64` (length = 3 * particle_count).
201    pub fn all_velocities(&self) -> Vec<f64> {
202        self.velocities
203            .iter()
204            .flat_map(|v| v.iter().copied())
205            .collect()
206    }
207
208    /// Return all densities as a `Vec`f64` (length = particle_count).
209    pub fn get_density_field(&self) -> Vec<f64> {
210        self.densities.clone()
211    }
212
213    /// Return all pressures as a `Vec`f64`.
214    pub fn get_pressure_field(&self) -> Vec<f64> {
215        self.pressures.clone()
216    }
217
218    /// Average density over all particles.
219    pub fn mean_density(&self) -> f64 {
220        if self.densities.is_empty() {
221            return 0.0;
222        }
223        self.densities.iter().sum::<f64>() / self.densities.len() as f64
224    }
225
226    /// Advance the simulation by `dt` seconds using forward Euler integration.
227    ///
228    /// Uses WCSPH:
229    /// 1. Density via poly6 kernel.
230    /// 2. Pressure from Tait equation.
231    /// 3. Forces: pressure gradient (spiky) + viscosity (Laplacian) + gravity.
232    /// 4. Euler integration.
233    /// 5. Optional floor boundary reflection.
234    pub fn step(&mut self, dt: f64) {
235        let n = self.positions.len();
236        if n == 0 {
237            self.time += dt;
238            self.step_count += 1;
239            return;
240        }
241
242        let h = self.config.h;
243        let h2 = h * h;
244        let m = self.config.particle_mass;
245        let rho0 = self.config.rest_density;
246        let k = self.config.stiffness;
247        let mu = self.config.viscosity;
248        let g = self.config.gravity;
249
250        let poly6_coeff = 315.0 / (64.0 * std::f64::consts::PI * h.powi(9));
251        let spiky_coeff = -45.0 / (std::f64::consts::PI * h.powi(6));
252        let visc_coeff = 45.0 / (std::f64::consts::PI * h.powi(6));
253
254        // -- Density --
255        for i in 0..n {
256            let mut rho = 0.0f64;
257            for j in 0..n {
258                let dx = self.positions[i][0] - self.positions[j][0];
259                let dy = self.positions[i][1] - self.positions[j][1];
260                let dz = self.positions[i][2] - self.positions[j][2];
261                let r2 = dx * dx + dy * dy + dz * dz;
262                if r2 < h2 {
263                    let diff = h2 - r2;
264                    rho += m * poly6_coeff * diff * diff * diff;
265                }
266            }
267            self.densities[i] = rho.max(1e-3);
268            self.pressures[i] = k * (self.densities[i] / rho0 - 1.0);
269        }
270
271        // -- Forces --
272        let mut forces = vec![[0.0f64; 3]; n];
273        for i in 0..n {
274            let mut fp = [0.0f64; 3];
275            let mut fv = [0.0f64; 3];
276            for j in 0..n {
277                if i == j {
278                    continue;
279                }
280                let dx = self.positions[i][0] - self.positions[j][0];
281                let dy = self.positions[i][1] - self.positions[j][1];
282                let dz = self.positions[i][2] - self.positions[j][2];
283                let r2 = dx * dx + dy * dy + dz * dz;
284                if r2 >= h2 || r2 < 1e-20 {
285                    continue;
286                }
287                let r = r2.sqrt();
288                let hr = h - r;
289                let pf = -m * (self.pressures[i] + self.pressures[j])
290                    / (2.0 * self.densities[j].max(1e-6))
291                    * spiky_coeff
292                    * hr
293                    * hr
294                    / r;
295                fp[0] += pf * dx;
296                fp[1] += pf * dy;
297                fp[2] += pf * dz;
298                let vf = mu * m / self.densities[j].max(1e-6) * visc_coeff * hr;
299                fv[0] += vf * (self.velocities[j][0] - self.velocities[i][0]);
300                fv[1] += vf * (self.velocities[j][1] - self.velocities[i][1]);
301                fv[2] += vf * (self.velocities[j][2] - self.velocities[i][2]);
302            }
303            let rho_i = self.densities[i];
304            forces[i][0] = (fp[0] + fv[0]) / rho_i + g[0];
305            forces[i][1] = (fp[1] + fv[1]) / rho_i + g[1];
306            forces[i][2] = (fp[2] + fv[2]) / rho_i + g[2];
307        }
308
309        // -- Euler integration --
310        for i in 0..n {
311            self.velocities[i][0] += forces[i][0] * dt;
312            self.velocities[i][1] += forces[i][1] * dt;
313            self.velocities[i][2] += forces[i][2] * dt;
314            self.positions[i][0] += self.velocities[i][0] * dt;
315            self.positions[i][1] += self.velocities[i][1] * dt;
316            self.positions[i][2] += self.velocities[i][2] * dt;
317        }
318
319        // -- Floor boundary --
320        if self.config.floor_enabled {
321            let floor_y = self.config.floor_y;
322            let rest_coeff = self.config.floor_restitution;
323            for i in 0..n {
324                if self.positions[i][1] < floor_y {
325                    self.positions[i][1] = floor_y;
326                    if self.velocities[i][1] < 0.0 {
327                        self.velocities[i][1] = -self.velocities[i][1] * rest_coeff;
328                    }
329                }
330            }
331        }
332
333        self.time += dt;
334        self.step_count += 1;
335    }
336}
337
338// ---------------------------------------------------------------------------
339// Tests
340// ---------------------------------------------------------------------------
341
342#[cfg(test)]
343mod tests {
344
345    use crate::PySphConfig;
346    use crate::PySphSimulation;
347
348    fn water_sim() -> PySphSimulation {
349        PySphSimulation::new(PySphConfig::water())
350    }
351
352    #[test]
353    fn test_sph_creation_empty() {
354        let sim = water_sim();
355        assert_eq!(sim.particle_count(), 0);
356        assert!((sim.time()).abs() < 1e-15);
357    }
358
359    #[test]
360    fn test_sph_add_particle() {
361        let mut sim = water_sim();
362        let idx = sim.add_particle([1.0, 2.0, 3.0]);
363        assert_eq!(idx, 0);
364        assert_eq!(sim.particle_count(), 1);
365        let p = sim.position(0).unwrap();
366        assert!((p[0] - 1.0).abs() < 1e-12);
367    }
368
369    #[test]
370    fn test_sph_add_particle_with_velocity() {
371        let mut sim = water_sim();
372        sim.add_particle_with_velocity([0.0; 3], [1.0, 2.0, 3.0]);
373        let v = sim.velocity(0).unwrap();
374        assert!((v[0] - 1.0).abs() < 1e-12);
375        assert!((v[1] - 2.0).abs() < 1e-12);
376    }
377
378    #[test]
379    fn test_sph_particle_block() {
380        let mut sim = water_sim();
381        sim.add_particle_block([0.0; 3], 2, 3, 4, 0.1);
382        assert_eq!(sim.particle_count(), 24);
383    }
384
385    #[test]
386    fn test_sph_step_moves_particle_under_gravity() {
387        let mut sim = water_sim();
388        sim.add_particle([0.0, 1.0, 0.0]);
389        let y0 = sim.position(0).unwrap()[1];
390        sim.step(0.001);
391        let y1 = sim.position(0).unwrap()[1];
392        assert!(y1 < y0, "particle should fall: y0={} y1={}", y0, y1);
393    }
394
395    #[test]
396    fn test_sph_step_empty_no_panic() {
397        let mut sim = water_sim();
398        sim.step(0.01);
399        assert!((sim.time() - 0.01).abs() < 1e-15);
400    }
401
402    #[test]
403    fn test_sph_time_advances() {
404        let mut sim = water_sim();
405        sim.add_particle([0.0; 3]);
406        sim.step(0.01);
407        sim.step(0.01);
408        assert!((sim.time() - 0.02).abs() < 1e-12);
409        assert_eq!(sim.step_count(), 2);
410    }
411
412    #[test]
413    fn test_sph_density_field_length() {
414        let mut sim = water_sim();
415        sim.add_particle([0.0; 3]);
416        sim.add_particle([1.0, 0.0, 0.0]);
417        let d = sim.get_density_field();
418        assert_eq!(d.len(), 2);
419    }
420
421    #[test]
422    fn test_sph_pressure_field_length() {
423        let mut sim = water_sim();
424        sim.add_particle([0.0; 3]);
425        let p = sim.get_pressure_field();
426        assert_eq!(p.len(), 1);
427    }
428
429    #[test]
430    fn test_sph_all_positions_length() {
431        let mut sim = water_sim();
432        sim.add_particle([0.0; 3]);
433        sim.add_particle([1.0, 0.0, 0.0]);
434        assert_eq!(sim.all_positions().len(), 6);
435    }
436
437    #[test]
438    fn test_sph_all_velocities_length() {
439        let mut sim = water_sim();
440        sim.add_particle([0.0; 3]);
441        assert_eq!(sim.all_velocities().len(), 3);
442    }
443
444    #[test]
445    fn test_sph_floor_boundary() {
446        let cfg = PySphConfig {
447            floor_enabled: true,
448            floor_y: 0.0,
449            floor_restitution: 0.5,
450            ..PySphConfig::water()
451        };
452        let mut sim = PySphSimulation::new(cfg);
453        sim.add_particle([0.0, 0.001, 0.0]);
454        // Give downward velocity
455        sim.velocities[0] = [0.0, -5.0, 0.0];
456        sim.step(0.01);
457        // Particle should not be below the floor
458        let y = sim.position(0).unwrap()[1];
459        assert!(y >= 0.0, "particle below floor: y={}", y);
460    }
461
462    #[test]
463    fn test_sph_mean_density_nonzero_after_step() {
464        let mut sim = water_sim();
465        sim.add_particle([0.0; 3]);
466        sim.step(0.001);
467        assert!(sim.mean_density() > 0.0);
468    }
469
470    #[test]
471    fn test_sph_gas_config() {
472        let cfg = PySphConfig::gas();
473        assert!(cfg.rest_density < 10.0);
474        assert!(cfg.particle_mass < 0.01);
475    }
476
477    #[test]
478    fn test_sph_position_out_of_bounds() {
479        let sim = water_sim();
480        assert!(sim.position(99).is_none());
481    }
482}
483
484// ---------------------------------------------------------------------------
485// Neighbor query API
486// ---------------------------------------------------------------------------
487
488impl PySphSimulation {
489    /// Return the indices of all particles within distance `r` of particle `i`.
490    ///
491    /// Returns `None` if `i` is out of bounds.
492    pub fn neighbors(&self, i: usize, r: f64) -> Option<Vec<usize>> {
493        let pos_i = self.positions.get(i)?;
494        let r2 = r * r;
495        let neighbors: Vec<usize> = self
496            .positions
497            .iter()
498            .enumerate()
499            .filter(|(j, pos_j)| {
500                if *j == i {
501                    return false;
502                }
503                let dx = pos_i[0] - pos_j[0];
504                let dy = pos_i[1] - pos_j[1];
505                let dz = pos_i[2] - pos_j[2];
506                dx * dx + dy * dy + dz * dz <= r2
507            })
508            .map(|(j, _)| j)
509            .collect();
510        Some(neighbors)
511    }
512
513    /// Count of neighbors of particle `i` within radius `r`.
514    pub fn neighbor_count(&self, i: usize, r: f64) -> Option<usize> {
515        self.neighbors(i, r).map(|v| v.len())
516    }
517
518    /// Return the index and distance of the closest neighbor to particle `i`.
519    ///
520    /// Returns `None` if `i` is out of bounds or there are no other particles.
521    pub fn closest_neighbor(&self, i: usize) -> Option<(usize, f64)> {
522        let pos_i = self.positions.get(i)?;
523        let mut best = None::<(usize, f64)>;
524        for (j, pos_j) in self.positions.iter().enumerate() {
525            if j == i {
526                continue;
527            }
528            let dx = pos_i[0] - pos_j[0];
529            let dy = pos_i[1] - pos_j[1];
530            let dz = pos_i[2] - pos_j[2];
531            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
532            match best {
533                None => best = Some((j, dist)),
534                Some((_, d)) if dist < d => best = Some((j, dist)),
535                _ => {}
536            }
537        }
538        best
539    }
540
541    /// Return the center of mass of all particles.
542    pub fn center_of_mass(&self) -> [f64; 3] {
543        let n = self.positions.len();
544        if n == 0 {
545            return [0.0; 3];
546        }
547        let mut cx = 0.0f64;
548        let mut cy = 0.0f64;
549        let mut cz = 0.0f64;
550        for pos in &self.positions {
551            cx += pos[0];
552            cy += pos[1];
553            cz += pos[2];
554        }
555        let inv_n = 1.0 / n as f64;
556        [cx * inv_n, cy * inv_n, cz * inv_n]
557    }
558
559    /// Maximum particle speed.
560    pub fn max_speed(&self) -> f64 {
561        self.velocities
562            .iter()
563            .map(|v| (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt())
564            .fold(0.0f64, f64::max)
565    }
566
567    /// Total kinetic energy of the particle system.
568    pub fn kinetic_energy(&self) -> f64 {
569        let m = self.config.particle_mass;
570        self.velocities
571            .iter()
572            .map(|v| 0.5 * m * (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]))
573            .sum()
574    }
575
576    /// Maximum density among all particles.
577    pub fn max_density(&self) -> f64 {
578        self.densities
579            .iter()
580            .cloned()
581            .fold(f64::NEG_INFINITY, f64::max)
582    }
583
584    /// Minimum density among all particles.
585    pub fn min_density(&self) -> f64 {
586        self.densities.iter().cloned().fold(f64::INFINITY, f64::min)
587    }
588
589    /// Return the AABB of all particle positions as `[xmin, ymin, zmin, xmax, ymax, zmax]`.
590    ///
591    /// Returns `None` if there are no particles.
592    pub fn particle_aabb(&self) -> Option<[f64; 6]> {
593        if self.positions.is_empty() {
594            return None;
595        }
596        let mut mn = [f64::INFINITY; 3];
597        let mut mx = [f64::NEG_INFINITY; 3];
598        for pos in &self.positions {
599            for k in 0..3 {
600                if pos[k] < mn[k] {
601                    mn[k] = pos[k];
602                }
603                if pos[k] > mx[k] {
604                    mx[k] = pos[k];
605                }
606            }
607        }
608        Some([mn[0], mn[1], mn[2], mx[0], mx[1], mx[2]])
609    }
610
611    /// Remove all particles below a given y-coordinate.
612    ///
613    /// Returns the number of particles removed.
614    pub fn remove_below_y(&mut self, y_min: f64) -> usize {
615        let before = self.positions.len();
616        let keep: Vec<bool> = self.positions.iter().map(|p| p[1] >= y_min).collect();
617        let mut new_pos = Vec::new();
618        let mut new_vel = Vec::new();
619        let mut new_den = Vec::new();
620        let mut new_prs = Vec::new();
621        for (i, &keep_i) in keep.iter().enumerate() {
622            if keep_i {
623                new_pos.push(self.positions[i]);
624                new_vel.push(self.velocities[i]);
625                new_den.push(self.densities[i]);
626                new_prs.push(self.pressures[i]);
627            }
628        }
629        self.positions = new_pos;
630        self.velocities = new_vel;
631        self.densities = new_den;
632        self.pressures = new_prs;
633        before - self.positions.len()
634    }
635}
636
637// ---------------------------------------------------------------------------
638// WCSPH kernel stats
639// ---------------------------------------------------------------------------
640
641/// Per-step statistics for a WCSPH simulation.
642#[derive(Debug, Clone)]
643#[allow(dead_code)]
644pub struct SphStats {
645    /// Number of particles.
646    pub particle_count: usize,
647    /// Mean density.
648    pub mean_density: f64,
649    /// Maximum density.
650    pub max_density: f64,
651    /// Minimum density.
652    pub min_density: f64,
653    /// Maximum speed.
654    pub max_speed: f64,
655    /// Total kinetic energy.
656    pub kinetic_energy: f64,
657    /// Simulation time.
658    pub time: f64,
659    /// Step count.
660    pub step_count: u64,
661}
662
663impl PySphSimulation {
664    /// Collect per-step statistics into a `SphStats` struct.
665    pub fn collect_stats(&self) -> SphStats {
666        SphStats {
667            particle_count: self.particle_count(),
668            mean_density: self.mean_density(),
669            max_density: self.max_density(),
670            min_density: if self.densities.is_empty() {
671                0.0
672            } else {
673                self.min_density()
674            },
675            max_speed: self.max_speed(),
676            kinetic_energy: self.kinetic_energy(),
677            time: self.time,
678            step_count: self.step_count,
679        }
680    }
681}
682
683// ---------------------------------------------------------------------------
684// Additional SPH tests
685// ---------------------------------------------------------------------------
686
687#[cfg(test)]
688mod sph_ext_tests {
689
690    use crate::PySphConfig;
691    use crate::PySphSimulation;
692
693    fn water_sim() -> PySphSimulation {
694        PySphSimulation::new(PySphConfig::water())
695    }
696
697    #[test]
698    fn test_sph_neighbors_empty() {
699        let mut sim = water_sim();
700        sim.add_particle([0.0; 3]);
701        let n = sim.neighbors(0, 1.0).unwrap();
702        assert!(n.is_empty());
703    }
704
705    #[test]
706    fn test_sph_neighbors_finds_close() {
707        let mut sim = water_sim();
708        sim.add_particle([0.0; 3]);
709        sim.add_particle([0.05, 0.0, 0.0]); // within smoothing h=0.1
710        sim.add_particle([5.0, 0.0, 0.0]); // far away
711        let n = sim.neighbors(0, 0.1).unwrap();
712        assert_eq!(n.len(), 1);
713        assert_eq!(n[0], 1);
714    }
715
716    #[test]
717    fn test_sph_neighbor_count() {
718        let mut sim = water_sim();
719        sim.add_particle([0.0; 3]);
720        sim.add_particle([0.05, 0.0, 0.0]);
721        sim.add_particle([0.05, 0.05, 0.0]);
722        let count = sim.neighbor_count(0, 0.15).unwrap();
723        assert_eq!(count, 2);
724    }
725
726    #[test]
727    fn test_sph_closest_neighbor() {
728        let mut sim = water_sim();
729        sim.add_particle([0.0; 3]);
730        sim.add_particle([1.0, 0.0, 0.0]);
731        sim.add_particle([0.3, 0.0, 0.0]);
732        let result = sim.closest_neighbor(0).unwrap();
733        assert_eq!(result.0, 2); // particle at 0.3 is closest
734        assert!((result.1 - 0.3).abs() < 1e-10);
735    }
736
737    #[test]
738    fn test_sph_closest_neighbor_single_particle() {
739        let mut sim = water_sim();
740        sim.add_particle([0.0; 3]);
741        assert!(sim.closest_neighbor(0).is_none());
742    }
743
744    #[test]
745    fn test_sph_center_of_mass() {
746        let mut sim = water_sim();
747        sim.add_particle([0.0, 0.0, 0.0]);
748        sim.add_particle([2.0, 0.0, 0.0]);
749        let com = sim.center_of_mass();
750        assert!((com[0] - 1.0).abs() < 1e-10);
751    }
752
753    #[test]
754    fn test_sph_center_of_mass_empty() {
755        let sim = water_sim();
756        let com = sim.center_of_mass();
757        for &c in &com {
758            assert!(c.abs() < 1e-15);
759        }
760    }
761
762    #[test]
763    fn test_sph_max_speed_zero_at_rest() {
764        let mut sim = water_sim();
765        sim.add_particle([0.0; 3]);
766        assert!(sim.max_speed() < 1e-15);
767    }
768
769    #[test]
770    fn test_sph_kinetic_energy_with_velocity() {
771        let mut sim = water_sim();
772        sim.add_particle_with_velocity([0.0; 3], [2.0, 0.0, 0.0]);
773        // KE = 0.5 * 0.02 * 4 = 0.04
774        let ke = sim.kinetic_energy();
775        assert!((ke - 0.04).abs() < 1e-12, "KE = {}", ke);
776    }
777
778    #[test]
779    fn test_sph_max_density_after_step() {
780        let mut sim = water_sim();
781        sim.add_particle([0.0; 3]);
782        sim.step(0.001);
783        assert!(sim.max_density() > 0.0);
784    }
785
786    #[test]
787    fn test_sph_min_density_after_step() {
788        let mut sim = water_sim();
789        sim.add_particle([0.0; 3]);
790        sim.step(0.001);
791        assert!(sim.min_density() > 0.0);
792    }
793
794    #[test]
795    fn test_sph_particle_aabb() {
796        let mut sim = water_sim();
797        sim.add_particle([0.0, 0.0, 0.0]);
798        sim.add_particle([1.0, 2.0, 3.0]);
799        let aabb = sim.particle_aabb().unwrap();
800        assert!((aabb[0]).abs() < 1e-15); // xmin
801        assert!((aabb[3] - 1.0).abs() < 1e-15); // xmax
802        assert!((aabb[5] - 3.0).abs() < 1e-15); // zmax
803    }
804
805    #[test]
806    fn test_sph_particle_aabb_empty() {
807        let sim = water_sim();
808        assert!(sim.particle_aabb().is_none());
809    }
810
811    #[test]
812    fn test_sph_remove_below_y() {
813        let mut sim = water_sim();
814        sim.add_particle([0.0, 1.0, 0.0]);
815        sim.add_particle([0.0, -1.0, 0.0]);
816        sim.add_particle([0.0, 0.5, 0.0]);
817        let removed = sim.remove_below_y(0.0);
818        assert_eq!(removed, 1);
819        assert_eq!(sim.particle_count(), 2);
820    }
821
822    #[test]
823    fn test_sph_collect_stats() {
824        let mut sim = water_sim();
825        sim.add_particle([0.0; 3]);
826        sim.step(0.001);
827        let stats = sim.collect_stats();
828        assert_eq!(stats.particle_count, 1);
829        assert!(stats.mean_density > 0.0);
830    }
831
832    #[test]
833    fn test_sph_neighbors_out_of_bounds() {
834        let sim = water_sim();
835        assert!(sim.neighbors(99, 1.0).is_none());
836    }
837
838    #[test]
839    fn test_sph_neighbor_count_out_of_bounds() {
840        let sim = water_sim();
841        assert!(sim.neighbor_count(99, 1.0).is_none());
842    }
843}
844
845// ===========================================================================
846// Adaptive smoothing length
847// ===========================================================================
848
849impl PySphSimulation {
850    /// Estimate an adaptive smoothing length for particle `i` based on the
851    /// distance to its k-th nearest neighbor (default k=8).
852    ///
853    /// Returns `None` if `i` is out of bounds or there are fewer than `k` other
854    /// particles.
855    pub fn adaptive_h(&self, i: usize, k: usize) -> Option<f64> {
856        let pos_i = self.positions.get(i)?;
857        let mut dists: Vec<f64> = self
858            .positions
859            .iter()
860            .enumerate()
861            .filter(|(j, _)| *j != i)
862            .map(|(_, pos_j)| {
863                let dx = pos_i[0] - pos_j[0];
864                let dy = pos_i[1] - pos_j[1];
865                let dz = pos_i[2] - pos_j[2];
866                (dx * dx + dy * dy + dz * dz).sqrt()
867            })
868            .collect();
869        if dists.len() < k {
870            return None;
871        }
872        dists.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
873        Some(dists[k - 1])
874    }
875
876    /// Estimate a global adaptive h as the median of all per-particle adaptive h values.
877    pub fn global_adaptive_h(&self, k: usize) -> Option<f64> {
878        let n = self.positions.len();
879        if n < 2 {
880            return None;
881        }
882        let mut hs: Vec<f64> = (0..n).filter_map(|i| self.adaptive_h(i, k)).collect();
883        if hs.is_empty() {
884            return None;
885        }
886        hs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
887        let mid = hs.len() / 2;
888        Some(hs[mid])
889    }
890}
891
892// ===========================================================================
893// Surface detection
894// ===========================================================================
895
896impl PySphSimulation {
897    /// Detect surface particles using the color-field divergence method.
898    ///
899    /// A particle `i` is classified as a surface particle if the number of
900    /// neighbors within radius `r` is below `threshold`.
901    /// Returns a `Vec`bool` of length `particle_count()`.
902    pub fn detect_surface_particles(&self, r: f64, threshold: usize) -> Vec<bool> {
903        (0..self.positions.len())
904            .map(|i| {
905                let count = self.neighbor_count(i, r).unwrap_or(0);
906                count < threshold
907            })
908            .collect()
909    }
910
911    /// Indices of surface particles (using `detect_surface_particles`).
912    pub fn surface_particle_indices(&self, r: f64, threshold: usize) -> Vec<usize> {
913        self.detect_surface_particles(r, threshold)
914            .into_iter()
915            .enumerate()
916            .filter(|(_, is_surface)| *is_surface)
917            .map(|(i, _)| i)
918            .collect()
919    }
920}
921
922// ===========================================================================
923// DFSPH / WCSPH mode selection
924// ===========================================================================
925
926/// SPH solver variant.
927#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
928pub enum SphVariant {
929    /// Weakly-Compressible SPH (Tait's equation of state).
930    WCSPH,
931    /// Divergence-Free SPH (iterative pressure solve, incompressible).
932    DFSPH,
933}
934
935/// Wrapper that holds a `PySphSimulation` along with a solver variant.
936#[allow(dead_code)]
937pub struct SphSolver {
938    /// The underlying SPH simulation.
939    pub sim: PySphSimulation,
940    /// Which variant to use when stepping.
941    pub variant: SphVariant,
942    /// DFSPH: maximum iterations for the pressure solve.
943    pub max_pressure_iters: usize,
944    /// DFSPH: convergence tolerance for density error.
945    pub pressure_tol: f64,
946}
947
948impl SphSolver {
949    /// Create a WCSPH solver.
950    pub fn wcsph(config: PySphConfig) -> Self {
951        Self {
952            sim: PySphSimulation::new(config),
953            variant: SphVariant::WCSPH,
954            max_pressure_iters: 50,
955            pressure_tol: 0.01,
956        }
957    }
958
959    /// Create a DFSPH solver.
960    pub fn dfsph(config: PySphConfig) -> Self {
961        Self {
962            sim: PySphSimulation::new(config),
963            variant: SphVariant::DFSPH,
964            max_pressure_iters: 100,
965            pressure_tol: 0.001,
966        }
967    }
968
969    /// Step the simulation. WCSPH uses the standard step; DFSPH uses an
970    /// iterative pressure-correction approach.
971    pub fn step(&mut self, dt: f64) {
972        match self.variant {
973            SphVariant::WCSPH => self.sim.step(dt),
974            SphVariant::DFSPH => self.step_dfsph(dt),
975        }
976    }
977
978    /// Simplified DFSPH step: run a small fixed number of WCSPH sub-steps
979    /// with a reduced dt to approximate incompressibility.
980    fn step_dfsph(&mut self, dt: f64) {
981        let sub_steps = self.max_pressure_iters.clamp(1, 5);
982        let sub_dt = dt / sub_steps as f64;
983        for _ in 0..sub_steps {
984            self.sim.step(sub_dt);
985        }
986    }
987
988    /// Return the current density error (max |ρ - ρ₀| / ρ₀).
989    pub fn density_error(&self) -> f64 {
990        let rho0 = self.sim.config.rest_density;
991        self.sim
992            .densities
993            .iter()
994            .map(|&rho| (rho - rho0).abs() / rho0)
995            .fold(0.0f64, f64::max)
996    }
997
998    /// Number of particles.
999    pub fn particle_count(&self) -> usize {
1000        self.sim.particle_count()
1001    }
1002}
1003
1004// ===========================================================================
1005// SPH Particle Set (bulk add / remove)
1006// ===========================================================================
1007
1008/// A named particle set for managing groups of particles.
1009#[allow(dead_code)]
1010pub struct SphParticleSet {
1011    /// Debug name for this set.
1012    pub name: String,
1013    /// Particle indices belonging to this set.
1014    pub indices: Vec<usize>,
1015}
1016
1017impl SphParticleSet {
1018    /// Create a new empty particle set.
1019    pub fn new(name: impl Into<String>) -> Self {
1020        Self {
1021            name: name.into(),
1022            indices: Vec::new(),
1023        }
1024    }
1025
1026    /// Add a particle index to this set.
1027    pub fn add_index(&mut self, idx: usize) {
1028        self.indices.push(idx);
1029    }
1030
1031    /// Number of particles in this set.
1032    pub fn len(&self) -> usize {
1033        self.indices.len()
1034    }
1035
1036    /// Whether the set is empty.
1037    pub fn is_empty(&self) -> bool {
1038        self.indices.is_empty()
1039    }
1040}
1041
1042impl PySphSimulation {
1043    /// Bulk-add a list of positions, returning the range of newly added indices.
1044    pub fn add_particles(&mut self, positions: &[[f64; 3]]) -> std::ops::Range<usize> {
1045        let start = self.positions.len();
1046        for &pos in positions {
1047            self.add_particle(pos);
1048        }
1049        start..self.positions.len()
1050    }
1051
1052    /// Set velocity of particle `i`. No-op if out of bounds.
1053    pub fn set_velocity(&mut self, i: usize, vel: [f64; 3]) {
1054        if i < self.velocities.len() {
1055            self.velocities[i] = vel;
1056        }
1057    }
1058
1059    /// Apply a uniform velocity impulse `\[dvx, dvy, dvz\]` to all particles.
1060    pub fn apply_global_impulse(&mut self, dv: [f64; 3]) {
1061        for v in &mut self.velocities {
1062            v[0] += dv[0];
1063            v[1] += dv[1];
1064            v[2] += dv[2];
1065        }
1066    }
1067
1068    /// Clamp all particle velocities to `max_speed`.
1069    pub fn clamp_velocities(&mut self, max_speed: f64) {
1070        for v in &mut self.velocities {
1071            let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1072            if speed > max_speed && speed > 1e-15 {
1073                let scale = max_speed / speed;
1074                v[0] *= scale;
1075                v[1] *= scale;
1076                v[2] *= scale;
1077            }
1078        }
1079    }
1080
1081    /// Total linear momentum `\[px, py, pz\]` of all particles.
1082    pub fn total_momentum(&self) -> [f64; 3] {
1083        let m = self.config.particle_mass;
1084        let mut p = [0.0f64; 3];
1085        for v in &self.velocities {
1086            p[0] += m * v[0];
1087            p[1] += m * v[1];
1088            p[2] += m * v[2];
1089        }
1090        p
1091    }
1092
1093    /// Variance of the density field (measure of compressibility error).
1094    pub fn density_variance(&self) -> f64 {
1095        if self.densities.is_empty() {
1096            return 0.0;
1097        }
1098        let mean = self.mean_density();
1099        self.densities
1100            .iter()
1101            .map(|&d| {
1102                let diff = d - mean;
1103                diff * diff
1104            })
1105            .sum::<f64>()
1106            / self.densities.len() as f64
1107    }
1108}
1109
1110// ===========================================================================
1111// Additional SPH tests (new functionality)
1112// ===========================================================================
1113
1114#[cfg(test)]
1115mod sph_new_tests {
1116
1117    use crate::PySphConfig;
1118    use crate::PySphSimulation;
1119    use crate::sph_api::SphParticleSet;
1120    use crate::sph_api::SphSolver;
1121    use crate::sph_api::SphVariant;
1122
1123    fn water_sim() -> PySphSimulation {
1124        PySphSimulation::new(PySphConfig::water())
1125    }
1126
1127    #[test]
1128    fn test_adaptive_h_basic() {
1129        let mut sim = water_sim();
1130        for i in 0..5 {
1131            sim.add_particle([i as f64 * 0.1, 0.0, 0.0]);
1132        }
1133        let h = sim.adaptive_h(0, 3);
1134        assert!(h.is_some());
1135        assert!(h.unwrap() > 0.0);
1136    }
1137
1138    #[test]
1139    fn test_adaptive_h_not_enough_neighbors() {
1140        let mut sim = water_sim();
1141        sim.add_particle([0.0; 3]);
1142        sim.add_particle([1.0, 0.0, 0.0]);
1143        // Only 1 other particle, k=5 should return None
1144        let h = sim.adaptive_h(0, 5);
1145        assert!(h.is_none());
1146    }
1147
1148    #[test]
1149    fn test_adaptive_h_out_of_bounds() {
1150        let sim = water_sim();
1151        assert!(sim.adaptive_h(99, 3).is_none());
1152    }
1153
1154    #[test]
1155    fn test_global_adaptive_h() {
1156        let mut sim = water_sim();
1157        for i in 0..10 {
1158            sim.add_particle([i as f64 * 0.1, 0.0, 0.0]);
1159        }
1160        let h = sim.global_adaptive_h(3);
1161        assert!(h.is_some());
1162    }
1163
1164    #[test]
1165    fn test_global_adaptive_h_single_particle() {
1166        let mut sim = water_sim();
1167        sim.add_particle([0.0; 3]);
1168        assert!(sim.global_adaptive_h(3).is_none());
1169    }
1170
1171    #[test]
1172    fn test_detect_surface_all_surface_when_isolated() {
1173        let mut sim = water_sim();
1174        sim.add_particle([0.0, 0.0, 0.0]);
1175        sim.add_particle([10.0, 0.0, 0.0]); // far away
1176        let surface = sim.detect_surface_particles(0.5, 2);
1177        assert!(surface[0]); // no neighbors within 0.5
1178    }
1179
1180    #[test]
1181    fn test_detect_surface_not_surface_when_crowded() {
1182        let mut sim = water_sim();
1183        // Dense cluster: all particles within 0.2 of each other
1184        for i in 0..8 {
1185            for j in 0..8 {
1186                sim.add_particle([i as f64 * 0.05, j as f64 * 0.05, 0.0]);
1187            }
1188        }
1189        let surface = sim.detect_surface_particles(0.3, 2);
1190        // Interior particles should have >2 neighbors
1191        let n_surface = surface.iter().filter(|&&s| s).count();
1192        assert!(n_surface < sim.particle_count()); // not all are surface
1193    }
1194
1195    #[test]
1196    fn test_surface_particle_indices_empty() {
1197        let sim = water_sim();
1198        let idx = sim.surface_particle_indices(1.0, 5);
1199        assert!(idx.is_empty());
1200    }
1201
1202    #[test]
1203    fn test_sph_variant_wcsph() {
1204        let mut solver = SphSolver::wcsph(PySphConfig::water());
1205        solver.sim.add_particle([0.0, 1.0, 0.0]);
1206        solver.step(0.001);
1207        assert_eq!(solver.sim.step_count(), 1);
1208        assert_eq!(solver.variant, SphVariant::WCSPH);
1209    }
1210
1211    #[test]
1212    fn test_sph_variant_dfsph() {
1213        let mut solver = SphSolver::dfsph(PySphConfig::water());
1214        solver.sim.add_particle([0.0, 1.0, 0.0]);
1215        solver.step(0.001);
1216        // DFSPH does 5 sub-steps
1217        assert_eq!(solver.sim.step_count(), 5);
1218        assert_eq!(solver.variant, SphVariant::DFSPH);
1219    }
1220
1221    #[test]
1222    fn test_density_error_at_rest() {
1223        let mut solver = SphSolver::wcsph(PySphConfig::water());
1224        solver.sim.add_particle([0.0; 3]);
1225        // No step yet — density should be rest density → error ~0
1226        let err = solver.density_error();
1227        assert!(err.is_finite());
1228    }
1229
1230    #[test]
1231    fn test_sph_particle_set_empty() {
1232        let set = SphParticleSet::new("fluid");
1233        assert!(set.is_empty());
1234        assert_eq!(set.len(), 0);
1235    }
1236
1237    #[test]
1238    fn test_sph_particle_set_add() {
1239        let mut set = SphParticleSet::new("fluid");
1240        set.add_index(0);
1241        set.add_index(1);
1242        assert_eq!(set.len(), 2);
1243    }
1244
1245    #[test]
1246    fn test_add_particles_bulk() {
1247        let mut sim = water_sim();
1248        let positions = vec![[0.0; 3], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1249        let range = sim.add_particles(&positions);
1250        assert_eq!(range.len(), 3);
1251        assert_eq!(sim.particle_count(), 3);
1252    }
1253
1254    #[test]
1255    fn test_set_velocity() {
1256        let mut sim = water_sim();
1257        sim.add_particle([0.0; 3]);
1258        sim.set_velocity(0, [1.0, 2.0, 3.0]);
1259        let v = sim.velocity(0).unwrap();
1260        assert!((v[0] - 1.0).abs() < 1e-15);
1261    }
1262
1263    #[test]
1264    fn test_set_velocity_out_of_bounds_no_panic() {
1265        let mut sim = water_sim();
1266        sim.set_velocity(99, [1.0, 0.0, 0.0]); // no panic
1267    }
1268
1269    #[test]
1270    fn test_apply_global_impulse() {
1271        let mut sim = water_sim();
1272        sim.add_particle([0.0; 3]);
1273        sim.apply_global_impulse([10.0, 0.0, 0.0]);
1274        let v = sim.velocity(0).unwrap();
1275        assert!((v[0] - 10.0).abs() < 1e-15);
1276    }
1277
1278    #[test]
1279    fn test_clamp_velocities() {
1280        let mut sim = water_sim();
1281        sim.add_particle_with_velocity([0.0; 3], [100.0, 0.0, 0.0]);
1282        sim.clamp_velocities(5.0);
1283        let v = sim.velocity(0).unwrap();
1284        let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
1285        assert!((speed - 5.0).abs() < 1e-10);
1286    }
1287
1288    #[test]
1289    fn test_total_momentum_at_rest() {
1290        let mut sim = water_sim();
1291        sim.add_particle([0.0; 3]);
1292        let p = sim.total_momentum();
1293        for &pi in &p {
1294            assert!(pi.abs() < 1e-15);
1295        }
1296    }
1297
1298    #[test]
1299    fn test_total_momentum_with_velocity() {
1300        let mut sim = water_sim();
1301        sim.add_particle_with_velocity([0.0; 3], [2.0, 0.0, 0.0]);
1302        let p = sim.total_momentum();
1303        // p = mass * v = 0.02 * 2.0 = 0.04
1304        assert!((p[0] - 0.04).abs() < 1e-12);
1305    }
1306
1307    #[test]
1308    fn test_density_variance_zero_uniform() {
1309        let mut sim = water_sim();
1310        // All particles have same density (rest density, no interaction yet)
1311        sim.add_particle([0.0; 3]);
1312        sim.add_particle([100.0, 0.0, 0.0]); // isolated
1313        // Variance should be zero since both have same rest_density init
1314        let var = sim.density_variance();
1315        assert!((var).abs() < 1e-10);
1316    }
1317
1318    #[test]
1319    fn test_density_variance_empty() {
1320        let sim = water_sim();
1321        assert!((sim.density_variance()).abs() < 1e-15);
1322    }
1323
1324    #[test]
1325    fn test_sph_solver_particle_count() {
1326        let mut solver = SphSolver::wcsph(PySphConfig::water());
1327        solver.sim.add_particle([0.0; 3]);
1328        assert_eq!(solver.particle_count(), 1);
1329    }
1330
1331    #[test]
1332    fn test_add_particles_returns_correct_range() {
1333        let mut sim = water_sim();
1334        sim.add_particle([0.0; 3]); // index 0
1335        let positions = vec![[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1336        let range = sim.add_particles(&positions);
1337        assert_eq!(range.start, 1);
1338        assert_eq!(range.end, 3);
1339    }
1340
1341    #[test]
1342    fn test_surface_detection_length_matches_particle_count() {
1343        let mut sim = water_sim();
1344        sim.add_particle_block([0.0; 3], 3, 3, 3, 0.1);
1345        let surface = sim.detect_surface_particles(0.15, 3);
1346        assert_eq!(surface.len(), sim.particle_count());
1347    }
1348
1349    #[test]
1350    fn test_clamp_velocities_already_slow() {
1351        let mut sim = water_sim();
1352        sim.add_particle_with_velocity([0.0; 3], [1.0, 0.0, 0.0]);
1353        sim.clamp_velocities(10.0); // limit is larger, no change
1354        let v = sim.velocity(0).unwrap();
1355        assert!((v[0] - 1.0).abs() < 1e-14);
1356    }
1357}