From 669813ee929f34c1d196ce4c2d10b51ab5c61176 Mon Sep 17 00:00:00 2001 From: Joe Schoonover Date: Thu, 12 Mar 2026 20:45:06 -0400 Subject: [PATCH 1/6] Add check for degenerate xgrid faces; emit warning if found --- src/parcels/_core/spatialhash.py | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index f7c551c62f..0ba315300d 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np from parcels._core.index_search import ( @@ -6,6 +8,7 @@ curvilinear_point_in_cell, uxgrid_point_in_cell, ) +from parcels._core.warnings import FieldSetWarning from parcels._python import isinstance_noimport @@ -88,6 +91,16 @@ def __init__( self._zlow = np.min(_zbound, axis=-1) self._zhigh = np.max(_zbound, axis=-1) + degeneracy_count = np.sum(_find_degenerate_xgrid_faces(x, y, z)) + if degeneracy_count > 0: + warnings.warn( + f"Grid contains {degeneracy_count} degenerate faces that span a large portion of the " + "hash grid. This may result in high memory usage for the hash table.", + FieldSetWarning, + stacklevel=2, + ) + + else: # Boundaries of the hash grid are the bounding box of the source grid self._xmin = self._source_grid.lon.min() @@ -483,6 +496,47 @@ def _dilate_bits(n): return n +def _find_degenerate_xgrid_faces(x, y, z, threshold_factor=10): + """Identify faces in structured grids that potentially span large portions of + the underlying hash grid. Such degenerate faces can result in high memory requirements + for the hash table. + + Detection is based on the maximum great-circle edge length of each cell. A cell + is flagged as degenerate when its longest edge exceeds ``threshold_factor`` multiplied by + the 99th percentile of all edge lengths. + + Parameters + ---------- + x, y, z : ndarray, shape (ny, nx) + Unit-sphere Cartesian coordinates of the grid nodes. + threshold_factor : float, optional + Multiplier applied to the 99th-percentile edge length to set the threshold. + Default is 10. + + Returns + ------- + degenerate : ndarray of bool, shape (ny-1, nx-1) + True for each cell whose maximum edge length exceeds the threshold. + """ + # Chord length between two sets of points on the unit sphere, shape (ny-1, nx-1) + def _chord(p1, p2): + return np.sqrt(((p1 - p2) ** 2).sum(axis=-1)) + + pts = np.stack([x, y, z], axis=-1) + c00, c01 = pts[:-1, :-1], pts[:-1, 1:] + c10, c11 = pts[1:, :-1], pts[1:, 1:] + + # Maximum chord across all four edges and both diagonals + max_chord = np.maximum.reduce([ + _chord(c00, c01), _chord(c10, c11), + _chord(c00, c10), _chord(c01, c11), + _chord(c00, c11), _chord(c01, c10), + ]) + + threshold = threshold_factor * np.percentile(max_chord, 99) + return max_chord > threshold + + def quantize_coordinates(x, y, z, xmin, xmax, ymin, ymax, zmin, zmax, bitwidth=1023): """ Normalize (x, y, z) to [0, 1] over their bounding box, then quantize to 10 bits each (0..1023). From 280f57ce833c10840f25e4f2deeb92f98e49ebaf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:48:41 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/parcels/_core/spatialhash.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index 0ba315300d..93ecc58f53 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -100,7 +100,6 @@ def __init__( stacklevel=2, ) - else: # Boundaries of the hash grid are the bounding box of the source grid self._xmin = self._source_grid.lon.min() @@ -503,7 +502,7 @@ def _find_degenerate_xgrid_faces(x, y, z, threshold_factor=10): Detection is based on the maximum great-circle edge length of each cell. A cell is flagged as degenerate when its longest edge exceeds ``threshold_factor`` multiplied by - the 99th percentile of all edge lengths. + the 99th percentile of all edge lengths. Parameters ---------- @@ -518,20 +517,26 @@ def _find_degenerate_xgrid_faces(x, y, z, threshold_factor=10): degenerate : ndarray of bool, shape (ny-1, nx-1) True for each cell whose maximum edge length exceeds the threshold. """ + # Chord length between two sets of points on the unit sphere, shape (ny-1, nx-1) def _chord(p1, p2): return np.sqrt(((p1 - p2) ** 2).sum(axis=-1)) pts = np.stack([x, y, z], axis=-1) c00, c01 = pts[:-1, :-1], pts[:-1, 1:] - c10, c11 = pts[1:, :-1], pts[1:, 1:] + c10, c11 = pts[1:, :-1], pts[1:, 1:] # Maximum chord across all four edges and both diagonals - max_chord = np.maximum.reduce([ - _chord(c00, c01), _chord(c10, c11), - _chord(c00, c10), _chord(c01, c11), - _chord(c00, c11), _chord(c01, c10), - ]) + max_chord = np.maximum.reduce( + [ + _chord(c00, c01), + _chord(c10, c11), + _chord(c00, c10), + _chord(c01, c11), + _chord(c00, c11), + _chord(c01, c10), + ] + ) threshold = threshold_factor * np.percentile(max_chord, 99) return max_chord > threshold From b0037ae820a8e194c3d15ee24aca6b825cd987b0 Mon Sep 17 00:00:00 2001 From: Joe Schoonover <11430768+fluidnumerics-joe@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:05:41 -0400 Subject: [PATCH 3/6] Update src/parcels/_core/spatialhash.py Co-authored-by: Nick Hodgskin <36369090+VeckoTheGecko@users.noreply.github.com> --- src/parcels/_core/spatialhash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index 93ecc58f53..87cc779d52 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -497,7 +497,7 @@ def _dilate_bits(n): def _find_degenerate_xgrid_faces(x, y, z, threshold_factor=10): """Identify faces in structured grids that potentially span large portions of - the underlying hash grid. Such degenerate faces can result in high memory requirements + the underlying hash grid (e.g., due to the mesh being incomplete, with 0.0 stored in missing lon/lat points). Such degenerate faces can result in high memory requirements for the hash table. Detection is based on the maximum great-circle edge length of each cell. A cell From 725b9b88d2616369bdd023e1d66a7eeb29c53633 Mon Sep 17 00:00:00 2001 From: Joe Schoonover <11430768+fluidnumerics-joe@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:06:17 -0400 Subject: [PATCH 4/6] Update warning language Co-authored-by: Nick Hodgskin <36369090+VeckoTheGecko@users.noreply.github.com> --- src/parcels/_core/spatialhash.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index 87cc779d52..c79e2e2d8a 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -95,7 +95,9 @@ def __init__( if degeneracy_count > 0: warnings.warn( f"Grid contains {degeneracy_count} degenerate faces that span a large portion of the " - "hash grid. This may result in high memory usage for the hash table.", + "hash grid. This is most likely due to a mesh that isn't fully defined (e.g., cells corresponding to land areas aren't defined in the mesh). + If particles are tried to be advected in these undefined regions, you may experience runtime + crashes due to high memory usage in the hash table.", FieldSetWarning, stacklevel=2, ) From 9333305612518694c28a40648188a4105d35aeac Mon Sep 17 00:00:00 2001 From: Joe Schoonover Date: Sat, 14 Mar 2026 06:52:15 -0400 Subject: [PATCH 5/6] Add more informative warning; provide first few (j,i) locations --- src/parcels/_core/spatialhash.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index c79e2e2d8a..04510ea550 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -91,13 +91,19 @@ def __init__( self._zlow = np.min(_zbound, axis=-1) self._zhigh = np.max(_zbound, axis=-1) - degeneracy_count = np.sum(_find_degenerate_xgrid_faces(x, y, z)) + degenerate_mask = _find_degenerate_xgrid_faces(x, y, z) + degeneracy_count = np.sum(degenerate_mask) if degeneracy_count > 0: + degen_locs = np.argwhere(degenerate_mask) # shape (N, 2), columns are (j, i) + max_shown = np.min([degeneracy_count,5]) + shown = degen_locs[:max_shown] + loc_str = ", ".join(f"(j={loc[0]}, i={loc[1]})" for loc in shown) warnings.warn( f"Grid contains {degeneracy_count} degenerate faces that span a large portion of the " - "hash grid. This is most likely due to a mesh that isn't fully defined (e.g., cells corresponding to land areas aren't defined in the mesh). - If particles are tried to be advected in these undefined regions, you may experience runtime - crashes due to high memory usage in the hash table.", + "hash grid. This is most likely due to a mesh that isn't fully defined (e.g., points corresponding to land with lat/lon masked to 0). " + "You may experience runtime crashes due to high memory usage in the hash table or cell lookup failures for particles" + "in the vicinity of these degenerate cells." + f"First degenerate face location(s): {loc_str}.", FieldSetWarning, stacklevel=2, ) From 2390cf2951b1d42f593fa507eacfa9042e9421ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:53:09 +0000 Subject: [PATCH 6/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/parcels/_core/spatialhash.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index 04510ea550..c4f4d0a64a 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -95,7 +95,7 @@ def __init__( degeneracy_count = np.sum(degenerate_mask) if degeneracy_count > 0: degen_locs = np.argwhere(degenerate_mask) # shape (N, 2), columns are (j, i) - max_shown = np.min([degeneracy_count,5]) + max_shown = np.min([degeneracy_count, 5]) shown = degen_locs[:max_shown] loc_str = ", ".join(f"(j={loc[0]}, i={loc[1]})" for loc in shown) warnings.warn(