Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0c4fa45
Added SphericalMesh object to new mesh.py module. Contains only plane…
maureenjcohen Jul 7, 2026
3ddff60
Add SphericalMesh object to imports in __init__.py.
maureenjcohen Jul 7, 2026
ffa6d85
Added SphericalMesh object unpacking to Xgrid.
maureenjcohen Jul 7, 2026
f566fe1
Switched self._radius = mesh.radius to None in second branch of if/el…
maureenjcohen Jul 7, 2026
69fa293
Applied SphericalMesh/radius imports to Uxgrid as well.
maureenjcohen Jul 7, 2026
6ec7382
Adjust assert_valid_mesh in typing to allow SphericalMesh to pass.
maureenjcohen Jul 7, 2026
b10e32c
Bugfix: missed colon in if isinstance(value, SphericalMesh) statement.
maureenjcohen Jul 7, 2026
493ef8f
Moved SphericalMesh import to top rather than within function.
maureenjcohen Jul 7, 2026
08fec2c
Replaced hard-coded instances of 1852 * 60 with calls to deg2m attrib…
maureenjcohen Jul 7, 2026
a277913
Added three tests: test that deg2m calculation works, test calculatio…
maureenjcohen Jul 7, 2026
da32a50
Added a check to SphericalMesh init to catch bad values of radius: no…
maureenjcohen Jul 7, 2026
72be313
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 9, 2026
0c3c1f0
Update src/parcels/_core/mesh.py
maureenjcohen Jul 10, 2026
ddac215
Update src/parcels/_core/mesh.py
maureenjcohen Jul 10, 2026
d28fa4c
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 10, 2026
ce76ee3
Update src/parcels/_core/uxgrid.py
maureenjcohen Jul 10, 2026
f781eeb
Modified radius setting so default is EARTH_RADIUS (specified in mesh…
maureenjcohen Jul 13, 2026
9ac08fc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 13, 2026
93a364b
Refactorings after review
VeckoTheGecko Jul 22, 2026
d466ccd
Fix typing
VeckoTheGecko Jul 22, 2026
066aeb1
Merge remote-tracking branch 'upstream/main' into pr/maureenjcohen/2739
VeckoTheGecko Jul 22, 2026
27e6456
Fix tests
VeckoTheGecko Jul 22, 2026
a50c0ff
Revert "Fix tests"
VeckoTheGecko Jul 23, 2026
a884bf8
Fix tests
VeckoTheGecko Jul 23, 2026
c82a69e
Review feedback
VeckoTheGecko Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/parcels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from parcels._core.basegrid import BaseGrid
from parcels._core.uxgrid import UxGrid
from parcels._core.xgrid import XGrid
from parcels._core.mesh import SphericalMesh

from parcels._core.statuscodes import (
AllParcelsErrorCodes,
Expand Down Expand Up @@ -54,6 +55,7 @@
"BaseGrid",
"UxGrid",
"XGrid",
"SphericalMesh",
# Status codes and errors
"AllParcelsErrorCodes",
"FieldInterpolationError",
Expand Down
4 changes: 2 additions & 2 deletions src/parcels/_core/basegrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import numpy as np

import parcels._typing as ptyping
from parcels._core.mesh import FlatMesh, SphericalMesh
from parcels._core.spatialhash import SpatialHash

if TYPE_CHECKING:
Expand All @@ -26,7 +26,7 @@ class BaseGrid(ABC):
"""Base class for parcels.XGrid and parcels.UxGrid defining common methods and properties"""

_spatialhash: SpatialHash | None
_mesh: ptyping.Mesh
_mesh: FlatMesh | SphericalMesh

@abstractmethod
def search(self, z: float, y: float, x: float, ei=None) -> dict[str, tuple[int, float | np.ndarray]]:
Expand Down
4 changes: 2 additions & 2 deletions src/parcels/_core/fieldset.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ def to_windowed_arrays(self, *, max_levels: int | None = None):
model.to_windowed_arrays(max_levels=max_levels)
return self

def add_constant_field(self, name: str, value, mesh: ptyping.Mesh = "spherical"):
def add_constant_field(self, name: str, value, mesh: ptyping.TMesh = "spherical"):
"""Wrapper function to add a Field that is constant in space,
useful e.g. when using constant horizontal diffusivity

Expand Down Expand Up @@ -287,7 +287,7 @@ def from_ugrid_conventions(
def from_sgrid_conventions(
cls,
ds: xr.Dataset,
mesh: ptyping.Mesh | None = None,
mesh: ptyping.TMesh | None = None,
vector_fields: ptyping.VectorFields | NotSetType = NOTSET,
): # TODO: Update mesh to be discovered from the dataset metadata
"""Create a FieldSet from a dataset using SGRID convention metadata.
Expand Down
4 changes: 2 additions & 2 deletions src/parcels/_core/index_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def curvilinear_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray
dtype=float,
)

if grid._mesh == "spherical":
if grid._mesh.is_spherical():
xsi, eta = _bilinear_inverse_tangent_plane(clon, clat, x, y)
is_in_cell = np.where((xsi >= 0) & (xsi <= 1) & (eta >= 0) & (eta <= 1), 1, 0)
else:
Expand Down Expand Up @@ -318,7 +318,7 @@ def uxgrid_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi:
coords : np.ndarray
Barycentric coordinates of the points within their respective cells.
"""
if grid._mesh == "spherical":
if grid._mesh.is_spherical():
lon_rad = np.deg2rad(x)
lat_rad = np.deg2rad(y)
x_cart, y_cart, z_cart = _latlon_rad_to_xyz(lat_rad, lon_rad)
Expand Down
4 changes: 2 additions & 2 deletions src/parcels/_core/kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,9 @@ def check_fieldsets_in_kernels(self, kernel): # TODO v4: this can go into anoth
stacklevel=2,
)
self.fieldset.add_context("RK45_tol", 10)
if self.fieldset.U.grid._mesh == "spherical":
if self.fieldset.U.grid._mesh.is_spherical():
self.fieldset.RK45_tol /= (
1852 * 60
self.fieldset.U.grid.deg2m
) # TODO does not account for zonal variation in meter -> degree conversion
if not hasattr(self.fieldset, "RK45_min_dt"):
warnings.warn(
Expand Down
73 changes: 73 additions & 0 deletions src/parcels/_core/mesh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from abc import ABC, abstractmethod
from typing import Literal

import numpy as np

EARTH_RADIUS = 6366707.019493707


class BaseMesh(ABC):
radius: float | None

@abstractmethod
def is_spherical(self) -> bool: ...


class SphericalMesh(BaseMesh):
"""Spherical mesh object with configurable planetary radius.

Pass to FieldSet object as ``mesh=SphericalMesh(radius=...)``.
radius is in meters; defaults to Earth radius.
"""

def __init__(self, radius: float = EARTH_RADIUS):
if not isinstance(radius, (int, float, np.number)):
raise TypeError(f"radius must be a number, got {type(radius).__name__}")
if radius <= 0:
raise ValueError(f"radius must be positive, got {radius}")
self.radius = radius

@property
def deg2m(self) -> float:
"""Meters per degree of arc."""
assert self.radius is not None
return self.radius * np.pi / 180.0

def is_spherical(self):
return True

def __repr__(self) -> str:
return f"SphericalMesh(radius={self.radius})"


class FlatMesh(BaseMesh):
"""Flat mesh object."""

def __init__(self):
self.radius = None
return

def __repr__(self) -> str:
return "FlatMesh()"

def is_spherical(self):
return False


TMesh = SphericalMesh | Literal["spherical", "flat"] # corresponds with `mesh`


def get_mesh(mesh: TMesh):
if isinstance(mesh, SphericalMesh):
return mesh
if mesh == "flat":
return FlatMesh()
if mesh == "spherical":
return SphericalMesh(EARTH_RADIUS)
raise ValueError(f"mesh must be 'flat', 'spherical', or a SphericalMesh object. Got {mesh=!r}")


def is_spherical(mesh: FlatMesh | SphericalMesh):
if isinstance(mesh, SphericalMesh):
return True
return False
11 changes: 6 additions & 5 deletions src/parcels/_core/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
)
from parcels._logger import logger
from parcels._python import NOTSET, NotSetType
from parcels._typing import Mesh
from parcels.convert import _ds_rename_using_standard_names
from parcels.interpolators import (
CGrid_Velocity,
Expand Down Expand Up @@ -133,7 +132,7 @@ def preprocess_sgrid_model_data(ds: xr.Dataset) -> xr.Dataset:


class StructuredModelData(ModelData):
def __init__(self, data: xr.Dataset, mesh: Mesh, vector_field_components: ptyping.VectorFields):
def __init__(self, data: xr.Dataset, mesh: ptyping.TMesh, vector_field_components: ptyping.VectorFields):
if not isinstance(data, xr.Dataset):
raise ValueError(f"Expected `data` to be an xarray.Dataset . Got {type(data)}")

Expand Down Expand Up @@ -182,7 +181,7 @@ def construct_fields(self) -> list[Field | VectorField]:

@classmethod
def from_sgrid_conventions(
cls, ds: xr.Dataset, mesh: Mesh | None, vector_fields: ptyping.VectorFields | NotSetType
cls, ds: xr.Dataset, mesh: ptyping.TMesh | None, vector_fields: ptyping.VectorFields | NotSetType
) -> Self:
ds = ds.copy()
if mesh is None:
Expand Down Expand Up @@ -329,7 +328,9 @@ def scalar_field_names(self) -> list[str]:
return list(self.data.data_vars)

@classmethod
def from_ugrid_conventions(cls, ds: ux.UxDataset, mesh: Mesh, vector_fields: ptyping.VectorFields | NotSetType):
def from_ugrid_conventions(
cls, ds: ux.UxDataset, mesh: ptyping.TMesh, vector_fields: ptyping.VectorFields | NotSetType
):
ds_dims = list(ds.dims)
if not all(dim in ds_dims for dim in ["time", "zf", "zc"]):
raise ValueError(
Expand All @@ -353,7 +354,7 @@ def from_ugrid_conventions(cls, ds: ux.UxDataset, mesh: Mesh, vector_fields: pty


# TODO: Refactor later into something like `parcels._metadata.discover(dataset)` helper that can be used to discover important metadata like this. I think this whole metadata handling should be refactored into its own module.
def _get_mesh_type_from_sgrid_dataset(ds_sgrid: xr.Dataset) -> Mesh:
def _get_mesh_type_from_sgrid_dataset(ds_sgrid: xr.Dataset) -> ptyping.TMesh:
"""Small helper to inspect SGRID metadata and dataset metadata to determine mesh type."""
sgrid_metadata = ds_sgrid.sgrid.metadata

Expand Down
5 changes: 3 additions & 2 deletions src/parcels/_core/particlefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import xarray as xr

import parcels
from parcels._core.mesh import BaseMesh
from parcels._core.particle import ParticleClass
from parcels._core.particlesetview import ParticleSetView
from parcels._core.utils.time import timedelta_to_float
Expand Down Expand Up @@ -119,14 +120,14 @@ def __init__(
def __repr__(self) -> str:
return particlefile_repr(self)

def set_metadata(self, parcels_grid_mesh: Literal["spherical", "flat"]):
def set_metadata(self, parcels_grid_mesh: BaseMesh):
self.metadata.update(
{
"feature_type": "trajectory",
"Conventions": "CF-1.6/CF-1.7",
"ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0",
"parcels_version": parcels.__version__,
"parcels_grid_mesh": parcels_grid_mesh,
"parcels_grid_mesh": repr(parcels_grid_mesh),
}
)

Expand Down
6 changes: 3 additions & 3 deletions src/parcels/_core/spatialhash.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def __init__(

if isinstance_noimport(grid, "XGrid"):
self._coord_dim = 2 # Number of computational coordinates is 2 (bilinear interpolation)
if self._source_grid._mesh == "spherical":
if self._source_grid._mesh.is_spherical():
lon = np.deg2rad(self._source_grid.lon)
lat = np.deg2rad(self._source_grid.lat)
x, y, z = _latlon_rad_to_xyz(lat, lon)
Expand Down Expand Up @@ -160,7 +160,7 @@ def __init__(

elif isinstance_noimport(grid, "UxGrid"):
self._coord_dim = grid.uxgrid.n_max_face_nodes # Number of barycentric coordinates
if self._source_grid._mesh == "spherical":
if self._source_grid._mesh.is_spherical():
# Reshape node coordinates to (nfaces, nnodes_per_face)
nids = self._source_grid.uxgrid.face_node_connectivity.values
lon = self._source_grid.uxgrid.node_lon.values[nids]
Expand Down Expand Up @@ -404,7 +404,7 @@ def query(self, y, x):

y = np.asarray(y)
x = np.asarray(x)
if self._source_grid._mesh == "spherical":
if self._source_grid._mesh.is_spherical():
# Convert coords to Cartesian coordinates (x, y, z)
lat = np.deg2rad(y)
lon = np.deg2rad(x)
Expand Down
22 changes: 12 additions & 10 deletions src/parcels/_core/utils/interpolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import numpy as np

from parcels._typing import Mesh
from parcels._core.mesh import BaseMesh

__all__ = []

Expand Down Expand Up @@ -61,12 +61,12 @@ def dphidxsi3D_lin(zeta: float, eta: float, xsi: float) -> tuple[list[float], li


def dxdxsi3D_lin(
hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: Mesh
hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: BaseMesh,
deg2m: float = 1852 * 60.0
) -> tuple[float, float, float, float, float, float, float, float, float]:
dphidxsi, dphideta, dphidzet = dphidxsi3D_lin(zeta, eta, xsi)

if mesh == 'spherical':
deg2m = 1852 * 60.
if mesh.is_spherical():
rad = np.pi / 180.
lat = (1-xsi) * (1-eta) * hexa_y[0] + \
xsi * (1-eta) * hexa_y[1] + \
Expand All @@ -92,9 +92,10 @@ def dxdxsi3D_lin(


def jacobian3D_lin(
hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: Mesh
hexa_z: list[float], hexa_y: list[float], hexa_x: list[float], zeta: float, eta: float, xsi: float, mesh: BaseMesh,
deg2m: float = 1852 * 60.0
) -> float:
dxdxsi, dxdeta, dxdzet, dydxsi, dydeta, dydzet, dzdxsi, dzdeta, dzdzet = dxdxsi3D_lin(hexa_z, hexa_y, hexa_x, zeta, eta, xsi, mesh)
dxdxsi, dxdeta, dxdzet, dydxsi, dydeta, dydzet, dzdxsi, dzdeta, dzdzet = dxdxsi3D_lin(hexa_z, hexa_y, hexa_x, zeta, eta, xsi, mesh, deg2m)

jac = (
dxdxsi * (dydeta * dzdzet - dzdeta * dydzet)
Expand All @@ -112,7 +113,7 @@ def jacobian3D_lin_face(
eta: float,
xsi: float,
orientation: Literal["zonal", "meridional", "vertical"],
mesh: Mesh,
mesh: BaseMesh,
) -> float:
dxdxsi, dxdeta, dxdzet, dydxsi, dydeta, dydzet, dzdxsi, dzdeta, dzdzet = dxdxsi3D_lin(hexa_z, hexa_y, hexa_x, zeta, eta, xsi, mesh)

Expand Down Expand Up @@ -174,10 +175,11 @@ def interpolate(phi: Callable[[float], list[float]], f: list[float], xsi: float)
return np.dot(phi(xsi), f)


def _geodetic_distance(lat1: float, lat2: float, lon1: float, lon2: float, mesh: Mesh, lat: float) -> float:
if mesh == "spherical":
def _geodetic_distance(
lat1: float, lat2: float, lon1: float, lon2: float, mesh: BaseMesh, lat: float, deg2m: float = 1852 * 60.0
) -> float:
if mesh.is_spherical():
rad = np.pi / 180.0
deg2m = 1852 * 60.0
return np.sqrt(((lon2 - lon1) * deg2m * np.cos(rad * lat)) ** 2 + ((lat2 - lat1) * deg2m) ** 2)
else:
return np.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2)
Expand Down
17 changes: 12 additions & 5 deletions src/parcels/_core/uxgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from parcels._core.basegrid import BaseGrid
from parcels._core.index_search import GRID_SEARCH_ERROR, _search_1d_array, uxgrid_point_in_cell
from parcels._typing import assert_valid_mesh
from parcels._core.mesh import SphericalMesh, get_mesh

_UXGRID_AXES = Literal["Z", "FACE"]

Expand All @@ -18,7 +18,9 @@ class UxGrid(BaseGrid):
for interpolation on unstructured grids.
"""

def __init__(self, grid: ux.grid.Grid, z: ux.UxDataArray, mesh) -> None:
def __init__(
self, grid: ux.grid.Grid, z: ux.UxDataArray, mesh: Literal["flat", "spherical"] | SphericalMesh
) -> None:
"""
Initializes the UxGrid with a uxarray grid and vertical coordinate array.

Expand All @@ -41,11 +43,9 @@ def __init__(self, grid: ux.grid.Grid, z: ux.UxDataArray, mesh) -> None:
if z.ndim != 1:
raise ValueError("z must be a 1D array of vertical coordinates")
self.z = z
self._mesh = mesh
self._mesh = get_mesh(mesh)
self._spatialhash = None

assert_valid_mesh(mesh)

@property
def depth(self):
"""
Expand Down Expand Up @@ -73,6 +73,13 @@ def get_axis_dim(self, axis: _UXGRID_AXES) -> int:
elif axis == "FACE":
return self.uxgrid.n_face

@property
def deg2m(self) -> float:
"""Metres per arcdegree for this grid's mesh."""
if self._mesh.is_spherical():
return self._mesh.deg2m
return 1.0

def search(self, z, y, x, ei=None, tol=1e-6):
"""
Search for the grid cell (face) and vertical layer that contains the given points.
Expand Down
Loading