Skip to content

Commit 5079236

Browse files
Merge pull request #2211 from OceanParcels/implement_ei_in_search
Use element indices `particle.ei` in curvilinear search
2 parents 6b267ed + 7eedf45 commit 5079236

8 files changed

Lines changed: 92 additions & 52 deletions

File tree

parcels/_index_search.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,20 +75,42 @@ def curvilinear_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray
7575

7676

7777
def _search_indices_curvilinear_2d(
78-
grid: XGrid, y: np.ndarray, x: np.ndarray, yi_guess: np.ndarray | None = None, xi_guess: np.ndarray | None = None
78+
grid: XGrid, y: np.ndarray, x: np.ndarray, yi: np.ndarray | None = None, xi: np.ndarray | None = None
7979
):
80-
yi_guess = np.array(yi_guess)
81-
xi_guess = np.array(xi_guess)
82-
xi = np.full(len(x), GRID_SEARCH_ERROR, dtype=np.int32)
83-
yi = np.full(len(y), GRID_SEARCH_ERROR, dtype=np.int32)
84-
if np.any(xi_guess):
80+
"""Searches a grid for particle locations in 2D curvilinear coordinates.
81+
82+
Parameters
83+
----------
84+
grid : XGrid
85+
The curvilinear grid to search within.
86+
y : np.ndarray
87+
Array of latitude-coordinates of the points to locate.
88+
x : np.ndarray
89+
Array of longitude-coordinates of the points to locate.
90+
yi : np.ndarray | None, optional
91+
Array of initial guesses for the j indices of the points to locate.
92+
xi : np.ndarray | None, optional
93+
Array of initial guesses for the i indices of the points to locate.
94+
95+
Returns
96+
-------
97+
tuple
98+
A tuple containing four elements:
99+
- yi (np.ndarray): Array of found j-indices corresponding to the input coordinates.
100+
- eta (np.ndarray): Array of barycentric coordinates in the j-direction within the found grid cells.
101+
- xi (np.ndarray): Array of found i-indices corresponding to the input cooordinates.
102+
- xsi (np.ndarray): Array of barycentric coordinates in the i-direction within the found grid cells.
103+
"""
104+
if np.any(xi):
85105
# If an initial guess is provided, we first perform a point in cell check for all guessed indices
86-
is_in_cell, coords = curvilinear_point_in_cell(grid, y, x, yi_guess, xi_guess)
106+
is_in_cell, coords = curvilinear_point_in_cell(grid, y, x, yi, xi)
87107
y_check = y[is_in_cell == 0]
88108
x_check = x[is_in_cell == 0]
89109
zero_indices = np.where(is_in_cell == 0)[0]
90110
else:
91111
# Otherwise, we need to check all points
112+
yi = np.full(len(y), GRID_SEARCH_ERROR, dtype=np.int32)
113+
xi = np.full(len(x), GRID_SEARCH_ERROR, dtype=np.int32)
92114
y_check = y
93115
x_check = x
94116
coords = -1.0 * np.ones((len(y), 2), dtype=np.float32)

parcels/basegrid.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def search(self, z: float, y: float, x: float, ei=None) -> dict[str, tuple[int,
6969
"""
7070
...
7171

72-
def ravel_index(self, axis_indices: dict[str, int]) -> int:
72+
def ravel_index(self, axis_indices: dict[str, np.ndarray]) -> np.ndarray:
7373
"""
7474
Convert a dictionary of axis indices to a single encoded index (ei).
7575
@@ -79,7 +79,7 @@ def ravel_index(self, axis_indices: dict[str, int]) -> int:
7979
8080
Parameters
8181
----------
82-
axis_indices : dict[str, int]
82+
axis_indices : dict[str, np.ndarray(int)]
8383
A dictionary mapping axis names to their corresponding indices.
8484
The expected keys depend on the grid dimensionality and type:
8585
@@ -90,8 +90,8 @@ def ravel_index(self, axis_indices: dict[str, int]) -> int:
9090
9191
Returns
9292
-------
93-
int
94-
The encoded index (ei) representing the unique grid cell or face.
93+
np.ndarray(int)
94+
The encoded indices (ei) representing the unique grid cells or faces.
9595
9696
Raises
9797
------
@@ -204,13 +204,13 @@ def _unravel(dims, ei):
204204
"""
205205
strides = np.cumprod(dims[::-1])[::-1]
206206

207-
indices = np.empty(len(dims), dtype=int)
207+
indices = np.empty((len(dims), len(ei)), dtype=int)
208208

209209
for i in range(len(dims) - 1):
210-
indices[i] = ei // strides[i + 1]
210+
indices[i, :] = ei // strides[i + 1]
211211
ei = ei % strides[i + 1]
212212

213-
indices[-1] = ei
213+
indices[-1, :] = ei
214214
return indices
215215

216216

parcels/field.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,14 @@ def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True):
212212
conversion to the result. Note that we defer to
213213
scipy.interpolate to perform spatial interpolation.
214214
"""
215-
# if particle is None:
216-
_ei = None
217-
# else:
218-
# _ei = particle.ei[self.igrid]
215+
if particles is None:
216+
_ei = None
217+
else:
218+
_ei = particles.ei[:, self.igrid]
219219

220220
tau, ti = _search_time_index(self, time)
221221
position = self.grid.search(z, y, x, ei=_ei)
222+
_update_particles_ei(particles, position, self)
222223
_update_particle_states_position(particles, position)
223224

224225
value = self._interp_method(self, ti, position, tau, time, z, y, x)
@@ -251,6 +252,7 @@ def __init__(
251252
self.V = V
252253
self.W = W
253254
self.grid = U.grid
255+
self.igrid = U.igrid
254256

255257
if W is None:
256258
_assert_same_time_interval((U, V))
@@ -294,13 +296,14 @@ def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True):
294296
conversion to the result. Note that we defer to
295297
scipy.interpolate to perform spatial interpolation.
296298
"""
297-
# if particle is None:
298-
_ei = None
299-
# else:
300-
# _ei = particle.ei[self.igrid]
299+
if particles is None:
300+
_ei = None
301+
else:
302+
_ei = particles.ei[:, self.igrid]
301303

302304
tau, ti = _search_time_index(self.U, time)
303305
position = self.grid.search(z, y, x, ei=_ei)
306+
_update_particles_ei(particles, position, self)
304307
_update_particle_states_position(particles, position)
305308

306309
if self._vector_interp_method is None:
@@ -339,6 +342,26 @@ def __getitem__(self, key):
339342
return _deal_with_errors(error, key, vector_type=self.vector_type)
340343

341344

345+
def _update_particles_ei(particles, position, field):
346+
"""Update the element index (ei) of the particles"""
347+
if particles is not None:
348+
if isinstance(field.grid, XGrid):
349+
particles.ei[:, field.igrid] = field.grid.ravel_index(
350+
{
351+
"X": position["X"][0],
352+
"Y": position["Y"][0],
353+
"Z": position["Z"][0],
354+
}
355+
)
356+
elif isinstance(field.grid, UxGrid):
357+
particles.ei[:, field.igrid] = field.grid.ravel_index(
358+
{
359+
"Z": position["Z"][0],
360+
"FACE": position["FACE"][0],
361+
}
362+
)
363+
364+
342365
def _update_particle_states_position(particles, position):
343366
"""Update the particle states based on the position dictionary."""
344367
if particles: # TODO also support uxgrid search

parcels/particleset.py

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,11 @@
66

77
import numpy as np
88
import xarray as xr
9-
from scipy.spatial import KDTree
109
from tqdm import tqdm
1110

1211
from parcels._core.utils.time import TimeInterval, maybe_convert_python_timedelta_to_numpy
1312
from parcels._reprs import particleset_repr
1413
from parcels.application_kernels.advection import AdvectionRK4
15-
from parcels.basegrid import GridType
1614
from parcels.kernel import Kernel
1715
from parcels.particle import KernelParticle, Particle, create_particle_data
1816
from parcels.tools.converters import convert_to_flat_array
@@ -305,28 +303,17 @@ def _neighbors_by_coor(self, coor):
305303
neighbor_ids = self._data["trajectory"][neighbor_idx]
306304
return neighbor_ids
307305

308-
# TODO: This method is only tested in tutorial notebook. Add unit test?
309306
def populate_indices(self):
310-
"""Pre-populate guesses of particle ei (element id) indices using a kdtree.
311-
312-
This is only intended for curvilinear grids, where the initial index search
313-
may be quite expensive.
314-
"""
307+
"""Pre-populate guesses of particle ei (element id) indices"""
315308
for i, grid in enumerate(self.fieldset.gridset):
316-
if grid._gtype not in [GridType.CurvilinearZGrid, GridType.CurvilinearSGrid]:
317-
continue
318-
319-
tree_data = np.stack((grid.lon.flat, grid.lat.flat), axis=-1)
320-
IN = np.all(~np.isnan(tree_data), axis=1)
321-
tree = KDTree(tree_data[IN, :])
322-
# stack all the particle positions for a single query
323-
pts = np.stack((self._data["lon"], self._data["lat"]), axis=-1)
324-
# query datatype needs to match tree datatype
325-
_, idx_nan = tree.query(pts.astype(tree_data.dtype))
326-
327-
idx = np.where(IN)[0][idx_nan]
328-
329-
self._data["ei"][:, i] = idx # assumes that we are in the surface layer (zi=0)
309+
position = grid.search(self.depth, self.lat, self.lon)
310+
self._data["ei"][:, i] = grid.ravel_index(
311+
{
312+
"X": position["X"][0],
313+
"Y": position["Y"][0],
314+
"Z": position["Z"][0],
315+
}
316+
)
330317

331318
@classmethod
332319
def from_particlefile(cls, fieldset, pclass, filename, restart=True, restarttime=None, **kwargs):

parcels/uxgrid.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,18 @@ def try_face(fid):
8787
zi, zeta = _search_1d_array(self.z.values, z)
8888

8989
if ei is not None:
90-
_, fi = self.unravel_index(ei)
90+
indices = self.unravel_index(ei)
91+
fi = indices["FACE"][0]
9192
bcoords = try_face(fi)
9293
if bcoords is not None:
93-
return bcoords, self.ravel_index(zi, fi)
94+
return {"Z": (zi, zeta), "FACE": (np.asarray([fi]), bcoords)}
9495
# Try neighbors of current face
9596
for neighbor in self.uxgrid.face_face_connectivity[fi, :]:
9697
if neighbor == -1:
9798
continue
9899
bcoords = try_face(neighbor)
99100
if bcoords is not None:
100-
return bcoords, self.ravel_index(zi, neighbor)
101+
return {"Z": (zi, zeta), "FACE": (np.asarray([neighbor]), bcoords)}
101102

102103
# Global fallback as last ditch effort
103104
points = np.column_stack((x, y))
@@ -113,7 +114,6 @@ def try_face(fid):
113114
def _get_barycentric_coordinates_latlon(self, y, x, fi):
114115
"""Checks if a point is inside a given face id on a UxGrid."""
115116
# Check if particle is in the same face, otherwise search again.
116-
117117
n_nodes = self.uxgrid.n_nodes_per_face[fi].to_numpy()
118118
node_ids = self.uxgrid.face_node_connectivity[fi, 0:n_nodes]
119119
nodes = np.column_stack(

tests/v4/test_basegrid.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def get_axis_dim(self, axis: str) -> int:
3535
def test_basegrid_ravel_unravel_index(grid):
3636
axes = grid.axes
3737
dimensionalities = (grid.get_axis_dim(axis) for axis in axes)
38-
all_possible_axis_indices = itertools.product(*[range(dim) for dim in dimensionalities])
38+
all_possible_axis_indices = itertools.product(*[np.arange(dim)[:, np.newaxis] for dim in dimensionalities])
3939

4040
encountered_eis = []
4141

@@ -45,7 +45,7 @@ def test_basegrid_ravel_unravel_index(grid):
4545
ei = grid.ravel_index(axis_indices)
4646
axis_indices_test = grid.unravel_index(ei)
4747
assert axis_indices_test == axis_indices
48-
encountered_eis.append(ei)
48+
encountered_eis.append(ei[0])
4949

5050
encountered_eis = sorted(encountered_eis)
5151
assert len(set(encountered_eis)) == len(encountered_eis), "Raveled indices are not unique."

tests/v4/test_particleset.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from parcels._datasets.structured.generic import datasets as datasets_structured
1818
from parcels.xgrid import XGrid
1919
from tests.common_kernels import DoNothing
20+
from tests.utils import round_and_hash_float_array
2021

2122

2223
@pytest.fixture
@@ -126,6 +127,13 @@ def Addlon(particles, fieldset): # pragma: no cover
126127
assert np.allclose([p.lon + p.dlon for p in pset], [8 - t for t in times])
127128

128129

130+
def test_populate_indices(fieldset):
131+
npart = 11
132+
pset = ParticleSet(fieldset, lon=np.linspace(0, 1, npart), lat=np.linspace(1, 0, npart))
133+
pset.populate_indices()
134+
np.testing.assert_equal(round_and_hash_float_array(pset.ei, decimals=0), 935996932384571063274191)
135+
136+
129137
def test_pset_add_explicit(fieldset):
130138
npart = 11
131139
lon = np.linspace(0, 1, npart)

tests/v4/test_particleset_execute.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,8 @@ def test_uxstommelgyre_pset_execute():
400400
dt=np.timedelta64(60, "s"),
401401
pyfunc=AdvectionEE,
402402
)
403-
assert utils.round_and_hash_float_array([p.lon for p in pset]) == 1165396086
404-
assert utils.round_and_hash_float_array([p.lat for p in pset]) == 1142124776
403+
assert utils.round_and_hash_float_array([p.lon for p in pset]) == 1165397121
404+
assert utils.round_and_hash_float_array([p.lat for p in pset]) == 1142123780
405405

406406

407407
@pytest.mark.xfail(reason="Output file not implemented yet")

0 commit comments

Comments
 (0)