Skip to content

Commit 88482c8

Browse files
maureenjcohenerikvansebillepre-commit-ci[bot]VeckoTheGecko
authored
Add support for defining planetary radius (#2739)
Co-authored-by: Erik van Sebille <e.vansebille@uu.nl> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Vecko <36369090+VeckoTheGecko@users.noreply.github.com>
1 parent be4f5d9 commit 88482c8

22 files changed

Lines changed: 269 additions & 115 deletions

src/parcels/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from parcels._core.basegrid import BaseGrid
2323
from parcels._core.uxgrid import UxGrid
2424
from parcels._core.xgrid import XGrid
25+
from parcels._core.mesh import SphericalMesh
2526

2627
from parcels._core.statuscodes import (
2728
AllParcelsErrorCodes,
@@ -54,6 +55,7 @@
5455
"BaseGrid",
5556
"UxGrid",
5657
"XGrid",
58+
"SphericalMesh",
5759
# Status codes and errors
5860
"AllParcelsErrorCodes",
5961
"FieldInterpolationError",

src/parcels/_core/basegrid.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import numpy as np
1010

11-
import parcels._typing as ptyping
11+
from parcels._core.mesh import FlatMesh, SphericalMesh
1212
from parcels._core.spatialhash import SpatialHash
1313

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

2828
_spatialhash: SpatialHash | None
29-
_mesh: ptyping.Mesh
29+
_mesh: FlatMesh | SphericalMesh
3030

3131
@abstractmethod
3232
def search(self, z: float, y: float, x: float, ei=None) -> dict[str, tuple[int, float | np.ndarray]]:

src/parcels/_core/fieldset.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def to_windowed_arrays(self, *, max_levels: int | None = None):
189189
model.to_windowed_arrays(max_levels=max_levels)
190190
return self
191191

192-
def add_constant_field(self, name: str, value, mesh: ptyping.Mesh = "spherical"):
192+
def add_constant_field(self, name: str, value, mesh: ptyping.TMesh = "spherical"):
193193
"""Wrapper function to add a Field that is constant in space,
194194
useful e.g. when using constant horizontal diffusivity
195195
@@ -287,7 +287,7 @@ def from_ugrid_conventions(
287287
def from_sgrid_conventions(
288288
cls,
289289
ds: xr.Dataset,
290-
mesh: ptyping.Mesh | None = None,
290+
mesh: ptyping.TMesh | None = None,
291291
vector_fields: ptyping.VectorFields | NotSetType = NOTSET,
292292
): # TODO: Update mesh to be discovered from the dataset metadata
293293
"""Create a FieldSet from a dataset using SGRID convention metadata.

src/parcels/_core/index_search.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def curvilinear_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray
109109
dtype=float,
110110
)
111111

112-
if grid._mesh == "spherical":
112+
if grid._mesh.is_spherical():
113113
xsi, eta = _bilinear_inverse_tangent_plane(clon, clat, x, y)
114114
is_in_cell = np.where((xsi >= 0) & (xsi <= 1) & (eta >= 0) & (eta <= 1), 1, 0)
115115
else:
@@ -318,7 +318,7 @@ def uxgrid_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi:
318318
coords : np.ndarray
319319
Barycentric coordinates of the points within their respective cells.
320320
"""
321-
if grid._mesh == "spherical":
321+
if grid._mesh.is_spherical():
322322
lon_rad = np.deg2rad(x)
323323
lat_rad = np.deg2rad(y)
324324
x_cart, y_cart, z_cart = _latlon_rad_to_xyz(lat_rad, lon_rad)

src/parcels/_core/kernel.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,9 @@ def check_fieldsets_in_kernels(self, kernel): # TODO v4: this can go into anoth
141141
stacklevel=2,
142142
)
143143
self.fieldset.add_context("RK45_tol", 10)
144-
if self.fieldset.U.grid._mesh == "spherical":
144+
if self.fieldset.U.grid._mesh.is_spherical():
145145
self.fieldset.RK45_tol /= (
146-
1852 * 60
146+
self.fieldset.U.grid.deg2m
147147
) # TODO does not account for zonal variation in meter -> degree conversion
148148
if not hasattr(self.fieldset, "RK45_min_dt"):
149149
warnings.warn(

src/parcels/_core/mesh.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Literal
3+
4+
import numpy as np
5+
6+
EARTH_RADIUS = 6366707.019493707
7+
8+
9+
class BaseMesh(ABC):
10+
radius: float | None
11+
12+
@abstractmethod
13+
def is_spherical(self) -> bool: ...
14+
15+
16+
class SphericalMesh(BaseMesh):
17+
"""Spherical mesh object with configurable planetary radius.
18+
19+
Pass to FieldSet object as ``mesh=SphericalMesh(radius=...)``.
20+
radius is in meters; defaults to Earth radius.
21+
"""
22+
23+
def __init__(self, radius: float = EARTH_RADIUS):
24+
if not isinstance(radius, (int, float, np.number)):
25+
raise TypeError(f"radius must be a number, got {type(radius).__name__}")
26+
if radius <= 0:
27+
raise ValueError(f"radius must be positive, got {radius}")
28+
self.radius = radius
29+
30+
@property
31+
def deg2m(self) -> float:
32+
"""Meters per degree of arc."""
33+
assert self.radius is not None
34+
return self.radius * np.pi / 180.0
35+
36+
def is_spherical(self):
37+
return True
38+
39+
def __repr__(self) -> str:
40+
return f"SphericalMesh(radius={self.radius})"
41+
42+
43+
class FlatMesh(BaseMesh):
44+
"""Flat mesh object."""
45+
46+
def __init__(self):
47+
self.radius = None
48+
return
49+
50+
def __repr__(self) -> str:
51+
return "FlatMesh()"
52+
53+
def is_spherical(self):
54+
return False
55+
56+
57+
TMesh = SphericalMesh | Literal["spherical", "flat"] # corresponds with `mesh`
58+
59+
60+
def get_mesh(mesh: TMesh):
61+
if isinstance(mesh, SphericalMesh):
62+
return mesh
63+
if mesh == "flat":
64+
return FlatMesh()
65+
if mesh == "spherical":
66+
return SphericalMesh(EARTH_RADIUS)
67+
raise ValueError(f"mesh must be 'flat', 'spherical', or a SphericalMesh object. Got {mesh=!r}")
68+
69+
70+
def is_spherical(mesh: FlatMesh | SphericalMesh):
71+
if isinstance(mesh, SphericalMesh):
72+
return True
73+
return False

src/parcels/_core/model.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
)
2424
from parcels._logger import logger
2525
from parcels._python import NOTSET, NotSetType
26-
from parcels._typing import Mesh
2726
from parcels.convert import _ds_rename_using_standard_names
2827
from parcels.interpolators import (
2928
CGrid_Velocity,
@@ -133,7 +132,7 @@ def preprocess_sgrid_model_data(ds: xr.Dataset) -> xr.Dataset:
133132

134133

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

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

183182
@classmethod
184183
def from_sgrid_conventions(
185-
cls, ds: xr.Dataset, mesh: Mesh | None, vector_fields: ptyping.VectorFields | NotSetType
184+
cls, ds: xr.Dataset, mesh: ptyping.TMesh | None, vector_fields: ptyping.VectorFields | NotSetType
186185
) -> Self:
187186
ds = ds.copy()
188187
if mesh is None:
@@ -329,7 +328,9 @@ def scalar_field_names(self) -> list[str]:
329328
return list(self.data.data_vars)
330329

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

354355

355356
# 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.
356-
def _get_mesh_type_from_sgrid_dataset(ds_sgrid: xr.Dataset) -> Mesh:
357+
def _get_mesh_type_from_sgrid_dataset(ds_sgrid: xr.Dataset) -> ptyping.TMesh:
357358
"""Small helper to inspect SGRID metadata and dataset metadata to determine mesh type."""
358359
sgrid_metadata = ds_sgrid.sgrid.metadata
359360

src/parcels/_core/particlefile.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import xarray as xr
1515

1616
import parcels
17+
from parcels._core.mesh import BaseMesh
1718
from parcels._core.particle import ParticleClass
1819
from parcels._core.particlesetview import ParticleSetView
1920
from parcels._core.utils.time import timedelta_to_float
@@ -119,14 +120,14 @@ def __init__(
119120
def __repr__(self) -> str:
120121
return particlefile_repr(self)
121122

122-
def set_metadata(self, parcels_grid_mesh: Literal["spherical", "flat"]):
123+
def set_metadata(self, parcels_grid_mesh: BaseMesh):
123124
self.metadata.update(
124125
{
125126
"feature_type": "trajectory",
126127
"Conventions": "CF-1.6/CF-1.7",
127128
"ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0",
128129
"parcels_version": parcels.__version__,
129-
"parcels_grid_mesh": parcels_grid_mesh,
130+
"parcels_grid_mesh": repr(parcels_grid_mesh),
130131
}
131132
)
132133

src/parcels/_core/spatialhash.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def __init__(
5555

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

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

405405
y = np.asarray(y)
406406
x = np.asarray(x)
407-
if self._source_grid._mesh == "spherical":
407+
if self._source_grid._mesh.is_spherical():
408408
# Convert coords to Cartesian coordinates (x, y, z)
409409
lat = np.deg2rad(y)
410410
lon = np.deg2rad(x)

src/parcels/_core/utils/interpolation.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import numpy as np
55

6-
from parcels._typing import Mesh
6+
from parcels._core.mesh import BaseMesh
77

88
__all__ = []
99

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

6262

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

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

9393

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

99100
jac = (
100101
dxdxsi * (dydeta * dzdzet - dzdeta * dydzet)
@@ -112,7 +113,7 @@ def jacobian3D_lin_face(
112113
eta: float,
113114
xsi: float,
114115
orientation: Literal["zonal", "meridional", "vertical"],
115-
mesh: Mesh,
116+
mesh: BaseMesh,
116117
) -> float:
117118
dxdxsi, dxdeta, dxdzet, dydxsi, dydeta, dydzet, dzdxsi, dzdeta, dzdzet = dxdxsi3D_lin(hexa_z, hexa_y, hexa_x, zeta, eta, xsi, mesh)
118119

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

176177

177-
def _geodetic_distance(lat1: float, lat2: float, lon1: float, lon2: float, mesh: Mesh, lat: float) -> float:
178-
if mesh == "spherical":
178+
def _geodetic_distance(
179+
lat1: float, lat2: float, lon1: float, lon2: float, mesh: BaseMesh, lat: float, deg2m: float = 1852 * 60.0
180+
) -> float:
181+
if mesh.is_spherical():
179182
rad = np.pi / 180.0
180-
deg2m = 1852 * 60.0
181183
return np.sqrt(((lon2 - lon1) * deg2m * np.cos(rad * lat)) ** 2 + ((lat2 - lat1) * deg2m) ** 2)
182184
else:
183185
return np.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2)

0 commit comments

Comments
 (0)