Skip to content

Commit a78d589

Browse files
Draft vectorized uxgrid point-in-cell check
1 parent 9b0e3ec commit a78d589

1 file changed

Lines changed: 136 additions & 102 deletions

File tree

parcels/uxgrid.py

Lines changed: 136 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import numpy as np
66
import uxarray as ux
77

8+
from parcels._index_search import GRID_SEARCH_ERROR
89
from parcels._typing import assert_valid_mesh
9-
from parcels.tools.statuscodes import FieldOutOfBoundError
1010
from parcels.xgrid import _search_1d_array
1111

1212
from .basegrid import BaseGrid
@@ -42,6 +42,7 @@ def __init__(self, grid: ux.grid.Grid, z: ux.UxDataArray, mesh="flat") -> UxGrid
4242
raise ValueError("z must be a 1D array of vertical coordinates")
4343
self.z = z
4444
self._mesh = mesh
45+
self._spatialhash = None
4546

4647
assert_valid_mesh(mesh)
4748

@@ -73,96 +74,100 @@ def get_axis_dim(self, axis: _UXGRID_AXES) -> int:
7374
return self.uxgrid.n_face
7475

7576
def search(self, z, y, x, ei=None, tol=1e-6):
76-
def try_face(fid):
77-
bcoords, err = self._get_barycentric_coordinates_latlon(y, x, fid)
78-
if (bcoords >= 0).all() and (bcoords <= 1).all() and err < tol:
79-
return bcoords
80-
else:
81-
bcoords = self._get_barycentric_coordinates_cartesian(y, x, fid)
82-
if (bcoords >= 0).all() and (bcoords <= 1).all():
83-
return bcoords
84-
85-
return None
77+
"""
78+
Search for the grid cell (face) and vertical layer that contains the given points.
8679
80+
Parameters
81+
----------
82+
z : float or np.ndarray
83+
The vertical coordinate(s) (depth) of the point(s).
84+
y : float or np.ndarray
85+
The latitude(s) of the point(s).
86+
x : float or np.ndarray
87+
The longitude(s) of the point(s).
88+
ei : np.ndarray, optional
89+
Precomputed horizontal indices (face indices) for the points.
90+
91+
TO BE IMPLEMENTED : If provided, we'll check
92+
if the points are within the faces specified by these indices. For cells where the particles
93+
are not found, a nearest neighbor search will be performed. As a last resort, the spatial hash will be used.
94+
tol : float, optional
95+
Tolerance for barycentric coordinate checks. Default is 1e-6.
96+
"""
97+
indices = self.unravel_index(ei)
98+
fi = indices["FACE"]
99+
zi = indices["Z"]
87100
zi, zeta = _search_1d_array(self.z.values, z)
101+
if np.any(ei):
102+
is_in_cell, coords = uxgrid_point_in_cell(self.uxgrid, y, x, fi, fi)
103+
y_check = y[is_in_cell == 0]
104+
x_check = x[is_in_cell == 0]
105+
zero_indices = np.where(is_in_cell == 0)[0]
106+
else:
107+
# Otherwise, we need to check all points
108+
fi = np.full(len(y), GRID_SEARCH_ERROR, dtype=np.int32)
109+
y_check = y
110+
x_check = x
111+
coords = -1.0 * np.ones((len(y), 2), dtype=np.float32)
112+
zero_indices = np.arange(len(y))
113+
114+
if len(zero_indices) > 0:
115+
face_ids_q, _, coords_q = self.uxgrid.get_spatial_hash().query(y_check, x_check)
116+
coords[zero_indices, :] = coords_q
117+
fi[zero_indices] = face_ids_q
118+
119+
return {"Z": (zi, zeta), "FACE": (fi, coords)}
120+
121+
122+
def uxgrid_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi: np.ndarray):
123+
"""Check if points are inside the grid cells defined by the given face indices.
88124
89-
if ei is not None:
90-
indices = self.unravel_index(ei)
91-
fi = indices["FACE"][0]
92-
bcoords = try_face(fi)
93-
if bcoords is not None:
94-
return {"Z": (zi, zeta), "FACE": (np.asarray([fi]), bcoords)}
95-
# Try neighbors of current face
96-
for neighbor in self.uxgrid.face_face_connectivity[fi, :]:
97-
if neighbor == -1:
98-
continue
99-
bcoords = try_face(neighbor)
100-
if bcoords is not None:
101-
return {"Z": (zi, zeta), "FACE": (np.asarray([neighbor]), bcoords)}
102-
103-
# Global fallback as last ditch effort
104-
points = np.column_stack((x, y))
105-
face_ids = self.uxgrid.get_faces_containing_point(points, return_counts=False)[0]
106-
fi = face_ids[0] if len(face_ids) > 0 else -1
107-
if fi == -1:
108-
raise FieldOutOfBoundError(z, y, x)
109-
bcoords = try_face(fi)
110-
if bcoords is None:
111-
raise FieldOutOfBoundError(z, y, x)
112-
return {"Z": (zi, zeta), "FACE": (fi, bcoords)}
113-
114-
def _get_barycentric_coordinates_latlon(self, y, x, fi):
115-
"""Checks if a point is inside a given face id on a UxGrid."""
116-
# Check if particle is in the same face, otherwise search again.
117-
n_nodes = self.uxgrid.n_nodes_per_face[fi].to_numpy()
118-
node_ids = self.uxgrid.face_node_connectivity[fi, 0:n_nodes]
119-
nodes = np.column_stack(
120-
(
121-
np.deg2rad(self.uxgrid.node_lon[node_ids].to_numpy()),
122-
np.deg2rad(self.uxgrid.node_lat[node_ids].to_numpy()),
123-
)
124-
)
125+
Parameters
126+
----------
127+
grid : ux.grid.Grid
128+
The uxarray grid object containing the unstructured grid data.
129+
y : np.ndarray
130+
Array of latitudes of the points to check.
131+
x : np.ndarray
132+
Array of longitudes of the points to check.
133+
yi : np.ndarray
134+
Array of face indices corresponding to the points.
135+
xi : np.ndarray
136+
Not used, but included for compatibility with other search functions.
125137
126-
coord = np.deg2rad(np.column_stack((x, y)))
127-
bcoord = np.asarray(_barycentric_coordinates(nodes, coord))
128-
proj_coord = np.matmul(np.transpose(nodes), bcoord)
129-
err = np.linalg.norm(proj_coord - coord)
130-
return bcoord, err
131-
132-
def _get_barycentric_coordinates_cartesian(self, y, x, fi):
133-
n_nodes = self.uxgrid.n_nodes_per_face[fi].to_numpy()
134-
node_ids = self.uxgrid.face_node_connectivity[fi, 0:n_nodes]
135-
136-
coord = np.deg2rad([x, y])
137-
x, y, z = _lonlat_rad_to_xyz(coord[0], coord[1])
138-
cart_coord = np.array([x, y, z]).T
139-
# Second attempt to find barycentric coordinates using cartesian coordinates
140-
nodes = np.stack(
141-
(
142-
self.uxgrid.node_x[node_ids].values,
143-
self.uxgrid.node_y[node_ids].values,
144-
self.uxgrid.node_z[node_ids].values,
145-
),
146-
axis=-1,
138+
Returns
139+
-------
140+
is_in_cell : np.ndarray
141+
An array indicating whether each point is inside (1) or outside (0) the corresponding cell.
142+
coords : np.ndarray
143+
Barycentric coordinates of the points within their respective cells.
144+
"""
145+
if grid.mesh == "spherical":
146+
lon_rad = np.deg2rad(grid.lon.values)
147+
lat_rad = np.deg2rad(grid.lat.values)
148+
x_cart, y_cart, z_cart = _lonlat_rad_to_xyz(lon_rad, lat_rad)
149+
points = np.column_stack((x_cart.flatten(), y_cart.flatten(), z_cart.flatten()))
150+
151+
# Get the vertex indices for each face
152+
nodeids = grid.face_node_connectivity[yi, :].values
153+
face_vertices = np.column_stack(
154+
(grid.node_x[nodeids].values, grid.node_y[nodeids].values, grid.node_z[nodeids].values)
147155
)
156+
else:
157+
nodeids = grid.face_node_connectivity[yi, :].values
158+
face_vertices = np.column_stack(
159+
(grid.node_lon[nodeids].values.flatten(), grid.node_lat[nodeids].values.flatten())
160+
)
161+
points = np.column_stack((x, y))
148162

149-
bcoord = np.asarray(_barycentric_coordinates(nodes, cart_coord))
150-
151-
return bcoord
163+
M = len(points)
152164

165+
is_in_cell = np.zeros(M, dtype=np.int32)
153166

154-
def _lonlat_rad_to_xyz(
155-
lon,
156-
lat,
157-
):
158-
"""Converts Spherical latitude and longitude coordinates into Cartesian x,
159-
y, z coordinates.
160-
"""
161-
x = np.cos(lon) * np.cos(lat)
162-
y = np.sin(lon) * np.cos(lat)
163-
z = np.sin(lat)
167+
coords = _barycentric_coordinates(face_vertices, points)
168+
is_in_cell = np.where(np.all((coords >= -1e-6) & (coords <= 1 + 1e-6), axis=1), 1, 0)
164169

165-
return x, y, z
170+
return is_in_cell, coords
166171

167172

168173
def _triangle_area(A, B, C):
@@ -173,7 +178,7 @@ def _triangle_area(A, B, C):
173178
return 0.5 * np.linalg.norm(d3)
174179

175180

176-
def _barycentric_coordinates(nodes, point, min_area=1e-8):
181+
def _barycentric_coordinates(nodes, points, min_area=1e-8):
177182
"""
178183
Compute the barycentric coordinates of a point P inside a convex polygon using area-based weights.
179184
So that this method generalizes to n-sided polygons, we use the Waschpress points as the generalized
@@ -182,29 +187,58 @@ def _barycentric_coordinates(nodes, point, min_area=1e-8):
182187
Parameters
183188
----------
184189
nodes : numpy.ndarray
185-
Spherical coordinates (lat,lon) of each corner node of a face
186-
point : numpy.ndarray
187-
Spherical coordinates (lat,lon) of the point
190+
Polygon verties per query of shape (M, 3, 2/3) where M is the number of query points. The second dimension corresponds to the number
191+
of vertices
192+
The last dimension can be either 2 or 3, where 3 corresponds to the (z, y, x) coordinates of each vertex and 2 corresponds to the
193+
(lat, lon) coordinates of each vertex.
194+
195+
points : numpy.ndarray
196+
Spherical coordinates of the point (M,2/3) where M is the number of query points.
188197
189198
Returns
190199
-------
191200
numpy.ndarray
192201
Barycentric coordinates corresponding to each vertex.
193202
194203
"""
195-
n = len(nodes)
196-
sum_wi = 0
197-
w = []
198-
199-
for i in range(0, n):
200-
vim1 = nodes[i - 1]
201-
vi = nodes[i]
202-
vi1 = nodes[(i + 1) % n]
203-
a0 = _triangle_area(vim1, vi, vi1)
204-
a1 = max(_triangle_area(point, vim1, vi), min_area)
205-
a2 = max(_triangle_area(point, vi, vi1), min_area)
206-
sum_wi += a0 / (a1 * a2)
207-
w.append(a0 / (a1 * a2))
208-
barycentric_coords = [w_i / sum_wi for w_i in w]
209-
210-
return barycentric_coords
204+
M, K = nodes.shape[:2]
205+
206+
# roll(-1) to get vi+1, roll(+1) to get vi-1
207+
vi = nodes # (M,K,2)
208+
vi1 = np.roll(nodes, shift=-1, axis=1) # (M,K,2)
209+
vim1 = np.roll(nodes, shift=+1, axis=1) # (M,K,2)
210+
211+
# a0 = area(v_{i-1}, v_i, v_{i+1})
212+
a0 = _triangle_area(vim1, vi, vi1) # (M,K)
213+
214+
# a1 = area(P, v_{i-1}, v_i); a2 = area(P, v_i, v_{i+1})
215+
P = points[:, None, :] # (M,1,2) -> (M,K,2)
216+
a1 = _triangle_area(P, vim1, vi)
217+
a2 = _triangle_area(P, vi, vi1)
218+
219+
# clamp tiny denominators for stability
220+
a1c = np.maximum(a1, min_area)
221+
a2c = np.maximum(a2, min_area)
222+
223+
wi = a0 / (a1c * a2c) # (M,K)
224+
225+
sum_wi = wi.sum(axis=1, keepdims=True) # (M,1)
226+
# Avoid 0/0: if sum_wi==0 (degenerate), keep zeros
227+
with np.errstate(invalid="ignore", divide="ignore"):
228+
bcoords = wi / sum_wi
229+
230+
return bcoords
231+
232+
233+
def _lonlat_rad_to_xyz(
234+
lon,
235+
lat,
236+
):
237+
"""Converts Spherical latitude and longitude coordinates into Cartesian x,
238+
y, z coordinates.
239+
"""
240+
x = np.cos(lon) * np.cos(lat)
241+
y = np.sin(lon) * np.cos(lat)
242+
z = np.sin(lat)
243+
244+
return x, y, z

0 commit comments

Comments
 (0)