Skip to content

Commit d92d214

Browse files
Merge branch 'v4-dev' into remove-time-extrapolation-option
2 parents 9697eca + 277038c commit d92d214

6 files changed

Lines changed: 160 additions & 9 deletions

File tree

parcels/_core/utils/time.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from __future__ import annotations
22

33
from datetime import datetime
4-
from typing import TypeVar
4+
from typing import TYPE_CHECKING, TypeVar
55

66
import cftime
77
import numpy as np
88

99
T = TypeVar("T", datetime, cftime.datetime)
1010

11+
if TYPE_CHECKING:
12+
from parcels._typing import DatetimeLike
13+
1114

1215
class TimeInterval:
1316
"""A class representing a time interval between two datetime objects.
@@ -70,3 +73,28 @@ def is_compatible(t1: datetime | cftime.datetime, t2: datetime | cftime.datetime
7073
return False
7174
else:
7275
return True
76+
77+
78+
def get_datetime_type_calendar(
79+
example_datetime: DatetimeLike,
80+
) -> tuple[type, str | None]:
81+
"""Get the type and calendar of a datetime object.
82+
83+
Parameters
84+
----------
85+
example_datetime : datetime, cftime.datetime, or np.datetime64
86+
The datetime object to check.
87+
88+
Returns
89+
-------
90+
tuple[type, str | None]
91+
A tuple containing the type of the datetime object and its calendar.
92+
The calendar will be None if the datetime object is not a cftime datetime object.
93+
"""
94+
calendar = None
95+
try:
96+
calendar = example_datetime.calendar
97+
except AttributeError:
98+
# datetime isn't a cftime datetime object
99+
pass
100+
return type(example_datetime), calendar

parcels/_typing.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88

99
import os
1010
from collections.abc import Callable
11+
from datetime import datetime
1112
from typing import Any, Literal, get_args
1213

14+
import numpy as np
15+
from cftime import datetime as cftime_datetime
16+
1317
InterpMethodOption = Literal[
1418
"linear",
1519
"nearest",
@@ -30,7 +34,7 @@
3034
VectorType = Literal["3D", "3DSigma", "2D"] | None # corresponds with `vector_type`
3135
GridIndexingType = Literal["pop", "mom5", "mitgcm", "nemo", "croco"] # corresponds with `gridindexingtype`
3236
NetcdfEngine = Literal["netcdf4", "xarray", "scipy"]
33-
37+
DatetimeLike = datetime | cftime_datetime | np.datetime64
3438

3539
KernelFunction = Callable[..., None]
3640

parcels/field.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,13 @@ def __init__(
165165
self.name = name
166166
self.data = data
167167
self.grid = grid
168-
self.time_interval = get_time_interval(data)
168+
try:
169+
self.time_interval = get_time_interval(data)
170+
except ValueError as e:
171+
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?"
173+
)
174+
raise e
169175

170176
# For compatibility with parts of the codebase that rely on v3 definition of Grid.
171177
# Should be worked to be removed in v4
@@ -529,6 +535,13 @@ def __init__(
529535
self.V = V
530536
self.W = W
531537

538+
if W is None:
539+
assert_same_time_interval((U, V))
540+
else:
541+
assert_same_time_interval((U, V, W))
542+
543+
self.time_interval = U.time_interval
544+
532545
if self.W:
533546
self.vector_type = "3D"
534547
else:
@@ -668,3 +681,16 @@ def get_time_interval(data: xr.DataArray | ux.UxDataArray) -> TimeInterval | Non
668681
return None
669682

670683
return TimeInterval(data.time.values[0], data.time.values[-1])
684+
685+
686+
def assert_same_time_interval(fields: list[Field]) -> None:
687+
if len(fields) == 0:
688+
return
689+
690+
reference_time_interval = fields[0].time_interval
691+
692+
for field in fields[1:]:
693+
if field.time_interval != reference_time_interval:
694+
raise ValueError(
695+
f"Fields must have the same time domain. {fields[0].name}: {reference_time_interval}, {field.name}: {field.time_interval}"
696+
)

parcels/fieldset.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
from __future__ import annotations
2+
13
import functools
4+
from collections.abc import Iterable
5+
from typing import TYPE_CHECKING
26

37
import numpy as np
48
import xarray as xr
59

10+
from parcels._core.utils.time import get_datetime_type_calendar
11+
from parcels._core.utils.time import is_compatible as datetime_is_compatible
612
from parcels._reprs import fieldset_repr
713
from parcels._typing import Mesh
814
from parcels.field import Field, VectorField
915
from parcels.v4.grid import Grid
1016

17+
if TYPE_CHECKING:
18+
from parcels._typing import DatetimeLike
1119
__all__ = ["FieldSet"]
1220

1321

@@ -45,6 +53,7 @@ def __init__(self, fields: list[Field | VectorField]):
4553
for field in fields:
4654
if not isinstance(field, (Field, VectorField)):
4755
raise ValueError(f"Expected `field` to be a Field or VectorField object. Got {field}")
56+
assert_compatible_calendars(fields)
4857

4958
self.fields = {f.name: f for f in fields}
5059
self.constants = {}
@@ -126,6 +135,7 @@ def add_field(self, field: Field, name: str | None = None):
126135
"""
127136
if not isinstance(field, (Field, VectorField)):
128137
raise ValueError(f"Expected `field` to be a Field or VectorField object. Got {type(field)}")
138+
assert_compatible_calendars((*self.fields.values(), field))
129139

130140
name = field.name if name is None else name
131141

@@ -236,3 +246,31 @@ def add_constant(self, name, value):
236246
# return nextTime
237247
# else:
238248
# return time + nSteps * dt
249+
250+
251+
class CalendarError(Exception): # TODO: Move to a parcels errors module
252+
"""Exception raised when the calendar of a field is not compatible with the rest of the Fields. The user should ensure that they only add fields to a FieldSet that have compatible CFtime calendars."""
253+
254+
255+
def assert_compatible_calendars(fields: Iterable[Field]):
256+
time_intervals = [f.time_interval for f in fields if f.time_interval is not None]
257+
reference_datetime_object = time_intervals[0].left
258+
259+
for field in fields:
260+
if field.time_interval is None:
261+
continue
262+
263+
if not datetime_is_compatible(reference_datetime_object, field.time_interval.left):
264+
msg = format_calendar_error_message(field, reference_datetime_object)
265+
raise CalendarError(msg)
266+
267+
268+
def format_calendar_error_message(field: Field, reference_datetime: DatetimeLike) -> str:
269+
def datetime_to_msg(example_datetime: DatetimeLike) -> str:
270+
datetime_type, calendar = get_datetime_type_calendar(example_datetime)
271+
msg = str(datetime_type)
272+
if calendar is not None:
273+
msg += f" with cftime calendar {calendar}'"
274+
return msg
275+
276+
return f"Expected field {field.name!r} to have calendar compatible with datetime object {datetime_to_msg(reference_datetime)}. Got field with calendar {datetime_to_msg(field.time_interval.left)}. Have you considered using xarray to update the time dimension of the dataset to have a compatible calendar?"

tests/v4/test_field.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import xarray as xr
55

66
from parcels import Field
7-
from parcels._datasets.structured.generic import datasets as structured_datasets
8-
from parcels._datasets.unstructured.generic import datasets as unstructured_datasets
7+
from parcels._datasets.structured.generic import T as T_structured
8+
from parcels._datasets.structured.generic import datasets as datasets_structured
9+
from parcels._datasets.unstructured.generic import datasets as datasets_unstructured
910
from parcels.v4.grid import Grid
1011

1112

@@ -36,7 +37,7 @@ def test_field_init_param_types():
3637
pytest.param(ux.UxDataArray(), Grid(xr.Dataset()), id="uxdata-grid"),
3738
pytest.param(
3839
xr.DataArray(),
39-
unstructured_datasets["stommel_gyre_delaunay"].uxgrid,
40+
datasets_unstructured["stommel_gyre_delaunay"].uxgrid,
4041
id="xarray-uxgrid",
4142
),
4243
],
@@ -54,11 +55,11 @@ def test_field_incompatible_combination(data, grid):
5455
"data,grid",
5556
[
5657
pytest.param(
57-
structured_datasets["ds_2d_left"]["data_g"], Grid(structured_datasets["ds_2d_left"]), id="ds_2d_left"
58+
datasets_structured["ds_2d_left"]["data_g"], Grid(datasets_structured["ds_2d_left"]), id="ds_2d_left"
5859
), # TODO: Perhaps this test should be expanded to cover more datasets?
5960
],
6061
)
61-
def test_field_structured_grid_creation(data, grid):
62+
def test_field_init_structured_grid(data, grid):
6263
"""Test creating a field."""
6364
field = Field(
6465
name="test_field",
@@ -70,11 +71,30 @@ def test_field_structured_grid_creation(data, grid):
7071
assert field.grid == grid
7172

7273

74+
@pytest.mark.parametrize("numpy_dtype", ["timedelta64[s]", "float64"])
75+
def test_field_init_fail_on_bad_time_type(numpy_dtype):
76+
"""Tests that field initialisation fails when the time isn't given as datetime object (i.e., is float or timedelta)."""
77+
ds = datasets_structured["ds_2d_left"].copy()
78+
ds["time"] = np.arange(0, T_structured, dtype=numpy_dtype)
79+
80+
data = ds["data_g"]
81+
grid = Grid(ds)
82+
with pytest.raises(
83+
ValueError,
84+
match="Error getting time interval.*. Are you sure that the time dimension on the xarray dataset is stored as datetime or cftime datetime objects\?",
85+
):
86+
Field(
87+
name="test_field",
88+
data=data,
89+
grid=grid,
90+
)
91+
92+
7393
@pytest.mark.parametrize(
7494
"data,grid",
7595
[
7696
pytest.param(
77-
structured_datasets["ds_2d_left"]["data_g"], Grid(structured_datasets["ds_2d_left"]), id="ds_2d_left"
97+
datasets_structured["ds_2d_left"]["data_g"], Grid(datasets_structured["ds_2d_left"]), id="ds_2d_left"
7898
),
7999
],
80100
)
@@ -85,6 +105,11 @@ def test_field_time_interval(data, grid):
85105
assert field.time_interval.right == np.datetime64("2001-01-01")
86106

87107

108+
def test_vectorfield_init_different_time_intervals():
109+
# Tests that a VectorField raises a ValueError if the component fields have different time domains.
110+
...
111+
112+
88113
def test_field_unstructured_grid_creation(): ...
89114

90115

tests/v4/test_fieldset.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import numpy as np
44
import pytest
5+
import xarray as xr
56

7+
from parcels._datasets.structured.generic import T as T_structured
68
from parcels._datasets.structured.generic import datasets as datasets_structured
79
from parcels.field import Field, VectorField
810
from parcels.fieldset import FieldSet
@@ -87,3 +89,31 @@ def test_fieldset_time_interval():
8789

8890
assert fieldset.time_interval.left == np.datetime64("2000-01-02")
8991
assert fieldset.time_interval.right == np.datetime64("2001-01-01")
92+
93+
94+
def test_fieldset_init_incompatible_calendars():
95+
ds1 = ds.copy()
96+
ds1["time"] = xr.date_range("2000", "2001", T_structured, calendar="365_day", use_cftime=True)
97+
98+
grid = Grid(ds1)
99+
U = Field("U", ds1["U (A grid)"], grid, mesh_type="flat")
100+
V = Field("V", ds1["V (A grid)"], grid, mesh_type="flat")
101+
UV = VectorField("UV", U, V)
102+
103+
ds2 = ds.copy()
104+
ds2["time"] = xr.date_range("2000", "2001", T_structured, calendar="360_day", use_cftime=True)
105+
grid2 = Grid(ds2)
106+
incompatible_calendar = Field("test", ds2["data_g"], grid2, mesh_type="flat")
107+
108+
with pytest.raises(ValueError):
109+
FieldSet([U, V, UV, incompatible_calendar])
110+
111+
112+
def test_fieldset_add_field_incompatible_calendars(fieldset):
113+
ds_test = ds.copy()
114+
ds_test["time"] = xr.date_range("2000", "2001", T_structured, calendar="360_day", use_cftime=True)
115+
grid = Grid(ds_test)
116+
field = Field("test_field", ds_test["data_g"], grid, mesh_type="flat")
117+
118+
with pytest.raises(ValueError):
119+
fieldset.add_field(field, "test_field")

0 commit comments

Comments
 (0)