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
1 change: 1 addition & 0 deletions src/parcels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"OutsideTimeInterval",
"StatusCode",
# Warnings
"FieldEvalWarning",
"FieldSetWarning",
"FileWarning",
"KernelWarning",
Comment on lines +64 to 67

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: I wonder whether we should have a parcels.warnings namespace.

I'm not strongly for or against - just a convention some packages adopt.

Similarly for parcels.errors.

Expand Down
17 changes: 17 additions & 0 deletions src/parcels/_core/field.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from parcels._core.utils.string import _assert_str_and_python_varname
from parcels._core.utils.time import TimeInterval
from parcels._core.uxgrid import UxGrid
from parcels._core.warnings import FieldEvalWarning
from parcels._core.xgrid import XGrid, _transpose_xfield_data_to_tzyx, assert_all_field_dims_have_axis
from parcels._python import assert_same_function_signature
from parcels._reprs import field_repr, vectorfield_repr
Expand Down Expand Up @@ -196,6 +197,7 @@ def eval(self, time: datetime, z, y, x, particles=None):
value = self._interp_method(particle_positions, grid_positions, self)

_update_particle_states_interp_value(particles, value)
_mask_outofbounds_values(grid_positions, value)

return value

Expand Down Expand Up @@ -299,6 +301,7 @@ def eval(self, time: datetime, z, y, x, particles=None):

for vel in (u, v, w):
_update_particle_states_interp_value(particles, vel)
_mask_outofbounds_values(grid_positions, vel)

if "3D" in self.vector_type:
return (u, v, w)
Expand Down Expand Up @@ -367,6 +370,20 @@ def _update_particle_states_position(particles, grid_positions: dict):
)


def _mask_outofbounds_values(grid_positions: dict, value):
mask = np.zeros(value.shape, dtype=bool)
for dim in ["X", "Y", "Z", "FACE"]:
if dim in grid_positions:
mask[grid_positions[dim]["index"] < 0] = True
if np.any(mask):
warnings.warn(
"Some interpolated values are out-of-bounds. These values are set to 0. Treat carefully.",
FieldEvalWarning,
stacklevel=2,
)
value[mask] = 0.0


def _update_particle_states_interp_value(particles, value):
"""Update the particle states based on the interpolated value, but only if state is not an Error already."""
if particles:
Expand Down
15 changes: 9 additions & 6 deletions src/parcels/_core/kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
_raise_grid_searching_error,
_raise_outside_time_interval_error,
)
from parcels._core.warnings import KernelWarning
from parcels._core.warnings import FieldEvalWarning, KernelWarning
from parcels._python import assert_same_function_signature
from parcels.kernels import (
AdvectionAnalytical,
Expand Down Expand Up @@ -206,13 +206,16 @@ def execute(self, pset, endtime, dt):

# run kernels for all particles that need to be evaluated
for f in self._kernels:
f(pset[evaluate_particles], self._fieldset)
with warnings.catch_warnings():
warnings.simplefilter("ignore", FieldEvalWarning)

# check for particles that have to be repeated
repeat_particles = pset.state == StatusCode.Repeat
while np.any(repeat_particles):
f(pset[repeat_particles], self._fieldset)
f(pset[evaluate_particles], self._fieldset)

# check for particles that have to be repeated
repeat_particles = pset.state == StatusCode.Repeat
while np.any(repeat_particles):
f(pset[repeat_particles], self._fieldset)
repeat_particles = pset.state == StatusCode.Repeat

# apply position/time update only to particles still in a normal state
# (particles that signalled Stop*/Delete/errors should not have time/position advanced)
Expand Down
10 changes: 10 additions & 0 deletions src/parcels/_core/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ class FileWarning(UserWarning):
pass


class FieldEvalWarning(UserWarning):
"""Warning that is raised when there are issues during the evaluation of a Field.

These warnings can be related to out-of-bounds indices during interpolation,
or other issues that arise during the evaluation of a Field at particle positions.
"""

pass


class KernelWarning(RuntimeWarning):
"""Warning that is raised when there are issues with the Kernel.

Expand Down
2 changes: 1 addition & 1 deletion src/parcels/interpolators/_uxinterpolators.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,5 +158,5 @@ def Ux_Velocity(
if "3D" in vectorfield.vector_type:
w = vectorfield.W._interp_method(particle_positions, grid_positions, vectorfield.W)
else:
w = 0.0
w = np.zeros_like(u)
Comment thread
erikvansebille marked this conversation as resolved.
return u, v, w
12 changes: 6 additions & 6 deletions src/parcels/interpolators/_xinterpolators.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def ZeroInterpolator(
field: Field,
) -> np.float32 | np.float64:
"""Template function used for the signature check of the lateral interpolation methods."""
return 0.0
return np.zeros_like(particle_positions["lon"])


def ZeroInterpolator_Vector(
Expand All @@ -31,7 +31,7 @@ def ZeroInterpolator_Vector(
vectorfield: VectorField,
) -> np.float32 | np.float64:
"""Template function used for the signature check of the interpolation methods for velocity fields."""
return 0.0
return np.zeros_like(particle_positions["lon"])


def _get_corner_data_Agrid(
Expand Down Expand Up @@ -140,8 +140,8 @@ def XConstantField(
grid_positions: dict[XgridAxis, dict[str, int | float | np.ndarray]],
field: Field,
):
"""Returning the single value of a Constant Field (with a size=(1,1,1,1) array)"""
return field.data[0, 0, 0, 0].values
"""Returning the value of a Constant Field (with a size=(1,1,1,1) array)"""
return field.data[0, 0, 0, 0].values * np.ones_like(particle_positions["lon"])


def XLinear_Velocity(
Expand All @@ -159,7 +159,7 @@ def XLinear_Velocity(
if vectorfield.W:
w = XLinear(particle_positions, grid_positions, vectorfield.W)
else:
w = 0.0
w = np.zeros_like(u)
return u, v, w


Expand Down Expand Up @@ -489,7 +489,7 @@ def is_land(ti: int, zi: int, yi: int, xi: int):

w *= f_w
else:
w = None
w = np.zeros_like(u)
return u, v, w


Expand Down
18 changes: 9 additions & 9 deletions src/parcels/kernels/_sigmagrids.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,44 +42,44 @@ def AdvectionRK4_3D_CROCO(particles, fieldset): # pragma: no cover
It also uses linear interpolation of the W field, which gives much better results than the default C-grid interpolation.
"""
dt = _constrain_dt_to_within_time_interval(fieldset.time_interval, particles.time, particles.dt)
sigma = particles.z / fieldset.h[particles.time, 0, particles.lat, particles.lon]
sigma = particles.z / fieldset.h[particles.time, np.zeros_like(particles.z), particles.lat, particles.lon]

sig = convert_z_to_sigma_croco(fieldset, particles.time, particles.z, particles.lat, particles.lon, particles)
(u1, v1) = fieldset.UV[particles.time, sig, particles.lat, particles.lon, particles]
w1 = fieldset.W[particles.time, sig, particles.lat, particles.lon, particles]
w1 *= sigma / fieldset.h[particles.time, 0, particles.lat, particles.lon]
w1 *= sigma / fieldset.h[particles.time, np.zeros_like(particles.z), particles.lat, particles.lon]
lon1 = particles.lon + u1 * 0.5 * dt
lat1 = particles.lat + v1 * 0.5 * dt
sig_dep1 = sigma + w1 * 0.5 * dt
dep1 = sig_dep1 * fieldset.h[particles.time, 0, lat1, lon1]
dep1 = sig_dep1 * fieldset.h[particles.time, np.zeros_like(particles.z), lat1, lon1]

sig1 = convert_z_to_sigma_croco(fieldset, particles.time + 0.5 * dt, dep1, lat1, lon1, particles)
(u2, v2) = fieldset.UV[particles.time + 0.5 * dt, sig1, lat1, lon1, particles]
w2 = fieldset.W[particles.time + 0.5 * dt, sig1, lat1, lon1, particles]
w2 *= sig_dep1 / fieldset.h[particles.time, 0, lat1, lon1]
w2 *= sig_dep1 / fieldset.h[particles.time, np.zeros_like(particles.z), lat1, lon1]
lon2 = particles.lon + u2 * 0.5 * dt
lat2 = particles.lat + v2 * 0.5 * dt
sig_dep2 = sigma + w2 * 0.5 * dt
dep2 = sig_dep2 * fieldset.h[particles.time, 0, lat2, lon2]
dep2 = sig_dep2 * fieldset.h[particles.time, np.zeros_like(particles.z), lat2, lon2]

sig2 = convert_z_to_sigma_croco(fieldset, particles.time + 0.5 * dt, dep2, lat2, lon2, particles)
(u3, v3) = fieldset.UV[particles.time + 0.5 * dt, sig2, lat2, lon2, particles]
w3 = fieldset.W[particles.time + 0.5 * dt, sig2, lat2, lon2, particles]
w3 *= sig_dep2 / fieldset.h[particles.time, 0, lat2, lon2]
w3 *= sig_dep2 / fieldset.h[particles.time, np.zeros_like(particles.z), lat2, lon2]
lon3 = particles.lon + u3 * dt
lat3 = particles.lat + v3 * dt
sig_dep3 = sigma + w3 * dt
dep3 = sig_dep3 * fieldset.h[particles.time, 0, lat3, lon3]
dep3 = sig_dep3 * fieldset.h[particles.time, np.zeros_like(particles.z), lat3, lon3]

sig3 = convert_z_to_sigma_croco(fieldset, particles.time + dt, dep3, lat3, lon3, particles)
(u4, v4) = fieldset.UV[particles.time + dt, sig3, lat3, lon3, particles]
w4 = fieldset.W[particles.time + dt, sig3, lat3, lon3, particles]
w4 *= sig_dep3 / fieldset.h[particles.time, 0, lat3, lon3]
w4 *= sig_dep3 / fieldset.h[particles.time, np.zeros_like(particles.z), lat3, lon3]
lon4 = particles.lon + u4 * dt
lat4 = particles.lat + v4 * dt
sig_dep4 = sigma + w4 * dt

dep4 = sig_dep4 * fieldset.h[particles.time, 0, lat4, lon4]
dep4 = sig_dep4 * fieldset.h[particles.time, np.zeros_like(particles.z), lat4, lon4]
particles.dlon += (u1 + 2 * u2 + 2 * u3 + u4) / 6 * dt
particles.dlat += (v1 + 2 * v2 + 2 * v3 + v4) / 6 * dt
particles.dz += (
Expand Down
7 changes: 6 additions & 1 deletion tests/test_advection.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import warnings

import numpy as np
import pandas as pd
import pytest
Expand All @@ -15,6 +17,7 @@
convert,
)
from parcels._core.utils.time import timedelta_to_float
from parcels._core.warnings import FieldEvalWarning
from parcels._datasets.structured.generated import (
decaying_moving_eddy_dataset,
moving_eddy_dataset,
Expand Down Expand Up @@ -176,7 +179,9 @@ def SubmergeParticle(particles, fieldset): # pragma: no cover
kernels.append(DeleteParticle)

pset = ParticleSet(fieldset=fieldset, lon=0.5, lat=0.5, z=0.9)
pset.execute(kernels, runtime=np.timedelta64(10, "s"), dt=np.timedelta64(1, "s"))
with warnings.catch_warnings():
warnings.simplefilter("error", FieldEvalWarning)
pset.execute(kernels, runtime=np.timedelta64(10, "s"), dt=np.timedelta64(1, "s"))

if direction == "up" and resubmerge_particle:
np.testing.assert_allclose(pset.lon[0], 0.6, atol=1e-5)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@

from parcels import Field, UxGrid, VectorField, XGrid
from parcels._core.fieldset import FieldSet
from parcels._core.warnings import FieldEvalWarning
from parcels._datasets.structured.generated import simple_UV_dataset
from parcels._datasets.structured.generic import T as T_structured
from parcels._datasets.structured.generic import datasets as datasets_structured
from parcels._datasets.unstructured.generic import _ux_constant_flow_face_centered_2D
from parcels._datasets.unstructured.generic import datasets as datasets_unstructured
from parcels.interpolators import (
UxConstantFaceConstantZC,
Expand Down Expand Up @@ -270,6 +273,54 @@ def test_field_constant_in_time():
assert np.isclose(P1, P2)


def test_field_eval_out_of_bounds_structured():
"""Test that Field.eval returns IndexError when queried outside the grid boundaries."""
ds = simple_UV_dataset(mesh="flat")
fieldset = FieldSet.from_sgrid_conventions(ds, mesh="flat")
fieldset.U.data[:] = 1.0
fieldset.V.data[:] = 2.0

# eval inside bounds should work
np.testing.assert_allclose(fieldset.U.eval(0.0, 0.0, 0.0, 0.0), 1.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 0.0, 0.0, 0.0), [[1.0], [2.0]])
Comment on lines +283 to +285

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see that in future we would (by default) have the test suite fail if any unexpected warnings are printed. This includes FieldEval warnings like this.

As such - I think this "eval inside bounds should work" test should be removed as the whole rest of the test suite already tests it.


# eval outside of bounds should return 0.0
with pytest.warns(
FieldEvalWarning,
match="Some interpolated values are out-of-bounds. These values are set to 0. Treat carefully.",
):
Comment on lines +288 to +291

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This block only tests that the warning is raised at some point during the evaluations below - not that it's being raised each time.

I think we can refactor the following test to something like the following

@pytest.fixture(scope="session")
def fieldset_1_2():
    ds = simple_UV_dataset(mesh="flat")
    fieldset = FieldSet.from_sgrid_conventions(ds, mesh="flat")
    fieldset.U.data[:] = 1.0
    fieldset.V.data[:] = 2.0
    return fieldset


@pytest.mark.parametrize(
    "field_name, location, expected",
    [
        ("U", (0.0, 0.0, 0.0, 5e6), 0.0),
        ("UV", (0.0, 0.0, 0.0, 5e6), [[0.0], [0.0]]),
        ("U", (0.0, 0.0, 5e6, 0.0), 0.0),
        ("UV", (0.0, 0.0, 5e6, 0.0), [[0.0], [0.0]]),
        ("U", (0.0, 5e6, 0.0, 0.0), 0.0),
        ("UV", (0.0, 5e6, 0.0, 0.0), [[0.0], [0.0]]),
    ],
)
def test_field_eval_out_of_bounds_structured(fieldset_1_2, field_name, location, expected):
    """Test that Field.eval returns IndexError when queried outside the grid boundaries."""
    # eval outside of bounds should return 0.0
    field = getattr(fieldset_1_2, field_name)
    with pytest.warns(
        FieldEvalWarning,
        match="Some interpolated values are out-of-bounds. These values are set to 0. Treat carefully.",
    ):
        np.testing.assert_allclose(field.eval(*location), expected)

np.testing.assert_allclose(fieldset.U.eval(0.0, 0.0, 0.0, 5e6), 0.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 0.0, 0.0, 5e6), [[0.0], [0.0]])
np.testing.assert_allclose(fieldset.U.eval(0.0, 0.0, 5e6, 0.0), 0.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 0.0, 5e6, 0.0), [[0.0], [0.0]])
np.testing.assert_allclose(fieldset.U.eval(0.0, 5e6, 0.0, 0.0), 0.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 5e6, 0.0, 0.0), [[0.0], [0.0]])


def test_field_eval_out_of_bounds_unstructured():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same as above

"""Test that Field.eval returns IndexError when queried outside the grid boundaries."""
ds = _ux_constant_flow_face_centered_2D()
fieldset = FieldSet.from_ugrid_conventions(ds, mesh="flat")
fieldset.U.data[:] = 1.0
fieldset.V.data[:] = 2.0

# eval inside bounds should work
np.testing.assert_allclose(fieldset.U.eval(0.0, 0.0, 0.0, 0.0), 1.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 0.0, 0.0, 0.0), [[1.0], [2.0]])

# eval outside of bounds should return 0.0
with pytest.warns(
FieldEvalWarning,
match="Some interpolated values are out-of-bounds. These values are set to 0. Treat carefully.",
):
np.testing.assert_allclose(fieldset.U.eval(0.0, 0.0, 0.0, 5e6), 0.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 0.0, 0.0, 5e6), [[0.0], [0.0]])
np.testing.assert_allclose(fieldset.U.eval(0.0, 0.0, 5e6, 0.0), 0.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 0.0, 5e6, 0.0), [[0.0], [0.0]])
np.testing.assert_allclose(fieldset.U.eval(0.0, 5e6, 0.0, 0.0), 0.0)
np.testing.assert_allclose(fieldset.UV.eval(0.0, 5e6, 0.0, 0.0), [[0.0], [0.0]])


Comment thread
fluidnumericsJoe marked this conversation as resolved.
def test_field_unstructured_grid_creation(): ...


Expand Down
Loading