From 280d43e3f2eff67f41932acaa2edbbea9bd22ed3 Mon Sep 17 00:00:00 2001 From: Wyatt Sieminski Date: Fri, 31 Jul 2026 13:42:50 -0400 Subject: [PATCH 1/9] Added spatialhash.describe to spatialhash.py and associated spatialhash_describe to _reprs.py --- src/parcels/_core/spatialhash.py | 21 ++++++++++++++++++++ src/parcels/_reprs.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index bcb11914a..39d69e26f 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -1,5 +1,8 @@ import warnings +import sys +from typing import IO + import numpy as np from parcels._core.index_search import ( @@ -10,6 +13,7 @@ ) from parcels._core.warnings import FieldSetWarning from parcels._python import isinstance_noimport +from parcels._reprs import spatialhash_describe # Budget on the total number of (face, hash cell) pairs in the hash table: # max(_HASH_ENTRIES_PER_FACE * nfaces, _HASH_ENTRY_BUDGET_MIN). @@ -524,6 +528,23 @@ def query(self, y, x): coords_best.reshape((num_queries, coordinates.shape[1])), ) + def describe(self, buf: IO | None = None) -> None: + """ + Summary of the SpatialHash's hash-table statistics (resolution, occupancy, + entry counts). + + Parameters + ---------- + buf : file-like, default: sys.stdout + writable buffer + """ + if buf is None: + buf = sys.stdout + assert buf is not None + + buf.write(spatialhash_describe(self)) + + def _dilate_bits(n): """ diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index 5a6ae2944..a8b1c55f1 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -18,6 +18,7 @@ from parcels import Field, FieldSet, ParticleSet from parcels._core.field import VectorField from parcels._core.model import ModelData + from parcels._core.spatialhash import SpatialHash from parcels._core.utils.time import TimeInterval @@ -280,6 +281,38 @@ def fieldset_describe(fieldset: FieldSet) -> str: ) +def spatialhash_describe(spatialhash: SpatialHash) -> str: + grid = spatialhash._source_grid + hash_table = spatialhash._hash_table + counts = hash_table["counts"] + + n_faces = int(np.size(spatialhash._xlow)) + n_entries = int(hash_table["faces"].size) + n_occupied_cells = int(hash_table["keys"].size) + n_total_cells = (spatialhash._bitwidth + 1) ** 3 + + rows = { + "Grid type": type(grid).__name__, + "Mesh": "spherical" if grid._mesh.is_spherical() else "flat", + "Total Mesh Faces": str(n_faces), + "Bitwidth (current / max)": f"{spatialhash._bitwidth} / 1023 (higher = finer resolution hash grid)", + "Total hash cells": str(n_total_cells), + "Occupied hash cells": str(n_occupied_cells), + "Total (cell --> face) entries": str(n_entries), + "Entries per occupied cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", + "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-", + "Faces per occupied cell (min / mean / max)": ( + f"{counts.min()} / {counts.mean():.2f} / {counts.max()}" if n_occupied_cells else "-" + ), + } + key_width = max(len(k) for k in rows) + table = "\n".join(f"{k.ljust(key_width)} : {v}" for k, v in rows.items()) + + return ( + "Spatial Hash Grid Statistics" + "\n" + + table + "\n" + ) + def _get_parent_model(field: Field | VectorField) -> ModelData: if isinstance_noimport(field, "Field"): return field.model # type:ignore[union-attr] From 7b204ca7dc865bfdfd6491456d36185f358ca2cc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:49:36 +0000 Subject: [PATCH 2/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/parcels/_core/spatialhash.py | 4 +--- src/parcels/_reprs.py | 6 ++---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/parcels/_core/spatialhash.py b/src/parcels/_core/spatialhash.py index 39d69e26f..18f8d4801 100644 --- a/src/parcels/_core/spatialhash.py +++ b/src/parcels/_core/spatialhash.py @@ -1,6 +1,5 @@ -import warnings - import sys +import warnings from typing import IO import numpy as np @@ -545,7 +544,6 @@ def describe(self, buf: IO | None = None) -> None: buf.write(spatialhash_describe(self)) - def _dilate_bits(n): """ Takes a 10-bit integer n, in range [0,1023], and "dilates" its bits so that diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index a8b1c55f1..1eb1bde0d 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -308,10 +308,8 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: key_width = max(len(k) for k in rows) table = "\n".join(f"{k.ljust(key_width)} : {v}" for k, v in rows.items()) - return ( - "Spatial Hash Grid Statistics" + "\n" - + table + "\n" - ) + return "Spatial Hash Grid Statistics" + "\n" + table + "\n" + def _get_parent_model(field: Field | VectorField) -> ModelData: if isinstance_noimport(field, "Field"): From de0e8c11d735ee2a758da16787eeafbbc92c6244 Mon Sep 17 00:00:00 2001 From: Wyatt Sieminski Date: Tue, 4 Aug 2026 15:45:46 +0000 Subject: [PATCH 3/9] Changed string formatting for large numbers to include commas. --- src/parcels/_reprs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index a8b1c55f1..9f8fa2aa0 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -293,16 +293,16 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: rows = { "Grid type": type(grid).__name__, - "Mesh": "spherical" if grid._mesh.is_spherical() else "flat", - "Total Mesh Faces": str(n_faces), + "Mesh": grid._mesh, + "Total Mesh Faces": f"{n_faces:,d}", "Bitwidth (current / max)": f"{spatialhash._bitwidth} / 1023 (higher = finer resolution hash grid)", - "Total hash cells": str(n_total_cells), - "Occupied hash cells": str(n_occupied_cells), - "Total (cell --> face) entries": str(n_entries), + "Total hash cells": f"{n_total_cells:,d}", + "Occupied hash cells": f"{n_occupied_cells:,d}", + "Total (cell --> face) entries": f"{n_entries:,d}", "Entries per occupied cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-", "Faces per occupied cell (min / mean / max)": ( - f"{counts.min()} / {counts.mean():.2f} / {counts.max()}" if n_occupied_cells else "-" + f"{counts.min():,d} / {counts.mean():.2f} / {counts.max():,d}" if n_occupied_cells else "-" ), } key_width = max(len(k) for k in rows) From f628c7e684eab74517c22a98b2440d1c89c6d7ba Mon Sep 17 00:00:00 2001 From: Wyatt Sieminski Date: Tue, 4 Aug 2026 15:37:59 -0400 Subject: [PATCH 4/9] Update src/parcels/_reprs.py Co-authored-by: Erik van Sebille --- src/parcels/_reprs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index d17bc9a97..1b4562d3d 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -301,7 +301,7 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: "Total (cell --> face) entries": f"{n_entries:,d}", "Entries per occupied cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-", - "Faces per occupied cell (min / mean / max)": ( + "Faces per occupied hash cell (min / mean / max)": ( f"{counts.min():,d} / {counts.mean():.2f} / {counts.max():,d}" if n_occupied_cells else "-" ), } From ad2bde672854bfd3ca4860baa17df7279788b6dc Mon Sep 17 00:00:00 2001 From: Wyatt Sieminski Date: Tue, 4 Aug 2026 15:38:32 -0400 Subject: [PATCH 5/9] Update src/parcels/_reprs.py Co-authored-by: Erik van Sebille --- src/parcels/_reprs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index 1b4562d3d..b993805e2 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -299,7 +299,7 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: "Total hash cells": f"{n_total_cells:,d}", "Occupied hash cells": f"{n_occupied_cells:,d}", "Total (cell --> face) entries": f"{n_entries:,d}", - "Entries per occupied cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", + "Entries per occupied hash cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-", "Faces per occupied hash cell (min / mean / max)": ( f"{counts.min():,d} / {counts.mean():.2f} / {counts.max():,d}" if n_occupied_cells else "-" From 69ac15f211708db2d4756dc85a5c251aeb68e4af Mon Sep 17 00:00:00 2001 From: Wyatt Sieminski Date: Tue, 4 Aug 2026 20:04:07 +0000 Subject: [PATCH 6/9] Added .describe to docs and small updates to desribe formatting --- docs/development/unstructured_grid_search.md | 2 ++ src/parcels/_reprs.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/development/unstructured_grid_search.md b/docs/development/unstructured_grid_search.md index bedc1aa5e..c4f519b54 100644 --- a/docs/development/unstructured_grid_search.md +++ b/docs/development/unstructured_grid_search.md @@ -5,6 +5,8 @@ This page documents the algorithm used in Parcels to locate which grid cell a pa - `src/parcels/_core/spatialhash.py` — `SpatialHash` class and Morton encoding utilities - `src/parcels/_core/index_search.py` — point-in-cell tests and the high-level search dispatch +For debugging information regarding the spatial hash grid that underlies a curvilinear or unstructured grid, call `SpatialHash.describe()`, which prints a summary of the hash table's statistics. This table includes the total number of occupied hash cells, the percentage of hash cells that are occupied, the minimum, mean, and maximum number of mesh faces in a hash cell, and more. + --- ## Motivation diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index b993805e2..5bdc1d8a4 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -294,11 +294,11 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: rows = { "Grid type": type(grid).__name__, "Mesh": grid._mesh, - "Total Mesh Faces": f"{n_faces:,d}", + "Total mesh faces": f"{n_faces:,d}", "Bitwidth (current / max)": f"{spatialhash._bitwidth} / 1023 (higher = finer resolution hash grid)", "Total hash cells": f"{n_total_cells:,d}", - "Occupied hash cells": f"{n_occupied_cells:,d}", - "Total (cell --> face) entries": f"{n_entries:,d}", + "Occupied hash cells": f"{n_occupied_cells:,d}, {n_occupied_cells/n_total_cells*100}%", + "Total (hash cell --> gird face) entries": f"{n_entries:,d}", "Entries per occupied hash cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-", "Faces per occupied hash cell (min / mean / max)": ( From 25ff88eb461b8abde275dbc429a2ad2de5f124a8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:04:20 +0000 Subject: [PATCH 7/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/parcels/_reprs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index 5bdc1d8a4..5ad8ab3fd 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -297,7 +297,7 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: "Total mesh faces": f"{n_faces:,d}", "Bitwidth (current / max)": f"{spatialhash._bitwidth} / 1023 (higher = finer resolution hash grid)", "Total hash cells": f"{n_total_cells:,d}", - "Occupied hash cells": f"{n_occupied_cells:,d}, {n_occupied_cells/n_total_cells*100}%", + "Occupied hash cells": f"{n_occupied_cells:,d}, {n_occupied_cells / n_total_cells * 100}%", "Total (hash cell --> gird face) entries": f"{n_entries:,d}", "Entries per occupied hash cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-", From 73cbb3af1c2cb2f5cd2a2d79189a60d1b0455524 Mon Sep 17 00:00:00 2001 From: Wyatt Sieminski Date: Tue, 4 Aug 2026 20:29:13 +0000 Subject: [PATCH 8/9] Added SpatialHash.describe testing and recuced occupied hash cell % to four decimals. --- src/parcels/_reprs.py | 2 +- tests/test_spatialhash.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index 5bdc1d8a4..1dd48cb09 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -297,7 +297,7 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: "Total mesh faces": f"{n_faces:,d}", "Bitwidth (current / max)": f"{spatialhash._bitwidth} / 1023 (higher = finer resolution hash grid)", "Total hash cells": f"{n_total_cells:,d}", - "Occupied hash cells": f"{n_occupied_cells:,d}, {n_occupied_cells/n_total_cells*100}%", + "Occupied hash cells": f"{n_occupied_cells:,d}, {n_occupied_cells/n_total_cells*100:.4f}%", "Total (hash cell --> gird face) entries": f"{n_entries:,d}", "Entries per occupied hash cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-", diff --git a/tests/test_spatialhash.py b/tests/test_spatialhash.py index 76cdf88d4..78ae1311b 100644 --- a/tests/test_spatialhash.py +++ b/tests/test_spatialhash.py @@ -1,3 +1,5 @@ +from io import StringIO + import numpy as np from parcels._core.fieldset import FieldSet @@ -20,6 +22,30 @@ def test_spatialhash_init(): assert spatialhash is not None +def test_spatialhash_describe(): + ds = datasets["2d_left_rotated"] + grid = FieldSet.from_sgrid_conventions(ds, mesh="flat").data_g.grid + spatialhash = grid.get_spatial_hash() + + io = StringIO() + expected = """\ +Spatial Hash Grid Statistics +Grid type : XGrid +Mesh : FlatMesh() +Total mesh faces : 1,711 +Bitwidth (current / max) : 1023 / 1023 (higher = finer resolution hash grid) +Total hash cells : 1,073,741,824 +Occupied hash cells : 796,054, 0.0741% +Total (hash cell --> gird face) entries : 1,080,194 +Entries per occupied hash cell (avg) : 1.36 +Entries per face (avg) : 631.32 +Faces per occupied hash cell (min / mean / max) : 1 / 1.36 / 4 +""" + spatialhash.describe(io) + actual = io.getvalue() + assert actual == expected + + def test_invalid_positions(): ds = datasets["2d_left_rotated"] grid = FieldSet.from_sgrid_conventions(ds, mesh="flat").data_g.grid From 455fdf8f178ac69aa5abd2564bfd3210e926000c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:34:24 +0000 Subject: [PATCH 9/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/parcels/_reprs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parcels/_reprs.py b/src/parcels/_reprs.py index 1dd48cb09..03323b2a7 100644 --- a/src/parcels/_reprs.py +++ b/src/parcels/_reprs.py @@ -297,7 +297,7 @@ def spatialhash_describe(spatialhash: SpatialHash) -> str: "Total mesh faces": f"{n_faces:,d}", "Bitwidth (current / max)": f"{spatialhash._bitwidth} / 1023 (higher = finer resolution hash grid)", "Total hash cells": f"{n_total_cells:,d}", - "Occupied hash cells": f"{n_occupied_cells:,d}, {n_occupied_cells/n_total_cells*100:.4f}%", + "Occupied hash cells": f"{n_occupied_cells:,d}, {n_occupied_cells / n_total_cells * 100:.4f}%", "Total (hash cell --> gird face) entries": f"{n_entries:,d}", "Entries per occupied hash cell (avg)": f"{n_entries / n_occupied_cells:.2f}" if n_occupied_cells else "-", "Entries per face (avg)": f"{n_entries / n_faces:.2f}" if n_faces else "-",