Skip to content

Commit 4415a32

Browse files
Merge remote-tracking branch 'origin/v4-dev' into minimal-v4-particleset
2 parents a55c852 + 8c01bf0 commit 4415a32

18 files changed

Lines changed: 609 additions & 141 deletions

docs/reference/grids.rst

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
Gridsets and grids
22
==================
33

4-
parcels.gridset module
5-
----------------------
6-
7-
.. automodule:: parcels.gridset
8-
:members:
9-
:show-inheritance:
10-
114
parcels.grid module
125
-------------------
136

parcels/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from parcels.field import *
77
from parcels.fieldset import *
88
from parcels.grid import *
9-
from parcels.gridset import *
109
from parcels.interaction import *
1110
from parcels.kernel import *
1211
from parcels.particle import *

parcels/_index_search.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from .grid import GridType
2222

2323
if TYPE_CHECKING:
24+
from parcels.xgrid import XGrid
25+
2426
from .field import Field
2527
# from .grid import Grid
2628

@@ -270,6 +272,89 @@ def _search_indices_rectilinear(
270272
return (zeta, eta, xsi, _ei)
271273

272274

275+
def _search_indices_curvilinear_2d(
276+
grid: XGrid, y: float, x: float, yi_guess: int | None = None, xi_guess: int | None = None
277+
):
278+
yi, xi = yi_guess, xi_guess
279+
if yi is None:
280+
yi = int(grid.ydim / 2) - 1
281+
282+
if xi is None:
283+
xi = int(grid.xdim / 2) - 1
284+
285+
xsi = eta = -1.0
286+
invA = np.array(
287+
[
288+
[1, 0, 0, 0],
289+
[-1, 1, 0, 0],
290+
[-1, 0, 0, 1],
291+
[1, -1, 1, -1],
292+
]
293+
)
294+
maxIterSearch = 1e6
295+
it = 0
296+
tol = 1.0e-10
297+
298+
# # ! Error handling for out of bounds
299+
# TODO: Re-enable in some capacity
300+
# if x < field.lonlat_minmax[0] or x > field.lonlat_minmax[1]:
301+
# if grid.lon[0, 0] < grid.lon[0, -1]:
302+
# _raise_field_out_of_bound_error(y, x)
303+
# elif x < grid.lon[0, 0] and x > grid.lon[0, -1]: # This prevents from crashing in [160, -160]
304+
# _raise_field_out_of_bound_error(z, y, x)
305+
306+
# if y < field.lonlat_minmax[2] or y > field.lonlat_minmax[3]:
307+
# _raise_field_out_of_bound_error(z, y, x)
308+
309+
while xsi < -tol or xsi > 1 + tol or eta < -tol or eta > 1 + tol:
310+
px = np.array([grid.lon[yi, xi], grid.lon[yi, xi + 1], grid.lon[yi + 1, xi + 1], grid.lon[yi + 1, xi]])
311+
312+
py = np.array([grid.lat[yi, xi], grid.lat[yi, xi + 1], grid.lat[yi + 1, xi + 1], grid.lat[yi + 1, xi]])
313+
a = np.dot(invA, px)
314+
b = np.dot(invA, py)
315+
316+
aa = a[3] * b[2] - a[2] * b[3]
317+
bb = a[3] * b[0] - a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + x * b[3] - y * a[3]
318+
cc = a[1] * b[0] - a[0] * b[1] + x * b[1] - y * a[1]
319+
if abs(aa) < 1e-12: # Rectilinear cell, or quasi
320+
eta = -cc / bb
321+
else:
322+
det2 = bb * bb - 4 * aa * cc
323+
if det2 > 0: # so, if det is nan we keep the xsi, eta from previous iter
324+
det = np.sqrt(det2)
325+
eta = (-bb + det) / (2 * aa)
326+
if abs(a[1] + a[3] * eta) < 1e-12: # this happens when recti cell rotated of 90deg
327+
xsi = ((y - py[0]) / (py[1] - py[0]) + (y - py[3]) / (py[2] - py[3])) * 0.5
328+
else:
329+
xsi = (x - a[0] - a[2] * eta) / (a[1] + a[3] * eta)
330+
if xsi < 0 and eta < 0 and xi == 0 and yi == 0:
331+
_raise_field_out_of_bound_error(0, y, x)
332+
if xsi > 1 and eta > 1 and xi == grid.xdim - 1 and yi == grid.ydim - 1:
333+
_raise_field_out_of_bound_error(0, y, x)
334+
if xsi < -tol:
335+
xi -= 1
336+
elif xsi > 1 + tol:
337+
xi += 1
338+
if eta < -tol:
339+
yi -= 1
340+
elif eta > 1 + tol:
341+
yi += 1
342+
(yi, xi) = _reconnect_bnd_indices(yi, xi, grid.ydim, grid.xdim, grid.mesh)
343+
it += 1
344+
if it > maxIterSearch:
345+
print(f"Correct cell not found after {maxIterSearch} iterations")
346+
_raise_field_out_of_bound_error(0, y, x)
347+
xsi = max(0.0, xsi)
348+
eta = max(0.0, eta)
349+
xsi = min(1.0, xsi)
350+
eta = min(1.0, eta)
351+
352+
if not ((0 <= xsi <= 1) and (0 <= eta <= 1)):
353+
_raise_field_sampling_error(y, x)
354+
355+
return (yi, eta, xi, xsi)
356+
357+
273358
## TODO : Still need to implement the search_indices_curvilinear
274359
def _search_indices_curvilinear(field: Field, time, z, y, x, ti, particle=None, search2D=False):
275360
if particle:

parcels/_reprs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def particleset_repr(pset: ParticleSet) -> str:
6565

6666
def fieldset_repr(fieldset: FieldSet) -> str: # TODO v4: Rework or remove entirely
6767
"""Return a pretty repr for FieldSet"""
68-
fields_repr = "\n".join([repr(f) for f in fieldset.get_fields()])
68+
fields_repr = "\n".join([repr(f) for f in fieldset.fields.values()])
6969

7070
out = f"""<{type(fieldset).__name__}>
7171
fields:

parcels/application_kernels/advection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ def AdvectionAnalytical(particle, fieldset, time): # pragma: no cover
175175
tol = 1e-10
176176
I_s = 10 # number of intermediate time steps
177177
direction = 1.0 if particle.dt > 0 else -1.0
178-
withW = True if "W" in [f.name for f in fieldset.get_fields()] else False
178+
withW = True if "W" in [f.name for f in fieldset.fields.values()] else False
179179
withTime = True if len(fieldset.U.grid.time) > 1 else False
180180
tau, zeta, eta, xsi, ti, zi, yi, xi = fieldset.U._search_indices(
181181
time, particle.depth, particle.lat, particle.lon, particle=particle

parcels/basegrid.py

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1+
from __future__ import annotations
2+
13
from abc import ABC, abstractmethod
4+
from typing import TYPE_CHECKING
5+
6+
if TYPE_CHECKING:
7+
import numpy as np
28

39

410
class BaseGrid(ABC):
511
@abstractmethod
6-
def search(self, z: float, y: float, x: float, ei=None, search2D: bool = False):
12+
def search(self, z: float, y: float, x: float, ei=None) -> dict[str, tuple[int, float | np.ndarray]]:
713
"""
814
Perform a spatial (and optionally vertical) search to locate the grid element
915
that contains a given point (x, y, z).
1016
1117
This method delegates to grid-type-specific logic (e.g., structured or unstructured)
12-
to determine the appropriate indices and interpolation coordinates for evaluating a field.
18+
to determine the appropriate indices and barycentric coordinates for evaluating a field.
1319
1420
Parameters
1521
----------
@@ -28,12 +34,21 @@ def search(self, z: float, y: float, x: float, ei=None, search2D: bool = False):
2834
2935
Returns
3036
-------
31-
bcoords : np.ndarray or tuple
32-
Interpolation weights or barycentric coordinates within the containing cell/face.
33-
The interpretation of `bcoords` depends on the grid type.
34-
ei : int
35-
Encoded index of the identified grid cell or face. This value can be cached for
36-
future lookups to accelerate repeated searches.
37+
dict
38+
A dictionary mapping spatial axis names to tuples of (index, barycentric_coordinates).
39+
The returned axes depend on the grid dimensionality and type:
40+
41+
- 3D structured grid: {"Z": (zi, zeta), "Y": (yi, eta), "X": (xi, xsi)}
42+
- 2D structured grid: {"Y": (yi, eta), "X": (xi, xsi)}
43+
- 1D structured grid (depth): {"Z": (zi, zeta)}
44+
- Unstructured grid: {"Z": (zi, zeta), "FACE": (fi, bcoords)}
45+
46+
Where:
47+
- index (int): The cell position of a particle along the given axis
48+
- barycentric_coordinates (float or np.ndarray): The coordinates defining
49+
a particle's position within the grid cell. For structured grids, this
50+
is a single coordinate per axis; for unstructured grids, this can be
51+
an array of coordinates for the face polygon.
3752
3853
Raises
3954
------
@@ -43,3 +58,72 @@ def search(self, z: float, y: float, x: float, ei=None, search2D: bool = False):
4358
Raised if the search method is not implemented for the current grid type.
4459
"""
4560
...
61+
62+
@abstractmethod
63+
def ravel_index(self, axis_indices: dict[str, int]) -> int:
64+
"""
65+
Convert a dictionary of axis indices to a single encoded index (ei).
66+
67+
This method takes the individual indices for each spatial axis and combines them
68+
into a single integer that uniquely identifies a grid cell. This encoded
69+
index can be used for efficient caching and lookup operations.
70+
71+
Parameters
72+
----------
73+
axis_indices : dict[str, int]
74+
A dictionary mapping axis names to their corresponding indices.
75+
The expected keys depend on the grid dimensionality and type:
76+
77+
- 3D structured grid: {"Z": zi, "Y": yi, "X": xi}
78+
- 2D structured grid: {"Y": yi, "X": xi}
79+
- 1D structured grid: {"Z": zi}
80+
- Unstructured grid: {"Z": zi, "FACE": fi}
81+
82+
Returns
83+
-------
84+
int
85+
The encoded index (ei) representing the unique grid cell or face.
86+
87+
Raises
88+
------
89+
KeyError
90+
Raised when required axis keys are missing from axis_indices.
91+
ValueError
92+
Raised when index values are out of bounds for the grid.
93+
NotImplementedError
94+
Raised if the method is not implemented for the current grid type.
95+
"""
96+
...
97+
98+
@abstractmethod
99+
def unravel_index(self, ei: int) -> dict[str, int]:
100+
"""
101+
Convert a single encoded index (ei) back to a dictionary of axis indices.
102+
103+
This method is the inverse of ravel_index, taking an encoded index and
104+
decomposing it back into the individual indices for each spatial axis.
105+
106+
Parameters
107+
----------
108+
ei : int
109+
The encoded index representing a unique grid cell or face.
110+
111+
Returns
112+
-------
113+
dict[str, int]
114+
A dictionary mapping axis names to their corresponding indices.
115+
The returned keys depend on the grid dimensionality and type:
116+
117+
- 3D structured grid: {"Z": zi, "Y": yi, "X": xi}
118+
- 2D structured grid: {"Y": yi, "X": xi}
119+
- 1D structured grid: {"Z": zi}
120+
- Unstructured grid: {"Z": zi, "FACE": fi}
121+
122+
Raises
123+
------
124+
ValueError
125+
Raised when the encoded index is out of bounds or invalid for the grid.
126+
NotImplementedError
127+
Raised if the method is not implemented for the current grid type.
128+
"""
129+
...

parcels/field.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def __init__(
169169
self.time_interval = get_time_interval(data)
170170
except ValueError as e:
171171
e.add_note(
172-
f"Error getting time interval for field {name!r}. Are you sure that the time dimension on the xarray dataset is stored as datetime or cftime datetime objects?"
172+
f"Error getting time interval for field {name!r}. Are you sure that the time dimension on the xarray dataset is stored as timedelta, datetime or cftime datetime objects?"
173173
)
174174
raise e
175175

parcels/fieldset.py

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
if TYPE_CHECKING:
1818
from parcels._typing import DatetimeLike
19+
from parcels.basegrid import BaseGrid
1920
__all__ = ["FieldSet"]
2021

2122

@@ -109,10 +110,6 @@ def dimrange(self, dim):
109110

110111
return maxleft, minright
111112

112-
@property
113-
def gridset_size(self):
114-
return len(self.fields)
115-
116113
def add_field(self, field: Field, name: str | None = None):
117114
"""Add a :class:`parcels.field.Field` object to the FieldSet.
118115
@@ -164,15 +161,6 @@ def add_constant_field(self, name: str, value, mesh: Mesh = "flat"):
164161
da = xr.DataArray(
165162
data=np.full((1, 1, 1, 1), value),
166163
dims=["time", "ZG", "YG", "XG"],
167-
coords={
168-
"ZG": (["ZG"], np.arange(1), {"axis": "Z"}),
169-
"YG": (["YG"], np.arange(1), {"axis": "Y"}),
170-
"XG": (["XG"], np.arange(1), {"axis": "X"}),
171-
"lon": (["XG"], np.arange(1), {"axis": "X"}),
172-
"lat": (["YG"], np.arange(1), {"axis": "Y"}),
173-
"depth": (["ZG"], np.arange(1), {"axis": "Z"}),
174-
"time": (["time"], np.arange(1), {"axis": "T"}),
175-
},
176164
)
177165
grid = XGrid(xgcm.Grid(da))
178166
self.add_field(
@@ -184,17 +172,6 @@ def add_constant_field(self, name: str, value, mesh: Mesh = "flat"):
184172
)
185173
)
186174

187-
def get_fields(self) -> list[Field | VectorField]:
188-
"""Returns a list of all the :class:`parcels.field.Field` and :class:`parcels.field.VectorField`
189-
objects associated with this FieldSet.
190-
"""
191-
fields = []
192-
for v in self.__dict__.values():
193-
if type(v) in [Field, VectorField]:
194-
if v not in fields:
195-
fields.append(v)
196-
return fields
197-
198175
def add_constant(self, name, value):
199176
"""Add a constant to the FieldSet. Note that all constants are
200177
stored as 32-bit floats.
@@ -219,6 +196,14 @@ def add_constant(self, name, value):
219196

220197
self.constants[name] = np.float32(value)
221198

199+
@property
200+
def gridset(self) -> list[BaseGrid]:
201+
grids = []
202+
for field in self.fields.values():
203+
if field.grid not in grids:
204+
grids.append(field.grid)
205+
return grids
206+
222207
# def computeTimeChunk(self, time=0.0, dt=1):
223208
# """Load a chunk of three data time steps into the FieldSet.
224209
# This is used when FieldSet uses data imported from netcdf,

0 commit comments

Comments
 (0)