Repository: trixi-framework/PointNeighbors.jl
Pull request #101: Spatial Hashing
Author: login: RubberLanding
Labels: name: enhancement
description: New feature or request
name: gpu
Created: 2025-04-03T03:40:30Z | Merged: 2025-05-15T13:08:00Z

Implement spatial hashing data structure for neighborhood search.

Depends on #86.


Diff:
diff --git a/Project.toml b/Project.toml
index a90aea82..ca802606 100644
--- a/Project.toml
+++ b/Project.toml
@@ -22,4 +22,4 @@ LinearAlgebra = "1"
 Polyester = "0.7.5"
 Reexport = "1"
 StaticArrays = "1"
-julia = "1.10"
+julia = "1.10"
\ No newline at end of file
diff --git a/src/PointNeighbors.jl b/src/PointNeighbors.jl
index f6458409..6e7ad93e 100644
--- a/src/PointNeighbors.jl
+++ b/src/PointNeighbors.jl
@@ -22,7 +22,7 @@ include("gpu.jl")
 
 export foreach_point_neighbor, foreach_neighbor
 export TrivialNeighborhoodSearch, GridNeighborhoodSearch, PrecomputedNeighborhoodSearch
-export DictionaryCellList, FullGridCellList
+export DictionaryCellList, FullGridCellList, SpatialHashingCellList
 export ParallelUpdate, SemiParallelUpdate, SerialIncrementalUpdate, SerialUpdate,
        ParallelIncrementalUpdate
 export requires_update
diff --git a/src/cell_lists/cell_lists.jl b/src/cell_lists/cell_lists.jl
index fef8c299..cde404ed 100644
--- a/src/cell_lists/cell_lists.jl
+++ b/src/cell_lists/cell_lists.jl
@@ -6,3 +6,4 @@ abstract type AbstractCellList end
 
 include("dictionary.jl")
 include("full_grid.jl")
+include("spatial_hashing.jl")
diff --git a/src/cell_lists/spatial_hashing.jl b/src/cell_lists/spatial_hashing.jl
new file mode 100644
index 00000000..4822cb2c
--- /dev/null
+++ b/src/cell_lists/spatial_hashing.jl
@@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell, 
+as described by Ihmsen et al. (2011)(@cite Ihmsen2011). By using a hash map that stores entries only for non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles 
+to balance memory consumption against the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{NDIMS, CL, CI, CF} <: AbstractCellList
+    points     :: CL
+    coords     :: CI
+    collisions :: CF
+    list_size  :: Int
+end
+
+@inline index_type(::SpatialHashingCellList) = Int32
+
+@inline Base.ndims(::SpatialHashingCellList{NDIMS}) where {NDIMS} = NDIMS
+
+function supported_update_strategies(::SpatialHashingCellList)
+    return (SerialUpdate,)
+end
+
+function SpatialHashingCellList{NDIMS}(list_size) where {NDIMS}
+    points = [Int[] for _ in 1:list_size]
+    collisions = [false for _ in 1:list_size]
+    coords = [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    return SpatialHashingCellList{NDIMS, typeof(points), typeof(coords),
+                                  typeof(collisions)}(points, coords, collisions, list_size)
+end
+
+function Base.empty!(cell_list::SpatialHashingCellList)
+    (; list_size) = cell_list
+    NDIMS = ndims(cell_list)
+
+    Base.empty!.(cell_list.points)
+    cell_list.coords .= [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    cell_list.collisions .= false
+    return cell_list
+end
+
+# For each entry in the hash table, store the coordinates of the cell of the first point being inserted at this entry.
+# If a point with a different cell coordinate is being added, we have found a collision.
+function push_cell!(cell_list::SpatialHashingCellList, cell, point)
+    (; points, coords, collisions, list_size) = cell_list
+    NDIMS = ndims(cell_list)
+    hash_key = spatial_hash(cell, list_size)
+    push!(points[hash_key], point)
+
+    cell_coord = coords[hash_key]
+    if cell_coord == ntuple(_ -> typemin(Int), NDIMS)
+        # If this cell is not used yet, set cell coordinates
+        coords[hash_key] = cell
+    elseif cell_coord != cell
+        # If it is already used by a different cell, mark as collision
+        collisions[hash_key] = true
+    end
+end
+
+function deleteat_cell!(cell_list::SpatialHashingCellList, cell, i)
+    deleteat!(cell_list[cell], i)
+end
+
+@inline each_cell_index(cell_list::SpatialHashingCellList) = eachindex(cell_list.points)
+
+function copy_cell_list(cell_list::SpatialHashingCellList, search_radius,
+                        periodic_box)
+    (; list_size) = cell_list
+    NDIMS = ndims(cell_list)
+
+    return SpatialHashingCellList{NDIMS}(list_size)
+end
+
+@inline function Base.getindex(cell_list::SpatialHashingCellList, cell::Tuple)
+    return cell_list.points[spatial_hash(cell, length(cell_list.points))]
+end
+
+@inline function Base.getindex(cell_list::SpatialHashingCellList, i::Integer)
+    return cell_list.points[i]
+end
+
+@inline function is_correct_cell(cell_list::SpatialHashingCellList{<:Any, Nothing},
+                                 coords, cell_index::Array)
+    return coords == cell_index
+end
+
+# Hash functions according to Ihmsen et al. (2001)
+function spatial_hash(cell::NTuple{1, Real}, list_size)
+    return mod(cell[1] * 73856093, list_size) + 1
+end
+
+function spatial_hash(cell::NTuple{2, Real}, list_size)
+    i, j = cell
+
+    return mod(xor(i * 73856093, j * 19349663), list_size) + 1
+end
+
+function spatial_hash(cell::NTuple{3, Real}, list_size)
+    i, j, k = cell
+
+    return mod(xor(i * 73856093, j * 19349663, k * 83492791), list_size) + 1
+end
diff --git a/src/nhs_grid.jl b/src/nhs_grid.jl
index 08139ba9..ba613ecd 100644
--- a/src/nhs_grid.jl
+++ b/src/nhs_grid.jl
@@ -449,19 +449,58 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend, eachindex_y)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
+    # This is only relevant for the `SpatialHashingCellList`
+    return false
+end
+
+# Check if `neighbor_coords` belong to `neighbor_cell`, which might not be the case
+# with the `SpatialHashingCellList` if this cell has a collision.
+function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
+                         cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+
+    return neighbor_cell != cell_coords(neighbor_coords, nhs)
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list, nhs)
+    # This is only relevant for the `SpatialHashingCellList`
+    return false
+end
+
+# Check if there is a collision in this cell, meaning there is at least one point
+# in this list that doesn't actually belong in this cell.
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+    hash = spatial_hash(neighbor_cell, list_size)
+
+    # `collisions[hash] == true` means points from multiple cells are in this list.
+    # `collisions[hash] == false` means points from only one cells are in this list.
+    # We could still have a collision though, if this one cell is not `neighbor_cell`,
+    # which is possible when `neighbor_cell` is empty.
+    return collisions[hash] || coords[hash] != neighbor_cell
+end
+
 # Specialized version of the function in `neighborhood_search.jl`, which is faster
 # than looping over `eachneighbor`.
 @inline function foreach_neighbor(f, neighbor_system_coords,
                                   neighborhood_search::GridNeighborhoodSearch,
                                   point, point_coords, search_radius)
-    (; periodic_box) = neighborhood_search
-
+    (; cell_list, periodic_box) = neighborhood_search
     cell = cell_coords(point_coords, neighborhood_search)
 
     for neighbor_cell_ in neighboring_cells(cell, neighborhood_search)
         neighbor_cell = Tuple(neighbor_cell_)
         neighbors = points_in_cell(neighbor_cell, neighborhood_search)
 
+        # Boolean to indicate if this cell has a collision (only with `SpatialHashingCellList`)
+        cell_collision = check_cell_collision(neighbor_cell_,
+                                              cell_list, neighborhood_search)
+
         for neighbor_ in eachindex(neighbors)
             neighbor = @inbounds neighbors[neighbor_]
 
@@ -480,6 +519,14 @@ end
             if distance2 <= search_radius^2
                 distance = sqrt(distance2)
 
+                # If this cell has a collision, check if this point belongs to this cell
+                # (only with `SpatialHashingCellList`).
+                if cell_collision &&
+                   check_collision(neighbor_cell_, neighbor_coords, cell_list,
+                                   neighborhood_search)
+                    continue
+                end
+
                 # Inline to avoid loss of performance
                 # compared to not using `foreach_point_neighbor`.
                 @inline f(point, neighbor, pos_diff, distance)
diff --git a/test/cell_lists/spatial_hashing.jl b/test/cell_lists/spatial_hashing.jl
new file mode 100644
index 00000000..2c83f611
--- /dev/null
+++ b/test/cell_lists/spatial_hashing.jl
@@ -0,0 +1,69 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    @testset "Collision Handling With Empty Cells" begin
+        # The point is in cell (-1, 0) which has a hash collision with cell (-2, -1)
+        coordinates = [-0.05; 0.05;;]
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                        cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
+        @testset "Test For Collision" begin
+            cell1 = (-1, 0)
+            cell2 = (-2, -1)
+            cell1_hash = PointNeighbors.spatial_hash(cell1, n_points)
+            cell2_hash = PointNeighbors.spatial_hash(cell2, n_points)
+            points1 = nhs.cell_list[cell1]
+            points2 = nhs.cell_list[cell2]
+
+            @test points1 == points2 == [1]
+            @test cell1_hash == cell2_hash
+        end
+
+        neighbors = Int[]
+        foreach_neighbor(coordinates, coordinates, nhs,
+                         point_index) do point, neighbor, pos_diff, distance
+            push!(neighbors, neighbor)
+        end
+
+        @test neighbors == [1]
+    end
+
+    @testset "Collision Handling With Non-Empty Cells" begin
+        # Cell (-1, 0) with point 1 has a hash collision with cell (-2, -1) with point 2
+        coordinates = [[-0.05 -0.15]; [0.05 -0.05]]
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                        cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
+        @testset "Test For Collision" begin
+            cell1 = (-1, 0)
+            cell2 = (-2, -1)
+            cell1_hash = PointNeighbors.spatial_hash(cell1, n_points)
+            cell2_hash = PointNeighbors.spatial_hash(cell2, n_points)
+            points1 = nhs.cell_list[cell1]
+            points2 = nhs.cell_list[cell2]
+
+            @test points1 == points2 == [1, 2]
+            @test cell1_hash == cell2_hash
+        end
+
+        neighbors = [Int[] for _ in axes(coordinates, 2)]
+        foreach_point_neighbor(coordinates, coordinates, nhs,
+                               points = axes(coordinates, 2)) do point, neighbor, pos_diff,
+                                                                 distance
+            push!(neighbors[point], neighbor)
+        end
+
+        @test neighbors[1] == [1]
+        @test neighbors[2] == [2]
+    end
+end
diff --git a/test/neighborhood_search.jl b/test/neighborhood_search.jl
index ac1da6a6..a1ef9b3b 100644
--- a/test/neighborhood_search.jl
+++ b/test/neighborhood_search.jl
@@ -58,7 +58,11 @@
                                                                            search_radius,
                                                                            backend = Vector{Vector{Int32}})),
                 PrecomputedNeighborhoodSearch{NDIMS}(; search_radius, n_points,
-                                                     periodic_box = periodic_boxes[i])
+                                                     periodic_box = periodic_boxes[i]),
+                GridNeighborhoodSearch{NDIMS}(; search_radius, n_points,
+                                              periodic_box = periodic_boxes[i],
+                                              cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                        n_points))
             ]
 
             names = [
@@ -66,7 +70,8 @@
                 "`GridNeighborhoodSearch`",
                 "`GridNeighborhoodSearch` with `FullGridCellList` with `DynamicVectorOfVectors`",
                 "`GridNeighborhoodSearch` with `FullGridCellList` with `Vector{Vector}`",
-                "`PrecomputedNeighborhoodSearch`"
+                "`PrecomputedNeighborhoodSearch`",
+                "`GridNeighborhoodSearch` with `SpatialHashingCellList`"
             ]
 
             # Also test copied templates
@@ -80,7 +85,10 @@
                                               cell_list = FullGridCellList(min_corner = periodic_boxes[i].min_corner,
                                                                            max_corner = periodic_boxes[i].max_corner,
                                                                            backend = Vector{Vector{Int32}})),
-                PrecomputedNeighborhoodSearch{NDIMS}(periodic_box = periodic_boxes[i])
+                PrecomputedNeighborhoodSearch{NDIMS}(periodic_box = periodic_boxes[i]),
+                GridNeighborhoodSearch{NDIMS}(periodic_box = periodic_boxes[i],
+                                              cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                        n_points))
             ]
             copied_nhs = copy_neighborhood_search.(template_nhs, search_radius, n_points)
             append!(neighborhood_searches, copied_nhs)
@@ -184,7 +192,10 @@
                                                                            max_corner,
                                                                            search_radius,
                                                                            backend = Vector{Vector{Int}})),
-                PrecomputedNeighborhoodSearch{NDIMS}(; search_radius, n_points)
+                PrecomputedNeighborhoodSearch{NDIMS}(; search_radius, n_points),
+                GridNeighborhoodSearch{NDIMS}(; search_radius, n_points,
+                                              cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                        n_points))
             ]
 
             names = [
@@ -195,7 +206,8 @@
                 "`GridNeighborhoodSearch` with `FullGridCellList` with `DynamicVectorOfVectors` and `ParallelIncrementalUpdate`",
                 "`GridNeighborhoodSearch` with `FullGridCellList` with `DynamicVectorOfVectors` and `SemiParallelUpdate`",
                 "`GridNeighborhoodSearch` with `FullGridCellList` with `Vector{Vector}`",
-                "`PrecomputedNeighborhoodSearch`"
+                "`PrecomputedNeighborhoodSearch`",
+                "`GridNeighborhoodSearch` with `SpatialHashingCellList`"
             ]
 
             # Also test copied templates
@@ -214,7 +226,9 @@
                 GridNeighborhoodSearch{NDIMS}(cell_list = FullGridCellList(; min_corner,
                                                                            max_corner,
                                                                            backend = Vector{Vector{Int32}})),
-                PrecomputedNeighborhoodSearch{NDIMS}()
+                PrecomputedNeighborhoodSearch{NDIMS}(),
+                GridNeighborhoodSearch{NDIMS}(cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                        n_points))
             ]
             copied_nhs = copy_neighborhood_search.(template_nhs, search_radius, n_points)
             append!(neighborhood_searches, copied_nhs)


Reviews:
user: login: efaulhaber
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-16T09:18:13Z
user: login: efaulhaber
body: Looking good! Most of my requested changes are just comments.

One of my requested changes is performance relevant. Could you please do a quick benchmark after you fixed this against `DictionaryCellList` and `FullGridCellList` and post the results here? Just the time for ~100k particles and for ~10M particles on RAMSES (multithreaded). No need to waste more time on benchmarks at this point, just want to see where we stand now. Performance will become more interesting in the next step when we go to GPUs.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-25T15:43:49Z
user: login: RubberLanding
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T06:30:04Z
user: login: efaulhaber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T07:52:39Z
user: login: efaulhaber
body: Just a few minor remarks. Otherwise, this is ready to merge from my side. @LasNikas @svchb please have a look as well.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-04T10:32:40Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This pull request implements a spatial hashing data structure for neighborhood search and removes some dependencies from the test configuration to streamline the project setup.  
- Removed dependencies and compatibility requirements from test/Project.toml  
- Cleans up the test configuration to support the new spatial hashing implementation

<details>
<summary>Files not reviewed (6)</summary>

* **src/PointNeighbors.jl**: Language not supported
* **src/cell_lists/cell_lists.jl**: Language not supported
* **src/cell_lists/spatial_hashing.jl**: Language not supported
* **src/nhs_grid.jl**: Language not supported
* **test/cell_lists/spatial_hashing.jl**: Language not supported
* **test/neighborhood_search.jl**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**test/Project.toml:1**
* Ensure that removing these dependencies is intentional and that no tests rely on them for the spatial hashing functionality.
```
-   -[deps]
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T10:33:04Z
user: login: LasNikas
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T12:05:03Z
user: login: efaulhaber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:35:56Z
user: login: svchb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T20:00:12Z
user: login: svchb
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T20:14:51Z
user: login: efaulhaber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T08:01:15Z
user: login: efaulhaber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T08:04:46Z
user: login: efaulhaber
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T08:05:50Z
user: login: svchb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T08:07:06Z
user: login: RubberLanding
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T10:48:24Z
user: login: svchb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T10:51:15Z
user: login: efaulhaber
body: LGTM, but the format check is failing.
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-14T09:47:56Z
user: login: efaulhaber
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-15T08:54:37Z
user: login: svchb
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-15T12:43:32Z

Review comments:
diff_hunk: @@ -0,0 +1,116 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+struct SpatialHashingCellList{CL, CI, CF}
+    list_size::Int
+    NDIMS::Int
+    points::CL
+    coords::CI
+    collisions::CF
+end
+
+@inline index_type(::SpatialHashingCellList) = Int32
+
+function supported_update_strategies(::PointNeighbors.SpatialHashingCellList)
+    return (SerialUpdate, SemiParallelUpdate)
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
    return (SerialUpdate)
```
Then you say "this NHS only supports reinitialization", and you don't need the special `update!` anymore.
created_at: 2025-04-16T09:18:00Z
author_association: MEMBER
diff_hunk: @@ -210,6 +210,18 @@ function update_grid!(neighborhood_search::GridNeighborhoodSearch{NDIMS},
                  parallelization_backend)
 end
 
+# Needed here for SpatialHashingCellList until IncrementalParallelUpdate is implemented
+function update!(neighborhood_search::GridNeighborhoodSearch{<:Any, <:Any,
+                                                             <:SpatialHashingCellList},
+                 x::AbstractMatrix,
+                 y::AbstractMatrix; points_moving = (true, true),
+                 parallelization_backend = x)
+    # The coordinates of the first set of points are irrelevant for this NHS.
+    # Only update when the second set is moving.
+    points_moving[2] || return neighborhood_search
+    initialize_grid!(neighborhood_search, y)
+end
+
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
```
created_at: 2025-04-16T09:18:09Z
author_association: MEMBER
diff_hunk: @@ -1,5 +1,6 @@
 using Plots
path: benchmarks/plot.jl
user: login: efaulhaber
body: Please restore the benchmark plotting code. This PR should not change anything here.
created_at: 2025-04-25T14:45:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,70 @@
+@testset verbose=true "SpatialHashNeighborhoodSearch" begin
path: test/nhs_spatial_hash.jl
user: login: efaulhaber
body: This old file is still here.
created_at: 2025-04-25T14:46:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{CL, CI, CF} <: AbstractCellList
+    points::CL
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: Please align the fields like in the other structs.
created_at: 2025-04-25T14:48:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{CL, CI, CF} <: AbstractCellList
+    points::CL
+    coords::CI
+    collisions::CF
+    list_size::Int
+    NDIMS::Int
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
struct SpatialHashingCellList{NDIMS, CL, CI, CF} <: AbstractCellList
    points::CL
    coords::CI
    collisions::CF
    list_size::Int
```
It makes more sense if `SpatialHashingCellList{2}(list_size)` returns a `SpatialHashingCellList{2}` (where NDIMS is the first type parameter).

And then you can use
```julia
@inline Base.ndims(::SpatialHashingCellList{NDIMS}) where {NDIMS} = NDIMS
```
created_at: 2025-04-25T14:49:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{CL, CI, CF} <: AbstractCellList
+    points::CL
+    coords::CI
+    collisions::CF
+    list_size::Int
+    NDIMS::Int
+end
+
+@inline index_type(::SpatialHashingCellList) = Int32
+
+function supported_update_strategies(::SpatialHashingCellList)
+    return (SerialUpdate,)
+end
+
+function SpatialHashingCellList{NDIMS}(list_size) where {NDIMS}
+    points = [Int[] for _ in 1:list_size]
+    collisions = [false for _ in 1:list_size]
+    coords = [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    return SpatialHashingCellList(points, coords, collisions, list_size, NDIMS)
+end
+
+function Base.empty!(cell_list::SpatialHashingCellList)
+    (; list_size, NDIMS) = cell_list
+
+    Base.empty!.(cell_list.points)
+    cell_list.coords .= [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    cell_list.collisions .= false
+    return cell_list
+end
+
+# For each entry in the hash table, store the coordinates of the cell of the first point being inserted at this entry.
+# If a point with a different cell coordinate is being added, we have found a collision.
+function push_cell!(cell_list::SpatialHashingCellList, cell, point)
+    (; points, coords, collisions, list_size, NDIMS) = cell_list
+
+    hash_key = spatial_hash(cell, list_size)
+    cell_coord = coords[hash_key]
+    push!(points[hash_key], point)
+    if cell_coord == ntuple(_ -> typemin(Int), NDIMS)
+        coords[hash_key] = cell
+    elseif cell_coord != cell
+        collisions[hash_key] = true
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
    hash_key = spatial_hash(cell, list_size)
    push!(points[hash_key], point)
    
    cell_coords = coords[hash_key]
    if cell_coords == ntuple(_ -> typemin(Int), NDIMS)
        # If this cell is not used yet, set cell coordinates
        coords[hash_key] = cell
    elseif cell_coord != cell
        # If it is already used by a different cell, mark as collision
        collisions[hash_key] = true
```
created_at: 2025-04-25T14:56:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{CL, CI, CF} <: AbstractCellList
+    points::CL
+    coords::CI
+    collisions::CF
+    list_size::Int
+    NDIMS::Int
+end
+
+@inline index_type(::SpatialHashingCellList) = Int32
+
+function supported_update_strategies(::SpatialHashingCellList)
+    return (SerialUpdate,)
+end
+
+function SpatialHashingCellList{NDIMS}(list_size) where {NDIMS}
+    points = [Int[] for _ in 1:list_size]
+    collisions = [false for _ in 1:list_size]
+    coords = [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    return SpatialHashingCellList(points, coords, collisions, list_size, NDIMS)
+end
+
+function Base.empty!(cell_list::SpatialHashingCellList)
+    (; list_size, NDIMS) = cell_list
+
+    Base.empty!.(cell_list.points)
+    cell_list.coords .= [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    cell_list.collisions .= false
+    return cell_list
+end
+
+# For each entry in the hash table, store the coordinates of the cell of the first point being inserted at this entry.
+# If a point with a different cell coordinate is being added, we have found a collision.
+function push_cell!(cell_list::SpatialHashingCellList, cell, point)
+    (; points, coords, collisions, list_size, NDIMS) = cell_list
+
+    hash_key = spatial_hash(cell, list_size)
+    cell_coord = coords[hash_key]
+    push!(points[hash_key], point)
+    if cell_coord == ntuple(_ -> typemin(Int), NDIMS)
+        coords[hash_key] = cell
+    elseif cell_coord != cell
+        collisions[hash_key] = true
+    end
+end
+
+function deleteat_cell!(cell_list::SpatialHashingCellList, cell, i)
+    deleteat!(cell_list[cell], i)
+end
+
+@inline each_cell_index(cell_list::SpatialHashingCellList) = eachindex(cell_list.points)
+
+function copy_cell_list(cell_list::SpatialHashingCellList, search_radius,
+                        periodic_box)
+    (; NDIMS, list_size) = cell_list
+    return SpatialHashingCellList{NDIMS}(list_size)
+end
+
+@inline function Base.getindex(cell_list::SpatialHashingCellList, cell::Tuple)
+    (; points) = cell_list
+    return points[spatial_hash(cell, length(points))]
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
    return cell_list.points[spatial_hash(cell, length(points))]
```
created_at: 2025-04-25T14:59:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{CL, CI, CF} <: AbstractCellList
+    points::CL
+    coords::CI
+    collisions::CF
+    list_size::Int
+    NDIMS::Int
+end
+
+@inline index_type(::SpatialHashingCellList) = Int32
+
+function supported_update_strategies(::SpatialHashingCellList)
+    return (SerialUpdate,)
+end
+
+function SpatialHashingCellList{NDIMS}(list_size) where {NDIMS}
+    points = [Int[] for _ in 1:list_size]
+    collisions = [false for _ in 1:list_size]
+    coords = [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    return SpatialHashingCellList(points, coords, collisions, list_size, NDIMS)
+end
+
+function Base.empty!(cell_list::SpatialHashingCellList)
+    (; list_size, NDIMS) = cell_list
+
+    Base.empty!.(cell_list.points)
+    cell_list.coords .= [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    cell_list.collisions .= false
+    return cell_list
+end
+
+# For each entry in the hash table, store the coordinates of the cell of the first point being inserted at this entry.
+# If a point with a different cell coordinate is being added, we have found a collision.
+function push_cell!(cell_list::SpatialHashingCellList, cell, point)
+    (; points, coords, collisions, list_size, NDIMS) = cell_list
+
+    hash_key = spatial_hash(cell, list_size)
+    cell_coord = coords[hash_key]
+    push!(points[hash_key], point)
+    if cell_coord == ntuple(_ -> typemin(Int), NDIMS)
+        coords[hash_key] = cell
+    elseif cell_coord != cell
+        collisions[hash_key] = true
+    end
+end
+
+function deleteat_cell!(cell_list::SpatialHashingCellList, cell, i)
+    deleteat!(cell_list[cell], i)
+end
+
+@inline each_cell_index(cell_list::SpatialHashingCellList) = eachindex(cell_list.points)
+
+function copy_cell_list(cell_list::SpatialHashingCellList, search_radius,
+                        periodic_box)
+    (; NDIMS, list_size) = cell_list
+    return SpatialHashingCellList{NDIMS}(list_size)
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
    return SpatialHashingCellList{ndims(cell_list)}(cell_list.list_size)
```
created_at: 2025-04-25T14:59:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{CL, CI, CF} <: AbstractCellList
+    points::CL
+    coords::CI
+    collisions::CF
+    list_size::Int
+    NDIMS::Int
+end
+
+@inline index_type(::SpatialHashingCellList) = Int32
+
+function supported_update_strategies(::SpatialHashingCellList)
+    return (SerialUpdate,)
+end
+
+function SpatialHashingCellList{NDIMS}(list_size) where {NDIMS}
+    points = [Int[] for _ in 1:list_size]
+    collisions = [false for _ in 1:list_size]
+    coords = [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    return SpatialHashingCellList(points, coords, collisions, list_size, NDIMS)
+end
+
+function Base.empty!(cell_list::SpatialHashingCellList)
+    (; list_size, NDIMS) = cell_list
+
+    Base.empty!.(cell_list.points)
+    cell_list.coords .= [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    cell_list.collisions .= false
+    return cell_list
+end
+
+# For each entry in the hash table, store the coordinates of the cell of the first point being inserted at this entry.
+# If a point with a different cell coordinate is being added, we have found a collision.
+function push_cell!(cell_list::SpatialHashingCellList, cell, point)
+    (; points, coords, collisions, list_size, NDIMS) = cell_list
+
+    hash_key = spatial_hash(cell, list_size)
+    cell_coord = coords[hash_key]
+    push!(points[hash_key], point)
+    if cell_coord == ntuple(_ -> typemin(Int), NDIMS)
+        coords[hash_key] = cell
+    elseif cell_coord != cell
+        collisions[hash_key] = true
+    end
+end
+
+function deleteat_cell!(cell_list::SpatialHashingCellList, cell, i)
+    deleteat!(cell_list[cell], i)
+end
+
+@inline each_cell_index(cell_list::SpatialHashingCellList) = eachindex(cell_list.points)
+
+function copy_cell_list(cell_list::SpatialHashingCellList, search_radius,
+                        periodic_box)
+    (; NDIMS, list_size) = cell_list
+    return SpatialHashingCellList{NDIMS}(list_size)
+end
+
+@inline function Base.getindex(cell_list::SpatialHashingCellList, cell::Tuple)
+    (; points) = cell_list
+    return points[spatial_hash(cell, length(points))]
+end
+
+@inline function Base.getindex(cell_list::SpatialHashingCellList, i::Integer)
+    return cell_list.points[i]
+end
+
+@inline function is_correct_cell(cell_list::SpatialHashingCellList{<:Any, Nothing},
+                                 coords, cell_index::Array)
+    return coords == cell_index
+end
+
+function spatial_hash(cell::CartesianIndex, list_size)
+    return spatial_hash(Tuple(cell), list_size)
+end
+
+function spatial_hash(cell::NTuple{1, Real}, list_size)
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
# Hash functions according to Ihmsen et al. (2001)
function spatial_hash(cell::NTuple{1, Real}, list_size)
```
created_at: 2025-04-25T15:01:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,111 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles balance memory consumption  
+and the likelihood of hash collisions.
+
+# Arguments
+- `NDIMS::Int`: Number of spatial dimensions (e.g., `2` or `3`).
+- `list_size::Int`: Size of the hash map (e.g., `2 * n_points`) .
+"""
+
+struct SpatialHashingCellList{CL, CI, CF} <: AbstractCellList
+    points::CL
+    coords::CI
+    collisions::CF
+    list_size::Int
+    NDIMS::Int
+end
+
+@inline index_type(::SpatialHashingCellList) = Int32
+
+function supported_update_strategies(::SpatialHashingCellList)
+    return (SerialUpdate,)
+end
+
+function SpatialHashingCellList{NDIMS}(list_size) where {NDIMS}
+    points = [Int[] for _ in 1:list_size]
+    collisions = [false for _ in 1:list_size]
+    coords = [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    return SpatialHashingCellList(points, coords, collisions, list_size, NDIMS)
+end
+
+function Base.empty!(cell_list::SpatialHashingCellList)
+    (; list_size, NDIMS) = cell_list
+
+    Base.empty!.(cell_list.points)
+    cell_list.coords .= [ntuple(_ -> typemin(Int), NDIMS) for _ in 1:list_size]
+    cell_list.collisions .= false
+    return cell_list
+end
+
+# For each entry in the hash table, store the coordinates of the cell of the first point being inserted at this entry.
+# If a point with a different cell coordinate is being added, we have found a collision.
+function push_cell!(cell_list::SpatialHashingCellList, cell, point)
+    (; points, coords, collisions, list_size, NDIMS) = cell_list
+
+    hash_key = spatial_hash(cell, list_size)
+    cell_coord = coords[hash_key]
+    push!(points[hash_key], point)
+    if cell_coord == ntuple(_ -> typemin(Int), NDIMS)
+        coords[hash_key] = cell
+    elseif cell_coord != cell
+        collisions[hash_key] = true
+    end
+end
+
+function deleteat_cell!(cell_list::SpatialHashingCellList, cell, i)
+    deleteat!(cell_list[cell], i)
+end
+
+@inline each_cell_index(cell_list::SpatialHashingCellList) = eachindex(cell_list.points)
+
+function copy_cell_list(cell_list::SpatialHashingCellList, search_radius,
+                        periodic_box)
+    (; NDIMS, list_size) = cell_list
+    return SpatialHashingCellList{NDIMS}(list_size)
+end
+
+@inline function Base.getindex(cell_list::SpatialHashingCellList, cell::Tuple)
+    (; points) = cell_list
+    return points[spatial_hash(cell, length(points))]
+end
+
+@inline function Base.getindex(cell_list::SpatialHashingCellList, i::Integer)
+    return cell_list.points[i]
+end
+
+@inline function is_correct_cell(cell_list::SpatialHashingCellList{<:Any, Nothing},
+                                 coords, cell_index::Array)
+    return coords == cell_index
+end
+
+function spatial_hash(cell::CartesianIndex, list_size)
+    return spatial_hash(Tuple(cell), list_size)
+end
+
+function spatial_hash(cell::NTuple{1, Real}, list_size)
+    return mod(cell[1] * 73856093, list_size) + 1
+end
+
+function spatial_hash(cell::NTuple{2, Real}, list_size)
+    i, j = cell
+
+    return mod(xor(i * 73856093, j * 19349663), list_size) + 1
+end
+
+function spatial_hash(cell::NTuple{3, Real}, list_size)
+    i, j, k = cell
+
+    return mod(xor(i * 73856093, j * 19349663, k * 83492791), list_size) + 1
+end
+
+function spatial_hash(cell::NTuple{3, Real}, index, list_size)
+    i, j, k = cell
+
+    return mod(xor(i * 73856093, j * 19349663, k * 83492791, index * 7238423947),
+               list_size) + 1
+end
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: Is this used anywhere or a relic of my early experiments with linear probing? Can it be removed?
created_at: 2025-04-25T15:01:58Z
author_association: MEMBER
diff_hunk: @@ -441,18 +441,45 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
    # This is only relevant for the `SpatialHashingCellList`
```
created_at: 2025-04-25T15:02:32Z
author_association: MEMBER
diff_hunk: @@ -441,18 +441,45 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
+    return false
+end
+
+function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
+                         cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+
+    return neighbor_cell != cell_coords(neighbor_coords, nhs)
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list, nhs)
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
                              cell_list, nhs)
    # This is only relevant for the `SpatialHashingCellList`
```
created_at: 2025-04-25T15:03:44Z
author_association: MEMBER
diff_hunk: @@ -441,18 +441,45 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
+    return false
+end
+
+function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
+                         cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+
+    return neighbor_cell != cell_coords(neighbor_coords, nhs)
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list, nhs)
+    return false
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+    hash = spatial_hash(neighbor_cell, list_size)
+
+    return collisions[hash] || coords[hash] != neighbor_cell
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
    # `collisions[hash] == true` means points from multiple cells are in this list.
    # `collisions[hash] == false` means points from only one cells are in this list.
    # We could still have a collision though, if this one cell is not `neighbor_cell`,
    # which is possible when `neighbor_cell` is empty.
    return collisions[hash] || coords[hash] != neighbor_cell
```
created_at: 2025-04-25T15:18:47Z
author_association: MEMBER
diff_hunk: @@ -441,18 +441,45 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
+    return false
+end
+
+function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
+                         cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+
+    return neighbor_cell != cell_coords(neighbor_coords, nhs)
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list, nhs)
+    return false
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+    hash = spatial_hash(neighbor_cell, list_size)
+
+    return collisions[hash] || coords[hash] != neighbor_cell
+end
+
 # Specialized version of the function in `neighborhood_search.jl`, which is faster
-# than looping over `eachneighbor`.
+# than looping over eachneighbor`.
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
# than looping over `eachneighbor`.
```
created_at: 2025-04-25T15:19:03Z
author_association: MEMBER
diff_hunk: @@ -441,18 +441,45 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
+    return false
+end
+
+function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
+                         cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+
+    return neighbor_cell != cell_coords(neighbor_coords, nhs)
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list, nhs)
+    return false
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+    hash = spatial_hash(neighbor_cell, list_size)
+
+    return collisions[hash] || coords[hash] != neighbor_cell
+end
+
 # Specialized version of the function in `neighborhood_search.jl`, which is faster
-# than looping over `eachneighbor`.
+# than looping over eachneighbor`.
 @inline function foreach_neighbor(f, neighbor_system_coords,
-                                  neighborhood_search::GridNeighborhoodSearch,
-                                  point, point_coords, search_radius)
-    (; periodic_box) = neighborhood_search
-
+                                    neighborhood_search::GridNeighborhoodSearch,
+                                    point, point_coords, search_radius)
+    (; cell_list, periodic_box) = neighborhood_search
     cell = cell_coords(point_coords, neighborhood_search)
 
     for neighbor_cell_ in neighboring_cells(cell, neighborhood_search)
         neighbor_cell = Tuple(neighbor_cell_)
         neighbors = points_in_cell(neighbor_cell, neighborhood_search)
+        cell_collision = check_cell_collision(neighbor_cell_,
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion

        # Boolean to indicate if this cell has a collision (only with `SpatialHashingCellList`)
        cell_collision = check_cell_collision(neighbor_cell_,
```
created_at: 2025-04-25T15:19:52Z
author_association: MEMBER
diff_hunk: @@ -471,6 +498,13 @@ end
             if distance2 <= search_radius^2
                 distance = sqrt(distance2)
 
+                # Check if `neighbor_coords` are in the cell `neighbor_cell_`.
+                # For the `SpatialHashingCellList`, this might not be the case
+                # if we have a collision.
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
                # If this cell has a collision, check if this point belongs to this cell
                # (only with `SpatialHashingCellList`).
```
created_at: 2025-04-25T15:21:02Z
author_association: MEMBER
diff_hunk: @@ -441,18 +441,45 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
+    return false
+end
+
+function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
# Check if `neighbor_coords` belong to `neighbor_cell`, which might not be the case
# with the `SpatialHashingCellList` if this cell has a collision.
function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
```
created_at: 2025-04-25T15:25:04Z
author_association: MEMBER
diff_hunk: @@ -441,18 +441,45 @@ function update_grid!(neighborhood_search::Union{GridNeighborhoodSearch{<:Any,
     initialize_grid!(neighborhood_search, y; parallelization_backend)
 end
 
+function check_collision(neighbor_cell_, neighbor_coords, cell_list, nhs)
+    return false
+end
+
+function check_collision(neighbor_cell_::CartesianIndex, neighbor_coords,
+                         cell_list::SpatialHashingCellList, nhs)
+    (; list_size, collisions, coords) = cell_list
+    neighbor_cell = periodic_cell_index(Tuple(neighbor_cell_), nhs)
+
+    return neighbor_cell != cell_coords(neighbor_coords, nhs)
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
+                              cell_list, nhs)
+    return false
+end
+
+function check_cell_collision(neighbor_cell_::CartesianIndex,
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
# Check if there is a collision in this cell, meaning there is at least one point
# in this list that doesn't actually belong in this cell.
function check_cell_collision(neighbor_cell_::CartesianIndex,
```
created_at: 2025-04-25T15:25:39Z
author_association: MEMBER
diff_hunk: @@ -471,6 +498,13 @@ end
             if distance2 <= search_radius^2
                 distance = sqrt(distance2)
 
+                # Check if `neighbor_coords` are in the cell `neighbor_cell_`.
+                # For the `SpatialHashingCellList`, this might not be the case
+                # if we have a collision.
+                if check_collision(neighbor_cell_, neighbor_coords, cell_list, neighborhood_search) && cell_collision
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
                if cell_collision && check_collision(neighbor_cell_, neighbor_coords, cell_list, neighborhood_search)
```
This is crucial! This is a short-circuit evaluation, which is evaluated from left to right, meaning you always call the function `check_collision`, and then check the flag `cell_collision`.
With my suggestions, you skip the call to `check_collision` for most cells because `cell_collision` is false.

This should also greatly improve performance.
created_at: 2025-04-25T15:30:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,75 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    # General test for 2D and 3D
+    @testset verbose=true "Compare Against `TrivialNeighborhoodSearch`" begin
+        cloud_sizes = [
+            (10, 11),
+            (100, 90),
+            (8, 10, 6),
+            (39, 40, 41)
+        ]
+        name(size) = "$(length(size))D with $(prod(size)) Particles"
+        @testset verbose=true "$(name(cloud_size))" for cloud_size in cloud_sizes
+            coords = point_cloud(cloud_size, seed = 1)
+            NDIMS = length(cloud_size)
+            n_points = size(coords, 2)
+            search_radius = 2.5
+
+            # Compute expected neighbor lists by brute-force looping over all points
+            # as potential neighbors (`TrivialNeighborhoodSearch`).
+            trivial_nhs = TrivialNeighborhoodSearch{NDIMS}(; search_radius,
+                                                           eachpoint = axes(coords, 2))
+
+            neighbors_expected = [Int[] for _ in axes(coords, 2)]
+
+            foreach_point_neighbor(coords, coords, trivial_nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                append!(neighbors_expected[point], neighbor)
+            end
+
+            nhs = GridNeighborhoodSearch{NDIMS}(; search_radius, n_points,
+                                                cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                      n_points))
+
+            initialize!(nhs, coords, coords)
+
+            # Test if there are any collisions.
+            @testset verbose=true "Collision Detection" begin
+                @test any(nhs.cell_list.collisions)
+            end
+
+            neighbors = [Int[] for _ in axes(coords, 2)]
+            foreach_point_neighbor(coords, coords, nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                push!(neighbors[point], neighbor)
+            end
+
+            @test sort.(neighbors) == neighbors_expected
+        end
+    end
path: test/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: This is already covered in `test/neighborhood_search.jl`, no?
created_at: 2025-04-25T15:38:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,75 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    # General test for 2D and 3D
+    @testset verbose=true "Compare Against `TrivialNeighborhoodSearch`" begin
+        cloud_sizes = [
+            (10, 11),
+            (100, 90),
+            (8, 10, 6),
+            (39, 40, 41)
+        ]
+        name(size) = "$(length(size))D with $(prod(size)) Particles"
+        @testset verbose=true "$(name(cloud_size))" for cloud_size in cloud_sizes
+            coords = point_cloud(cloud_size, seed = 1)
+            NDIMS = length(cloud_size)
+            n_points = size(coords, 2)
+            search_radius = 2.5
+
+            # Compute expected neighbor lists by brute-force looping over all points
+            # as potential neighbors (`TrivialNeighborhoodSearch`).
+            trivial_nhs = TrivialNeighborhoodSearch{NDIMS}(; search_radius,
+                                                           eachpoint = axes(coords, 2))
+
+            neighbors_expected = [Int[] for _ in axes(coords, 2)]
+
+            foreach_point_neighbor(coords, coords, trivial_nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                append!(neighbors_expected[point], neighbor)
+            end
+
+            nhs = GridNeighborhoodSearch{NDIMS}(; search_radius, n_points,
+                                                cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                      n_points))
+
+            initialize!(nhs, coords, coords)
+
+            # Test if there are any collisions.
+            @testset verbose=true "Collision Detection" begin
+                @test any(nhs.cell_list.collisions)
+            end
+
+            neighbors = [Int[] for _ in axes(coords, 2)]
+            foreach_point_neighbor(coords, coords, nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                push!(neighbors[point], neighbor)
+            end
+
+            @test sort.(neighbors) == neighbors_expected
+        end
+    end
+
+    # Test list behavior with empty cells
+    @testset "Collision Handling with empty cells" begin
+        # The point is in cell (-1, 0) which has a hash collision with cell (-2, -1)
+        point = [-0.05, 0.05]
+        coordinates = hcat(point)
path: test/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
        coordinates = [-0.05; 0.05;;]
```
created_at: 2025-04-25T15:39:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,75 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    # General test for 2D and 3D
+    @testset verbose=true "Compare Against `TrivialNeighborhoodSearch`" begin
+        cloud_sizes = [
+            (10, 11),
+            (100, 90),
+            (8, 10, 6),
+            (39, 40, 41)
+        ]
+        name(size) = "$(length(size))D with $(prod(size)) Particles"
+        @testset verbose=true "$(name(cloud_size))" for cloud_size in cloud_sizes
+            coords = point_cloud(cloud_size, seed = 1)
+            NDIMS = length(cloud_size)
+            n_points = size(coords, 2)
+            search_radius = 2.5
+
+            # Compute expected neighbor lists by brute-force looping over all points
+            # as potential neighbors (`TrivialNeighborhoodSearch`).
+            trivial_nhs = TrivialNeighborhoodSearch{NDIMS}(; search_radius,
+                                                           eachpoint = axes(coords, 2))
+
+            neighbors_expected = [Int[] for _ in axes(coords, 2)]
+
+            foreach_point_neighbor(coords, coords, trivial_nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                append!(neighbors_expected[point], neighbor)
+            end
+
+            nhs = GridNeighborhoodSearch{NDIMS}(; search_radius, n_points,
+                                                cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                      n_points))
+
+            initialize!(nhs, coords, coords)
+
+            # Test if there are any collisions.
+            @testset verbose=true "Collision Detection" begin
+                @test any(nhs.cell_list.collisions)
+            end
+
+            neighbors = [Int[] for _ in axes(coords, 2)]
+            foreach_point_neighbor(coords, coords, nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                push!(neighbors[point], neighbor)
+            end
+
+            @test sort.(neighbors) == neighbors_expected
+        end
+    end
+
+    # Test list behavior with empty cells
+    @testset "Collision Handling with empty cells" begin
+        # The point is in cell (-1, 0) which has a hash collision with cell (-2, -1)
+        point = [-0.05, 0.05]
+        coordinates = hcat(point)
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                            cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
path: test/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: Please add a check here that you indeed have a collision. Check that both (-1, 0) and (-2, -1) have the same list.

Also, it would be nice if you had another test with two points in separate cells, which have a collision to cover this case as well.
created_at: 2025-04-25T15:40:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,75 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    # General test for 2D and 3D
+    @testset verbose=true "Compare Against `TrivialNeighborhoodSearch`" begin
+        cloud_sizes = [
+            (10, 11),
+            (100, 90),
+            (8, 10, 6),
+            (39, 40, 41)
+        ]
+        name(size) = "$(length(size))D with $(prod(size)) Particles"
+        @testset verbose=true "$(name(cloud_size))" for cloud_size in cloud_sizes
+            coords = point_cloud(cloud_size, seed = 1)
+            NDIMS = length(cloud_size)
+            n_points = size(coords, 2)
+            search_radius = 2.5
+
+            # Compute expected neighbor lists by brute-force looping over all points
+            # as potential neighbors (`TrivialNeighborhoodSearch`).
+            trivial_nhs = TrivialNeighborhoodSearch{NDIMS}(; search_radius,
+                                                           eachpoint = axes(coords, 2))
+
+            neighbors_expected = [Int[] for _ in axes(coords, 2)]
+
+            foreach_point_neighbor(coords, coords, trivial_nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                append!(neighbors_expected[point], neighbor)
+            end
+
+            nhs = GridNeighborhoodSearch{NDIMS}(; search_radius, n_points,
+                                                cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                      n_points))
+
+            initialize!(nhs, coords, coords)
+
+            # Test if there are any collisions.
+            @testset verbose=true "Collision Detection" begin
+                @test any(nhs.cell_list.collisions)
+            end
+
+            neighbors = [Int[] for _ in axes(coords, 2)]
+            foreach_point_neighbor(coords, coords, nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                push!(neighbors[point], neighbor)
+            end
+
+            @test sort.(neighbors) == neighbors_expected
+        end
+    end
+
+    # Test list behavior with empty cells
+    @testset "Collision Handling with empty cells" begin
+        # The point is in cell (-1, 0) which has a hash collision with cell (-2, -1)
+        point = [-0.05, 0.05]
+        coordinates = hcat(point)
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                            cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
path: test/cell_lists/spatial_hashing.jl
user: login: RubberLanding
body: Do you want to explicitly compare the two lists or is it enough to check that both have the same hash value?
created_at: 2025-04-29T06:30:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,75 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    # General test for 2D and 3D
+    @testset verbose=true "Compare Against `TrivialNeighborhoodSearch`" begin
+        cloud_sizes = [
+            (10, 11),
+            (100, 90),
+            (8, 10, 6),
+            (39, 40, 41)
+        ]
+        name(size) = "$(length(size))D with $(prod(size)) Particles"
+        @testset verbose=true "$(name(cloud_size))" for cloud_size in cloud_sizes
+            coords = point_cloud(cloud_size, seed = 1)
+            NDIMS = length(cloud_size)
+            n_points = size(coords, 2)
+            search_radius = 2.5
+
+            # Compute expected neighbor lists by brute-force looping over all points
+            # as potential neighbors (`TrivialNeighborhoodSearch`).
+            trivial_nhs = TrivialNeighborhoodSearch{NDIMS}(; search_radius,
+                                                           eachpoint = axes(coords, 2))
+
+            neighbors_expected = [Int[] for _ in axes(coords, 2)]
+
+            foreach_point_neighbor(coords, coords, trivial_nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                append!(neighbors_expected[point], neighbor)
+            end
+
+            nhs = GridNeighborhoodSearch{NDIMS}(; search_radius, n_points,
+                                                cell_list = SpatialHashingCellList{NDIMS}(2 *
+                                                                                      n_points))
+
+            initialize!(nhs, coords, coords)
+
+            # Test if there are any collisions.
+            @testset verbose=true "Collision Detection" begin
+                @test any(nhs.cell_list.collisions)
+            end
+
+            neighbors = [Int[] for _ in axes(coords, 2)]
+            foreach_point_neighbor(coords, coords, nhs,
+                                   parallel = false) do point, neighbor,
+                                                        pos_diff, distance
+                push!(neighbors[point], neighbor)
+            end
+
+            @test sort.(neighbors) == neighbors_expected
+        end
+    end
+
+    # Test list behavior with empty cells
+    @testset "Collision Handling with empty cells" begin
+        # The point is in cell (-1, 0) which has a hash collision with cell (-2, -1)
+        point = [-0.05, 0.05]
+        coordinates = hcat(point)
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                            cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
path: test/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: Why not both to be sure?
```julia
@test hash1 == hash2
@test cell_list[cell1] == cell_list[cell2]
```
To test both the low-level hash and the cell list `getindex`.
created_at: 2025-04-29T07:52:39Z
author_association: MEMBER
diff_hunk: @@ -1,7 +1,7 @@
 name = "PointNeighbors"
 uuid = "1c4d5385-0a27-49de-8e2c-43b175c8985c"
 authors = ["Erik Faulhaber <erik.faulhaber@uni-koeln.de>"]
-version = "0.5"
+version = "0.4.9"
path: Project.toml
user: login: efaulhaber
body: ```suggestion
version = "0.4.9-dev"
```
created_at: 2025-05-04T10:22:26Z
author_association: MEMBER
diff_hunk: @@ -498,15 +510,16 @@ end
             if distance2 <= search_radius^2
                 distance = sqrt(distance2)
 
-                # Check if `neighbor_coords` are in the cell `neighbor_cell_`.
-                # For the `SpatialHashingCellList`, this might not be the case
-                # if we have a collision.
-                if check_collision(neighbor_cell_, neighbor_coords, cell_list, neighborhood_search) && cell_collision
+                # If this cell has a collision, check if this point belongs to this cell
+                # (only with `SpatialHashingCellList`).
+                if cell_collision &&
+                   check_collision(neighbor_cell_, neighbor_coords, cell_list,
+                                   neighborhood_search)
                     continue
                 end
 
                 # Inline to avoid loss of performance
-                # compared to not using `foreach_point_neighbor`.
+                # compared to not using `foreach_point_neighbor
path: src/nhs_grid.jl
user: login: efaulhaber
body: ```suggestion
                # compared to not using `foreach_point_neighbor`.
```
created_at: 2025-05-04T10:26:58Z
author_association: MEMBER
diff_hunk: @@ -1,12 +0,0 @@
-[deps]
path: test/Project.toml
user: login: efaulhaber
body: Please restore this file. This is also the reason why the tests are failing.
created_at: 2025-05-04T10:28:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,63 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    @testset "Collision Handling With Empty Cells" begin
+        # The point is in cell (-1, 0) which has a hash collision with cell (-2, -1)
+        coordinates = [-0.05; 0.05;;]
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                        cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
+        @testset "Test For Collision" begin
+            cell1 = (-1, 0)
+            cell2 = (-2, -1)
+            cell1_hash = PointNeighbors.spatial_hash(cell1, n_points)
+            cell2_hash = PointNeighbors.spatial_hash(cell2, n_points)
path: test/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
            cell2_hash = PointNeighbors.spatial_hash(cell2, n_points)
            
            points1 = nhs.cell_list[cell1]
            points2 = nhs.cell_list[cell2]
            @test points1 == points2 == [1]
```
created_at: 2025-05-04T10:30:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,63 @@
+@testset verbose=true "SpatialHashingCellList" begin
+    @testset "Collision Handling With Empty Cells" begin
+        # The point is in cell (-1, 0) which has a hash collision with cell (-2, -1)
+        coordinates = [-0.05; 0.05;;]
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                        cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
+        @testset "Test For Collision" begin
+            cell1 = (-1, 0)
+            cell2 = (-2, -1)
+            cell1_hash = PointNeighbors.spatial_hash(cell1, n_points)
+            cell2_hash = PointNeighbors.spatial_hash(cell2, n_points)
+
+            @test cell1_hash == cell2_hash
+        end
+
+        neighbors = Int[]
+        foreach_neighbor(coordinates, coordinates, nhs,
+                         point_index) do point, neighbor, pos_diff, distance
+            push!(neighbors, neighbor)
+        end
+
+        @test neighbors == [1]
+    end
+
+    @testset "Collision Handling With Non-Empty Cells" begin
+        # Cell (-1, 0) with point 1 has a hash collision with cell (-2, -1) with point 2
+        coordinates = [[-0.05 -0.15]; [0.05 -0.05]]
+        NDIMS = size(coordinates, 1)
+        n_points = size(coordinates, 2)
+        search_radius = 0.1 + 10 * eps()
+        point_index = 1
+
+        nhs = GridNeighborhoodSearch{2}(; search_radius, n_points,
+                                        cell_list = SpatialHashingCellList{NDIMS}(n_points))
+        initialize_grid!(nhs, coordinates)
+
+        @testset "Test For Collision" begin
+            cell1 = (-1, 0)
+            cell2 = (-2, -1)
+            cell1_hash = PointNeighbors.spatial_hash(cell1, n_points)
+            cell2_hash = PointNeighbors.spatial_hash(cell2, n_points)
path: test/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: ```suggestion
            cell2_hash = PointNeighbors.spatial_hash(cell2, n_points)
            
            points1 = nhs.cell_list[cell1]
            points2 = nhs.cell_list[cell2]
            @test points1 == points2 == [1, 2]
```
created_at: 2025-05-04T10:30:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
path: src/cell_lists/spatial_hashing.jl
user: login: LasNikas
body: Can you add this to the table in the "Features" section in the README.md? 
Or is it still considered experimental at this stage?
Additionally, do you have any benchmarks available for the spatial hashing cell list?
created_at: 2025-05-07T12:05:03Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: The next step is to make this work with a `DynamicVectorOfVectors` backend. Then it is GPU-compatible and we can run proper benchmarks. Before that, it doesn't really make sense to list it in the README.
created_at: 2025-05-07T12:35:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
path: src/cell_lists/spatial_hashing.jl
user: login: svchb
body: Well in general it might be good to add better docs here and then even do a short JOSS paper to get a pub out of this.
created_at: 2025-05-07T20:00:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1,7 +1,7 @@
 name = "PointNeighbors"
 uuid = "1c4d5385-0a27-49de-8e2c-43b175c8985c"
 authors = ["Erik Faulhaber <erik.faulhaber@uni-koeln.de>"]
-version = "0.5"
+version = "0.4.9-dev"
path: Project.toml
user: login: svchb
body: @efaulhaber is this correct?
created_at: 2025-05-07T20:03:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
path: src/cell_lists/spatial_hashing.jl
user: login: svchb
body: ```suggestion
[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
```
Ihmsen 2001 or 2003?
created_at: 2025-05-07T20:07:38Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
path: src/cell_lists/spatial_hashing.jl
user: login: svchb
body: ```suggestion
, as described by Ihmsen et al. (2001)(@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
```
created_at: 2025-05-07T20:12:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
+the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles to balance memory consumption  
path: src/cell_lists/spatial_hashing.jl
user: login: svchb
body: ```suggestion
[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map that stores entries only for non-empty cells, the domain is effectively infinite. The size of the hash map is recommended to be approximately twice the number of particles to balance memory consumption  
```
created_at: 2025-05-07T20:14:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: Not worth a JOSS paper at this point. Once we get GPU support, we can think about it again.
created_at: 2025-05-08T08:01:15Z
author_association: MEMBER
diff_hunk: @@ -1,7 +1,7 @@
 name = "PointNeighbors"
 uuid = "1c4d5385-0a27-49de-8e2c-43b175c8985c"
 authors = ["Erik Faulhaber <erik.faulhaber@uni-koeln.de>"]
-version = "0.5"
+version = "0.4.9-dev"
path: Project.toml
user: login: efaulhaber
body: Yes, we need this for the tests to run because the TP release doesn't support PN 0.5 yet.
created_at: 2025-05-08T08:04:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
path: src/cell_lists/spatial_hashing.jl
user: login: efaulhaber
body: I think even 2011. @RubberLanding ?
created_at: 2025-05-08T08:05:50Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
path: src/cell_lists/spatial_hashing.jl
user: login: svchb
body: yes I meant in the future.
created_at: 2025-05-08T08:07:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
path: src/cell_lists/spatial_hashing.jl
user: login: RubberLanding
body: I was using [this paper](https://cg.informatik.uni-freiburg.de/publications/2011_CGF_dataStructuresSPH.pdf) which is from 2010. 
created_at: 2025-05-13T10:48:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,108 @@
+"""
+    SpatialHashingCellList{NDIMS}(; list_size)
+
+A basic spatial hashing implementation. Similar to [`DictionaryCellList`](@ref), the domain is discretized into cells, 
+and the particles in each cell are stored in a hash map. The hash is computed using the spatial location of each cell 
+[as described by Ihmsen et al. (2001)](@cite Ihmsen2003). By using a hash map, which only stores non-empty cells, 
path: src/cell_lists/spatial_hashing.jl
user: login: svchb
body: yes for references only the official publication date counts. If you check the bibtex:
@inproceedings{ihmsen2011parallel,
  title={A parallel SPH implementation on multi-core CPUs},
  author={Ihmsen, Markus and Akinci, Nadir and Becker, Markus and Teschner, Matthias},
  booktitle={Computer Graphics Forum},
  volume={30},
  number={1},
  pages={99--112},
  year={2011},
  organization={Wiley Online Library}
}
created_at: 2025-05-13T10:51:15Z
author_association: COLLABORATOR

PR comments:
user: login: RubberLanding
created_at: 2025-04-03T03:42:13Z
body: @efaulhaber Please take a look at the current state of the spatial hashing implementation. 
author_association: CONTRIBUTOR
user: login: RubberLanding
created_at: 2025-04-03T04:39:13Z
body: The problem is that the current code does not set the collision flag correctly for empty cells without any points. 
We only set the collision flag when we call `initialize!()` which in turn calls `push_cell!()` for all points. If there is an empty cell `A` that has a hash collision with a non-empty cell `B`, the collision flag is not set for this hash collision since we did not add a point from `A` where we would notice the collision. 
```julia 
function push_cell!(cell_list::SpatialHashingCellList, cell)
	(; cell_coords, cell_collision, list_size, NDIMS) = cell_list
	key = spatial_hash(cell, list_size)
	cell_coord = cell_coords[key]
	if cell_coord == ntuple(_ -> typemin(Int), NDIMS)
		cell_coords[key] = cell
	elseif cell_coord != cell
		cell_collision[key] = true
	end
end
```

The issue occurs when using `_foreach_neighbor()`, where we iterate over all neighboring cells, and all points in these cells. The points to iterate over are obtained by computing the hash of a neighboring cell `X` and collecting the points stored at this hash location (via `points_in_cell(index)` -> `cell_list[index]`). If the collision flag at this hash location is set, we compute the cell for each point and check if its the same as the current cell `X`. 
If we iterate over an empty cell `A`, we collect all points stored at `A`'s hash location (i.e. since `A` and `B` have a hash collision, all the points that are actually in cell `B`). But since the collision flag is not set, we do not check whether a point actually is in `A`. 

```julia
function check_collision(neighbor_cell_::Tuple, neighbor_coords,
                         cell_list::SpatialHashingCellList, nhs)
    (; list_size, cell_collision) = cell_list

    if cell_collision[spatial_hash(neighbor_cell_, list_size)]
        # Check if `neighbor_coords` are in the cell `neighbor_cell_`
        cell_coords_ = cell_coords(neighbor_coords, nhs)
        return neighbor_cell_ != cell_coords_
    end
    return false
end

@inline function __foreach_neighbor(f, system_coords, neighbor_system_coords,
                                    neighborhood_search::GridNeighborhoodSearch,
                                    point, point_coords, search_radius)
    (; cell_list, periodic_box) = neighborhood_search
    cell = cell_coords(point_coords, neighborhood_search)

    for neighbor_cell_ in neighboring_cells(cell, neighborhood_search)
        neighbor_cell = Tuple(neighbor_cell_)
        neighbors = points_in_cell(neighbor_cell, neighborhood_search)

        for neighbor_ in eachindex(neighbors)
            neighbor = @inbounds neighbors[neighbor_]

            # Making the following `@inbounds` yields a ~2% speedup on an NVIDIA H100.
            # But we don't know if `neighbor` (extracted from the cell list) is in bounds.
            neighbor_coords = extract_svector(neighbor_system_coords,
                                              Val(ndims(neighborhood_search)), neighbor)

            pos_diff = point_coords - neighbor_coords
            distance2 = dot(pos_diff, pos_diff)

            pos_diff, distance2 = compute_periodic_distance(pos_diff, distance2,
                                                            search_radius,
                                                            periodic_box)

            if distance2 <= search_radius^2
                distance = sqrt(distance2)

                # Check if `neighbor_coords` are in the cell `neighbor_cell_`.
                # For the `SpatialHashingCellList`, this might not be the case
                # if we have a collision.
                if check_collision(neighbor_cell_, neighbor_coords, cell_list,
                                   neighborhood_search)
                    continue
                end

                # Inline to avoid loss of performance
                # compared to not using `foreach_point_neighbor`.
                @inline f(point, neighbor, pos_diff, distance)
            end
        end
    end
end
```

author_association: CONTRIBUTOR
user: login: efaulhaber
created_at: 2025-04-03T22:01:49Z
body: Would this work?
```julia
function check_collision(neighbor_cell::Tuple, neighbor_coords,
                         cell_list::SpatialHashingCellList, nhs)
    (; list_size, cell_collision) = cell_list

    hash = spatial_hash(neighbor_cell_, list_size)
    if cell_collision[hash]
        # Points from multiple cells are in this list.
        # Check if `neighbor_coords` are in the cell `neighbor_cell`.
        return neighbor_cell != cell_coords(neighbor_coords, nhs)
    elseif cell_coords[hash] != neighbor_cell
        # `cell_collision` is false for this list, meaning only points from one cell are in the list.
        # However, the cell of this list is not our `neighbor_cell`, so `neighbor_coords`
        # are not in `neighbor_cell`.
        return true
    end
    return false
end
```
author_association: MEMBER
user: login: efaulhaber
created_at: 2025-04-16T07:46:01Z
body: @RubberLanding #86 is merged now. You can go ahead and prepare this PR for review.
author_association: MEMBER
user: login: codecov[bot]
created_at: 2025-05-07T09:28:39Z
body: ## [Codecov](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) Report
Attention: Patch coverage is `79.10448%` with `14 lines` in your changes missing coverage. Please review.
> Project coverage is 87.94%. Comparing base [(`d4944f5`)](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/commit/d4944f521a0ec2209b0f71bc0d2e33cd11540687?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) to head [(`7ce370c`)](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/commit/7ce370c20c2d5e5cf665c0205d3f8dff87f30a1a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) | Patch % | Lines |
|---|---|---|
| [src/cell\_lists/spatial\_hashing.jl](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?src=pr&el=tree&filepath=src%2Fcell_lists%2Fspatial_hashing.jl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework#diff-c3JjL2NlbGxfbGlzdHMvc3BhdGlhbF9oYXNoaW5nLmps) | 79.16% | [10 Missing :warning: ](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) |
| [src/nhs\_grid.jl](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?src=pr&el=tree&filepath=src%2Fnhs_grid.jl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework#diff-c3JjL25oc19ncmlkLmps) | 88.23% | [2 Missing :warning: ](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) |
| [test/cell\_lists/spatial\_hashing.jl](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?src=pr&el=tree&filepath=test%2Fcell_lists%2Fspatial_hashing.jl&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework#diff-dGVzdC9jZWxsX2xpc3RzL3NwYXRpYWxfaGFzaGluZy5qbA==) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #101      +/-   ##
==========================================
- Coverage   89.02%   87.94%   -1.09%     
==========================================
  Files          13       15       +2     
  Lines         556      622      +66     
==========================================
+ Hits          495      547      +52     
- Misses         61       75      +14     
```

| [Flag](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) | Coverage Δ | |
|---|---|---|
| [unit](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework) | `87.94% <79.10%> (-1.09%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/trixi-framework/PointNeighbors.jl/pull/101?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=trixi-framework).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: svchb
created_at: 2025-05-15T07:49:27Z
body: @efaulhaber If you are starting to add references you should also add a bib file and setup stub docs.
author_association: COLLABORATOR
user: login: svchb
created_at: 2025-05-15T07:51:03Z
body: @RubberLanding For the formatting test to not fail you need to use JuliaFormatter.jl and apply it e.g.

```
using JuliaFormatter
format(".")
```
author_association: COLLABORATOR
user: login: efaulhaber
created_at: 2025-05-15T07:56:09Z
body: > @efaulhaber If you are starting to add references you should also add a bib file and setup stub docs.

We have references already. But yes, at some point we should do that.
author_association: MEMBER