Skip to content

Commit ef757f9

Browse files
Add spatialhash class
1 parent 30aec22 commit ef757f9

File tree

1 file changed

+332
-0
lines changed

1 file changed

+332
-0
lines changed

parcels/spatialhash.py

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
# from __future__ import annotations
2+
3+
# from typing import Literal
4+
5+
# import numpy as np
6+
# import uxarray as ux
7+
# from uxarray.grid.coordinates import _lonlat_rad_to_xyz
8+
9+
# from parcels.field import FieldOutOfBoundError # Adjust import as necessary
10+
# from parcels.xgrid import _search_1d_array
11+
12+
# from .basegrid import BaseGrid
13+
import numpy as np
14+
15+
16+
class SpatialHash:
17+
"""Custom data structure that is used for performing grid searches using Spatial Hashing. This class constructs an overlying
18+
uniformly spaced rectilinear grid, called the "hash grid" on top parcels.xgrid.XGrid. It is particularly useful for grid searching
19+
on curvilinear grids. Faces in the Xgrid are related to the cells in the hash grid by determining the hash cells the bounding box
20+
of the unstructured face cells overlap with.
21+
22+
Parameters
23+
----------
24+
grid : parcels.xgrid.XGrid
25+
Source grid used to construct the hash grid and hash table
26+
global_grid : bool, default=False
27+
If true, the hash grid is constructed using the domain [-pi,pi] x [-pi,pi]
28+
reconstruct : bool, default=False
29+
If true, reconstructs the spatial hash
30+
31+
Note
32+
----
33+
Does not currently support queries on periodic elements.
34+
"""
35+
36+
def __init__(
37+
self,
38+
grid,
39+
global_grid=False,
40+
reconstruct=False,
41+
):
42+
# TODO : Enforce grid to be an instance of parcels.xgrid.XGrid and curvilinear.
43+
self._source_grid = grid
44+
self.reconstruct = reconstruct
45+
46+
# Hash grid size
47+
self._dh = self._hash_cell_size()
48+
49+
if global_grid:
50+
# Set the hash grid to be a global grid
51+
self._xmin = -180.0
52+
self._ymin = -180.0
53+
self._xmax = 180.0
54+
self._ymax = 180.0
55+
else:
56+
# Lower left corner of the hash grid
57+
lon_min = self._source_grid.lon.min
58+
lat_min = self._source_grid.lat.min
59+
lon_max = self._source_grid.lon.max
60+
lat_max = self._source_grid.lat.max
61+
62+
self._xmin = lon_min - self._dh
63+
self._ymin = lat_min - self._dh
64+
self._xmax = lon_max + self._dh
65+
self._ymax = lat_max + self._dh
66+
67+
# Number of x points in the hash grid; used for
68+
# array flattening
69+
Lx = self._xmax - self._xmin
70+
Ly = self._ymax - self._ymin
71+
self._nx = int(np.ceil(Lx / self._dh))
72+
self._ny = int(np.ceil(Ly / self._dh))
73+
74+
# Generate the mapping from the hash indices to unstructured grid elements
75+
self._face_hash_table = None
76+
# self._face_hash_table = self._initialize_face_hash_table()
77+
78+
def _hash_cell_size(self):
79+
"""Computes the size of the hash cells from the source grid.
80+
The hash cell size is set to 1/2 of the square root of the curvilinear cell area
81+
"""
82+
return np.sqrt(np.median(planar_quad_area(self._source_grid.lon, self._source_grid.lat))) * 0.5
83+
84+
def _hash_index2d(self, coords):
85+
"""Computes the 2-d hash index (i,j) for the location (x,y), where x and y are given in spherical
86+
coordinates (in degrees)
87+
"""
88+
# Wrap longitude to [-pi, pi]
89+
lon = (coords[:, 0] + np.pi) % (2 * np.pi) - np.pi
90+
i = ((lon - self._xmin) / self._dh).astype(np.int32)
91+
j = ((coords[:, 1] - self._ymin) / self._dh).astype(np.int32)
92+
return i, j
93+
94+
def _hash_index(self, coords):
95+
"""Computes the flattened hash index for the location (x,y), where x and y are given in spherical
96+
coordinates (in degrees). The single dimensioned hash index orders the flat index with all of the
97+
i-points first and then all the j-points.
98+
"""
99+
i, j = self._hash_index2d(coords)
100+
return i + self._nx * j
101+
102+
def _grid_ij_for_eid(self, eid):
103+
"""Returns the (i,j) grid coordinates for the given element id (eid)"""
104+
j = eid // (self._source_grid.xdim - 1)
105+
i = eid - j * (self._source_grid.xdim - 1)
106+
return i, j
107+
108+
def _initialize_face_hash_table(self):
109+
"""Create a mapping that relates unstructured grid faces to hash indices by determining
110+
which faces overlap with which hash cells
111+
"""
112+
if self._face_hash_table is None or self.reconstruct:
113+
index_to_face = [[] for i in range(self._nx * self._ny)]
114+
# Get the bounds of each curvilinear faces
115+
lon_bounds, lat_bounds = curvilinear_grid_facebounds(
116+
self._source_grid.lon,
117+
self._source_grid.lat,
118+
)
119+
coords = np.stack(
120+
(
121+
lon_bounds[:, :, 0].flatten(),
122+
lat_bounds[:, :, 0].flatten(),
123+
),
124+
axis=-1,
125+
)
126+
xi1, yi1 = self._hash_index2d(coords)
127+
coords = np.stack(
128+
(
129+
lon_bounds[:, :, 1].flatten(),
130+
lat_bounds[:, :, 1].flatten(),
131+
),
132+
axis=-1,
133+
)
134+
xi2, yi2 = self._hash_index2d(coords)
135+
for eid in range(lon_bounds.shape[0]):
136+
for j in range(yi1[eid], yi2[eid] + 1):
137+
if xi1[eid] <= xi2[eid]:
138+
# Normal case, no wrap
139+
for i in range(xi1[eid], xi2[eid] + 1):
140+
index_to_face[(i % self._nx) + self._nx * j].append(eid)
141+
else:
142+
# Wrap-around case
143+
for i in range(xi1[eid], self._nx):
144+
index_to_face[(i % self._nx) + self._nx * j].append(eid)
145+
for i in range(0, xi2[eid] + 1):
146+
index_to_face[(i % self._nx) + self._nx * j].append(eid)
147+
return index_to_face
148+
149+
def query(
150+
self,
151+
coords,
152+
tol=1e-6,
153+
):
154+
"""Queries the hash table.
155+
156+
Parameters
157+
----------
158+
coords : array_like
159+
coordinate pairs in degrees (lon, lat) or cartesian (x,y,z) to query. If the SpatialHash.coordinate_system is
160+
"spherical", then coords should be in degrees (lon, lat). If the SpatialHash.coordinate_system is "cartesian",
161+
then coords can either be in degrees (lon, lat) or cartesian (x,y,z).
162+
163+
in_radians : bool, optional
164+
if True, queries assuming coords are inputted in radians, not degrees. Only applies to spherical coords
165+
166+
167+
Returns
168+
-------
169+
faces : ndarray of shape (coords.shape[0]), dtype=np.int32
170+
Face id's in the self._source_grid where each coords element is found. When a coords element is not found, the
171+
corresponding array entry in faces is set to -1.
172+
bcoords : ndarray of shape (coords.shape[0], self._source_grid.n_max_face_nodes), dtype=double
173+
Barycentric coordinates of each coords element
174+
"""
175+
num_coords = coords.shape[0]
176+
177+
# Preallocate results
178+
bcoords = np.zeros((num_coords, 4), dtype=np.double)
179+
faces = np.full(num_coords, -1, dtype=np.int32)
180+
181+
# Get the list of candidate faces for each coordinate
182+
candidate_faces = [self._face_hash_table[pid] for pid in self._hash_index(coords)]
183+
184+
# Get corner vertices of each face
185+
xbound = np.stack(
186+
(
187+
self._source_grid.lon[:-1, :-1],
188+
self._source_grid.lon[:-1, 1:],
189+
self._source_grid.lon[1:, 1:],
190+
self._source_grid.lon[1:, :-1],
191+
),
192+
axis=-1,
193+
)
194+
ybound = np.stack(
195+
(
196+
self._source_grid.lat[:-1, :-1],
197+
self._source_grid.lat[:-1, 1:],
198+
self._source_grid.lat[1:, 1:],
199+
self._source_grid.lat[1:, :-1],
200+
),
201+
axis=-1,
202+
)
203+
204+
for i, (coord, candidates) in enumerate(zip(coords, candidate_faces, strict=False)):
205+
for face_id in candidates:
206+
xi, yi = self._grid_ij_for_eid(face_id)
207+
nodes = np.stack(
208+
(
209+
xbound[xi, yi, :],
210+
ybound[xi, yi, :],
211+
),
212+
axis=-1,
213+
)
214+
215+
bcoord = np.asarray(_barycentric_coordinates(nodes, coord))
216+
err = abs(np.dot(bcoord, nodes[:, 0]) - coord[0]) + abs(np.dot(bcoord, nodes[:, 1]) - coord[1])
217+
if (bcoord >= 0).all() and err < tol:
218+
faces[i, :] = [yi, xi]
219+
bcoords[i, :] = bcoord
220+
break
221+
222+
return faces, bcoords
223+
224+
225+
def _triangle_area(A, B, C):
226+
"""Compute the area of a triangle given by three points."""
227+
d1 = B - A
228+
d2 = C - A
229+
d3 = np.cross(d1, d2)
230+
return 0.5 * np.linalg.norm(d3)
231+
232+
233+
def _barycentric_coordinates(nodes, point, min_area=1e-8):
234+
"""
235+
Compute the barycentric coordinates of a point P inside a convex polygon using area-based weights.
236+
So that this method generalizes to n-sided polygons, we use the Waschpress points as the generalized
237+
barycentric coordinates, which is only valid for convex polygons.
238+
239+
Parameters
240+
----------
241+
nodes : numpy.ndarray
242+
Spherical coordinates (lon,lat) of each corner node of a face
243+
point : numpy.ndarray
244+
Spherical coordinates (lon,lat) of the point
245+
246+
Returns
247+
-------
248+
numpy.ndarray
249+
Barycentric coordinates corresponding to each vertex.
250+
251+
"""
252+
n = len(nodes)
253+
sum_wi = 0
254+
w = []
255+
256+
for i in range(0, n):
257+
vim1 = nodes[i - 1]
258+
vi = nodes[i]
259+
vi1 = nodes[(i + 1) % n]
260+
a0 = _triangle_area(vim1, vi, vi1)
261+
a1 = max(_triangle_area(point, vim1, vi), min_area)
262+
a2 = max(_triangle_area(point, vi, vi1), min_area)
263+
sum_wi += a0 / (a1 * a2)
264+
w.append(a0 / (a1 * a2))
265+
barycentric_coords = [w_i / sum_wi for w_i in w]
266+
267+
return barycentric_coords
268+
269+
270+
def planar_quad_area(lon, lat):
271+
"""Computes the area of each quadrilateral face in a curvilinear grid.
272+
The lon and lat arrays are assumed to be 2D arrays of points with dimensions (n_y, n_x).
273+
The area is computed using the Shoelace formula.
274+
275+
Parameters
276+
----------
277+
lon : np.ndarray
278+
2D array of shape (n_y, n_x) containing the longitude of each corner node of the curvilinear grid.
279+
lat : np.ndarray
280+
2D array of shape (n_y, n_x) containing the latitude of each corner node of the curvilinear grid.
281+
282+
Returns
283+
-------
284+
area : np.ndarray
285+
2D array of shape (n_y-1, n_x-1) containing the area of each quadrilateral face in the curvilinear grid.
286+
"""
287+
x0 = lon[:-1, :-1]
288+
x1 = lon[:-1, 1:]
289+
x2 = lon[1:, 1:]
290+
x3 = lon[1:, :-1]
291+
292+
y0 = lat[:-1, :-1]
293+
y1 = lat[:-1, 1:]
294+
y2 = lat[1:, 1:]
295+
y3 = lat[1:, :-1]
296+
297+
# Shoelace formula: 0.5 * |sum(x_i*y_{i+1} - x_{i+1}*y_i)|
298+
area = 0.5 * np.abs(x0 * y1 + x1 * y2 + x2 * y3 + x3 * y0 - y0 * x1 - y1 * x2 - y2 * x3 - y3 * x0)
299+
return area
300+
301+
302+
def curvilinear_grid_facebounds(lon, lat):
303+
"""Computes the bounds of each curvilinear face in the grid.
304+
The lon and lat arrays are assumed to be 2D arrays of points with dimensions (n_y, n_x).
305+
The bounds are for faces whose corner node vertices are defined by lon,lat.
306+
Face(yi,xi) is surrounding by points (yi,xi), (yi,xi+1), (yi+1,xi+1), (yi+1,xi).
307+
308+
Parameters
309+
----------
310+
lon : np.ndarray
311+
2D array of shape (n_y, n_x) containing the longitude of each corner node of the curvilinear grid.
312+
lat : np.ndarray
313+
2D array of shape (n_y, n_x) containing the latitude of each corner node of the curvilinear grid.
314+
315+
Returns
316+
-------
317+
xbounds : np.ndarray
318+
Array of shape (n_y-1, n_x-1, 2) containing the bounds of each face in the x-direction.
319+
ybounds : np.ndarray
320+
Array of shape (n_y-1, n_x-1, 2) containing the bounds of each face in the y-direction.
321+
"""
322+
xf = np.stack((lon[:-1, :-1], lon[:-1, 1:], lon[1:, 1:], lon[1:, :-1]), axis=-1)
323+
xf_low = xf.min(axis=-1)
324+
xf_high = xf.max(axis=-1)
325+
xbounds = np.stack([xf_low, xf_high], axis=-1)
326+
327+
yf = np.stack((lat[:-1, :-1], lat[:-1, 1:], lat[1:, 1:], lat[1:, :-1]), axis=-1)
328+
yf_low = yf.min(axis=-1)
329+
yf_high = yf.max(axis=-1)
330+
ybounds = np.stack([yf_low, yf_high], axis=-1)
331+
332+
return xbounds, ybounds

0 commit comments

Comments
 (0)