Skip to content

Commit bbc8e28

Browse files
Merge branch 'v4-dev' into deal_with_search_interpolation_errors
2 parents 1b6fe29 + b185396 commit bbc8e28

6 files changed

Lines changed: 230 additions & 121 deletions

File tree

docs/v4/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The key goals of this update are
77
1. to support `Fields` on unstructured grids;
88
2. to allow for user-defined interpolation methods (somewhat similar to user-defined kernels);
99
3. to make the codebase more modular, easier to extend, and more maintainable;
10-
4. to align Parcels more with other tools in the [Pangeo ecosystemand](https://www.pangeo.io/#ecosystem), particularly by leveraging `xarray` more; and
10+
4. to align Parcels more with other tools in the [Pangeo ecosystem](https://www.pangeo.io/#ecosystem), particularly by leveraging `xarray` more; and
1111
5. to improve the performance of Parcels.
1212

1313
The timeline for the release of Parcels v4 is not yet fixed, but we are aiming for a release of an 'alpha' version in September 2025. This v4-alpha will have support for unstructured grids and user-defined interpolation methods, but is not yet performance-optimised.

parcels/particleset.py

Lines changed: 68 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import sys
22
import warnings
33
from collections.abc import Iterable
4+
from typing import Literal
45

56
import numpy as np
67
import xarray as xr
@@ -506,59 +507,17 @@ def execute(
506507
if output_file:
507508
output_file.metadata["parcels_kernels"] = self._kernel.name
508509

509-
if (dt is not None) and (not isinstance(dt, np.timedelta64)):
510-
raise TypeError("dt must be a np.timedelta64 object")
511-
if dt is None or np.isnat(dt):
510+
if dt is None:
512511
dt = np.timedelta64(1, "s")
513-
self._data["dt"][:] = dt
514-
sign_dt = np.sign(dt).astype(int)
515-
if sign_dt not in [-1, 1]:
516-
raise ValueError("dt must be a positive or negative np.timedelta64 object")
517512

518-
if self.fieldset.time_interval is None:
519-
start_time = np.timedelta64(0, "s") # For the execution loop, we need a start time as a timedelta object
520-
if runtime is None:
521-
raise TypeError("The runtime must be provided when the time_interval is not defined for a fieldset.")
513+
if not isinstance(dt, np.timedelta64) or np.isnat(dt) or (sign_dt := np.sign(dt).astype(int)) not in [-1, 1]:
514+
raise ValueError(f"dt must be a positive or negative np.timedelta64 object, got {dt=!r}")
522515

523-
else:
524-
if isinstance(runtime, np.timedelta64):
525-
end_time = runtime
526-
else:
527-
raise TypeError("The runtime must be a np.timedelta64 object")
516+
self._data["dt"][:] = dt
528517

529-
else:
530-
if not np.isnat(self.time_nextloop).any():
531-
if sign_dt > 0:
532-
start_time = self.time_nextloop.min()
533-
else:
534-
start_time = self.time_nextloop.max()
535-
else:
536-
if sign_dt > 0:
537-
start_time = self.fieldset.time_interval.left
538-
else:
539-
start_time = self.fieldset.time_interval.right
540-
541-
if runtime is None:
542-
if endtime is None:
543-
raise ValueError(
544-
"Must provide either runtime or endtime when time_interval is defined for a fieldset."
545-
)
546-
# Ensure that the endtime uses the same type as the start_time
547-
if isinstance(endtime, self.fieldset.time_interval.left.__class__):
548-
if sign_dt > 0:
549-
if endtime < self.fieldset.time_interval.left:
550-
raise ValueError("The endtime must be after the start time of the fieldset.time_interval")
551-
end_time = min(endtime, self.fieldset.time_interval.right)
552-
else:
553-
if endtime > self.fieldset.time_interval.right:
554-
raise ValueError(
555-
"The endtime must be before the end time of the fieldset.time_interval when dt < 0"
556-
)
557-
end_time = max(endtime, self.fieldset.time_interval.left)
558-
else:
559-
raise TypeError("The endtime must be of the same type as the fieldset.time_interval start time.")
560-
else:
561-
end_time = start_time + runtime * sign_dt
518+
start_time, end_time = _get_simulation_start_and_end_times(
519+
self.fieldset.time_interval, self._data["time_nextloop"], runtime, endtime, sign_dt
520+
)
562521

563522
# Set the time of the particles if it hadn't been set on initialisation
564523
if np.isnat(self._data["time"]).any():
@@ -619,15 +578,69 @@ def _warn_particle_times_outside_fieldset_time_bounds(release_times: np.ndarray,
619578

620579
if isinstance(time.left, np.datetime64) and isinstance(release_times[0], np.timedelta64):
621580
release_times = np.array([t + time.left for t in release_times])
622-
if np.any(release_times < time.left):
581+
if np.any(release_times < time.left) or np.any(release_times > time.right):
623582
warnings.warn(
624583
"Some particles are set to be released outside the FieldSet's executable time domain.",
625584
ParticleSetWarning,
626585
stacklevel=2,
627586
)
628-
if np.any(release_times > time.right):
629-
warnings.warn(
630-
"Some particles are set to be released after the fieldset's last time and the fields are not constant in time.",
631-
ParticleSetWarning,
632-
stacklevel=2,
587+
588+
589+
def _get_simulation_start_and_end_times(
590+
time_interval: TimeInterval,
591+
particle_release_times: np.ndarray,
592+
runtime: np.timedelta64 | None,
593+
endtime: np.datetime64 | None,
594+
sign_dt: Literal[-1, 1],
595+
) -> tuple[np.datetime64, np.datetime64]:
596+
if runtime is not None and endtime is not None:
597+
raise ValueError(
598+
f"runtime and endtime are mutually exclusive - provide one or the other. Got {runtime=!r}, {endtime=!r}"
633599
)
600+
601+
if runtime is None and time_interval is None:
602+
raise ValueError("The runtime must be provided when the time_interval is not defined for a fieldset.")
603+
604+
if sign_dt == 1:
605+
first_release_time = particle_release_times.min()
606+
else:
607+
first_release_time = particle_release_times.max()
608+
609+
start_time = _get_start_time(first_release_time, time_interval, sign_dt, runtime)
610+
611+
if endtime is None:
612+
if not isinstance(runtime, np.timedelta64):
613+
raise ValueError(f"The runtime must be a np.timedelta64 object. Got {type(runtime)}")
614+
615+
endtime = start_time + sign_dt * runtime
616+
617+
if time_interval is not None:
618+
if type(endtime) != type(time_interval.left): # noqa: E721
619+
raise ValueError(
620+
f"The endtime must be of the same type as the fieldset.time_interval start time. Got {endtime=!r} with {time_interval=!r}"
621+
)
622+
if endtime not in time_interval:
623+
msg = (
624+
f"Calculated/provided end time of {endtime!r} is not in fieldset time interval {time_interval!r}. Either reduce your runtime, modify your "
625+
"provided endtime, or change your release timing."
626+
"Important info:\n"
627+
f" First particle release: {first_release_time!r}\n"
628+
f" runtime: {runtime!r}\n"
629+
f" (calculated) endtime: {endtime!r}"
630+
)
631+
raise ValueError(msg)
632+
633+
return start_time, endtime
634+
635+
636+
def _get_start_time(first_release_time, time_interval, sign_dt, runtime):
637+
if time_interval is None:
638+
time_interval = TimeInterval(left=np.timedelta64(0, "s"), right=runtime)
639+
640+
if sign_dt == 1:
641+
fieldset_start = time_interval.left
642+
else:
643+
fieldset_start = time_interval.right
644+
645+
start_time = first_release_time if not np.isnat(first_release_time) else fieldset_start
646+
return start_time

tests/v4/test_index_search.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import numpy as np
22
import pytest
3+
import xarray as xr
34

45
from parcels._datasets.structured.generic import datasets
56
from parcels._index_search import _search_indices_curvilinear_2d
67
from parcels.field import Field
7-
from parcels.xgrid import (
8-
XGrid,
9-
)
8+
from parcels.tools.exampledata_utils import download_example_dataset
9+
from parcels.xgcm import Grid
10+
from parcels.xgrid import XGrid
1011

1112

1213
@pytest.fixture
@@ -51,3 +52,32 @@ def test_grid_indexing_fpoints(field_cone):
5152
]
5253
assert x > np.min(cell_lon) and x < np.max(cell_lon)
5354
assert y > np.min(cell_lat) and y < np.max(cell_lat)
55+
56+
57+
def test_indexing_nemo_curvilinear():
58+
data_folder = download_example_dataset("NemoCurvilinear_data")
59+
ds = xr.open_mfdataset(
60+
data_folder.glob("*.nc4"), combine="nested", data_vars="minimal", coords="minimal", compat="override"
61+
)
62+
ds = ds.isel({"time_counter": 0, "time": 0, "z_a": 0}, drop=True).rename(
63+
{"glamf": "lon", "gphif": "lat", "z": "depth"}
64+
)
65+
xgcm_grid = Grid(ds, coords={"X": {"left": "x"}, "Y": {"left": "y"}}, periodic=False)
66+
grid = XGrid(xgcm_grid)
67+
68+
# Test points on the NEMO 1/4 degree curvilinear grid
69+
lats = np.array([-30, 0, 88])
70+
lons = np.array([30, 60, -150])
71+
72+
yi, eta, xi, xsi = _search_indices_curvilinear_2d(grid, lats, lons)
73+
74+
# Construct cornerpoints px
75+
px = np.array([grid.lon[yi, xi], grid.lon[yi, xi + 1], grid.lon[yi + 1, xi + 1], grid.lon[yi + 1, xi]])
76+
77+
# Maximum 5 degree difference between px values
78+
for i in range(lons.shape[0]):
79+
np.testing.assert_allclose(px[1, i], px[:, i], atol=5)
80+
81+
# Reconstruct lons values from cornerpoints
82+
xx = (1 - xsi) * (1 - eta) * px[0] + xsi * (1 - eta) * px[1] + xsi * eta * px[2] + (1 - xsi) * eta * px[3]
83+
np.testing.assert_allclose(xx, lons, atol=1e-6)

tests/v4/test_particleset.py

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -114,21 +114,6 @@ def test_pset_create_outside_time(fieldset):
114114
ParticleSet(fieldset, pclass=Particle, lon=[0] * len(time), lat=[0] * len(time), time=time)
115115

116116

117-
@pytest.mark.parametrize(
118-
"dt, expectation",
119-
[
120-
(np.timedelta64(5, "s"), does_not_raise()),
121-
(5.0, pytest.raises(TypeError)),
122-
(np.datetime64("2000-01-02T00:00:00"), pytest.raises(TypeError)),
123-
(timedelta(seconds=2), pytest.raises(TypeError)),
124-
],
125-
)
126-
def test_particleset_dt_type(fieldset, dt, expectation):
127-
pset = ParticleSet(fieldset, lon=[0.2], lat=[5.0], depth=[50.0], pclass=Particle)
128-
with expectation:
129-
pset.execute(runtime=np.timedelta64(10, "s"), dt=dt, pyfunc=DoNothing)
130-
131-
132117
def test_pset_starttime_not_multiple_dt(fieldset):
133118
times = [0, 1, 2]
134119
datetimes = [fieldset.time_interval.left + np.timedelta64(t, "s") for t in times]
@@ -141,38 +126,6 @@ def Addlon(particle, fieldset, time): # pragma: no cover
141126
assert np.allclose([p.lon_nextloop for p in pset], [8 - t for t in times])
142127

143128

144-
@pytest.mark.parametrize(
145-
"runtime, expectation",
146-
[
147-
(np.timedelta64(5, "s"), does_not_raise()),
148-
(5.0, pytest.raises(TypeError)),
149-
(timedelta(seconds=2), pytest.raises(TypeError)),
150-
(np.datetime64("2001-01-02T00:00:00"), pytest.raises(TypeError)),
151-
(datetime(2000, 1, 2, 0, 0, 0), pytest.raises(TypeError)),
152-
],
153-
)
154-
def test_particleset_runtime_type(fieldset, runtime, expectation):
155-
pset = ParticleSet(fieldset, lon=[0.2], lat=[5.0], depth=[50.0], pclass=Particle)
156-
with expectation:
157-
pset.execute(runtime=runtime, dt=np.timedelta64(10, "s"), pyfunc=DoNothing)
158-
159-
160-
@pytest.mark.parametrize(
161-
"endtime, expectation",
162-
[
163-
(np.datetime64("2000-01-02T00:00:00"), does_not_raise()),
164-
(5.0, pytest.raises(TypeError)),
165-
(np.timedelta64(5, "s"), pytest.raises(TypeError)),
166-
(timedelta(seconds=2), pytest.raises(TypeError)),
167-
(datetime(2000, 1, 2, 0, 0, 0), pytest.raises(TypeError)),
168-
],
169-
)
170-
def test_particleset_endtime_type(fieldset, endtime, expectation):
171-
pset = ParticleSet(fieldset, lon=[0.2], lat=[5.0], depth=[50.0], pclass=Particle)
172-
with expectation:
173-
pset.execute(endtime=endtime, dt=np.timedelta64(10, "m"), pyfunc=DoNothing)
174-
175-
176129
def test_pset_add_explicit(fieldset):
177130
npart = 11
178131
lon = np.linspace(0, 1, npart)

0 commit comments

Comments
 (0)