Skip to content

Commit fa5a352

Browse files
Vectorize particle-in-cell check; resolve runtime bugs
1 parent 1a7c585 commit fa5a352

7 files changed

Lines changed: 330 additions & 262 deletions

File tree

parcels/_index_search.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,150 @@ def _search_indices_curvilinear_2d(
104104
eta = coords[:, 1]
105105

106106
return (yi, eta, xi, xsi)
107+
108+
109+
def uxgrid_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi: np.ndarray):
110+
"""Check if points are inside the grid cells defined by the given face indices.
111+
112+
Parameters
113+
----------
114+
grid : ux.grid.Grid
115+
The uxarray grid object containing the unstructured grid data.
116+
y : np.ndarray
117+
Array of latitudes of the points to check.
118+
x : np.ndarray
119+
Array of longitudes of the points to check.
120+
yi : np.ndarray
121+
Array of face indices corresponding to the points.
122+
xi : np.ndarray
123+
Not used, but included for compatibility with other search functions.
124+
125+
Returns
126+
-------
127+
is_in_cell : np.ndarray
128+
An array indicating whether each point is inside (1) or outside (0) the corresponding cell.
129+
coords : np.ndarray
130+
Barycentric coordinates of the points within their respective cells.
131+
"""
132+
if grid._mesh == "spherical":
133+
# lon_rad = np.deg2rad(grid.lon.values)
134+
# lat_rad = np.deg2rad(grid.lat.values)
135+
# x_cart, y_cart, z_cart = _latlon_rad_to_xyz(lat_rad, lon_rad)
136+
# points = np.column_stack((x_cart.flatten(), y_cart.flatten(), z_cart.flatten()))
137+
138+
# Get the vertex indices for each face
139+
nids = grid.uxgrid.face_node_connectivity[yi].values
140+
face_vertices = np.stack(
141+
(
142+
grid.uxgrid.node_x[nids.ravel()].values.reshape(nids.shape),
143+
grid.uxgrid.node_y[nids.ravel()].values.reshape(nids.shape),
144+
grid.uxgrid.node_z[nids.ravel()].values.reshape(nids.shape),
145+
),
146+
axis=-1,
147+
)
148+
else:
149+
nids = grid.uxgrid.face_node_connectivity[yi].values
150+
face_vertices = np.stack(
151+
(
152+
grid.uxgrid.node_lon[nids.ravel()].values.reshape(nids.shape),
153+
grid.uxgrid.node_lat[nids.ravel()].values.reshape(nids.shape),
154+
),
155+
axis=-1,
156+
)
157+
points = np.stack((x, y))
158+
159+
M = len(points)
160+
161+
is_in_cell = np.zeros(M, dtype=np.int32)
162+
163+
coords = _barycentric_coordinates(face_vertices, points)
164+
is_in_cell = np.where(np.all((coords >= -1e-6) & (coords <= 1 + 1e-6), axis=1), 1, 0)
165+
166+
return is_in_cell, coords
167+
168+
169+
def _triangle_area(A, B, C):
170+
"""Compute the area of a triangle given by three points."""
171+
d1 = B - A
172+
d2 = C - A
173+
if A.shape[-1] == 2:
174+
# 2D case: cross product reduces to scalar z-component
175+
cross = d1[..., 0] * d2[..., 1] - d1[..., 1] * d2[..., 0]
176+
area = 0.5 * np.abs(cross)
177+
elif A.shape[-1] == 3:
178+
# 3D case: full vector cross product
179+
cross = np.cross(d1, d2)
180+
area = 0.5 * np.linalg.norm(cross, axis=-1)
181+
else:
182+
raise ValueError(f"Expected last dim=2 or 3, got {A.shape[-1]}")
183+
184+
return area
185+
# d3 = np.cross(d1, d2, axis=-1)
186+
# breakpoint()
187+
# return 0.5 * np.linalg.norm(d3, axis=-1)
188+
189+
190+
def _barycentric_coordinates(nodes, points, min_area=1e-8):
191+
"""
192+
Compute the barycentric coordinates of a point P inside a convex polygon using area-based weights.
193+
So that this method generalizes to n-sided polygons, we use the Waschpress points as the generalized
194+
barycentric coordinates, which is only valid for convex polygons.
195+
196+
Parameters
197+
----------
198+
nodes : numpy.ndarray
199+
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
200+
of vertices
201+
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
202+
(lat, lon) coordinates of each vertex.
203+
204+
points : numpy.ndarray
205+
Spherical coordinates of the point (M,2/3) where M is the number of query points.
206+
207+
Returns
208+
-------
209+
numpy.ndarray
210+
Barycentric coordinates corresponding to each vertex.
211+
212+
"""
213+
M, K = nodes.shape[:2]
214+
215+
# roll(-1) to get vi+1, roll(+1) to get vi-1
216+
vi = nodes # (M,K,2)
217+
vi1 = np.roll(nodes, shift=-1, axis=1) # (M,K,2)
218+
vim1 = np.roll(nodes, shift=+1, axis=1) # (M,K,2)
219+
220+
# a0 = area(v_{i-1}, v_i, v_{i+1})
221+
a0 = _triangle_area(vim1, vi, vi1) # (M,K)
222+
223+
# a1 = area(P, v_{i-1}, v_i); a2 = area(P, v_i, v_{i+1})
224+
P = points[:, None, :] # (M,1,2) -> (M,K,2)
225+
a1 = _triangle_area(P, vim1, vi)
226+
a2 = _triangle_area(P, vi, vi1)
227+
228+
# clamp tiny denominators for stability
229+
a1c = np.maximum(a1, min_area)
230+
a2c = np.maximum(a2, min_area)
231+
232+
wi = a0 / (a1c * a2c) # (M,K)
233+
234+
sum_wi = wi.sum(axis=1, keepdims=True) # (M,1)
235+
# Avoid 0/0: if sum_wi==0 (degenerate), keep zeros
236+
with np.errstate(invalid="ignore", divide="ignore"):
237+
bcoords = wi / sum_wi
238+
239+
return bcoords
240+
241+
242+
def _latlon_rad_to_xyz(
243+
lat,
244+
lon,
245+
):
246+
"""Converts Spherical latitude and longitude coordinates into Cartesian x,
247+
y, z coordinates.
248+
"""
249+
x = np.cos(lon) * np.cos(lat)
250+
y = np.sin(lon) * np.cos(lat)
251+
z = np.sin(lat)
252+
253+
return x, y, z

parcels/application_kernels/interpolation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -657,8 +657,8 @@ def UXPiecewiseLinearNode(
657657
# The zi refers to the vertical layer index. The field in this routine are assumed to be defined at the vertical interface levels.
658658
# For interface zi, the interface indices are [zi, zi+1], so we need to use the values at zi and zi+1.
659659
# First, do barycentric interpolation in the lateral direction for each interface level
660-
fzk = np.dot(field.data.values[ti, k, node_ids], bcoords)
661-
fzkp1 = np.dot(field.data.values[ti, k + 1, node_ids], bcoords)
660+
fzk = np.sum(field.data.values[ti, k, node_ids] * bcoords, axis=-1)
661+
fzkp1 = np.sum(field.data.values[ti, k + 1, node_ids] * bcoords, axis=-1)
662662

663663
# Then, do piecewise linear interpolation in the vertical direction
664664
zk = field.grid.z.values[k]

parcels/basegrid.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import numpy as np
88

9+
from parcels.spatialhash import SpatialHash
10+
911
if TYPE_CHECKING:
1012
import numpy as np
1113

@@ -178,6 +180,32 @@ def get_axis_dim(self, axis: str) -> int:
178180
"""
179181
...
180182

183+
def get_spatial_hash(
184+
self,
185+
reconstruct=False,
186+
):
187+
"""Get the SpatialHash data structure of this Grid that allows for
188+
fast face search queries. Face searches are used to find the faces that
189+
a list of points, in spherical coordinates, are contained within.
190+
191+
Parameters
192+
----------
193+
global_grid : bool, default=False
194+
If true, the hash grid is constructed using the domain [-pi,pi] x [-pi,pi]
195+
reconstruct : bool, default=False
196+
If true, reconstructs the spatial hash
197+
198+
Returns
199+
-------
200+
self._spatialhash : parcels.spatialhash.SpatialHash
201+
SpatialHash instance
202+
203+
"""
204+
if self._spatialhash is None or reconstruct:
205+
self._spatialhash = SpatialHash(self)
206+
207+
return self._spatialhash
208+
181209

182210
def _unravel(dims, ei):
183211
"""

0 commit comments

Comments
 (0)