Skip to content

Commit 83e76da

Browse files
Merge branch 'v4-dev' into support_1d_fields
2 parents a1815d8 + 9dca8d5 commit 83e76da

File tree

15 files changed

+230
-82
lines changed

15 files changed

+230
-82
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
strategy:
2929
fail-fast: false
3030
matrix:
31-
os: [ubuntu, mac, windows]
31+
os: [ubuntu, windows]
3232
pixi-environment: [test-latest]
3333
include:
3434
- os: ubuntu

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ repos:
1010
types: [text]
1111
files: \.(json|ipynb)$
1212
- repo: https://github.com/astral-sh/ruff-pre-commit
13-
rev: v0.14.2
13+
rev: v0.14.4
1414
hooks:
1515
- id: ruff
1616
name: ruff lint (.py)

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## GitHub Interaction Guidelines
2+
3+
- **NEVER impersonate the user on GitHub**, always sign off with something like
4+
"[This is Claude Code on behalf of Jane Doe]"
5+
- Never create issues nor pull requests on the GitHub repository unless
6+
explicitly instructed
7+
- Never post "update" messages, progress reports, or explanatory comments on
8+
GitHub issues/PRs unless specifically instructed
9+
- When creating commits, always include a co-authorship trailer:
10+
`Co-authored-by: Claude <[email protected]>`

docs/development/policies.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Policies
22

3+
## Use of AI in development
4+
5+
Many developers use AI Large Language Models to help them in their work. These LLMs have received both praise and criticism when it comes to software development.
6+
7+
We accept that Parcels developers have their own motivation for using (or not using) AI. However, we have one policy that we expect all Parcels developers to follow:
8+
9+
> It is ultimately your responsibility to understand the code that you commit.
10+
11+
Remember that reviews are done by human maintainers - asking us to review code that an AI wrote but you don't understand isn't kind to these maintainers.
12+
13+
The [CLAUDE.md](/CLAUDE.md) file in the repository has additional instructions for AI agents to follow when contributing to Parcels.
14+
315
## Versioning
416

517
Parcels follows [Intended Effort Versioning (EffVer)](https://jacobtomlinson.dev/effver/), where the version number (e.g., v2.1.0) is thought of as `MACRO.MESO.MICRO`.

src/parcels/_core/field.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@
2626
from parcels._reprs import default_repr
2727
from parcels._typing import VectorType
2828
from parcels.interpolators import (
29-
UXPiecewiseLinearNode,
30-
XLinear,
3129
ZeroInterpolator,
3230
ZeroInterpolator_Vector,
3331
)
@@ -51,17 +49,11 @@ def _deal_with_errors(error, key, vector_type: VectorType):
5149
return 0
5250

5351

54-
_DEFAULT_INTERPOLATOR_MAPPING = {
55-
XGrid: XLinear,
56-
UxGrid: UXPiecewiseLinearNode,
57-
}
58-
59-
6052
class Field:
6153
"""The Field class that holds scalar field data.
6254
The `Field` object is a wrapper around a xarray.DataArray or uxarray.UxDataArray object.
6355
Additionally, it holds a dynamic Callable procedure that is used to interpolate the field data.
64-
During initialization, the user can supply a custom interpolation method that is used to interpolate the field data,
56+
During initialization, the user is required to supply a custom interpolation method that is used to interpolate the field data,
6557
so long as the interpolation method has the correct signature.
6658
6759
Notes
@@ -96,7 +88,7 @@ def __init__(
9688
name: str,
9789
data: xr.DataArray | ux.UxDataArray,
9890
grid: UxGrid | XGrid,
99-
interp_method: Callable | None = None,
91+
interp_method: Callable,
10092
):
10193
if not isinstance(data, (ux.UxDataArray, xr.DataArray)):
10294
raise ValueError(
@@ -136,11 +128,8 @@ def __init__(
136128
raise e
137129

138130
# Setting the interpolation method dynamically
139-
if interp_method is None:
140-
self._interp_method = _DEFAULT_INTERPOLATOR_MAPPING[type(self.grid)]
141-
else:
142-
assert_same_function_signature(interp_method, ref=ZeroInterpolator, context="Interpolation")
143-
self._interp_method = interp_method
131+
assert_same_function_signature(interp_method, ref=ZeroInterpolator, context="Interpolation")
132+
self._interp_method = interp_method
144133

145134
self.igrid = -1 # Default the grid index to -1
146135

src/parcels/_core/fieldset.py

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import cf_xarray # noqa: F401
88
import numpy as np
9+
import uxarray as ux
910
import xarray as xr
1011
import xgcm
1112

@@ -14,10 +15,11 @@
1415
from parcels._core.utils.string import _assert_str_and_python_varname
1516
from parcels._core.utils.time import get_datetime_type_calendar
1617
from parcels._core.utils.time import is_compatible as datetime_is_compatible
18+
from parcels._core.uxgrid import UxGrid
1719
from parcels._core.xgrid import _DEFAULT_XGCM_KWARGS, XGrid
1820
from parcels._logger import logger
1921
from parcels._typing import Mesh
20-
from parcels.interpolators import XConstantField
22+
from parcels.interpolators import UXPiecewiseConstantFace, UXPiecewiseLinearNode, XConstantField, XLinear
2123

2224
if TYPE_CHECKING:
2325
from parcels._core.basegrid import BaseGrid
@@ -238,22 +240,62 @@ def from_copernicusmarine(ds: xr.Dataset):
238240

239241
fields = {}
240242
if "U" in ds.data_vars and "V" in ds.data_vars:
241-
fields["U"] = Field("U", ds["U"], grid)
242-
fields["V"] = Field("V", ds["V"], grid)
243+
fields["U"] = Field("U", ds["U"], grid, XLinear)
244+
fields["V"] = Field("V", ds["V"], grid, XLinear)
243245
fields["U"].units = GeographicPolar()
244246
fields["V"].units = Geographic()
245247

246248
if "W" in ds.data_vars:
247249
ds["W"] -= ds[
248250
"W"
249251
] # Negate W to convert from up positive to down positive (as that's the direction of positive z)
250-
fields["W"] = Field("W", ds["W"], grid)
252+
fields["W"] = Field("W", ds["W"], grid, XLinear)
251253
fields["UVW"] = VectorField("UVW", fields["U"], fields["V"], fields["W"])
252254
else:
253255
fields["UV"] = VectorField("UV", fields["U"], fields["V"])
254256

255257
for varname in set(ds.data_vars) - set(fields.keys()):
256-
fields[varname] = Field(varname, ds[varname], grid)
258+
fields[varname] = Field(varname, ds[varname], grid, XLinear)
259+
260+
return FieldSet(list(fields.values()))
261+
262+
def from_fesom2(ds: ux.UxDataset):
263+
"""Create a FieldSet from a FESOM2 uxarray.UxDataset.
264+
265+
Parameters
266+
----------
267+
ds : uxarray.UxDataset
268+
uxarray.UxDataset as obtained from the uxarray package.
269+
270+
Returns
271+
-------
272+
FieldSet
273+
FieldSet object containing the fields from the dataset that can be used for a Parcels simulation.
274+
"""
275+
ds = ds.copy()
276+
ds_dims = list(ds.dims)
277+
if not all(dim in ds_dims for dim in ["time", "nz", "nz1"]):
278+
raise ValueError(
279+
f"Dataset missing one of the required dimensions 'time', 'nz', or 'nz1'. Found dimensions {ds_dims}"
280+
)
281+
grid = UxGrid(ds.uxgrid, z=ds.coords["nz"])
282+
ds = _discover_fesom2_U_and_V(ds)
283+
284+
fields = {}
285+
if "U" in ds.data_vars and "V" in ds.data_vars:
286+
fields["U"] = Field("U", ds["U"], grid, _select_uxinterpolator(ds["U"]))
287+
fields["V"] = Field("V", ds["V"], grid, _select_uxinterpolator(ds["U"]))
288+
fields["U"].units = GeographicPolar()
289+
fields["V"].units = Geographic()
290+
291+
if "W" in ds.data_vars:
292+
fields["W"] = Field("W", ds["W"], grid, _select_uxinterpolator(ds["U"]))
293+
fields["UVW"] = VectorField("UVW", fields["U"], fields["V"], fields["W"])
294+
else:
295+
fields["UV"] = VectorField("UV", fields["U"], fields["V"])
296+
297+
for varname in set(ds.data_vars) - set(fields.keys()):
298+
fields[varname] = Field(varname, ds[varname], grid, _select_uxinterpolator(ds[varname]))
257299

258300
return FieldSet(list(fields.values()))
259301

@@ -365,11 +407,86 @@ def _discover_copernicusmarine_U_and_V(ds: xr.Dataset) -> xr.Dataset:
365407
return ds
366408

367409

368-
def _ds_rename_using_standard_names(ds: xr.Dataset, name_dict: dict[str, str]) -> xr.Dataset:
410+
def _discover_fesom2_U_and_V(ds: ux.UxDataset) -> ux.UxDataset:
411+
# Common variable names for U and V found in UxDatasets
412+
common_fesom_UV = [("unod", "vnod"), ("u", "v")]
413+
common_fesom_W = ["w"]
414+
415+
if "W" not in ds:
416+
for common_W in common_fesom_W:
417+
if common_W in ds:
418+
ds = _ds_rename_using_standard_names(ds, {common_W: "W"})
419+
break
420+
421+
if "U" in ds and "V" in ds:
422+
return ds # U and V already present
423+
elif "U" in ds or "V" in ds:
424+
raise ValueError(
425+
"Dataset has only one of the two variables 'U' and 'V'. Please rename the appropriate variable in your dataset to have both 'U' and 'V' for Parcels simulation."
426+
)
427+
428+
for common_U, common_V in common_fesom_UV:
429+
if common_U in ds:
430+
if common_V not in ds:
431+
raise ValueError(
432+
f"Dataset has variable with standard name {common_U!r}, "
433+
f"but not the matching variable with standard name {common_V!r}. "
434+
"Please rename the appropriate variables in your dataset to have both 'U' and 'V' for Parcels simulation."
435+
)
436+
else:
437+
ds = _ds_rename_using_standard_names(ds, {common_U: "U", common_V: "V"})
438+
break
439+
440+
else:
441+
if common_V in ds:
442+
raise ValueError(
443+
f"Dataset has variable with standard name {common_V!r}, "
444+
f"but not the matching variable with standard name {common_U!r}. "
445+
"Please rename the appropriate variables in your dataset to have both 'U' and 'V' for Parcels simulation."
446+
)
447+
continue
448+
449+
return ds
450+
451+
452+
def _ds_rename_using_standard_names(ds: xr.Dataset | ux.UxDataset, name_dict: dict[str, str]) -> xr.Dataset:
369453
for standard_name, rename_to in name_dict.items():
370454
name = ds.cf[standard_name].name
371455
ds = ds.rename({name: rename_to})
372456
logger.info(
373457
f"cf_xarray found variable {name!r} with CF standard name {standard_name!r} in dataset, renamed it to {rename_to!r} for Parcels simulation."
374458
)
375459
return ds
460+
461+
462+
def _select_uxinterpolator(da: ux.UxDataArray):
463+
"""Selects the appropriate uxarray interpolator for a given uxarray UxDataArray"""
464+
supported_uxinterp_mapping = {
465+
# (nz1,n_face): face-center laterally, layer centers vertically — piecewise constant
466+
"nz1,n_face": UXPiecewiseConstantFace,
467+
# (nz,n_node): node/corner laterally, layer interfaces vertically — barycentric lateral & linear vertical
468+
"nz,n_node": UXPiecewiseLinearNode,
469+
}
470+
# Extract only spatial dimensions, neglecting time
471+
da_spatial_dims = tuple(d for d in da.dims if d not in ("time",))
472+
if len(da_spatial_dims) != 2:
473+
raise ValueError(
474+
"Fields on unstructured grids must have two spatial dimensions, one vertical (nz or nz1) and one lateral (n_face, n_edge, or n_node)"
475+
)
476+
477+
# Construct key (string) for mapping to interpolator
478+
# Find vertical and lateral tokens
479+
vdim = None
480+
ldim = None
481+
for d in da_spatial_dims:
482+
if d in ("nz", "nz1"):
483+
vdim = d
484+
if d in ("n_face", "n_node"):
485+
ldim = d
486+
# Map to supported interpolators
487+
if vdim and ldim:
488+
key = f"{vdim},{ldim}"
489+
if key in supported_uxinterp_mapping.keys():
490+
return supported_uxinterp_mapping[key]
491+
492+
return None

tests/test_advection.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,8 @@ def test_stommelgyre_fieldset(kernel, rtol, grid_type):
362362
ds = stommel_gyre_dataset(grid_type=grid_type)
363363
grid = XGrid.from_dataset(ds)
364364
vector_interp_method = None if grid_type == "A" else CGrid_Velocity
365-
U = Field("U", ds["U"], grid)
366-
V = Field("V", ds["V"], grid)
365+
U = Field("U", ds["U"], grid, interp_method=XLinear)
366+
V = Field("V", ds["V"], grid, interp_method=XLinear)
367367
P = Field("P", ds["P"], grid, interp_method=XLinear)
368368
UV = VectorField("UV", U, V, vector_interp_method=vector_interp_method)
369369
fieldset = FieldSet([U, V, P, UV])
@@ -451,8 +451,8 @@ def test_nemo_curvilinear_fieldset():
451451
)
452452
grid = XGrid(xgcm_grid, mesh="spherical")
453453

454-
U = parcels.Field("U", ds["U"], grid)
455-
V = parcels.Field("V", ds["V"], grid)
454+
U = parcels.Field("U", ds["U"], grid, interp_method=XLinear)
455+
V = parcels.Field("V", ds["V"], grid, interp_method=XLinear)
456456
U.units = parcels.GeographicPolar()
457457
V.units = parcels.GeographicPolar() # U and V need GoegraphicPolar for C-Grid interpolation to work correctly
458458
UV = parcels.VectorField("UV", U, V, vector_interp_method=CGrid_Velocity)
@@ -536,9 +536,9 @@ def test_nemo_3D_curvilinear_fieldset(kernel):
536536
)
537537
grid = XGrid(xgcm_grid, mesh="spherical")
538538

539-
U = parcels.Field("U", ds["U"], grid)
540-
V = parcels.Field("V", ds["V"], grid)
541-
W = parcels.Field("W", ds["W"], grid)
539+
U = parcels.Field("U", ds["U"], grid, interp_method=XLinear)
540+
V = parcels.Field("V", ds["V"], grid, interp_method=XLinear)
541+
W = parcels.Field("W", ds["W"], grid, interp_method=XLinear)
542542
U.units = parcels.GeographicPolar()
543543
V.units = parcels.GeographicPolar() # U and V need GoegraphicPolar for C-Grid interpolation to work correctly
544544
UV = parcels.VectorField("UV", U, V, vector_interp_method=CGrid_Velocity)

tests/test_field.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,37 @@
99
from parcels._datasets.structured.generic import T as T_structured
1010
from parcels._datasets.structured.generic import datasets as datasets_structured
1111
from parcels._datasets.unstructured.generic import datasets as datasets_unstructured
12-
from parcels.interpolators import UXPiecewiseConstantFace, UXPiecewiseLinearNode
12+
from parcels.interpolators import UXPiecewiseConstantFace, UXPiecewiseLinearNode, XLinear
1313

1414

1515
def test_field_init_param_types():
1616
data = datasets_structured["ds_2d_left"]
1717
grid = XGrid.from_dataset(data)
1818

1919
with pytest.raises(TypeError, match="Expected a string for variable name, got int instead."):
20-
Field(name=123, data=data["data_g"], grid=grid)
20+
Field(name=123, data=data["data_g"], grid=grid, interp_method=XLinear)
2121

2222
for name in ["a b", "123"]:
2323
with pytest.raises(
2424
ValueError,
2525
match=r"Received invalid Python variable name.*: not a valid identifier. HINT: avoid using spaces, special characters, and starting with a number.",
2626
):
27-
Field(name=name, data=data["data_g"], grid=grid)
27+
Field(name=name, data=data["data_g"], grid=grid, interp_method=XLinear)
2828

2929
with pytest.raises(
3030
ValueError,
3131
match=r"Received invalid Python variable name.*: it is a reserved keyword. HINT: avoid using the following names:.*",
3232
):
33-
Field(name="while", data=data["data_g"], grid=grid)
33+
Field(name="while", data=data["data_g"], grid=grid, interp_method=XLinear)
3434

3535
with pytest.raises(
3636
ValueError,
3737
match="Expected `data` to be a uxarray.UxDataArray or xarray.DataArray",
3838
):
39-
Field(name="test", data=123, grid=grid)
39+
Field(name="test", data=123, grid=grid, interp_method=XLinear)
4040

4141
with pytest.raises(ValueError, match="Expected `grid` to be a parcels UxGrid, or parcels XGrid"):
42-
Field(name="test", data=data["data_g"], grid=123)
42+
Field(name="test", data=data["data_g"], grid=123, interp_method=XLinear)
4343

4444

4545
@pytest.mark.parametrize(
@@ -66,6 +66,7 @@ def test_field_incompatible_combination(data, grid):
6666
name="test_field",
6767
data=data,
6868
grid=grid,
69+
interp_method=XLinear,
6970
)
7071

7172

@@ -85,6 +86,7 @@ def test_field_init_structured_grid(data, grid):
8586
name="test_field",
8687
data=data,
8788
grid=grid,
89+
interp_method=XLinear,
8890
)
8991
assert field.name == "test_field"
9092
assert field.data.equals(data)
@@ -113,6 +115,7 @@ def test_field_init_fail_on_float_time_dim():
113115
name="test_field",
114116
data=data,
115117
grid=grid,
118+
interp_method=XLinear,
116119
)
117120

118121

@@ -128,7 +131,7 @@ def test_field_init_fail_on_float_time_dim():
128131
)
129132
def test_field_time_interval(data, grid):
130133
"""Test creating a field."""
131-
field = Field(name="test_field", data=data, grid=grid)
134+
field = Field(name="test_field", data=data, grid=grid, interp_method=XLinear)
132135
assert field.time_interval.left == np.datetime64("2000-01-01")
133136
assert field.time_interval.right == np.datetime64("2001-01-01")
134137

@@ -163,8 +166,8 @@ def invalid_interpolator_wrong_signature(particle_positions, grid_positions, inv
163166
return 0.0
164167

165168
# Create component fields
166-
U = Field(name="U", data=ds["data_g"], grid=grid)
167-
V = Field(name="V", data=ds["data_g"], grid=grid)
169+
U = Field(name="U", data=ds["data_g"], grid=grid, interp_method=XLinear)
170+
V = Field(name="V", data=ds["data_g"], grid=grid, interp_method=XLinear)
168171

169172
# Test invalid interpolator with wrong signature
170173
with pytest.raises(ValueError, match=".*incorrect name.*"):

0 commit comments

Comments
 (0)