Skip to content

Commit ab2cba3

Browse files
Merge pull request #2461 from Parcels-code/add_XLinearVelocity
Adding XLinear_Velocity interpolator
2 parents afc820a + 30e1245 commit ab2cba3

15 files changed

Lines changed: 157 additions & 131 deletions

docs/user_guide/examples/explanation_kernelloop.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ ds_fields["VWind"] = xr.DataArray(
7171
fieldset = parcels.FieldSet.from_copernicusmarine(ds_fields)
7272
7373
# Create a vecorfield for the wind
74-
windvector = parcels.VectorField("Wind", fieldset.UWind, fieldset.VWind)
74+
windvector = parcels.VectorField(
75+
"Wind",
76+
fieldset.UWind,
77+
fieldset.VWind,
78+
vector_interp_method=parcels.interpolators.XLinear_Velocity
79+
)
7580
fieldset.add_field(windvector)
7681
```
7782

docs/user_guide/examples/tutorial_nemo_curvilinear.ipynb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,11 +147,12 @@
147147
"metadata": {},
148148
"outputs": [],
149149
"source": [
150-
"u, v = fieldset.UV.eval(\n",
151-
" np.array([0]), np.array([0]), np.array([20]), np.array([50]), apply_conversion=False\n",
152-
") # do not convert m/s to deg/s\n",
150+
"u, v = fieldset.UV.eval(np.array([0]), np.array([0]), np.array([60]), np.array([50]))\n",
151+
"u *= 1852 * 60 * np.cos(np.deg2rad(60)) # convert from degrees s^-1 to m s^-1\n",
152+
"v *= 1852 * 60 # convert from degrees s^-1 to m s\n",
153153
"print(f\"(u, v) = ({u[0]:.3f}, {v[0]:.3f})\")\n",
154-
"assert np.isclose(u, 1.0, atol=1e-3)"
154+
"assert np.isclose(u, 1.0, atol=1e-3)\n",
155+
"assert np.isclose(v, 0.0, atol=1e-3)"
155156
]
156157
},
157158
{

docs/user_guide/examples/tutorial_unitconverters.ipynb

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
"source": [
1616
"In most applications, Parcels works with `spherical` meshes, where longitude and latitude are given in degrees, while depth is given in meters. But it is also possible to use `flat` meshes, where longitude and latitude are given in meters (note that the dimensions are then still called `longitude` and `latitude` for consistency reasons).\n",
1717
"\n",
18-
"In all cases, velocities are given in m/s. Parcels seamlessly converts between meters and degrees, under the hood. For transparency, this guide explains how this works.\n"
18+
"In all cases, velocities are given in m s<sup>-1</sup>. But for advection (such as `particles.dlat = v * particles.dt`), the velocity needs to be in degrees s<sup>-1</sup>. \n",
19+
"\n",
20+
"Parcels seamlessly converts between meters s<sup>-1</sup> and degrees s<sup>-1</sup>, under the hood. For transparency, this guide explains how this works.\n"
1921
]
2022
},
2123
{
@@ -130,7 +132,7 @@
130132
"cell_type": "markdown",
131133
"metadata": {},
132134
"source": [
133-
"Indeed, if we multiply the value of the U field with 1852 \\* 60 \\* cos(lat) (the number of meters in 1 degree of longitude) and the value of the V field with 1852 \\* 60 (the number of meters in 1 degree of latitude), we get the expected 1 m s<sup>-1</sup> for `u` and `v`.\n"
135+
"Indeed, if we multiply the value of `u` with 1852 \\* 60 \\* cos(lat) (the number of meters in 1 degree of longitude) and the value of `v` with 1852 \\* 60 (the number of meters in 1 degree of latitude), we get the expected 1 m s<sup>-1</sup> for `u` and `v`.\n"
134136
]
135137
},
136138
{
@@ -149,31 +151,6 @@
149151
"assert np.isclose(v, 1.0)"
150152
]
151153
},
152-
{
153-
"attachments": {},
154-
"cell_type": "markdown",
155-
"metadata": {},
156-
"source": [
157-
"You can also interpolate the Field to these values directly by using the `eval()` method and setting `applyConversion=False`, as below\n"
158-
]
159-
},
160-
{
161-
"cell_type": "code",
162-
"execution_count": null,
163-
"metadata": {},
164-
"outputs": [],
165-
"source": [
166-
"print(\n",
167-
" fieldset.UV.eval(\n",
168-
" time,\n",
169-
" z,\n",
170-
" lat,\n",
171-
" lon,\n",
172-
" apply_conversion=False,\n",
173-
" )\n",
174-
")"
175-
]
176-
},
177154
{
178155
"cell_type": "markdown",
179156
"metadata": {},
@@ -185,7 +162,7 @@
185162
"cell_type": "markdown",
186163
"metadata": {},
187164
"source": [
188-
"Sampling `U` and `V` separately will _not_ convert to degrees s<sup>-1</sup>, so these velocities cannot be used directly for advection on spherical coordinates. This is one of the main reasons to always use the `UV` VectorField for velocity sampling in Parcels.\n"
165+
"Sampling `U` and `V` separately will _not_ convert to degrees s<sup>-1</sup>, so these velocities _should not_ be used directly for advection on spherical coordinates. This is one of the main reasons to always use the `UV` VectorField for velocity sampling in Parcels.\n"
189166
]
190167
},
191168
{
@@ -204,7 +181,7 @@
204181
"source": [
205182
"## Unit conversion for other fields such as diffusivity\n",
206183
"\n",
207-
"For other fields such as diffusivity, Parcels does not apply similar unit conversions when using a `spherical` mesh. For example, if we define a diffusivity field with value 10 m<sup>2</sup> s<sup>-1</sup>, Parcels will not convert this to degrees<sup>2</sup> s<sup>-1</sup> under the hood. \n",
184+
"For other fields such as diffusivity, Parcels does not apply similar unit conversions when using a `spherical` mesh. For example, if we define a diffusivity field in m<sup>2</sup> s<sup>-1</sup>, Parcels will _not_ convert this to degrees<sup>2</sup> s<sup>-1</sup> under the hood. \n",
208185
"\n",
209186
"If you want to work with diffusivity in degrees<sup>2</sup> s<sup>-1</sup> (for example to move particles using a random walk), you will have to convert this yourself in your kernel. \n",
210187
"\n",

docs/user_guide/v4-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Version 4 of Parcels is unreleased at the moment. The information in this migrat
4040
- `Field.eval()` returns an array of floats instead of a single float (related to the vectorization)
4141
- `Field.eval()` does not throw OutOfBounds or other errors
4242
- The `NestedField` class has been removed. See the Nested Grids how-to guide for how to set up Nested Grids in v4.
43-
- `applyConversion` has been renamed to `apply_conversion` and only works for VectorFields. Conversion of units should be handled in Kernels.
43+
- `applyConversion` has been removed. Interpolation on VectorFields automatically converts from m/s to degrees/s for spherical meshes. Other conversion of units should be handled in Interpolators or Kernels.
4444

4545
## GridSet
4646

src/parcels/_core/field.py

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,10 @@ def __init__(
247247
W: Field | None = None, # noqa: N803
248248
vector_interp_method: Callable | None = None,
249249
):
250-
_assert_str_and_python_varname(name)
250+
if vector_interp_method is None:
251+
raise ValueError("vector_interp_method must be provided for VectorField initialization.")
251252

253+
_assert_str_and_python_varname(name)
252254
self.name = name
253255
self.U = U
254256
self.V = V
@@ -268,12 +270,8 @@ def __init__(
268270
else:
269271
self.vector_type = "2D"
270272

271-
# Setting the interpolation method dynamically
272-
if vector_interp_method is None:
273-
self._vector_interp_method = None
274-
else:
275-
assert_same_function_signature(vector_interp_method, ref=ZeroInterpolator_Vector, context="Interpolation")
276-
self._vector_interp_method = vector_interp_method
273+
assert_same_function_signature(vector_interp_method, ref=ZeroInterpolator_Vector, context="Interpolation")
274+
self._vector_interp_method = vector_interp_method
277275

278276
def __repr__(self):
279277
return vectorfield_repr(self)
@@ -287,7 +285,7 @@ def vector_interp_method(self, method: Callable):
287285
assert_same_function_signature(method, ref=ZeroInterpolator_Vector, context="Interpolation")
288286
self._vector_interp_method = method
289287

290-
def eval(self, time: datetime, z, y, x, particles=None, apply_conversion=True):
288+
def eval(self, time: datetime, z, y, x, particles=None):
291289
"""Interpolate vectorfield values in space and time.
292290
293291
Parameters
@@ -300,10 +298,6 @@ def eval(self, time: datetime, z, y, x, particles=None, apply_conversion=True):
300298
particles : ParticleSet, optional
301299
If provided, used to associate results with particle indices and to
302300
update particle state and element indices. Defaults to None.
303-
apply_conversion : bool, default True
304-
If True and the underlying grid is spherical, the horizontal velocity
305-
components (U, V) are converted from m s^-1 to degrees s^-1.
306-
If False, raw interpolated values are returned.
307301
308302
Returns
309303
-------
@@ -327,26 +321,7 @@ def eval(self, time: datetime, z, y, x, particles=None, apply_conversion=True):
327321

328322
particle_positions, grid_positions = _get_positions(self.U, time, z, y, x, particles, _ei)
329323

330-
if self._vector_interp_method is None:
331-
u = self.U._interp_method(particle_positions, grid_positions, self.U)
332-
v = self.V._interp_method(particle_positions, grid_positions, self.V)
333-
334-
if apply_conversion and self.U.grid._mesh == "spherical":
335-
u /= 1852 * 60.0 * np.cos(np.deg2rad(y))
336-
v /= 1852 * 60.0
337-
338-
if "3D" in self.vector_type:
339-
w = self.W._interp_method(particle_positions, grid_positions, self.W)
340-
else:
341-
w = 0.0
342-
else:
343-
(u, v, w) = self._vector_interp_method(particle_positions, grid_positions, self)
344-
345-
if apply_conversion:
346-
if self.U.grid._mesh == "spherical":
347-
meshJac = 1852 * 60.0 * np.cos(np.deg2rad(y))
348-
u = u / meshJac
349-
v = v / meshJac
324+
(u, v, w) = self._vector_interp_method(particle_positions, grid_positions, self)
350325

351326
for vel in (u, v, w):
352327
_update_particle_states_interp_value(particles, vel)

src/parcels/_core/fieldset.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@
2020
from parcels._logger import logger
2121
from parcels._reprs import fieldset_repr
2222
from parcels._typing import Mesh
23-
from parcels.interpolators import UxPiecewiseConstantFace, UxPiecewiseLinearNode, XConstantField, XLinear
23+
from parcels.interpolators import (
24+
Ux_Velocity,
25+
UxPiecewiseConstantFace,
26+
UxPiecewiseLinearNode,
27+
XConstantField,
28+
XLinear,
29+
XLinear_Velocity,
30+
)
2431

2532
if TYPE_CHECKING:
2633
from parcels._core.basegrid import BaseGrid
@@ -266,9 +273,11 @@ def from_fesom2(cls, ds: ux.UxDataset):
266273

267274
if "W" in ds.data_vars:
268275
fields["W"] = Field("W", ds["W"], grid, _select_uxinterpolator(ds["U"]))
269-
fields["UVW"] = VectorField("UVW", fields["U"], fields["V"], fields["W"])
276+
fields["UVW"] = VectorField(
277+
"UVW", fields["U"], fields["V"], fields["W"], vector_interp_method=Ux_Velocity
278+
)
270279
else:
271-
fields["UV"] = VectorField("UV", fields["U"], fields["V"])
280+
fields["UV"] = VectorField("UV", fields["U"], fields["V"], vector_interp_method=Ux_Velocity)
272281

273282
for varname in set(ds.data_vars) - set(fields.keys()):
274283
fields[varname] = Field(varname, ds[varname], grid, _select_uxinterpolator(ds[varname]))
@@ -350,11 +359,13 @@ def from_sgrid_conventions(
350359
if "U" in ds.data_vars and "V" in ds.data_vars:
351360
fields["U"] = Field("U", ds["U"], grid, XLinear)
352361
fields["V"] = Field("V", ds["V"], grid, XLinear)
353-
fields["UV"] = VectorField("UV", fields["U"], fields["V"])
362+
fields["UV"] = VectorField("UV", fields["U"], fields["V"], vector_interp_method=XLinear_Velocity)
354363

355364
if "W" in ds.data_vars:
356365
fields["W"] = Field("W", ds["W"], grid, XLinear)
357-
fields["UVW"] = VectorField("UVW", fields["U"], fields["V"], fields["W"])
366+
fields["UVW"] = VectorField(
367+
"UVW", fields["U"], fields["V"], fields["W"], vector_interp_method=XLinear_Velocity
368+
)
358369

359370
for varname in set(ds.data_vars) - set(fields.keys()) - skip_vars:
360371
fields[varname] = Field(varname, ds[varname], grid, XLinear)

src/parcels/_datasets/structured/generated.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
def simple_UV_dataset(dims=(360, 2, 30, 4), maxdepth=1, mesh="spherical"):
1717
max_lon = 180.0 if mesh == "spherical" else 1e6
18+
max_lat = 90.0 if mesh == "spherical" else 1e6
1819

1920
return xr.Dataset(
2021
{"U": (["time", "depth", "YG", "XG"], np.zeros(dims)), "V": (["time", "depth", "YG", "XG"], np.zeros(dims))},
@@ -25,7 +26,7 @@ def simple_UV_dataset(dims=(360, 2, 30, 4), maxdepth=1, mesh="spherical"):
2526
"YG": (["YG"], np.arange(dims[2]), {"axis": "Y", "c_grid_axis_shift": -0.5}),
2627
"XC": (["XC"], np.arange(dims[3]) + 0.5, {"axis": "X"}),
2728
"XG": (["XG"], np.arange(dims[3]), {"axis": "X", "c_grid_axis_shift": -0.5}),
28-
"lat": (["YG"], np.linspace(-90, 90, dims[2]), {"axis": "Y", "c_grid_axis_shift": -0.5}),
29+
"lat": (["YG"], np.linspace(-max_lat, max_lat, dims[2]), {"axis": "Y", "c_grid_axis_shift": -0.5}),
2930
"lon": (["XG"], np.linspace(-max_lon, max_lon, dims[3]), {"axis": "X", "c_grid_axis_shift": -0.5}),
3031
},
3132
).pipe(

src/parcels/interpolators.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020
"CGrid_Velocity",
2121
"UxPiecewiseConstantFace",
2222
"UxPiecewiseLinearNode",
23+
"Ux_Velocity",
2324
"XConstantField",
2425
"XFreeslip",
2526
"XLinear",
2627
"XLinearInvdistLandTracer",
28+
"XLinear_Velocity",
2729
"XNearest",
2830
"XPartialslip",
2931
"ZeroInterpolator",
@@ -146,6 +148,25 @@ def XConstantField(
146148
return field.data[0, 0, 0, 0].values
147149

148150

151+
def XLinear_Velocity(
152+
particle_positions: dict[str, float | np.ndarray],
153+
grid_positions: dict[_XGRID_AXES, dict[str, int | float | np.ndarray]],
154+
vectorfield: VectorField,
155+
):
156+
"""Trilinear interpolation on a regular grid for VectorFields of velocity."""
157+
u = XLinear(particle_positions, grid_positions, vectorfield.U)
158+
v = XLinear(particle_positions, grid_positions, vectorfield.V)
159+
if vectorfield.grid._mesh == "spherical":
160+
u /= 1852 * 60 * np.cos(np.deg2rad(particle_positions["lat"]))
161+
v /= 1852 * 60
162+
163+
if vectorfield.W:
164+
w = XLinear(particle_positions, grid_positions, vectorfield.W)
165+
else:
166+
w = 0.0
167+
return u, v, w
168+
169+
149170
def CGrid_Velocity(
150171
particle_positions: dict[str, float | np.ndarray],
151172
grid_positions: dict[_XGRID_AXES, dict[str, int | float | np.ndarray]],
@@ -268,9 +289,10 @@ def CGrid_Velocity(
268289
U = (1 - xsi) * U0 + xsi * U1
269290
V = (1 - eta) * V0 + eta * V1
270291

271-
meshJac = 1852 * 60.0 if grid._mesh == "spherical" else 1
272-
273-
jac = i_u._compute_jacobian_determinant(py, px, eta, xsi) * meshJac
292+
if grid._mesh == "spherical":
293+
jac = i_u._compute_jacobian_determinant(py, px, eta, xsi) * 1852 * 60.0
294+
else:
295+
jac = i_u._compute_jacobian_determinant(py, px, eta, xsi)
274296

275297
u = (
276298
(-(1 - eta) * U - (1 - xsi) * V) * px[0]
@@ -288,6 +310,11 @@ def CGrid_Velocity(
288310
u = u.compute()
289311
v = v.compute()
290312

313+
if grid._mesh == "spherical":
314+
conversion = 1852 * 60.0 * np.cos(np.deg2rad(particle_positions["lat"]))
315+
u /= conversion
316+
v /= conversion
317+
291318
# check whether the grid conversion has been applied correctly
292319
xx = (1 - xsi) * (1 - eta) * px[0] + xsi * (1 - eta) * px[1] + xsi * eta * px[2] + (1 - xsi) * eta * px[3]
293320
dlon = xx - lon
@@ -682,3 +709,22 @@ def UxPiecewiseLinearNode(
682709
zk = field.grid.z.values[zi]
683710
zkp1 = field.grid.z.values[zi + 1]
684711
return (fzk * (zkp1 - z) + fzkp1 * (z - zk)) / (zkp1 - zk) # Linear interpolation in the vertical direction
712+
713+
714+
def Ux_Velocity(
715+
particle_positions: dict[str, float | np.ndarray],
716+
grid_positions: dict[_UXGRID_AXES, dict[str, int | float | np.ndarray]],
717+
vectorfield: VectorField,
718+
):
719+
"""Interpolation kernel for Vectorfields of velocity on a UxGrid."""
720+
u = vectorfield.U._interp_method(particle_positions, grid_positions, vectorfield.U)
721+
v = vectorfield.V._interp_method(particle_positions, grid_positions, vectorfield.V)
722+
if vectorfield.grid._mesh == "spherical":
723+
u /= 1852 * 60 * np.cos(np.deg2rad(particle_positions["lat"]))
724+
v /= 1852 * 60
725+
726+
if "3D" in vectorfield.vector_type:
727+
w = vectorfield.W._interp_method(particle_positions, grid_positions, vectorfield.W)
728+
else:
729+
w = 0.0
730+
return u, v, w

tests-v3/test_fieldset_sampling.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@ def SampleUV(particle, fieldset, time): # pragma: no cover
2929
(particle.u, particle.v) = fieldset.UV[time, particle.depth, particle.lat, particle.lon]
3030

3131

32-
def SampleUVNoConvert(particle, fieldset, time): # pragma: no cover
33-
(particle.u, particle.v) = fieldset.UV.eval(time, particle.depth, particle.lat, particle.lon, applyConversion=False)
34-
35-
3632
def SampleP(particle, fieldset, time): # pragma: no cover
3733
particle.p = fieldset.P[particle]
3834

@@ -439,24 +435,6 @@ def test_fieldset_sample_geographic(fieldset_geometric):
439435
assert np.allclose(pset.u, lat, rtol=1e-6)
440436

441437

442-
@pytest.mark.v4alpha
443-
@pytest.mark.xfail(reason="GH1946")
444-
def test_fieldset_sample_geographic_noconvert(fieldset_geometric):
445-
"""Sample a fieldset without conversion to geographic units."""
446-
npart = 120
447-
fieldset = fieldset_geometric
448-
lon = np.linspace(-170, 170, npart)
449-
lat = np.linspace(-80, 80, npart)
450-
451-
pset = ParticleSet(fieldset, pclass=pclass(), lon=lon, lat=np.zeros(npart) + 70.0)
452-
pset.execute(pset.Kernel(SampleUVNoConvert), endtime=1.0, dt=1.0)
453-
assert np.allclose(pset.v, lon * 1000 * 1.852 * 60, rtol=1e-6)
454-
455-
pset = ParticleSet(fieldset, pclass=pclass(), lat=lat, lon=np.zeros(npart) - 45.0)
456-
pset.execute(pset.Kernel(SampleUVNoConvert), endtime=1.0, dt=1.0)
457-
assert np.allclose(pset.u, lat * 1000 * 1.852 * 60, rtol=1e-6)
458-
459-
460438
@pytest.mark.v4alpha
461439
@pytest.mark.xfail(reason="GH1946")
462440
def test_fieldset_sample_geographic_polar(fieldset_geometric_polar):

0 commit comments

Comments
 (0)