Skip to main content

oxiphysics_gpu/cell_list/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use rayon::prelude::*;
6
7use super::types::GpuCellList;
8
9/// Expand a 10-bit integer by inserting 2 zero bits after each bit.
10///
11/// `x` must be in `[0, 1023]`.
12pub(super) fn expand_bits(mut x: u32) -> u32 {
13    x &= 0x000003FF;
14    x = (x | (x << 16)) & 0x030000FF;
15    x = (x | (x << 8)) & 0x0300F00F;
16    x = (x | (x << 4)) & 0x030C30C3;
17    x = (x | (x << 2)) & 0x09249249;
18    x
19}
20/// Compute a 30-bit Morton code from 3D integer coordinates.
21///
22/// Each coordinate must be in `[0, 1023]`.
23pub fn morton_encode(x: u32, y: u32, z: u32) -> u32 {
24    expand_bits(x) | (expand_bits(y) << 1) | (expand_bits(z) << 2)
25}
26/// Decode a 30-bit Morton code back to 3D integer coordinates.
27pub fn morton_decode(code: u32) -> (u32, u32, u32) {
28    (
29        compact_bits(code),
30        compact_bits(code >> 1),
31        compact_bits(code >> 2),
32    )
33}
34/// Compact every third bit (inverse of expand_bits).
35pub(super) fn compact_bits(mut x: u32) -> u32 {
36    x &= 0x09249249;
37    x = (x | (x >> 2)) & 0x030C30C3;
38    x = (x | (x >> 4)) & 0x0300F00F;
39    x = (x | (x >> 8)) & 0x030000FF;
40    x = (x | (x >> 16)) & 0x000003FF;
41    x
42}
43/// Compute Morton codes for a set of positions and sort indices by Morton code.
44///
45/// `positions` - particle positions
46/// `box_min` - minimum corner of the bounding box
47/// `box_max` - maximum corner of the bounding box
48///
49/// Returns `(sorted_indices, morton_codes)` where `sorted_indices[i]` is the
50/// original index of the particle that should be in position `i` after sorting.
51pub fn morton_sort(
52    positions: &[[f64; 3]],
53    box_min: [f64; 3],
54    box_max: [f64; 3],
55) -> (Vec<usize>, Vec<u32>) {
56    let range = [
57        (box_max[0] - box_min[0]).max(1e-10),
58        (box_max[1] - box_min[1]).max(1e-10),
59        (box_max[2] - box_min[2]).max(1e-10),
60    ];
61    let mut codes: Vec<(u32, usize)> = positions
62        .par_iter()
63        .enumerate()
64        .map(|(i, p)| {
65            let x = (((p[0] - box_min[0]) / range[0] * 1023.0) as u32).min(1023);
66            let y = (((p[1] - box_min[1]) / range[1] * 1023.0) as u32).min(1023);
67            let z = (((p[2] - box_min[2]) / range[2] * 1023.0) as u32).min(1023);
68            (morton_encode(x, y, z), i)
69        })
70        .collect();
71    codes.sort_by_key(|&(code, _)| code);
72    let sorted_indices: Vec<usize> = codes.iter().map(|&(_, idx)| idx).collect();
73    let morton_codes: Vec<u32> = codes.iter().map(|&(code, _)| code).collect();
74    (sorted_indices, morton_codes)
75}
76/// Compute an exclusive prefix sum (scan) over `counts`.
77pub fn parallel_prefix_sum(counts: &[usize]) -> Vec<usize> {
78    let mut out = Vec::with_capacity(counts.len());
79    let mut acc = 0usize;
80    for &c in counts {
81        out.push(acc);
82        acc += c;
83    }
84    out
85}
86/// Compute the bounding box of a set of positions.
87///
88/// Returns `(min, max)` corner coordinates.
89pub fn compute_bounding_box(positions: &[[f64; 3]]) -> ([f64; 3], [f64; 3]) {
90    if positions.is_empty() {
91        return ([0.0; 3], [0.0; 3]);
92    }
93    let mut min = positions[0];
94    let mut max = positions[0];
95    for p in positions {
96        for d in 0..3 {
97            if p[d] < min[d] {
98                min[d] = p[d];
99            }
100            if p[d] > max[d] {
101                max[d] = p[d];
102            }
103        }
104    }
105    (min, max)
106}
107/// Reorder an array according to a permutation.
108///
109/// `perm[i]` is the original index of the element that should appear at position `i`.
110pub fn reorder_by_permutation<T: Clone>(data: &[T], perm: &[usize]) -> Vec<T> {
111    perm.iter().map(|&i| data[i].clone()).collect()
112}
113/// Mock radix sort: stable sort of `keys` returning `(sorted_keys, sorted_indices)`.
114///
115/// `sorted_indices[i]` is the original index of the element now at position `i`.
116/// This is a CPU reference implementation matching the expected GPU radix sort output.
117pub fn radix_sort_mock(keys: &[u32]) -> (Vec<u32>, Vec<usize>) {
118    if keys.is_empty() {
119        return (vec![], vec![]);
120    }
121    let mut indexed: Vec<(u32, usize)> = keys.iter().copied().zip(0..).collect();
122    indexed.sort_by_key(|&(k, _)| k);
123    let sorted_keys: Vec<u32> = indexed.iter().map(|&(k, _)| k).collect();
124    let sorted_indices: Vec<usize> = indexed.iter().map(|&(_, i)| i).collect();
125    (sorted_keys, sorted_indices)
126}
127/// Exclusive prefix sum (scan) of `counts`.
128///
129/// For input `[a, b, c, d]` the output is `[0, a, a+b, a+b+c]`.
130/// This mirrors what a parallel GPU prefix-sum kernel would produce.
131pub fn gpu_prefix_sum(counts: &[usize]) -> Vec<usize> {
132    let mut out = Vec::with_capacity(counts.len());
133    let mut running = 0usize;
134    for &c in counts {
135        out.push(running);
136        running += c;
137    }
138    out
139}
140/// Count how many particles fall into each cell of a regular grid.
141///
142/// The grid has `n_cells = [nx, ny, nz]` cells, each of side length `cell_size`.
143/// The origin is `[0, 0, 0]`.  Particles outside the grid are clamped to the
144/// nearest boundary cell.
145///
146/// Returns a flat Vec of length `nx * ny * nz`.
147pub fn parallel_count_particles(
148    positions: &[[f64; 3]],
149    n_cells: [usize; 3],
150    cell_size: f64,
151) -> Vec<usize> {
152    let [nx, ny, nz] = n_cells;
153    let total = nx * ny * nz;
154    let mut counts = vec![0usize; total];
155    for p in positions {
156        let ix = ((p[0] / cell_size) as isize).clamp(0, nx as isize - 1) as usize;
157        let iy = ((p[1] / cell_size) as isize).clamp(0, ny as isize - 1) as usize;
158        let iz = ((p[2] / cell_size) as isize).clamp(0, nz as isize - 1) as usize;
159        counts[ix + nx * (iy + ny * iz)] += 1;
160    }
161    counts
162}
163/// Distribute `n_cells` cells evenly across `n_gpus` GPUs.
164///
165/// Returns a Vec of non-overlapping contiguous ranges that together cover
166/// `0..n_cells`.  Remainder cells are spread among the first GPUs.
167pub fn distribute_cells_to_gpus(n_cells: usize, n_gpus: usize) -> Vec<std::ops::Range<usize>> {
168    if n_gpus == 0 || n_cells == 0 {
169        return vec![];
170    }
171    let base = n_cells / n_gpus;
172    let remainder = n_cells % n_gpus;
173    let mut ranges = Vec::with_capacity(n_gpus);
174    let mut start = 0;
175    for gpu in 0..n_gpus {
176        let extra = if gpu < remainder { 1 } else { 0 };
177        let end = start + base + extra;
178        ranges.push(start..end);
179        start = end;
180    }
181    ranges
182}
183/// Return all particle pairs `(i, j)` with `i < j` and `dist(i, j) < cutoff`.
184///
185/// Uses the [`GpuCellList`] for candidate acceleration, filtering by exact
186/// distance afterward.  The returned pairs are in undefined order.
187pub fn gpu_neighbor_search_kernel(
188    cl: &GpuCellList,
189    positions: &[[f64; 3]],
190    cutoff: f64,
191) -> Vec<(usize, usize)> {
192    let mut pairs = Vec::new();
193    cl.for_each_pair(positions, cutoff, |i, j, _d2| {
194        let (a, b) = if i < j { (i, j) } else { (j, i) };
195        pairs.push((a, b));
196    });
197    pairs.sort_unstable();
198    pairs.dedup();
199    pairs
200}
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::cell_list::CellList;
205
206    use crate::cell_list::GpuCellList;
207
208    use crate::cell_list::SpatialHash;
209
210    #[test]
211    fn test_prefix_sum_empty() {
212        assert_eq!(parallel_prefix_sum(&[]), Vec::<usize>::new());
213    }
214    #[test]
215    fn test_prefix_sum_basic() {
216        let counts = [1usize, 2, 3, 4];
217        let result = parallel_prefix_sum(&counts);
218        assert_eq!(result, vec![0, 1, 3, 6]);
219    }
220    #[test]
221    fn test_cell_index_clamp() {
222        let list = GpuCellList::new([4, 4, 4], 1.0, [4.0, 4.0, 4.0]);
223        let idx = list.cell_index([4.5, 4.5, 4.5]);
224        assert_eq!(idx, 3 * 4 * 4 + 3 * 4 + 3);
225    }
226    #[test]
227    fn test_total_cells() {
228        let list = GpuCellList::new([3, 4, 5], 1.0, [3.0, 4.0, 5.0]);
229        assert_eq!(list.total_cells(), 60);
230    }
231    #[test]
232    fn test_build_parallel_counts() {
233        let positions: Vec<[f64; 3]> = vec![
234            [0.5, 0.5, 0.5],
235            [1.5, 0.5, 0.5],
236            [0.5, 1.5, 0.5],
237            [1.5, 1.5, 0.5],
238            [0.5, 0.5, 1.5],
239            [1.5, 0.5, 1.5],
240            [0.5, 1.5, 1.5],
241            [1.5, 1.5, 1.5],
242        ];
243        let cl = GpuCellList::build_parallel(&positions);
244        assert_eq!(cl.sorted_indices.len(), 8);
245        for c in 0..cl.total_cells() {
246            assert_eq!(cl.cell_counts[c], 1);
247        }
248    }
249    #[test]
250    fn test_neighbors_in_radius() {
251        let positions: Vec<[f64; 3]> = vec![[0.5, 0.5, 0.5], [0.6, 0.5, 0.5], [5.0, 5.0, 5.0]];
252        let cl = GpuCellList::build_parallel(&positions);
253        let mut neighbours = cl.neighbors_in_radius(&positions, [0.5, 0.5, 0.5], 0.5);
254        neighbours.sort_unstable();
255        assert!(neighbours.contains(&0));
256        assert!(neighbours.contains(&1));
257        assert!(!neighbours.contains(&2));
258    }
259    #[test]
260    fn cell_list_find_neighbors_all_pairs() {
261        let positions: Vec<[f64; 3]> = vec![
262            [1.0, 1.0, 1.0],
263            [1.2, 1.0, 1.0],
264            [1.0, 1.3, 1.0],
265            [9.0, 9.0, 9.0],
266        ];
267        let cl = CellList::build(&positions);
268        let radius = 0.5;
269        let mut neighbours = cl.find_neighbors([1.0, 1.0, 1.0], radius);
270        neighbours.sort_unstable();
271        assert!(neighbours.contains(&0), "should find self: {neighbours:?}");
272        assert!(
273            neighbours.contains(&1),
274            "should find particle 1: {neighbours:?}"
275        );
276        assert!(
277            neighbours.contains(&2),
278            "should find particle 2: {neighbours:?}"
279        );
280        assert!(
281            !neighbours.contains(&3),
282            "particle 3 is far: {neighbours:?}"
283        );
284    }
285    #[test]
286    fn cell_list_new_compiles() {
287        let cl = CellList::new([10.0, 10.0, 10.0], 2.0);
288        assert_eq!(cl.inner.total_cells(), 125);
289    }
290    /// Morton encode/decode should be inverses.
291    #[test]
292    fn test_morton_roundtrip() {
293        let test_cases = [
294            (0, 0, 0),
295            (1, 0, 0),
296            (0, 1, 0),
297            (0, 0, 1),
298            (7, 3, 5),
299            (1023, 1023, 1023),
300            (512, 256, 128),
301        ];
302        for (x, y, z) in test_cases {
303            let code = morton_encode(x, y, z);
304            let (dx, dy, dz) = morton_decode(code);
305            assert_eq!(dx, x, "x mismatch for ({x},{y},{z})");
306            assert_eq!(dy, y, "y mismatch for ({x},{y},{z})");
307            assert_eq!(dz, z, "z mismatch for ({x},{y},{z})");
308        }
309    }
310    /// Morton codes should preserve locality: nearby points have nearby codes.
311    #[test]
312    fn test_morton_locality() {
313        let c1 = morton_encode(1, 1, 1);
314        let c2 = morton_encode(2, 1, 1);
315        let c_far = morton_encode(100, 100, 100);
316        let d_near = c1.abs_diff(c2);
317        let d_far = c1.abs_diff(c_far);
318        assert!(
319            d_near < d_far,
320            "near distance {d_near} should be less than far {d_far}"
321        );
322    }
323    /// Morton sort should produce a valid permutation.
324    #[test]
325    fn test_morton_sort_permutation() {
326        let positions = vec![[5.0, 5.0, 5.0], [1.0, 1.0, 1.0], [3.0, 3.0, 3.0]];
327        let (indices, codes) = morton_sort(&positions, [0.0; 3], [10.0, 10.0, 10.0]);
328        assert_eq!(indices.len(), 3);
329        assert_eq!(codes.len(), 3);
330        for i in 0..codes.len() - 1 {
331            assert!(codes[i] <= codes[i + 1], "codes not sorted at {i}");
332        }
333        let mut sorted = indices.clone();
334        sorted.sort();
335        assert_eq!(sorted, vec![0, 1, 2]);
336    }
337    /// Spatial hash should find nearby particles.
338    #[test]
339    fn test_spatial_hash_query() {
340        let positions = vec![[0.5, 0.5, 0.5], [0.6, 0.5, 0.5], [5.0, 5.0, 5.0]];
341        let mut hash = SpatialHash::new(64, 1.0);
342        hash.build(&positions);
343        assert_eq!(hash.len(), 3);
344        let mut neighbours = hash.query_radius(&positions, [0.5, 0.5, 0.5], 0.5);
345        neighbours.sort_unstable();
346        neighbours.dedup();
347        assert!(neighbours.contains(&0));
348        assert!(neighbours.contains(&1));
349        assert!(!neighbours.contains(&2));
350    }
351    /// Empty spatial hash.
352    #[test]
353    fn test_spatial_hash_empty() {
354        let hash = SpatialHash::new(64, 1.0);
355        assert!(hash.is_empty());
356        assert_eq!(hash.len(), 0);
357    }
358    /// Spatial hash clear.
359    #[test]
360    fn test_spatial_hash_clear() {
361        let mut hash = SpatialHash::new(64, 1.0);
362        hash.insert(0, [0.5, 0.5, 0.5]);
363        assert!(!hash.is_empty());
364        hash.clear();
365        assert!(hash.is_empty());
366    }
367    /// Bounding box computation.
368    #[test]
369    fn test_bounding_box() {
370        let positions = vec![[1.0, 2.0, 3.0], [4.0, 0.0, 1.0], [2.0, 5.0, 2.0]];
371        let (min, max) = compute_bounding_box(&positions);
372        assert_eq!(min, [1.0, 0.0, 1.0]);
373        assert_eq!(max, [4.0, 5.0, 3.0]);
374    }
375    /// Empty bounding box.
376    #[test]
377    fn test_bounding_box_empty() {
378        let (min, max) = compute_bounding_box(&[]);
379        assert_eq!(min, [0.0; 3]);
380        assert_eq!(max, [0.0; 3]);
381    }
382    /// Reorder by permutation.
383    #[test]
384    fn test_reorder() {
385        let data = vec![10, 20, 30, 40];
386        let perm = vec![3, 1, 0, 2];
387        let reordered = reorder_by_permutation(&data, &perm);
388        assert_eq!(reordered, vec![40, 20, 10, 30]);
389    }
390    /// Max cell occupancy.
391    #[test]
392    fn test_max_cell_occupancy() {
393        let positions: Vec<[f64; 3]> = vec![[0.5, 0.5, 0.5], [0.6, 0.5, 0.5], [5.0, 5.0, 5.0]];
394        let cl = GpuCellList::build_parallel(&positions);
395        let max = cl.max_cell_occupancy();
396        assert!(max >= 2, "max occupancy should be at least 2, got {max}");
397    }
398    /// Non-empty cells count.
399    #[test]
400    fn test_nonempty_cells() {
401        let positions: Vec<[f64; 3]> = vec![[0.5, 0.5, 0.5], [5.0, 5.0, 5.0]];
402        let cl = GpuCellList::build_parallel(&positions);
403        let ne = cl.num_nonempty_cells();
404        assert_eq!(ne, 2, "should have 2 non-empty cells, got {ne}");
405    }
406    /// for_each_pair should find all pairs within cutoff.
407    #[test]
408    fn test_for_each_pair() {
409        let positions: Vec<[f64; 3]> = vec![[0.5, 0.5, 0.5], [0.6, 0.5, 0.5], [5.0, 5.0, 5.0]];
410        let cl = GpuCellList::build_parallel(&positions);
411        let mut pairs = Vec::new();
412        cl.for_each_pair(&positions, 0.5, |i, j, _d2| {
413            pairs.push((i.min(j), i.max(j)));
414        });
415        pairs.sort();
416        pairs.dedup();
417        assert!(
418            pairs.contains(&(0, 1)),
419            "should find pair (0,1), got {pairs:?}"
420        );
421        assert!(
422            !pairs.iter().any(|&(a, b)| a == 2 || b == 2),
423            "should not find pairs with particle 2"
424        );
425    }
426    #[test]
427    fn test_radix_sort_sorted_output() {
428        let keys = vec![5u32, 1, 9, 3, 7, 2];
429        let (sorted_keys, sorted_indices) = radix_sort_mock(&keys);
430        for i in 0..sorted_keys.len() - 1 {
431            assert!(
432                sorted_keys[i] <= sorted_keys[i + 1],
433                "radix sort not sorted at {i}"
434            );
435        }
436        for &idx in &sorted_indices {
437            assert!(idx < keys.len(), "invalid index {idx}");
438        }
439    }
440    #[test]
441    fn test_radix_sort_permutation_correct() {
442        let keys = vec![30u32, 10, 20];
443        let (sorted_keys, sorted_indices) = radix_sort_mock(&keys);
444        assert_eq!(sorted_keys[0], 10);
445        assert_eq!(sorted_keys[1], 20);
446        assert_eq!(sorted_keys[2], 30);
447        assert_eq!(sorted_indices[0], 1);
448        assert_eq!(sorted_indices[1], 2);
449        assert_eq!(sorted_indices[2], 0);
450    }
451    #[test]
452    fn test_radix_sort_empty() {
453        let keys: Vec<u32> = vec![];
454        let (sk, si) = radix_sort_mock(&keys);
455        assert!(sk.is_empty());
456        assert!(si.is_empty());
457    }
458    #[test]
459    fn test_radix_sort_all_equal() {
460        let keys = vec![7u32; 10];
461        let (sorted_keys, sorted_indices) = radix_sort_mock(&keys);
462        assert_eq!(sorted_keys.len(), 10);
463        assert!(sorted_keys.iter().all(|&k| k == 7));
464        assert_eq!(sorted_indices.len(), 10);
465    }
466    #[test]
467    fn test_gpu_prefix_sum_basic() {
468        let counts = vec![0usize, 1, 3, 0, 2, 5];
469        let result = gpu_prefix_sum(&counts);
470        assert_eq!(result, vec![0, 0, 1, 4, 4, 6]);
471    }
472    #[test]
473    fn test_gpu_prefix_sum_all_zeros() {
474        let counts = vec![0usize; 5];
475        let result = gpu_prefix_sum(&counts);
476        assert_eq!(result, vec![0, 0, 0, 0, 0]);
477    }
478    #[test]
479    fn test_gpu_prefix_sum_single() {
480        let result = gpu_prefix_sum(&[7usize]);
481        assert_eq!(result, vec![0]);
482    }
483    #[test]
484    fn test_parallel_cell_counting() {
485        let positions = vec![[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [2.5, 0.5, 0.5]];
486        let n_cells = [4usize, 4, 4];
487        let counts = parallel_count_particles(&positions, n_cells, 1.0);
488        let total: usize = counts.iter().sum();
489        assert_eq!(total, 3, "total count should equal number of particles");
490    }
491    #[test]
492    fn test_parallel_cell_counting_all_in_one_cell() {
493        let positions: Vec<[f64; 3]> = vec![[0.1, 0.1, 0.1], [0.2, 0.1, 0.1], [0.1, 0.2, 0.1]];
494        let counts = parallel_count_particles(&positions, [4, 4, 4], 1.0);
495        let max = counts.iter().cloned().max().unwrap_or(0);
496        assert!(max >= 3, "all particles in one cell: max_count={max}");
497    }
498    #[test]
499    fn test_multi_gpu_distribution_two_gpus() {
500        let n_cells = 100;
501        let n_gpus = 2;
502        let ranges = distribute_cells_to_gpus(n_cells, n_gpus);
503        assert_eq!(ranges.len(), n_gpus);
504        assert_eq!(ranges[0].start, 0);
505        assert_eq!(ranges[n_gpus - 1].end, n_cells);
506        for i in 0..n_gpus - 1 {
507            assert_eq!(ranges[i].end, ranges[i + 1].start, "gap at gpu {i}");
508        }
509    }
510    #[test]
511    fn test_multi_gpu_distribution_odd_cells() {
512        let ranges = distribute_cells_to_gpus(7, 3);
513        assert_eq!(ranges.len(), 3);
514        let total: usize = ranges.iter().map(|r| r.end - r.start).sum();
515        assert_eq!(total, 7);
516    }
517    #[test]
518    fn test_multi_gpu_distribution_single_gpu() {
519        let ranges = distribute_cells_to_gpus(50, 1);
520        assert_eq!(ranges.len(), 1);
521        assert_eq!(ranges[0].start, 0);
522        assert_eq!(ranges[0].end, 50);
523    }
524    #[test]
525    fn test_neighbor_search_kernel_finds_close_pair() {
526        let positions: Vec<[f64; 3]> = vec![[1.0, 1.0, 1.0], [1.1, 1.0, 1.0], [5.0, 5.0, 5.0]];
527        let cl = GpuCellList::build_parallel(&positions);
528        let pairs = gpu_neighbor_search_kernel(&cl, &positions, 0.5);
529        assert!(
530            pairs.contains(&(0, 1)) || pairs.contains(&(1, 0)),
531            "should find pair (0,1), got {pairs:?}"
532        );
533        assert!(
534            !pairs.iter().any(|&(a, b)| a == 2 || b == 2),
535            "particle 2 should not appear in pairs"
536        );
537    }
538    #[test]
539    fn test_neighbor_search_kernel_no_pairs() {
540        let positions: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [20.0, 0.0, 0.0]];
541        let cl = GpuCellList::build_parallel(&positions);
542        let pairs = gpu_neighbor_search_kernel(&cl, &positions, 0.5);
543        assert!(pairs.is_empty(), "well-separated particles → no pairs");
544    }
545    #[test]
546    fn test_spatial_hash_rebuild() {
547        let mut hash = SpatialHash::new(128, 1.0);
548        let positions1 = vec![[0.5, 0.5, 0.5], [1.5, 0.5, 0.5]];
549        hash.build(&positions1);
550        assert_eq!(hash.len(), 2);
551        let positions2 = vec![[0.1, 0.1, 0.1]];
552        hash.build(&positions2);
553        assert_eq!(hash.len(), 1, "rebuild should replace old data");
554    }
555    #[test]
556    fn test_spatial_hash_large_number_of_particles() {
557        let positions: Vec<[f64; 3]> = (0..200).map(|i| [i as f64 * 0.1, 0.0, 0.0]).collect();
558        let mut hash = SpatialHash::new(256, 1.0);
559        hash.build(&positions);
560        assert_eq!(hash.len(), 200);
561    }
562}
563/// Sort particle indices by Morton code using Rayon for parallel code generation.
564///
565/// This variant uses Rayon for parallel code generation, then a sequential
566/// sort for the final ordering (radix sort on a GPU would be fully parallel).
567///
568/// Returns `(sorted_indices, sorted_morton_codes)`.
569pub fn parallel_morton_sort(
570    positions: &[[f64; 3]],
571    box_min: [f64; 3],
572    box_max: [f64; 3],
573) -> (Vec<usize>, Vec<u32>) {
574    let range = [
575        (box_max[0] - box_min[0]).max(1e-10),
576        (box_max[1] - box_min[1]).max(1e-10),
577        (box_max[2] - box_min[2]).max(1e-10),
578    ];
579    let mut code_index_pairs: Vec<(u32, usize)> = positions
580        .par_iter()
581        .enumerate()
582        .map(|(i, p)| {
583            let xi = (((p[0] - box_min[0]) / range[0]) * 1023.0) as u32;
584            let yi = (((p[1] - box_min[1]) / range[1]) * 1023.0) as u32;
585            let zi = (((p[2] - box_min[2]) / range[2]) * 1023.0) as u32;
586            let x = xi.min(1023);
587            let y = yi.min(1023);
588            let z = zi.min(1023);
589            (morton_encode(x, y, z), i)
590        })
591        .collect();
592    code_index_pairs.sort_by_key(|&(code, _)| code);
593    let sorted_indices: Vec<usize> = code_index_pairs.iter().map(|&(_, i)| i).collect();
594    let sorted_codes: Vec<u32> = code_index_pairs.iter().map(|&(c, _)| c).collect();
595    (sorted_indices, sorted_codes)
596}
597/// Compute the 30-bit Morton code for a position relative to a bounding box.
598///
599/// Normalises `pos` into `[0, 1023]^3` integer coordinates and encodes them.
600pub fn position_to_morton(pos: [f64; 3], box_min: [f64; 3], box_max: [f64; 3]) -> u32 {
601    let range = [
602        (box_max[0] - box_min[0]).max(1e-10),
603        (box_max[1] - box_min[1]).max(1e-10),
604        (box_max[2] - box_min[2]).max(1e-10),
605    ];
606    let x = (((pos[0] - box_min[0]) / range[0]) * 1023.0) as u32;
607    let y = (((pos[1] - box_min[1]) / range[1]) * 1023.0) as u32;
608    let z = (((pos[2] - box_min[2]) / range[2]) * 1023.0) as u32;
609    morton_encode(x.min(1023), y.min(1023), z.min(1023))
610}
611/// Insert particles into an existing cell list without a full rebuild.
612///
613/// This is a **sequential** incremental insert that updates `cell_counts` and
614/// `sorted_indices` in place.  The `cell_starts` offsets remain valid only if
615/// the new particles land in cells that already have capacity; otherwise a
616/// full rebuild should be used.
617///
618/// Returns the number of particles successfully inserted.
619pub fn insert_particles(cl: &mut GpuCellList, new_positions: &[[f64; 3]]) -> usize {
620    let old_n = cl.sorted_indices.len();
621    let mut inserted = 0usize;
622    for (i, &pos) in new_positions.iter().enumerate() {
623        let cell = cl.cell_index(pos);
624        cl.sorted_indices.push(old_n + i);
625        cl.cell_counts[cell] += 1;
626        inserted += 1;
627    }
628    let new_starts = parallel_prefix_sum(
629        &cl.cell_counts
630            .iter()
631            .map(|&c| c as usize)
632            .collect::<Vec<_>>(),
633    );
634    cl.cell_starts = new_starts.iter().map(|&s| s as i32).collect();
635    inserted
636}
637/// Query all particles within `radius` of `query_pos` from a set of positions,
638/// using a pre-built `GpuCellList`.
639///
640/// This is an alias for `GpuCellList::neighbors_in_radius` exposed at module level
641/// for ergonomic access.
642pub fn query_neighbors(
643    cl: &GpuCellList,
644    positions: &[[f64; 3]],
645    query_pos: [f64; 3],
646    radius: f64,
647) -> Vec<usize> {
648    cl.neighbors_in_radius(positions, query_pos, radius)
649}
650#[cfg(test)]
651mod extended_cell_tests {
652    use crate::cell_list::CellList;
653    use crate::cell_list::GhostCellManager;
654    use crate::cell_list::GpuCellList;
655    use crate::cell_list::GridResizer;
656    use crate::cell_list::OccupancyStats;
657
658    use crate::cell_list::insert_particles;
659    use crate::cell_list::parallel_morton_sort;
660    use crate::cell_list::position_to_morton;
661    use crate::cell_list::query_neighbors;
662    #[test]
663    fn test_occupancy_stats_uniform() {
664        let positions: Vec<[f64; 3]> = vec![
665            [0.5, 0.5, 0.5],
666            [1.5, 0.5, 0.5],
667            [0.5, 1.5, 0.5],
668            [1.5, 1.5, 0.5],
669            [0.5, 0.5, 1.5],
670            [1.5, 0.5, 1.5],
671            [0.5, 1.5, 1.5],
672            [1.5, 1.5, 1.5],
673        ];
674        let cl = GpuCellList::build_parallel(&positions);
675        let stats = OccupancyStats::compute(&cl);
676        assert_eq!(stats.total_particles, 8);
677        assert_eq!(stats.max_occupancy, 1);
678        assert!(stats.is_perfectly_spread());
679    }
680    #[test]
681    fn test_occupancy_stats_clustered() {
682        let positions: Vec<[f64; 3]> = vec![
683            [0.1, 0.1, 0.1],
684            [0.2, 0.1, 0.1],
685            [0.1, 0.2, 0.1],
686            [10.0, 10.0, 10.0],
687        ];
688        let cl = GpuCellList::build_parallel(&positions);
689        let stats = OccupancyStats::compute(&cl);
690        assert_eq!(stats.total_particles, 4);
691        assert!(
692            stats.max_occupancy >= 2,
693            "clustered particles should share a cell"
694        );
695        assert_eq!(stats.nonempty_cells, 2);
696    }
697    #[test]
698    fn test_occupancy_stats_load_imbalance_uniform() {
699        let positions: Vec<[f64; 3]> = vec![[0.5, 0.5, 0.5], [1.5, 0.5, 0.5]];
700        let cl = GpuCellList::build_parallel(&positions);
701        let stats = OccupancyStats::compute(&cl);
702        assert!(
703            (stats.load_imbalance - 1.0).abs() < 1e-10,
704            "load_imbalance = {}",
705            stats.load_imbalance
706        );
707    }
708    #[test]
709    fn test_occupancy_stats_completely_unbalanced() {
710        let positions: Vec<[f64; 3]> = vec![[0.1, 0.1, 0.1], [0.2, 0.2, 0.2], [0.3, 0.1, 0.1]];
711        let cl = GpuCellList::build_parallel(&positions);
712        let stats = OccupancyStats::compute(&cl);
713        assert!(stats.is_completely_unbalanced() || stats.max_occupancy >= 2);
714    }
715    #[test]
716    fn test_grid_resizer_initial_build() {
717        let mut resizer = GridResizer::new(1.0, 0.5);
718        let positions = vec![[1.0, 1.0, 1.0], [3.0, 3.0, 3.0]];
719        resizer.update(&positions);
720        assert!(resizer.get().is_some(), "cell list should be built");
721    }
722    #[test]
723    fn test_grid_resizer_no_resize_needed() {
724        let mut resizer = GridResizer::new(1.0, 1.0);
725        let positions = vec![[2.0, 2.0, 2.0]];
726        resizer.update(&positions);
727        let needs = resizer.needs_resize(&positions);
728        assert!(!needs, "same positions should not need resize");
729    }
730    #[test]
731    fn test_grid_resizer_escaping_particle() {
732        let mut resizer = GridResizer::new(1.0, 0.5);
733        let positions = vec![[1.0, 1.0, 1.0]];
734        resizer.rebuild(&positions);
735        let new_positions = vec![[100.0, 100.0, 100.0]];
736        assert!(
737            resizer.needs_resize(&new_positions),
738            "escaped particle should trigger resize"
739        );
740    }
741    #[test]
742    fn test_grid_resizer_empty_positions() {
743        let mut resizer = GridResizer::new(1.0, 0.5);
744        resizer.rebuild(&[]);
745        assert!(
746            resizer.get().is_some(),
747            "empty rebuild should produce valid list"
748        );
749    }
750    #[test]
751    fn test_ghost_manager_no_ghosts_interior() {
752        let mut mgr = GhostCellManager::new([10.0, 10.0, 10.0], 1.0);
753        let positions = vec![[5.0, 5.0, 5.0], [6.0, 6.0, 6.0]];
754        mgr.build_ghosts(&positions);
755        assert_eq!(mgr.num_ghosts(), 0, "interior particles need no ghosts");
756    }
757    #[test]
758    fn test_ghost_manager_near_one_face() {
759        let mut mgr = GhostCellManager::new([10.0, 10.0, 10.0], 1.5);
760        let positions = vec![[0.5, 5.0, 5.0]];
761        mgr.build_ghosts(&positions);
762        assert_eq!(mgr.num_ghosts(), 1, "should create 1 ghost on +x side");
763        assert!(
764            (mgr.ghost_positions[0][0] - 10.5).abs() < 1e-10,
765            "ghost x = {}",
766            mgr.ghost_positions[0][0]
767        );
768    }
769    #[test]
770    fn test_ghost_manager_near_two_faces() {
771        let mut mgr = GhostCellManager::new([10.0, 10.0, 10.0], 1.5);
772        let positions = vec![[0.5, 0.5, 5.0]];
773        mgr.build_ghosts(&positions);
774        assert_eq!(mgr.num_ghosts(), 2, "particle near two faces → 2 ghosts");
775    }
776    #[test]
777    fn test_ghost_manager_near_corner() {
778        let mut mgr = GhostCellManager::new([10.0, 10.0, 10.0], 1.5);
779        let positions = vec![[0.5, 0.5, 0.5]];
780        mgr.build_ghosts(&positions);
781        assert_eq!(mgr.num_ghosts(), 3, "corner particle → 3 primary ghosts");
782    }
783    #[test]
784    fn test_ghost_manager_map_to_real() {
785        let mut mgr = GhostCellManager::new([10.0, 10.0, 10.0], 1.0);
786        let positions = vec![[0.5, 5.0, 5.0], [9.5, 5.0, 5.0]];
787        mgr.build_ghosts(&positions);
788        for &ri in &mgr.ghost_to_real {
789            assert!(ri < positions.len(), "real index {ri} out of range");
790        }
791    }
792    #[test]
793    fn test_minimum_image_convention() {
794        let mgr = GhostCellManager::new([10.0, 10.0, 10.0], 1.0);
795        let d = mgr.minimum_image([9.0, 0.0, 0.0]);
796        assert!((d[0] - (-1.0)).abs() < 1e-10, "min image x = {}", d[0]);
797        assert!(d[1].abs() < 1e-12);
798        assert!(d[2].abs() < 1e-12);
799    }
800    #[test]
801    fn test_wrap_position_basic() {
802        let mgr = GhostCellManager::new([10.0, 10.0, 10.0], 1.0);
803        let p = mgr.wrap_position([11.5, -0.5, 10.0]);
804        assert!((p[0] - 1.5).abs() < 1e-10, "wrapped x = {}", p[0]);
805        assert!((p[1] - 9.5).abs() < 1e-10, "wrapped y = {}", p[1]);
806        assert!(p[2].abs() < 1e-10, "wrapped z = {}", p[2]);
807    }
808    #[test]
809    fn test_wrap_all_in_place() {
810        let mgr = GhostCellManager::new([5.0, 5.0, 5.0], 0.5);
811        let mut positions = vec![[6.0, 7.0, 0.0], [-1.0, 2.5, 11.0]];
812        mgr.wrap_all(&mut positions);
813        for p in &positions {
814            for &coord in p.iter() {
815                assert!(
816                    (0.0..5.0).contains(&coord),
817                    "wrapped coord out of range: {}",
818                    coord
819                );
820            }
821        }
822    }
823    #[test]
824    fn test_parallel_morton_sort_sorted_codes() {
825        let positions = vec![
826            [3.0, 3.0, 3.0],
827            [1.0, 1.0, 1.0],
828            [7.0, 7.0, 7.0],
829            [5.0, 5.0, 5.0],
830        ];
831        let (_idx, codes) = parallel_morton_sort(&positions, [0.0; 3], [10.0; 3]);
832        for i in 0..codes.len() - 1 {
833            assert!(
834                codes[i] <= codes[i + 1],
835                "parallel morton sort codes not sorted at {i}"
836            );
837        }
838    }
839    #[test]
840    fn test_parallel_morton_sort_valid_permutation() {
841        let positions: Vec<[f64; 3]> = (0..10).map(|i| [i as f64, 0.0, 0.0]).collect();
842        let (idx, codes) = parallel_morton_sort(&positions, [0.0; 3], [10.0, 1.0, 1.0]);
843        assert_eq!(idx.len(), 10);
844        assert_eq!(codes.len(), 10);
845        let mut sorted_idx = idx.clone();
846        sorted_idx.sort_unstable();
847        assert_eq!(sorted_idx, (0..10).collect::<Vec<_>>());
848    }
849    #[test]
850    fn test_position_to_morton_corner() {
851        let code = position_to_morton([0.0, 0.0, 0.0], [0.0; 3], [1.0; 3]);
852        assert_eq!(code, 0, "corner should give Morton code 0");
853    }
854    #[test]
855    fn test_position_to_morton_different_positions() {
856        let p1 = position_to_morton([1.0, 0.0, 0.0], [0.0; 3], [10.0; 3]);
857        let p2 = position_to_morton([0.0, 1.0, 0.0], [0.0; 3], [10.0; 3]);
858        let p3 = position_to_morton([5.0, 5.0, 5.0], [0.0; 3], [10.0; 3]);
859        assert_ne!(p1, p3);
860        assert_ne!(p2, p3);
861    }
862    #[test]
863    fn test_insert_particles_increases_count() {
864        let mut cl = GpuCellList::build_parallel(&[[1.0, 1.0, 1.0]]);
865        let original_len = cl.sorted_indices.len();
866        let new_particles = vec![[2.0, 2.0, 2.0], [3.0, 3.0, 3.0]];
867        let inserted = insert_particles(&mut cl, &new_particles);
868        assert_eq!(inserted, 2);
869        assert_eq!(cl.sorted_indices.len(), original_len + 2);
870    }
871    #[test]
872    fn test_insert_particles_empty_grid() {
873        let mut cl = GpuCellList::new([4, 4, 4], 1.0, [4.0, 4.0, 4.0]);
874        let positions = vec![[0.5, 0.5, 0.5], [1.5, 0.5, 0.5]];
875        let inserted = insert_particles(&mut cl, &positions);
876        assert_eq!(inserted, 2);
877    }
878    #[test]
879    fn test_query_neighbors_finds_close_particle() {
880        let positions = vec![[1.0, 1.0, 1.0], [1.1, 1.0, 1.0], [9.0, 9.0, 9.0]];
881        let cl = GpuCellList::build_parallel(&positions);
882        let mut neighbours = query_neighbors(&cl, &positions, [1.0, 1.0, 1.0], 0.5);
883        neighbours.sort_unstable();
884        assert!(neighbours.contains(&0), "should find self");
885        assert!(neighbours.contains(&1), "should find nearby particle");
886        assert!(!neighbours.contains(&2), "should not find far particle");
887    }
888    #[test]
889    fn test_query_neighbors_empty_result() {
890        let positions = vec![[0.0, 0.0, 0.0], [100.0, 100.0, 100.0]];
891        let cl = GpuCellList::build_parallel(&positions);
892        let neighbours = query_neighbors(&cl, &positions, [50.0, 50.0, 50.0], 0.1);
893        assert!(neighbours.is_empty(), "no particles near middle of box");
894    }
895    #[test]
896    fn test_verlet_list_close_pair_found() {
897        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0], [10.0, 10.0, 10.0]];
898        let cl = CellList::build(&positions);
899        let pairs = cl.build_neighbor_list_verlet(1.0, 0.2);
900        let has_01 = pairs.contains(&(0, 1));
901        assert!(has_01, "pair (0,1) must be in Verlet list");
902    }
903    #[test]
904    fn test_verlet_list_far_pair_excluded() {
905        let positions = vec![[0.0, 0.0, 0.0], [20.0, 20.0, 20.0]];
906        let cl = CellList::build(&positions);
907        let pairs = cl.build_neighbor_list_verlet(1.0, 0.2);
908        assert!(pairs.is_empty(), "far pair must not appear in Verlet list");
909    }
910    #[test]
911    fn test_verlet_list_no_self_pairs() {
912        let positions = vec![[1.0, 1.0, 1.0], [1.1, 1.0, 1.0], [1.2, 1.0, 1.0]];
913        let cl = CellList::build(&positions);
914        let pairs = cl.build_neighbor_list_verlet(1.0, 0.5);
915        for &(i, j) in &pairs {
916            assert_ne!(i, j, "self-pair found");
917        }
918    }
919    #[test]
920    fn test_verlet_list_pairs_ordered() {
921        let positions: Vec<[f64; 3]> = (0..5).map(|i| [i as f64 * 0.3, 0.0, 0.0]).collect();
922        let cl = CellList::build(&positions);
923        let pairs = cl.build_neighbor_list_verlet(1.0, 0.1);
924        for &(i, j) in &pairs {
925            assert!(i < j, "Verlet pair must have i < j");
926        }
927    }
928    #[test]
929    fn test_update_incremental_no_move() {
930        let positions = vec![[1.0, 1.0, 1.0], [3.0, 3.0, 3.0]];
931        let mut cl = CellList::build(&positions);
932        let relocated = cl.update_incremental(&positions, &positions, 0.1);
933        assert_eq!(relocated, 0, "no particle moved");
934    }
935    #[test]
936    fn test_update_incremental_large_move_counted() {
937        let old = vec![[1.0, 1.0, 1.0], [3.0, 3.0, 3.0]];
938        let new_pos = vec![[1.0, 1.0, 1.0], [6.0, 6.0, 6.0]];
939        let mut cl = CellList::build(&old);
940        let relocated = cl.update_incremental(&new_pos, &old, 0.5);
941        assert!(relocated >= 1, "at least one particle relocated");
942    }
943    #[test]
944    fn test_update_incremental_threshold_respected() {
945        let old = vec![[0.0, 0.0, 0.0], [5.0, 5.0, 5.0]];
946        let new_pos = vec![[0.05, 0.0, 0.0], [8.0, 8.0, 8.0]];
947        let mut cl = CellList::build(&old);
948        let relocated = cl.update_incremental(&new_pos, &old, 1.0);
949        assert_eq!(relocated, 1);
950    }
951    #[test]
952    fn test_pair_density_single_bin() {
953        let positions = vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0]];
954        let cl = CellList::build(&positions);
955        let hist = cl.compute_pair_density(2.0, 1.0);
956        assert!(hist[0] >= 1, "pair must appear in bin 0");
957    }
958    #[test]
959    fn test_pair_density_no_pairs_beyond_max_r() {
960        let positions = vec![[0.0, 0.0, 0.0], [5.0, 0.0, 0.0]];
961        let cl = CellList::build(&positions);
962        let hist = cl.compute_pair_density(2.0, 0.5);
963        let total: usize = hist.iter().sum();
964        assert_eq!(total, 0, "pair beyond max_r should not be counted");
965    }
966    #[test]
967    fn test_pair_density_histogram_length() {
968        let positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
969        let cl = CellList::build(&positions);
970        let hist = cl.compute_pair_density(5.0, 1.0);
971        assert_eq!(hist.len(), 5, "histogram length = ceil(max_r/dr)");
972    }
973}