Skip to content

Commit 40072bc

Browse files
Merge branch 'main' into add-windowarray-ugrid-tests
2 parents 0df5042 + 1b1862b commit 40072bc

3 files changed

Lines changed: 238 additions & 104 deletions

File tree

docs/development/unstructured_grid_search.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,12 @@ The algorithm has two phases: **initialisation** (once, when the grid is first u
4242

4343
The hash grid is three-dimensional regardless of the source grid:
4444

45-
| Source grid | Mesh type | Hash-grid space |
46-
| ----------- | --------- | ------------------------------------- |
47-
| `XGrid` | spherical | Cartesian unit cube (lon/lat → x,y,z) |
48-
| `XGrid` | flat | 2-D lon/lat bounding box (z set to 0) |
49-
| `UxGrid` | spherical | Cartesian unit cube |
50-
| `UxGrid` | flat | 2-D lon/lat bounding box |
45+
| Source grid | Mesh type | Hash-grid space |
46+
| ----------- | --------- | ---------------------------------------- |
47+
| `XGrid` | spherical | Cartesian bounding box (lon/lat → x,y,z) |
48+
| `XGrid` | flat | 2-D lon/lat bounding box (z set to 0) |
49+
| `UxGrid` | spherical | Cartesian bounding box (lon/lat → x,y,z) |
50+
| `UxGrid` | flat | 2-D lon/lat bounding box |
5151

5252
For spherical grids, node coordinates are converted from degrees to radians and then to Cartesian (x, y, z) on the unit sphere using `parcels._core.index_search._latlon_rad_to_xyz`:
5353

@@ -57,7 +57,7 @@ y = sin(lon) * cos(lat)
5757
z = sin(lat)
5858
```
5959

60-
The hash grid then spans the unit cube `[-1, 1]³`. Working in Cartesian space avoids the longitude wrap-around discontinuity that would otherwise cause the bounding boxes of cells crossing the antimeridian to erroneously span the entire domain.
60+
The hash grid then spans the axis-aligned Cartesian bounding box of the transformed grid nodes. For a global grid this is (nearly) the unit cube `[-1, 1]³`; for a regional grid it is much tighter, so the full quantization resolution (1024 bins per axis) is spent on the region actually covered by the grid rather than the whole sphere. Working in Cartesian space avoids the longitude wrap-around discontinuity that would otherwise cause the bounding boxes of cells crossing the antimeridian to erroneously span the entire domain.
6161

6262
For flat meshes the hash grid simply spans `[lon_min, lon_max] × [lat_min, lat_max]`, with z fixed at 0.
6363

src/parcels/_core/spatialhash.py

Lines changed: 170 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
from parcels._core.warnings import FieldSetWarning
1212
from parcels._python import isinstance_noimport
1313

14+
# Budget on the total number of (face, hash cell) pairs in the hash table:
15+
# max(_HASH_ENTRIES_PER_FACE * nfaces, _HASH_ENTRY_BUDGET_MIN).
16+
# When the hash grid cell size, set by the bitwidth, would result in too many
17+
# hash entries per face, the hash grid is coarsened by lowering the bitwidth
18+
# The target value for the number of hash entries per face is chosen to keep
19+
# the number of particle in cell checks as small as possible, while also
20+
# minimizing the hash table construction memory footprint.
21+
_HASH_ENTRIES_PER_FACE = 16
22+
_HASH_ENTRY_BUDGET_MIN = 2**22
23+
_HASH_MAX_BITWIDTH = 1023
24+
1425

1526
class SpatialHash:
1627
"""Custom data structure that is used for performing grid searches using Spatial Hashing. This class constructs an overlying
@@ -31,7 +42,6 @@ class SpatialHash:
3142
def __init__(
3243
self,
3344
grid,
34-
bitwidth=1023,
3545
):
3646
if isinstance_noimport(grid, "XGrid"):
3747
self._point_in_cell = curvilinear_point_in_cell
@@ -41,21 +51,23 @@ def __init__(
4151
raise ValueError("Expected `grid` to be a parcels.XGrid or parcels.UxGrid")
4252

4353
self._source_grid = grid
44-
self._bitwidth = bitwidth # Max integer to use per coordinate in quantization (10 bits = 0..1023)
54+
self._bitwidth = _HASH_MAX_BITWIDTH # Max integer to use per coordinate in quantization (10 bits = 0..1023)
4555

4656
if isinstance_noimport(grid, "XGrid"):
4757
self._coord_dim = 2 # Number of computational coordinates is 2 (bilinear interpolation)
4858
if self._source_grid._mesh == "spherical":
49-
# Boundaries of the hash grid are the unit cube
50-
self._xmin = -1.0
51-
self._ymin = -1.0
52-
self._zmin = -1.0
53-
self._xmax = 1.0
54-
self._ymax = 1.0
55-
self._zmax = 1.0 # Compute the cell centers of the source grid (for now, assuming Xgrid)
5659
lon = np.deg2rad(self._source_grid.lon)
5760
lat = np.deg2rad(self._source_grid.lat)
5861
x, y, z = _latlon_rad_to_xyz(lat, lon)
62+
# Boundaries of the hash grid are the Cartesian bounding box of the
63+
# transformed grid, so that regional domains retain full quantization
64+
# resolution instead of spreading it over the whole unit cube
65+
self._xmin = x.min()
66+
self._xmax = x.max()
67+
self._ymin = y.min()
68+
self._ymax = y.max()
69+
self._zmin = z.min()
70+
self._zmax = z.max()
5971
_xbound = np.stack(
6072
(
6173
x[:-1, :-1],
@@ -149,19 +161,20 @@ def __init__(
149161
elif isinstance_noimport(grid, "UxGrid"):
150162
self._coord_dim = grid.uxgrid.n_max_face_nodes # Number of barycentric coordinates
151163
if self._source_grid._mesh == "spherical":
152-
# Boundaries of the hash grid are the unit cube
153-
self._xmin = -1.0
154-
self._ymin = -1.0
155-
self._zmin = -1.0
156-
self._xmax = 1.0
157-
self._ymax = 1.0
158-
self._zmax = 1.0 # Compute the cell centers of the source grid (for now, assuming Xgrid)
159164
# Reshape node coordinates to (nfaces, nnodes_per_face)
160165
nids = self._source_grid.uxgrid.face_node_connectivity.values
161166
lon = self._source_grid.uxgrid.node_lon.values[nids]
162167
lat = self._source_grid.uxgrid.node_lat.values[nids]
163-
x, y, z = _latlon_rad_to_xyz(np.deg2rad(lat), np.deg2rad(lon))
164168
_xbound, _ybound, _zbound = _latlon_rad_to_xyz(np.deg2rad(lat), np.deg2rad(lon))
169+
# Boundaries of the hash grid are the Cartesian bounding box of the
170+
# transformed grid, so that regional domains retain full quantization
171+
# resolution instead of spreading it over the whole unit cube
172+
self._xmin = _xbound.min()
173+
self._xmax = _xbound.max()
174+
self._ymin = _ybound.min()
175+
self._ymax = _ybound.max()
176+
self._zmin = _zbound.min()
177+
self._zmax = _zbound.max()
165178

166179
# Compute bounding box of each face
167180
self._xlow = np.atleast_2d(np.min(_xbound, axis=-1))
@@ -193,9 +206,60 @@ def __init__(
193206
self._zlow = np.zeros_like(self._xlow)
194207
self._zhigh = np.zeros_like(self._xlow)
195208

209+
# Cap the quantization resolution so the hash table stays within a fixed entry
210+
# budget.
211+
budget = max(_HASH_ENTRIES_PER_FACE * np.size(self._xlow), _HASH_ENTRY_BUDGET_MIN)
212+
if self._total_hash_entries(self._bitwidth) > budget:
213+
# Binary search for the largest bitwidth whose table fits the budget. The
214+
# entry count is not perfectly monotone in bitwidth (cell-boundary flooring
215+
# effects), so the result may sit marginally below the true maximum; any
216+
# in-budget bitwidth is valid. At bitwidth 1 the count equals nfaces, which
217+
# is always within budget, so the search cannot fail.
218+
lo, hi = 1, self._bitwidth
219+
while lo < hi:
220+
mid = (lo + hi + 1) // 2
221+
if self._total_hash_entries(mid) <= budget:
222+
lo = mid
223+
else:
224+
hi = mid - 1
225+
self._bitwidth = lo
226+
196227
# Generate the mapping from the hash indices to unstructured grid elements
197228
self._hash_table = self._initialize_hash_table()
198229

230+
def _total_hash_entries(self, bitwidth):
231+
"""Total number of (face, hash cell) pairs the hash table would hold at a given
232+
quantization resolution, i.e. the summed hash-cell count of all face bounding boxes.
233+
"""
234+
xqlow, yqlow, zqlow = quantize_coordinates(
235+
self._xlow,
236+
self._ylow,
237+
self._zlow,
238+
self._xmin,
239+
self._xmax,
240+
self._ymin,
241+
self._ymax,
242+
self._zmin,
243+
self._zmax,
244+
bitwidth,
245+
)
246+
xqhigh, yqhigh, zqhigh = quantize_coordinates(
247+
self._xhigh,
248+
self._yhigh,
249+
self._zhigh,
250+
self._xmin,
251+
self._xmax,
252+
self._ymin,
253+
self._ymax,
254+
self._zmin,
255+
self._zmax,
256+
bitwidth,
257+
)
258+
nx = xqhigh.astype(np.int64) - xqlow + 1
259+
ny = yqhigh.astype(np.int64) - yqlow + 1
260+
nz = zqhigh.astype(np.int64) - zqlow + 1
261+
return int((nx * ny * nz).sum())
262+
199263
def _initialize_hash_table(self):
200264
"""Create a mapping that relates unstructured grid faces to hash indices by determining
201265
which faces overlap with which hash cells
@@ -238,82 +302,77 @@ def _initialize_hash_table(self):
238302
num_hash_per_face = (nx * ny * nz).astype(
239303
np.int32, copy=False
240304
) # Since nx, ny, nz are in the 10-bit range, their product fits in int32
241-
total_hash_entries = int(num_hash_per_face.sum())
242-
243-
# Preallocate output arrays
244-
morton_codes = np.zeros(total_hash_entries, dtype=np.uint32)
245-
246-
# Compute the j, i indices corresponding to each hash entry
305+
# Sums over faces can exceed int32, so accumulate in int64
306+
total_hash_entries = int(num_hash_per_face.sum(dtype=np.int64))
307+
# Entry indices fit in int32 for all but extreme cases; fall back to int64 when needed
308+
idx_dtype = np.int64 if total_hash_entries > np.iinfo(np.int32).max else np.int32
309+
310+
# Every face overlaps at least one hash cell (nx, ny, nz >= 1 since quantization
311+
# is monotone), and contributes one hash entry per cell of its quantized bounding
312+
# box. Entries are generated in face-major order: face_ids maps each entry to its
313+
# face, and intra enumerates the cells of that face's box (0..num_hash_per_face-1).
247314
nface = np.size(self._xlow)
248-
face_ids = np.repeat(np.arange(nface, dtype=np.int32), num_hash_per_face)
249-
offsets = np.concatenate(([0], np.cumsum(num_hash_per_face))).astype(np.int32)[:-1]
250-
251-
valid = num_hash_per_face != 0
252-
if not np.any(valid):
253-
# nothing to do
254-
pass
255-
else:
256-
# Grab only valid faces to avoid empty arrays
257-
nx_v = np.asarray(nx[valid], dtype=np.int32)
258-
ny_v = np.asarray(ny[valid], dtype=np.int32)
259-
nz_v = np.asarray(nz[valid], dtype=np.int32)
260-
xlow_v = np.asarray(xqlow[valid], dtype=np.int32)
261-
ylow_v = np.asarray(yqlow[valid], dtype=np.int32)
262-
zlow_v = np.asarray(zqlow[valid], dtype=np.int32)
263-
starts_v = np.asarray(offsets[valid], dtype=np.int32)
264-
265-
# Count of elements per valid face (should match num_hash_per_face[valid])
266-
counts = (nx_v * ny_v * nz_v).astype(np.int32)
267-
total = int(counts.sum())
268-
269-
# Map each global element to its face and output position
270-
start_for_elem = np.repeat(starts_v, counts) # shape (total,)
271-
272-
# Intra-face linear index for each element (0..counts_i-1)
273-
# Offsets per face within the concatenation of valid faces:
274-
face_starts_local = np.cumsum(np.r_[0, counts[:-1]])
275-
intra = np.arange(total, dtype=np.int32) - np.repeat(face_starts_local, counts)
276-
277-
# Derive (zi, yi, xi) from intra using per-face sizes
278-
ny_nz = np.repeat(ny_v * nz_v, counts)
279-
nz_rep = np.repeat(nz_v, counts)
280-
281-
xi = intra // ny_nz
282-
rem = intra % ny_nz
283-
yi = rem // nz_rep
284-
zi = rem % nz_rep
285-
286-
# Add per-face lows
287-
x0 = np.repeat(xlow_v, counts)
288-
y0 = np.repeat(ylow_v, counts)
289-
z0 = np.repeat(zlow_v, counts)
290-
291-
xq = x0 + xi
292-
yq = y0 + yi
293-
zq = z0 + zi
294-
295-
# Vectorized morton encode for all elements at once
296-
codes_all = _encode_quantized_morton3d(xq, yq, zq)
297-
298-
# Scatter into the preallocated output using computed absolute indices
299-
out_idx = start_for_elem + intra
300-
morton_codes[out_idx] = codes_all
301-
302-
# Sort face indices by morton code
303-
order = np.argsort(morton_codes)
304-
morton_codes_sorted = morton_codes[order]
305-
face_sorted = face_ids[order]
306-
j_sorted, i_sorted = np.unravel_index(face_sorted, self._xlow.shape)
307-
308-
# Get a list of unique morton codes and their corresponding starts and counts (CSR format)
309-
keys, starts, counts = np.unique(morton_codes_sorted, return_index=True, return_counts=True)
315+
face_ids = np.repeat(np.arange(nface, dtype=np.uint32), num_hash_per_face)
316+
face_starts = np.concatenate(([0], np.cumsum(num_hash_per_face, dtype=np.int64)))[:-1]
317+
intra = np.arange(total_hash_entries, dtype=idx_dtype) - np.repeat(
318+
face_starts.astype(idx_dtype, copy=False), num_hash_per_face
319+
)
310320

321+
# Derive (xi, yi, zi) cell offsets within each face's box from intra,
322+
# then shift by the per-face low corner to get quantized cell coordinates
323+
ny_nz = np.repeat(ny * nz, num_hash_per_face)
324+
nz_rep = np.repeat(nz, num_hash_per_face)
325+
326+
xi = intra // ny_nz
327+
rem = intra % ny_nz
328+
yi = rem // nz_rep
329+
zi = rem % nz_rep
330+
331+
xq = np.repeat(xqlow, num_hash_per_face) + xi
332+
yq = np.repeat(yqlow, num_hash_per_face) + yi
333+
zq = np.repeat(zqlow, num_hash_per_face) + zi
334+
335+
# Vectorized morton encode for all entries at once, already in face-major order
336+
morton_codes = _encode_quantized_morton3d(xq, yq, zq)
337+
del intra, rem, xi, yi, zi, ny_nz, nz_rep, xq, yq, zq
338+
339+
# Sort entries by morton code. Each (code, face) pair is fused into one uint64
340+
# with the code in the high 32 bits and the face id in the low 32 bits: unsigned
341+
# comparison then orders by code, with ties broken by ascending face id. Sorting
342+
# the fused array in place avoids the argsort permutation array and the gather
343+
# copies it would imply. Pairs are unique, so the ordering is deterministic.
344+
packed = morton_codes.astype(np.uint64)
345+
del morton_codes
346+
packed <<= np.uint64(32)
347+
np.bitwise_or(packed, face_ids, out=packed)
348+
del face_ids
349+
# Perform a single sort on the packed (morton_code | face_id ) list
350+
packed.sort()
351+
# Trunctating back to a uint32 keeps the lower 32 bits (the face_id's)
352+
face_sorted = packed.astype(np.uint32)
353+
# Purge the face ids from the packed list to retain only the morton codes
354+
packed >>= np.uint64(32)
355+
# Cast the morton codes back to uint32
356+
morton_codes_sorted = packed.astype(np.uint32)
357+
del packed
358+
359+
# Get a list of unique morton codes and their corresponding starts and counts (CSR format).
360+
# The codes are already sorted at this point, first by morton code, then by face_id
361+
# Starting indices of the matrix rows are located by finding indices where the morton codes differ
362+
starts = np.concatenate(([0], np.flatnonzero(morton_codes_sorted[1:] != morton_codes_sorted[:-1]) + 1))
363+
# The unique keys for the hash table are the unique morton codes
364+
keys = morton_codes_sorted[starts]
365+
# The number of faces per hash keys (morton codes) is easily calculated as the difference betwee the start values
366+
counts = np.diff(np.concatenate((starts, [morton_codes_sorted.size])))
367+
368+
# The flat face id is stored (4 bytes per entry); query() unravels the gathered
369+
# candidates to (j, i) on demand, instead of holding two precomputed int64
370+
# index arrays (16 bytes per entry) for the lifetime of the grid.
311371
hash_table = {
312372
"keys": keys,
313373
"starts": starts,
314374
"counts": counts,
315-
"i": i_sorted,
316-
"j": j_sorted,
375+
"faces": face_sorted,
317376
}
318377
return hash_table
319378

@@ -341,8 +400,7 @@ def query(self, y, x):
341400
keys = self._hash_table["keys"]
342401
starts = self._hash_table["starts"]
343402
counts = self._hash_table["counts"]
344-
i = self._hash_table["i"]
345-
j = self._hash_table["j"]
403+
faces = self._hash_table["faces"]
346404

347405
y = np.asarray(y)
348406
x = np.asarray(x)
@@ -358,7 +416,16 @@ def query(self, y, x):
358416
qz = np.zeros_like(qx)
359417

360418
query_codes = _encode_morton3d(
361-
qx, qy, qz, self._xmin, self._xmax, self._ymin, self._ymax, self._zmin, self._zmax
419+
qx,
420+
qy,
421+
qz,
422+
self._xmin,
423+
self._xmax,
424+
self._ymin,
425+
self._ymax,
426+
self._zmin,
427+
self._zmax,
428+
bitwidth=self._bitwidth,
362429
).ravel()
363430
num_queries = query_codes.size
364431

@@ -418,9 +485,10 @@ def query(self, y, x):
418485
# use to quickly gather the (i,j) pairs for each query
419486
source_idx = starts[hash_positions].astype(np.int32) + intra
420487

421-
# Gather all candidate (j,i) pairs in one shot
422-
j_all = j[source_idx]
423-
i_all = i[source_idx]
488+
# Gather all candidate face ids in one shot and unravel them to (j, i) pairs;
489+
# only the gathered candidates are unraveled, not the whole table
490+
face_all = faces[source_idx]
491+
j_all, i_all = np.unravel_index(face_all, self._xlow.shape)
424492

425493
# Now we need to construct arrays that repeats the y and x coordinates for each candidate
426494
# to enable vectorized point-in-cell checks
@@ -588,10 +656,15 @@ def quantize_coordinates(x, y, z, xmin, xmax, ymin, ymax, zmin, zmax, bitwidth=1
588656
zn = np.where(dz != 0, (z - zmin) / dz, 0.0)
589657

590658
# --- 2) Quantize to (0..bitwidth). ---
591-
# Multiply by bitwidth, round down, and clip to be safe against slight overshoot.
592-
xq = np.clip((xn * bitwidth).astype(np.uint32), 0, bitwidth)
593-
yq = np.clip((yn * bitwidth).astype(np.uint32), 0, bitwidth)
594-
zq = np.clip((zn * bitwidth).astype(np.uint32), 0, bitwidth)
659+
# Multiply by bitwidth, round down, and clip to be safe against overshoot.
660+
# Clip in float space before casting: out-of-range queries (e.g., points outside
661+
# a regional domain) would otherwise wrap around when a negative float is cast to uint32.
662+
# NaN queries produce arbitrary codes here; they are discarded downstream by the
663+
# finite-coordinate mask in SpatialHash.query.
664+
with np.errstate(invalid="ignore"):
665+
xq = np.clip(xn * bitwidth, 0, bitwidth).astype(np.uint32)
666+
yq = np.clip(yn * bitwidth, 0, bitwidth).astype(np.uint32)
667+
zq = np.clip(zn * bitwidth, 0, bitwidth).astype(np.uint32)
595668

596669
return xq, yq, zq
597670

0 commit comments

Comments
 (0)