Skip to content

Commit 2154ea8

Browse files
Add spatial hashing (#1169)
I attempted to stay close to the KD-Tree and BallTree API. However, the purpose of the SpatialHash class is to help locate the faces that a list of coordinates lie within. Because of this, the intent of the `query` function for the SpatialHash is a bit different than that for the KD and Ball trees. Additional support routines are provided for assessing whether a point is inside or outside a polygon. This is done by calculating the barycentric coordinates of a point in a convex polygon. The method is defined so that the sum of the barycentric coordinates is exactly one. Because of this, if any of the barycentric coordinates are negative, we immediately know that a point is outside the polygon. All coordinate calculations are done using (lon,lat) in radians for the SpatialHash class. Cartesian coordinates are not supported in this commit. * Add `get_spatial_hash` to grid api * Add example usage in spatial hashing docs * Add spatialhashing to userguide * Add basic tests * Fix call to get_spatialhash * Fix formatting * Prepend attributes intended for internal use with _ * Add a few patches for hashtable setup and query check * Ensure that the lon bounds are sorted from low to high. This is needed to make sure that the index looping for filling the hashtable actually executes. * All barycentric coordinates are calculated directly. Rather than calculating the last coordinate to ensure they sum to one, we now compute all coordinates. The coordinates are calculated now to align with the indexing of the node ID's so that np.sum(bcoords*nodes) returns the barycentric interpolation estimate of point. If the point is within the element, then the barycentric interpolation estimate matches the input coordinate (to machine precision). I've added this as a check. * Fix docstrings for tests; add fesom test with particle list * Add docstring for get_spatialhash * remove empty notes * Update uxarray/grid/grid.py Co-authored-by: Philip Chmielowiec <[email protected]> * Fix naming in test * Update uxarray/grid/neighbors.py Co-authored-by: Philip Chmielowiec <[email protected]> * Update uxarray/grid/neighbors.py Co-authored-by: Philip Chmielowiec <[email protected]> * Update test/test_spatial_hashing.py Co-authored-by: Philip Chmielowiec <[email protected]> * Fix docstring for reconstruct field Co-authored-by: Philip Chmielowiec <[email protected]> * Update uxarray/grid/grid.py Co-authored-by: Philip Chmielowiec <[email protected]> * Add example to docstring * Use waschpress points for generalized barycentric coordinates Added dual and primal grids for mpas. The generalized waschpress points give us the correct generalized barycentric coordinates that satisfy the Lagrange property for barycentric interpolation in convex polygons. * Switch to mpas example in notebook * Update uxarray/grid/grid.py Co-authored-by: Philip Chmielowiec <[email protected]> * Retain output in notebook. Make requested formatting changes * Fix formatting * Change to smaller/simpler 4xhex grid * Add get_spatial_hash to the Grid/Methods section * Clear outputs from notebook. * Fix docstring; we're not computing signed area any longer. Instead, we're favoring a check of the predicted coordinates using barycentric interpolation * Update uxarray/grid/neighbors.py Co-authored-by: Philip Chmielowiec <[email protected]> * Update uxarray/grid/neighbors.py Co-authored-by: Philip Chmielowiec <[email protected]> * Add query optimization suggested by @philipc2 * Remove extra return statement * Clip denominator in barycentric weights calculation The denominator in the barycentric weights calculation (Weischell weights) is clipped to be no smaller than `ERROR_TOLERANCE` from the uxarray.constants module. This change is made to fix an issue, reported anectdotally by @Philip2c in #1169 * Clip triangle areas rather than denominator When the denominator is clipped, rather than the individual triangle areas, the Weischell weights are incorrect for the case when a query point is on a face vertex. I've added tests for query points on edges and vertices --------- Co-authored-by: Philip Chmielowiec <[email protected]>
1 parent 03923f4 commit 2154ea8

File tree

6 files changed

+495
-1
lines changed

6 files changed

+495
-1
lines changed

docs/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Methods
161161
Grid.calculate_total_face_area
162162
Grid.normalize_cartesian_coordinates
163163
Grid.construct_face_centers
164+
Grid.get_spatial_hash
164165
Grid.get_faces_containing_point
165166

166167
Inheritance of Xarray Functionality
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Spatial Hashing\n",
8+
"UXarray supports spatial hashing, which a fast search method for finding the face that a point lies within on an unstructured grid. This can be useful for building particle-in-cell interpolators for Lagrangian fluid flow analysis. "
9+
]
10+
},
11+
{
12+
"cell_type": "code",
13+
"execution_count": null,
14+
"metadata": {},
15+
"outputs": [],
16+
"source": [
17+
"import uxarray as ux"
18+
]
19+
},
20+
{
21+
"cell_type": "markdown",
22+
"metadata": {},
23+
"source": [
24+
"For this example we will be using a simple hexagonal grid."
25+
]
26+
},
27+
{
28+
"cell_type": "code",
29+
"execution_count": null,
30+
"metadata": {},
31+
"outputs": [],
32+
"source": [
33+
"grid_path = \"../../test/meshfiles/ugrid/quad-hexagon/grid.nc\"\n",
34+
"uxgrid = ux.open_grid(grid_path)"
35+
]
36+
},
37+
{
38+
"cell_type": "markdown",
39+
"metadata": {},
40+
"source": [
41+
"\n",
42+
"## SpatialHash\n",
43+
"UXarray `SpatialHash` class provides an API for locating the unstructured grid faces that a list of $(x,y)$ points (in spherical coordinates) lie within. This search is conducted by relating unstructured grid faces and arbitrary physical positions to a uniformly spaced structured grid (the hash grid). The relationship between the unstructured grid elements and the hash grid is maintained in a hash table. \n",
44+
"\n",
45+
"### Parameters\n",
46+
"The `SpatialHash` class can be accessed through the `Grid.get_spatial_hash(reconstruct)` method, which takes in the following parameters:\n",
47+
"* `reconstruct` is a bool variable that allows the user to reconstruct the spatial hash structure. As default for performance, if a user calls `get_spatial_hash` and a spatial hash structure has already been created, it will simply use that one. If `reconstruct` is set to `True`, it will override this and reconstruct the spatial hash structure. The default parameter is `False`.\n",
48+
"\n",
49+
"\n",
50+
"### Constructing a Spatial Hash\n",
51+
"We can store the SpatialHash data structure in a variable, which allows us to access the spatial hash in a simple way for queries."
52+
]
53+
},
54+
{
55+
"cell_type": "code",
56+
"execution_count": null,
57+
"metadata": {},
58+
"outputs": [],
59+
"source": [
60+
"spatial_hash = uxgrid.get_spatial_hash(\n",
61+
" reconstruct=\"False\",\n",
62+
")"
63+
]
64+
},
65+
{
66+
"cell_type": "markdown",
67+
"metadata": {},
68+
"source": [
69+
"### Query\n",
70+
"Now we can use that variable to query for the face that a point lies within in addition to its barycentric coordinates. The first parameter is the point from which to do the search, which must be in spherical coordinates."
71+
]
72+
},
73+
{
74+
"cell_type": "code",
75+
"execution_count": null,
76+
"metadata": {},
77+
"outputs": [],
78+
"source": [
79+
"face_ids, bcoords = uxgrid.get_spatial_hash().query([-0.1, -0.1])\n",
80+
"print(f\"face ids : {face_ids}\")\n",
81+
"print(f\"Barycentric Coordinates: {bcoords}\")"
82+
]
83+
}
84+
],
85+
"metadata": {
86+
"kernelspec": {
87+
"display_name": "uxarray-dev",
88+
"language": "python",
89+
"name": "python3"
90+
},
91+
"language_info": {
92+
"codemirror_mode": {
93+
"name": "ipython",
94+
"version": 3
95+
},
96+
"file_extension": ".py",
97+
"mimetype": "text/x-python",
98+
"name": "python",
99+
"nbconvert_exporter": "python",
100+
"pygments_lexer": "ipython3",
101+
"version": "3.10.16"
102+
}
103+
},
104+
"nbformat": 4,
105+
"nbformat_minor": 2
106+
}

docs/userguide.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ These user guides provide detailed explanations of the core functionality in UXa
4949
`Subsetting <user-guide/subset.ipynb>`_
5050
Select specific regions of a grid
5151

52+
`Spatial Hashing <user-guide/spatial-hashing.ipynb>`_
53+
Use spatial hashing to locate the faces a list of points reside in.
54+
5255
`Cross-Sections <user-guide/cross-sections.ipynb>`_
5356
Select cross-sections of a grid
5457

test/test_spatial_hashing.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import os
2+
import numpy as np
3+
import pytest
4+
import xarray as xr
5+
from pathlib import Path
6+
import uxarray as ux
7+
from uxarray.constants import ERROR_TOLERANCE
8+
9+
current_path = Path(os.path.dirname(os.path.realpath(__file__)))
10+
11+
# Sample grid file paths
12+
gridfile_CSne8 = current_path / "meshfiles" / "scrip" / "outCSne8" / "outCSne8.nc"
13+
gridfile_RLL1deg = current_path / "meshfiles" / "ugrid" / "outRLL1deg" / "outRLL1deg.ug"
14+
gridfile_RLL10deg_CSne4 = current_path / "meshfiles" / "ugrid" / "ov_RLL10deg_CSne4" / "ov_RLL10deg_CSne4.ug"
15+
gridfile_CSne30 = current_path / "meshfiles" / "ugrid" / "outCSne30" / "outCSne30.ug"
16+
gridfile_fesom = current_path / "meshfiles" / "ugrid" / "fesom" / "fesom.mesh.diag.nc"
17+
gridfile_geoflow = current_path / "meshfiles" / "ugrid" / "geoflow-small" / "grid.nc"
18+
gridfile_mpas = current_path / 'meshfiles' / "mpas" / "QU" / 'mesh.QU.1920km.151026.nc'
19+
20+
grid_files = [gridfile_CSne8,
21+
gridfile_RLL1deg,
22+
gridfile_RLL10deg_CSne4,
23+
gridfile_CSne30,
24+
gridfile_fesom,
25+
gridfile_geoflow,
26+
gridfile_mpas]
27+
28+
def test_construction():
29+
"""Tests the construction of the SpatialHash object"""
30+
for grid_file in grid_files:
31+
uxgrid = ux.open_grid(grid_file)
32+
face_ids, bcoords = uxgrid.get_spatial_hash().query([0.9, 1.8])
33+
assert face_ids.shape[0] == bcoords.shape[0]
34+
35+
36+
def test_is_inside():
37+
"""Verifies simple test for points inside and outside an element."""
38+
verts = [(0.0, 90.0), (-180, 0.0), (0.0, -90)]
39+
uxgrid = ux.open_grid(verts, latlon=True)
40+
# Verify that a point outside the element returns a face id of -1
41+
face_ids, bcoords = uxgrid.get_spatial_hash().query([90.0, 0.0])
42+
assert face_ids[0] == -1
43+
# Verify that a point inside the element returns a face id of 0
44+
face_ids, bcoords = uxgrid.get_spatial_hash().query([-90.0, 0.0])
45+
46+
assert face_ids[0] == 0
47+
assert np.allclose(bcoords[0], [0.25, 0.5, 0.25], atol=1e-06)
48+
49+
50+
def test_query_on_vertex():
51+
"""Verifies correct values when a query is made exactly on a vertex"""
52+
verts = [(0.0, 90.0), (-180, 0.0), (0.0, -90)]
53+
uxgrid = ux.open_grid(verts, latlon=True)
54+
# Verify that a point outside the element returns a face id of -1
55+
face_ids, bcoords = uxgrid.get_spatial_hash().query([0.0, 90.0])
56+
assert face_ids[0] == 0
57+
assert np.isclose(bcoords[0,0],1.0,atol=ERROR_TOLERANCE)
58+
assert np.isclose(bcoords[0,1],0.0,atol=ERROR_TOLERANCE)
59+
assert np.isclose(bcoords[0,2],0.0,atol=ERROR_TOLERANCE)
60+
61+
62+
def test_query_on_edge():
63+
"""Verifies correct values when a query is made exactly on an edge of a face"""
64+
verts = [(0.0, 90.0), (-180, 0.0), (0.0, -90)]
65+
uxgrid = ux.open_grid(verts, latlon=True)
66+
# Verify that a point outside the element returns a face id of -1
67+
face_ids, bcoords = uxgrid.get_spatial_hash().query([0.0, 0.0])
68+
assert face_ids[0] == 0
69+
assert np.isclose(bcoords[0,0],0.5,atol=ERROR_TOLERANCE)
70+
assert np.isclose(bcoords[0,1],0.0,atol=ERROR_TOLERANCE)
71+
assert np.isclose(bcoords[0,2],0.5,atol=ERROR_TOLERANCE)
72+
73+
74+
def test_list_of_coords_simple():
75+
"""Verifies test using list of points inside and outside an element"""
76+
verts = [(0.0, 90.0), (-180, 0.0), (0.0, -90)]
77+
uxgrid = ux.open_grid(verts, latlon=True)
78+
79+
coords = [[90.0, 0.0], [-90.0, 0.0]]
80+
face_ids, bcoords = uxgrid.get_spatial_hash().query(coords)
81+
assert face_ids[0] == -1
82+
assert face_ids[1] == 0
83+
assert np.allclose(bcoords[1], [0.25, 0.5, 0.25], atol=1e-06)
84+
85+
86+
def test_list_of_coords_fesom():
87+
"""Verifies test using list of points on the fesom grid"""
88+
uxgrid = ux.open_grid(gridfile_fesom)
89+
90+
num_particles = 20
91+
coords = np.zeros((num_particles,2))
92+
x_min = 1.0
93+
x_max = 3.0
94+
y_min = 2.0
95+
y_max = 10.0
96+
for k in range(num_particles):
97+
coords[k,0] = np.deg2rad(np.random.uniform(x_min, x_max))
98+
coords[k,1] = np.deg2rad(np.random.uniform(y_min, y_max))
99+
face_ids, bcoords = uxgrid.get_spatial_hash().query(coords)
100+
assert len(face_ids) == num_particles
101+
assert bcoords.shape[0] == num_particles
102+
assert bcoords.shape[1] == 3
103+
assert np.all(face_ids >= 0) # All particles should be inside an element
104+
105+
106+
def test_list_of_coords_mpas_dual():
107+
"""Verifies test using list of points on the dual MPAS grid"""
108+
uxgrid = ux.open_grid(gridfile_mpas, use_dual=True)
109+
110+
num_particles = 20
111+
coords = np.zeros((num_particles,2))
112+
x_min = -40.0
113+
x_max = 40.0
114+
y_min = -20.0
115+
y_max = 20.0
116+
for k in range(num_particles):
117+
coords[k,0] = np.deg2rad(np.random.uniform(x_min, x_max))
118+
coords[k,1] = np.deg2rad(np.random.uniform(y_min, y_max))
119+
face_ids, bcoords = uxgrid.get_spatial_hash().query(coords)
120+
assert len(face_ids) == num_particles
121+
assert bcoords.shape[0] == num_particles
122+
assert bcoords.shape[1] == 3 # max sides of an element
123+
assert np.all(face_ids >= 0) # All particles should be inside an element
124+
125+
126+
def test_list_of_coords_mpas_primal():
127+
"""Verifies test using list of points on the primal MPAS grid"""
128+
uxgrid = ux.open_grid(gridfile_mpas, use_dual=False)
129+
130+
num_particles = 20
131+
coords = np.zeros((num_particles,2))
132+
x_min = -40.0
133+
x_max = 40.0
134+
y_min = -20.0
135+
y_max = 20.0
136+
for k in range(num_particles):
137+
coords[k,0] = np.deg2rad(np.random.uniform(x_min, x_max))
138+
coords[k,1] = np.deg2rad(np.random.uniform(y_min, y_max))
139+
face_ids, bcoords = uxgrid.get_spatial_hash().query(coords)
140+
assert len(face_ids) == num_particles
141+
assert bcoords.shape[0] == num_particles
142+
assert bcoords.shape[1] == 6 # max sides of an element
143+
assert np.all(face_ids >= 0) # All particles should be inside an element

uxarray/grid/grid.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
from uxarray.grid.neighbors import (
8181
BallTree,
8282
KDTree,
83+
SpatialHash,
8384
_populate_edge_face_distances,
8485
_populate_edge_node_distances,
8586
)
@@ -255,6 +256,7 @@ def __init__(
255256
# initialize cached data structures (nearest neighbor operations)
256257
self._ball_tree = None
257258
self._kd_tree = None
259+
self._spatialhash = None
258260

259261
# flag to track if coordinates are normalized
260262
self._normalized = None
@@ -1762,6 +1764,44 @@ def get_kd_tree(
17621764

17631765
return self._kd_tree
17641766

1767+
def get_spatial_hash(
1768+
self,
1769+
reconstruct: bool = False,
1770+
):
1771+
"""Get the SpatialHash data structure of this Grid that allows for
1772+
fast face search queries. Face searches are used to find the faces that
1773+
a list of points, in spherical coordinates, are contained within.
1774+
1775+
Parameters
1776+
----------
1777+
reconstruct : bool, default=False
1778+
If true, reconstructs the spatial hash
1779+
1780+
Returns
1781+
-------
1782+
self._spatialhash : grid.Neighbors.SpatialHash
1783+
SpatialHash instance
1784+
1785+
Examples
1786+
--------
1787+
Open a grid from a file path:
1788+
1789+
>>> import uxarray as ux
1790+
>>> uxgrid = ux.open_grid("grid_filename.nc")
1791+
1792+
Obtain SpatialHash instance:
1793+
1794+
>>> spatial_hash = uxgrid.get_spatial_hash()
1795+
1796+
Query to find the face a point lies within in addition to its barycentric coordinates:
1797+
1798+
>>> face_ids, bcoords = spatial_hash.query([0.0, 0.0])
1799+
"""
1800+
if self._spatialhash is None or reconstruct:
1801+
self._spatialhash = SpatialHash(self, reconstruct)
1802+
1803+
return self._spatialhash
1804+
17651805
def copy(self):
17661806
"""Returns a deep copy of this grid."""
17671807

0 commit comments

Comments
 (0)