Skip to content

Commit 1197923

Browse files
Fixing OutOfBounds errors on field.eval (attempt 2) (#2681)
* Adding unit test for OutOfBounds field.eval * Fixing OutOfBounds errors on field.eval By masking out all the interpolated values to zero for particles out of bound * Removing debug print statements * Incorporating reviewer feedback * Adding warning when field.eval is out of bounds And filtering this warning in kernel execution * Implementing reviewer feedback from @VeckoTheGecko
1 parent 32a7f2a commit 1197923

9 files changed

Lines changed: 110 additions & 20 deletions

File tree

src/parcels/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"OutsideTimeInterval",
6262
"StatusCode",
6363
# Warnings
64+
"FieldEvalWarning",
6465
"FieldSetWarning",
6566
"FileWarning",
6667
"KernelWarning",

src/parcels/_core/field.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from parcels._core.utils.string import _assert_str_and_python_varname
1717
from parcels._core.uxgrid import UxGrid
18+
from parcels._core.warnings import FieldEvalWarning
1819
from parcels._core.xgrid import XGrid
1920
from parcels._typing import VectorType
2021
from parcels.interpolators._base import ScalarInterpolator, VectorInterpolator
@@ -175,6 +176,7 @@ def eval(self, time: datetime, z, y, x, particles=None):
175176
value = self.interp_method.interp(particle_positions, grid_positions, self)
176177

177178
_update_particle_states_interp_value(particles, value)
179+
_mask_outofbounds_values(grid_positions, value)
178180

179181
return value
180182

@@ -281,6 +283,7 @@ def eval(self, time: datetime, z, y, x, particles=None):
281283

282284
for vel in (u, v, w):
283285
_update_particle_states_interp_value(particles, vel)
286+
_mask_outofbounds_values(grid_positions, vel)
284287

285288
if "3D" in self.vector_type:
286289
return (u, v, w)
@@ -349,6 +352,20 @@ def _update_particle_states_position(particles, grid_positions: dict):
349352
)
350353

351354

355+
def _mask_outofbounds_values(grid_positions: dict, value):
356+
mask = np.zeros(value.shape, dtype=bool)
357+
for dim in ["X", "Y", "Z", "FACE"]:
358+
if dim in grid_positions:
359+
mask[grid_positions[dim]["index"] < 0] = True
360+
if np.any(mask):
361+
warnings.warn(
362+
"Some interpolated values are out-of-bounds. These values are set to 0. Treat carefully.",
363+
FieldEvalWarning,
364+
stacklevel=2,
365+
)
366+
value[mask] = 0.0
367+
368+
352369
def _update_particle_states_interp_value(particles, value):
353370
"""Update the particle states based on the interpolated value, but only if state is not an Error already."""
354371
if particles:

src/parcels/_core/kernel.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
_raise_grid_searching_error,
1717
_raise_outside_time_interval_error,
1818
)
19-
from parcels._core.warnings import KernelWarning
19+
from parcels._core.warnings import FieldEvalWarning, KernelWarning
2020
from parcels._python import assert_same_function_signature
2121
from parcels.kernels import (
2222
AdvectionAnalytical,
@@ -206,13 +206,16 @@ def execute(self, pset, endtime, dt):
206206

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

211-
# check for particles that have to be repeated
212-
repeat_particles = pset.state == StatusCode.Repeat
213-
while np.any(repeat_particles):
214-
f(pset[repeat_particles], self._fieldset)
212+
f(pset[evaluate_particles], self._fieldset)
213+
214+
# check for particles that have to be repeated
215215
repeat_particles = pset.state == StatusCode.Repeat
216+
while np.any(repeat_particles):
217+
f(pset[repeat_particles], self._fieldset)
218+
repeat_particles = pset.state == StatusCode.Repeat
216219

217220
# apply position/time update only to particles still in a normal state
218221
# (particles that signalled Stop*/Delete/errors should not have time/position advanced)

src/parcels/_core/warnings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ class FileWarning(UserWarning):
2828
pass
2929

3030

31+
class FieldEvalWarning(UserWarning):
32+
"""Warning that is raised when there are issues during the evaluation of a Field.
33+
34+
These warnings can be related to out-of-bounds indices during interpolation,
35+
or other issues that arise during the evaluation of a Field at particle positions.
36+
"""
37+
38+
pass
39+
40+
3141
class KernelWarning(RuntimeWarning):
3242
"""Warning that is raised when there are issues with the Kernel.
3343

src/parcels/interpolators/_uxinterpolators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,5 +177,5 @@ def interp(
177177
if "3D" in vectorfield.vector_type:
178178
w = vectorfield.W.interp_method.interp(particle_positions, grid_positions, vectorfield.W)
179179
else:
180-
w = 0.0
180+
w = np.zeros_like(u)
181181
return u, v, w

src/parcels/interpolators/_xinterpolators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def interp(
132132
field: Field,
133133
):
134134
"""Returning the single value of a Constant Field (with a size=(1,1,1,1) array)"""
135-
return field.data[0, 0, 0, 0].values
135+
return field.data[0, 0, 0, 0].values * np.ones_like(particle_positions["lon"])
136136

137137

138138
class XLinear_Velocity(VectorInterpolator): # noqa: N801
@@ -155,7 +155,7 @@ def interp(
155155
if vectorfield.W:
156156
w = _xlinear.interp(particle_positions, grid_positions, vectorfield.W)
157157
else:
158-
w = 0.0
158+
w = np.zeros_like(u)
159159
return u, v, w
160160

161161

@@ -502,7 +502,7 @@ def is_land(ti: int, zi: int, yi: int, xi: int):
502502

503503
w *= f_w
504504
else:
505-
w = None
505+
w = np.zeros_like(u)
506506
return u, v, w
507507

508508

src/parcels/kernels/_sigmagrids.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,44 +42,44 @@ def AdvectionRK4_3D_CROCO(particles, fieldset): # pragma: no cover
4242
It also uses linear interpolation of the W field, which gives much better results than the default C-grid interpolation.
4343
"""
4444
dt = _constrain_dt_to_within_time_interval(fieldset.time_interval, particles.time, particles.dt)
45-
sigma = particles.z / fieldset.h[particles.time, 0, particles.lat, particles.lon]
45+
sigma = particles.z / fieldset.h[particles.time, np.zeros_like(particles.z), particles.lat, particles.lon]
4646

4747
sig = convert_z_to_sigma_croco(fieldset, particles.time, particles.z, particles.lat, particles.lon, particles)
4848
(u1, v1) = fieldset.UV[particles.time, sig, particles.lat, particles.lon, particles]
4949
w1 = fieldset.W[particles.time, sig, particles.lat, particles.lon, particles]
50-
w1 *= sigma / fieldset.h[particles.time, 0, particles.lat, particles.lon]
50+
w1 *= sigma / fieldset.h[particles.time, np.zeros_like(particles.z), particles.lat, particles.lon]
5151
lon1 = particles.lon + u1 * 0.5 * dt
5252
lat1 = particles.lat + v1 * 0.5 * dt
5353
sig_dep1 = sigma + w1 * 0.5 * dt
54-
dep1 = sig_dep1 * fieldset.h[particles.time, 0, lat1, lon1]
54+
dep1 = sig_dep1 * fieldset.h[particles.time, np.zeros_like(particles.z), lat1, lon1]
5555

5656
sig1 = convert_z_to_sigma_croco(fieldset, particles.time + 0.5 * dt, dep1, lat1, lon1, particles)
5757
(u2, v2) = fieldset.UV[particles.time + 0.5 * dt, sig1, lat1, lon1, particles]
5858
w2 = fieldset.W[particles.time + 0.5 * dt, sig1, lat1, lon1, particles]
59-
w2 *= sig_dep1 / fieldset.h[particles.time, 0, lat1, lon1]
59+
w2 *= sig_dep1 / fieldset.h[particles.time, np.zeros_like(particles.z), lat1, lon1]
6060
lon2 = particles.lon + u2 * 0.5 * dt
6161
lat2 = particles.lat + v2 * 0.5 * dt
6262
sig_dep2 = sigma + w2 * 0.5 * dt
63-
dep2 = sig_dep2 * fieldset.h[particles.time, 0, lat2, lon2]
63+
dep2 = sig_dep2 * fieldset.h[particles.time, np.zeros_like(particles.z), lat2, lon2]
6464

6565
sig2 = convert_z_to_sigma_croco(fieldset, particles.time + 0.5 * dt, dep2, lat2, lon2, particles)
6666
(u3, v3) = fieldset.UV[particles.time + 0.5 * dt, sig2, lat2, lon2, particles]
6767
w3 = fieldset.W[particles.time + 0.5 * dt, sig2, lat2, lon2, particles]
68-
w3 *= sig_dep2 / fieldset.h[particles.time, 0, lat2, lon2]
68+
w3 *= sig_dep2 / fieldset.h[particles.time, np.zeros_like(particles.z), lat2, lon2]
6969
lon3 = particles.lon + u3 * dt
7070
lat3 = particles.lat + v3 * dt
7171
sig_dep3 = sigma + w3 * dt
72-
dep3 = sig_dep3 * fieldset.h[particles.time, 0, lat3, lon3]
72+
dep3 = sig_dep3 * fieldset.h[particles.time, np.zeros_like(particles.z), lat3, lon3]
7373

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

82-
dep4 = sig_dep4 * fieldset.h[particles.time, 0, lat4, lon4]
82+
dep4 = sig_dep4 * fieldset.h[particles.time, np.zeros_like(particles.z), lat4, lon4]
8383
particles.dlon += (u1 + 2 * u2 + 2 * u3 + u4) / 6 * dt
8484
particles.dlat += (v1 + 2 * v2 + 2 * v3 + v4) / 6 * dt
8585
particles.dz += (

tests/test_advection.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import warnings
2+
13
import numpy as np
24
import pandas as pd
35
import pytest
@@ -15,6 +17,7 @@
1517
convert,
1618
)
1719
from parcels._core.utils.time import timedelta_to_float
20+
from parcels._core.warnings import FieldEvalWarning
1821
from parcels._datasets.structured.generated import (
1922
decaying_moving_eddy_dataset,
2023
moving_eddy_dataset,
@@ -176,7 +179,9 @@ def SubmergeParticle(particles, fieldset): # pragma: no cover
176179
kernels.append(DeleteParticle)
177180

178181
pset = ParticleSet(fieldset=fieldset, lon=0.5, lat=0.5, z=0.9)
179-
pset.execute(kernels, runtime=np.timedelta64(10, "s"), dt=np.timedelta64(1, "s"))
182+
with warnings.catch_warnings():
183+
warnings.simplefilter("error", FieldEvalWarning)
184+
pset.execute(kernels, runtime=np.timedelta64(10, "s"), dt=np.timedelta64(1, "s"))
180185

181186
if direction == "up" and resubmerge_particle:
182187
np.testing.assert_allclose(pset.lon[0], 0.6, atol=1e-5)

tests/test_field.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
from parcels import Field, VectorField
77
from parcels._core.fieldset import FieldSet
88
from parcels._core.model import StructuredModelData
9+
from parcels._core.warnings import FieldEvalWarning
10+
from parcels._datasets.structured.generated import simple_UV_dataset
911
from parcels._datasets.structured.generic import T as T_structured
1012
from parcels._datasets.structured.generic import datasets as datasets_structured
13+
from parcels._datasets.unstructured.generic import _ux_constant_flow_face_centered_2D
1114
from parcels._datasets.unstructured.generic import datasets as datasets_unstructured
1215
from parcels.interpolators import (
1316
UxConstantFaceConstantZC,
@@ -180,6 +183,57 @@ def test_field_constant_in_time():
180183
assert np.isclose(P1, P2)
181184

182185

186+
@pytest.mark.parametrize(
187+
"field_name, location, expected",
188+
[
189+
("U", (0.0, 0.0, 0.0, 5e6), 0.0),
190+
("UV", (0.0, 0.0, 0.0, 5e6), [[0.0], [0.0]]),
191+
("U", (0.0, 0.0, 5e6, 0.0), 0.0),
192+
("UV", (0.0, 0.0, 5e6, 0.0), [[0.0], [0.0]]),
193+
("U", (0.0, 5e6, 0.0, 0.0), 0.0),
194+
("UV", (0.0, 5e6, 0.0, 0.0), [[0.0], [0.0]]),
195+
],
196+
)
197+
def test_field_eval_out_of_bounds_structured(field_name, location, expected):
198+
"""Test that Field.eval returns IndexError when queried outside the grid boundaries."""
199+
# eval outside of bounds should return 0.0
200+
ds = simple_UV_dataset(mesh="flat")
201+
fieldset = FieldSet.from_sgrid_conventions(ds, mesh="flat")
202+
fieldset.U.data[:] = 1.0
203+
fieldset.V.data[:] = 2.0
204+
field = getattr(fieldset, field_name)
205+
with pytest.warns(
206+
FieldEvalWarning,
207+
match="Some interpolated values are out-of-bounds. These values are set to 0. Treat carefully.",
208+
):
209+
np.testing.assert_allclose(field.eval(*location), expected)
210+
211+
212+
@pytest.mark.parametrize(
213+
"field_name, location, expected",
214+
[
215+
("U", (0.0, 0.0, 0.0, 5e6), 0.0),
216+
("UV", (0.0, 0.0, 0.0, 5e6), [[0.0], [0.0]]),
217+
("U", (0.0, 0.0, 5e6, 0.0), 0.0),
218+
("UV", (0.0, 0.0, 5e6, 0.0), [[0.0], [0.0]]),
219+
("U", (0.0, 5e6, 0.0, 0.0), 0.0),
220+
("UV", (0.0, 5e6, 0.0, 0.0), [[0.0], [0.0]]),
221+
],
222+
)
223+
def test_field_eval_out_of_bounds_unstructured(field_name, location, expected):
224+
"""Test that Field.eval returns IndexError when queried outside the grid boundaries."""
225+
ds = _ux_constant_flow_face_centered_2D()
226+
fieldset = FieldSet.from_ugrid_conventions(ds, mesh="flat")
227+
fieldset.U.data[:] = 1.0
228+
fieldset.V.data[:] = 2.0
229+
field = getattr(fieldset, field_name)
230+
with pytest.warns(
231+
FieldEvalWarning,
232+
match="Some interpolated values are out-of-bounds. These values are set to 0. Treat carefully.",
233+
):
234+
np.testing.assert_allclose(field.eval(*location), expected)
235+
236+
183237
def test_field_unstructured_grid_creation(): ...
184238

185239

0 commit comments

Comments
 (0)