Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion docs/user_guide/examples/tutorial_interaction.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"from matplotlib.animation import FuncAnimation\n",
"\n",
"import parcels\n",
"from parcels._datasets.structured.generated import simple_UV_dataset\n",
"\n",
"# for interactive display of animations\n",
"plt.rcParams[\"animation.html\"] = \"jshtml\""
Expand Down Expand Up @@ -98,7 +99,8 @@
"source": [
"def DiffusionFieldSet():\n",
" \"\"\"Define a fieldset with only diffusion\"\"\"\n",
" fieldset = parcels.FieldSet([])\n",
" ds = simple_UV_dataset(dims=(1, 1, 1, 1), mesh=\"flat\")\n",
" fieldset = parcels.FieldSet.from_sgrid_conventions(ds, mesh=\"flat\")\n",
" fieldset.add_constant_field(\"Kh_zonal\", 0.0005, mesh=\"flat\")\n",
" fieldset.add_constant_field(\"Kh_meridional\", 0.0005, mesh=\"flat\")\n",
" return fieldset"
Expand Down
48 changes: 25 additions & 23 deletions src/parcels/_core/fieldset.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import sys
import warnings
from collections.abc import Iterable
from typing import IO, TYPE_CHECKING

Expand All @@ -12,6 +11,7 @@

import parcels._typing as ptyping
from parcels._core.field import Field, VectorField
from parcels._core.mesh import FlatMesh, SphericalMesh
from parcels._core.model import (
CONSTANT_FIELD_MODELS,
ModelData,
Expand All @@ -21,7 +21,6 @@
from parcels._core.utils.string import _assert_str_and_python_varname
from parcels._core.utils.time import get_datetime_type_calendar
from parcels._core.utils.time import is_compatible as datetime_is_compatible
from parcels._core.warnings import FieldSetWarning
from parcels._python import NOTSET, NotSetType
from parcels._repr_utils import fieldset_describe
from parcels.interpolators import (
Expand Down Expand Up @@ -65,16 +64,22 @@ class FieldSet:
"""

def __init__(self, models: list[ModelData]):
if models == []:
raise ValueError("List of models can't be empty.")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this change we can't have fieldsets with only constant fields (which is very reasonable), since that would result in an ambigious fieldset mesh.

Hence I also removed test_fieldset_time_interval_constant_fields

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise now that our docs relied on "empty, constant only" fieldsets.

docs/user_guide/examples/tutorial_interaction.ipynb

I assume that you wrote this @erikvansebille . I'll postpone merging, and leave this open until you're back

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think there are use cases where we have empty fieldsets - like indeed in the particle-particle interaction tutorial. If users want to use parcels as an Agent-Based-Model engine, they typically don't have/use Fields.

How problematic is it to support empty fieldsets? Perhaps we should change the interaction-tutorial to not have any fields at all (instead of only constant fields)? Or will that be a major endeavour (for another PR)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If users want to use parcels as an Agent-Based-Model engine, they typically don't have/use Fields.

I'm not quite sure I understand this - I feel that this is scope creep for the project. Why would users want to use Parcels as an engine for agent based modelling? (instead of other libraries in Python for this)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, you're right that this may be project scope creep. But what do we do then about the particle-particle interaction tutorial? That just needs a flat Field with a constant value for diffusivity everywhere

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can recontextualize it in the context of FADs or a usecase with flow fields? Or just add an grid with no attached data to show off the interactivity, and mention that you would want to specify the flow fields yourself as well.

Would you like to work on a diff or would you like me to?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just pushed 2bd7bf7 - which now uses a very simple UV dataset. Hope this fixes the issue

for model in models:
if not isinstance(model, ModelData):
raise ValueError(f"Expected `model` to be a ModelData object. Got {model}")
# assert_compatible_calendars(fields)

self.models = list(models)
self.models = models
self._fields: dict[str, Field | VectorField] | None = None
self.reconstruct_fields()
self.context: dict[str, float] = {}
_warn_if_fields_use_different_meshes(self.fields.values())
assert_models_have_same_mesh(self.models)

@property
def mesh(self) -> FlatMesh | SphericalMesh:
return self.models[0].mesh

def __setattr__(self, name, value):
"""Set field attribute by name. If context exists and name in context, raise error to prevent overwriting context variable."""
Expand Down Expand Up @@ -202,7 +207,7 @@ def add_constant_field(self, name: str, value, mesh: ptyping.TMesh = "spherical"
self.reconstruct_fields()
field = getattr(self, name)
field.interp_method = XConstantField()
_warn_if_fields_use_different_meshes(self.fields.values())
assert_models_have_same_mesh(self.models)

def add_context(self, name, value):
"""Add context variable to the FieldSet.
Expand Down Expand Up @@ -355,26 +360,23 @@ def assert_compatible_fieldsets(left: FieldSet, right: FieldSet) -> None:
)


def _warn_if_fields_use_different_meshes(fields: Iterable[Field | VectorField]):
"""Warn if multiple fields use different meshes on the underlying grids.
class IncompatibleMeshesException(Exception): ...

Parameters
----------
fields : Iterable[Field | VectorField]
The fields to check for conflicting meshes.

Warns
-----
FieldSetWarning
If the fields have different meshes on the underlying grids.
"""
meshes = {field.grid._mesh for field in fields}
if len(meshes) > 1:
warnings.warn(
f"FieldSet has multiple different meshes: {meshes}. This may lead to unexpected behavior during execution.",
category=FieldSetWarning,
stacklevel=3,
)
def assert_models_have_same_mesh(models: list[ModelData]):
if models == []:
return

first_mesh = None
for i, model in enumerate(models):
if first_mesh is None:
first_mesh = model.mesh
continue

if model.mesh != first_mesh:
raise IncompatibleMeshesException(
f"All ModelData objects must have the same meshes. ModelData at index 0 has a mesh of {first_mesh!r} while ModelData at index {i} has mesh {model.mesh!r} "
)


class CalendarError(Exception): # TODO: Move to a Parcels errors module
Expand Down
2 changes: 1 addition & 1 deletion src/parcels/_core/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import numpy as np

EARTH_RADIUS = 6366707.019493707
EARTH_RADIUS = 6_366_707.019493707 # m


class BaseMesh(ABC):
Expand Down
5 changes: 5 additions & 0 deletions src/parcels/_core/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from parcels._core._windowed_array import maybe_windowed
from parcels._core.basegrid import BaseGrid
from parcels._core.field import Field, VectorField
from parcels._core.mesh import FlatMesh, SphericalMesh
from parcels._core.utils.time import TimeInterval
from parcels._core.uxgrid import UxGrid
from parcels._core.xgrid import (
Expand Down Expand Up @@ -44,6 +45,10 @@ class ModelData(ABC):
field_to_interpolator: dict[str, ScalarInterpolator | VectorInterpolator]
vector_field_components: ptyping.VectorFields

@property
def mesh(self) -> FlatMesh | SphericalMesh:
return self.grid._mesh

@abstractmethod
def construct_fields(self) -> list[Field | VectorField]: ...

Expand Down
31 changes: 22 additions & 9 deletions tests/test_fieldset.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import parcels.tutorial
import tests
from parcels import ParticleFile, ParticleSet, convert, open_raw_zarr
from parcels._core.fieldset import FieldSet, _datetime_to_msg
from parcels._core.fieldset import FieldSet, IncompatibleMeshesException, _datetime_to_msg
from parcels._core.mesh import SphericalMesh
from parcels._core.model import _default_vector_field_components
from parcels._datasets.structured.generic import datasets as datasets_structured
from parcels._datasets.structured.generic import datasets_sgrid
Expand Down Expand Up @@ -257,14 +258,6 @@ def test_multi_model_nonoverlapping_time_interval():
assert fieldset.time_interval is None


def test_fieldset_time_interval_constant_fields():
fieldset = FieldSet([])
fieldset.add_constant_field("constant_field", 1.0)
fieldset.add_constant_field("constant_field2", 2.0)

assert fieldset.time_interval is None


def test_fieldset_add_incompatible_calendars():
# tests the adding of fieldsets that have incompatible calendars
...
Expand Down Expand Up @@ -384,6 +377,26 @@ def test_fieldset_add():
assert set(fields_before) == set(fset.fields.keys())


def test_fieldset_add_different_meshes():
ds1 = datasets_structured["ds_2d_left"][["U_A_grid", "V_A_grid", "grid"]].rename({"U_A_grid": "U", "V_A_grid": "V"})
ds2 = datasets_structured["ds_2d_left"][["U_A_grid", "V_A_grid", "grid"]].rename(
{"U_A_grid": "U_wind", "V_A_grid": "V_wind"}
)

fset1 = FieldSet.from_sgrid_conventions(
ds1,
mesh=SphericalMesh(71_492_000), # Jupiter
)
fset2 = FieldSet.from_sgrid_conventions(
ds2,
mesh="spherical", # earth
vector_fields={"UV_wind": ("U_wind", "V_wind")},
)

with pytest.raises(IncompatibleMeshesException, match="All ModelData objects must have the same meshes."):
_ = fset1 + fset2


def test_vectorfields_without_time():
"""Test that vector fields without a time dimension can be evaluated."""
ds1 = datasets_structured["ds_2d_left"][["U_A_grid", "V_A_grid", "grid"]].rename({"U_A_grid": "U", "V_A_grid": "V"})
Expand Down
23 changes: 21 additions & 2 deletions tests/test_mesh.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import numpy as np
import pytest

from parcels import FieldSet, ParticleSet, SphericalMesh
from parcels._core.mesh import EARTH_RADIUS
from parcels import FieldSet, ParticleSet
from parcels._core.mesh import EARTH_RADIUS, FlatMesh, SphericalMesh
from parcels._datasets.structured.generated import simple_UV_dataset
from parcels.kernels import AdvectionRK4

Expand Down Expand Up @@ -82,3 +82,22 @@ def test_spherical_mesh_rejects_non_numeric_radius(bad_radius):
def test_spherical_mesh_rejects_nonpos_radius(bad_radius):
with pytest.raises(ValueError):
SphericalMesh(radius=bad_radius)


@pytest.mark.parametrize(
"lhs, op, rhs",
[
(FlatMesh(), "==", FlatMesh()),
(FlatMesh(), "!=", SphericalMesh()),
(SphericalMesh(), "==", SphericalMesh()),
(SphericalMesh(20_000), "!=", SphericalMesh(30_000)),
],
)
def test_mesh_equality(lhs, op, rhs):
match op:
case "==":
assert lhs == rhs
case "!=":
assert lhs != rhs
case _:
raise NotImplementedError