1111from parcels ._core .warnings import FieldSetWarning
1212from 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
1526class 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