diff --git a/docs/user_guide/examples/tutorial_Argofloats.ipynb b/docs/user_guide/examples/tutorial_Argofloats.ipynb index 3ca0841ab..94cb9808c 100644 --- a/docs/user_guide/examples/tutorial_Argofloats.ipynb +++ b/docs/user_guide/examples/tutorial_Argofloats.ipynb @@ -26,7 +26,7 @@ "source": [ "import numpy as np\n", "\n", - "# Define the new Kernels that mimic Argo vertical movement\n", + "# Define the new Kernel that mimics Argo vertical movement\n", "driftdepth = 1000 # maximum depth in m\n", "maxdepth = 2000 # maximum depth in m\n", "vertical_speed = 0.10 # sink and rise speed in m/s\n", @@ -34,62 +34,54 @@ "drifttime = 9 * 86400 # time of deep drift in seconds\n", "\n", "\n", - "def ArgoPhase1(particles, fieldset):\n", - " def SinkingPhase(p):\n", - " \"\"\"Phase 0: Sinking with vertical_speed until depth is driftdepth\"\"\"\n", - " p.dz += vertical_speed * particles.dt\n", - " p.cycle_phase = np.where(p.z + p.dz >= driftdepth, 1, p.cycle_phase)\n", - " p.dz = np.where(p.z + p.dz >= driftdepth, driftdepth - p.z, p.dz)\n", + "def ArgoVerticalMovement(particles, fieldset):\n", + " # Split particles based on their current cycle_phase\n", + " ptcls0 = particles[particles.cycle_phase == 0]\n", + " ptcls1 = particles[particles.cycle_phase == 1]\n", + " ptcls2 = particles[particles.cycle_phase == 2]\n", + " ptcls3 = particles[particles.cycle_phase == 3]\n", + " ptcls4 = particles[particles.cycle_phase == 4]\n", + "\n", + " # Phase 0: Sinking with vertical_speed until depth is driftdepth\n", + " ptcls0.dz += vertical_speed * ptcls0.dt\n", + " ptcls0.cycle_phase = np.where(\n", + " ptcls0.z + ptcls0.dz >= driftdepth, 1, ptcls0.cycle_phase\n", + " )\n", + " ptcls0.dz = np.where(\n", + " ptcls0.z + ptcls0.dz >= driftdepth, driftdepth - ptcls0.z, ptcls0.dz\n", + " )\n", + "\n", + " # Phase 1: Drifting at depth for drifttime seconds\n", + " ptcls1.drift_age += ptcls1.dt\n", + " ptcls1.cycle_phase = np.where(ptcls1.drift_age >= drifttime, 2, ptcls1.cycle_phase)\n", + " ptcls1.drift_age = np.where(ptcls1.drift_age >= drifttime, 0, ptcls1.drift_age)\n", + "\n", + " # Phase 2: Sinking further to maxdepth\n", + " ptcls2.dz += vertical_speed * ptcls2.dt\n", + " ptcls2.cycle_phase = np.where(\n", + " ptcls2.z + ptcls2.dz >= maxdepth, 3, ptcls2.cycle_phase\n", + " )\n", + " ptcls2.dz = np.where(\n", + " ptcls2.z + ptcls2.dz >= maxdepth, maxdepth - ptcls2.z, ptcls2.dz\n", + " )\n", + "\n", + " # Phase 3: Rising with vertical_speed until at surface\n", + " ptcls3.dz -= vertical_speed * ptcls3.dt\n", + " ptcls3.temp = fieldset.thetao[ptcls3.time, ptcls3.z, ptcls3.lat, ptcls3.lon]\n", + " ptcls3.cycle_phase = np.where(\n", + " ptcls3.z + ptcls3.dz <= fieldset.mindepth, 4, ptcls3.cycle_phase\n", + " )\n", + " ptcls3.dz = np.where(\n", + " ptcls3.z + ptcls3.dz <= fieldset.mindepth,\n", + " fieldset.mindepth - ptcls3.z,\n", + " ptcls3.dz,\n", + " )\n", + "\n", + " # Phase 4: Transmitting at surface until cycletime is reached\n", + " ptcls4.cycle_phase = np.where(ptcls4.cycle_age >= cycletime, 0, ptcls4.cycle_phase)\n", + " ptcls4.cycle_age = np.where(ptcls4.cycle_age >= cycletime, 0, ptcls4.cycle_age)\n", + " ptcls4.temp = np.nan # no temperature measurement when at surface\n", "\n", - " SinkingPhase(particles[particles.cycle_phase == 0])\n", - "\n", - "\n", - "def ArgoPhase2(particles, fieldset):\n", - " def DriftingPhase(p):\n", - " \"\"\"Phase 1: Drifting at depth for drifttime seconds\"\"\"\n", - " p.drift_age += particles.dt\n", - " p.cycle_phase = np.where(p.drift_age >= drifttime, 2, p.cycle_phase)\n", - " p.drift_age = np.where(p.drift_age >= drifttime, 0, p.drift_age)\n", - "\n", - " DriftingPhase(particles[particles.cycle_phase == 1])\n", - "\n", - "\n", - "def ArgoPhase3(particles, fieldset):\n", - " def SecondSinkingPhase(p):\n", - " \"\"\"Phase 2: Sinking further to maxdepth\"\"\"\n", - " p.dz += vertical_speed * particles.dt\n", - " p.cycle_phase = np.where(p.z + p.dz >= maxdepth, 3, p.cycle_phase)\n", - " p.dz = np.where(p.z + p.dz >= maxdepth, maxdepth - p.z, p.dz)\n", - "\n", - " SecondSinkingPhase(particles[particles.cycle_phase == 2])\n", - "\n", - "\n", - "def ArgoPhase4(particles, fieldset):\n", - " def RisingPhase(p):\n", - " \"\"\"Phase 3: Rising with vertical_speed until at surface\"\"\"\n", - " p.dz -= vertical_speed * particles.dt\n", - " p.temp = fieldset.thetao[p.time, p.z, p.lat, p.lon]\n", - " p.cycle_phase = np.where(p.z + p.dz <= fieldset.mindepth, 4, p.cycle_phase)\n", - " p.dz = np.where(\n", - " p.z + p.dz <= fieldset.mindepth,\n", - " fieldset.mindepth - p.z,\n", - " p.dz,\n", - " )\n", - "\n", - " RisingPhase(particles[particles.cycle_phase == 3])\n", - "\n", - "\n", - "def ArgoPhase5(particles, fieldset):\n", - " def TransmittingPhase(p):\n", - " \"\"\"Phase 4: Transmitting at surface until cycletime is reached\"\"\"\n", - " p.cycle_phase = np.where(p.cycle_age >= cycletime, 0, p.cycle_phase)\n", - " p.cycle_age = np.where(p.cycle_age >= cycletime, 0, p.cycle_age)\n", - " p.temp = np.nan # no temperature measurement when at surface\n", - "\n", - " TransmittingPhase(particles[particles.cycle_phase == 4])\n", - "\n", - "\n", - "def ArgoPhase6(particles, fieldset):\n", " particles.cycle_age += particles.dt # update cycle_age" ] }, @@ -136,9 +128,7 @@ "ArgoParticle = parcels.Particle.add_variable(\n", " [\n", " parcels.Variable(\"cycle_phase\", dtype=np.int32, initial=0.0),\n", - " parcels.Variable(\n", - " \"cycle_age\", dtype=np.float32, initial=0.0\n", - " ), # TODO update to \"timedelta64[s]\"\n", + " parcels.Variable(\"cycle_age\", dtype=np.float32, initial=0.0),\n", " parcels.Variable(\"drift_age\", dtype=np.float32, initial=0.0),\n", " parcels.Variable(\"temp\", dtype=np.float32, initial=np.nan),\n", " ]\n", @@ -155,12 +145,7 @@ "\n", "# combine Argo vertical movement kernel with built-in Advection kernel\n", "kernels = [\n", - " ArgoPhase1,\n", - " ArgoPhase2,\n", - " ArgoPhase3,\n", - " ArgoPhase4,\n", - " ArgoPhase5,\n", - " ArgoPhase6,\n", + " ArgoVerticalMovement,\n", " parcels.kernels.AdvectionRK4,\n", "]\n", "\n", diff --git a/docs/user_guide/examples/tutorial_interaction.ipynb b/docs/user_guide/examples/tutorial_interaction.ipynb index 47f10e9d5..09d8079e7 100644 --- a/docs/user_guide/examples/tutorial_interaction.ipynb +++ b/docs/user_guide/examples/tutorial_interaction.ipynb @@ -293,18 +293,9 @@ " larger_idx = np.where(mass_j > mass_i, pair_j, pair_i)\n", " smaller_idx = np.where(mass_j > mass_i, pair_i, pair_j)\n", "\n", - " # perform transfer and mark deletions\n", - " # TODO note that we use temporary arrays for indexing because of KernelParticle bug (GH #2143)\n", - " masses = particles.mass\n", - " states = particles.state\n", - "\n", " # transfer mass from smaller to larger and mark smaller for deletion\n", - " masses[larger_idx] += particles.mass[smaller_idx]\n", - " states[smaller_idx] = parcels.StatusCode.Delete\n", - "\n", - " # TODO use particle variables directly after KernelParticle bug (GH #2143) is fixed\n", - " particles.mass = masses\n", - " particles.state = states" + " particles.mass[larger_idx] += particles.mass[smaller_idx]\n", + " particles.state[smaller_idx] = parcels.StatusCode.Delete" ] }, { diff --git a/src/parcels/__init__.py b/src/parcels/__init__.py index 177eceb7b..8f1756348 100644 --- a/src/parcels/__init__.py +++ b/src/parcels/__init__.py @@ -17,7 +17,7 @@ Variable, Particle, ParticleClass, - KernelParticle, # ? remove? + ParticleSetView, ) from parcels._core.field import Field, VectorField from parcels._core.basegrid import BaseGrid @@ -87,8 +87,8 @@ "logger", "download_example_dataset", "list_example_datasets", - # (marked for potential removal) - "KernelParticle", + # Helpers for particle handling + "ParticleSetView", ] _stdlib_warnings.warn( diff --git a/src/parcels/_core/field.py b/src/parcels/_core/field.py index bbeff928b..d9551023d 100644 --- a/src/parcels/_core/field.py +++ b/src/parcels/_core/field.py @@ -14,7 +14,7 @@ _unitconverters_map, ) from parcels._core.index_search import GRID_SEARCH_ERROR, LEFT_OUT_OF_BOUNDS, RIGHT_OUT_OF_BOUNDS, _search_time_index -from parcels._core.particle import KernelParticle +from parcels._core.particle import ParticleSetView from parcels._core.statuscodes import ( AllParcelsErrorCodes, StatusCode, @@ -35,9 +35,9 @@ def _deal_with_errors(error, key, vector_type: VectorType): - if isinstance(key, KernelParticle): + if isinstance(key, ParticleSetView): key.state = AllParcelsErrorCodes[type(error)] - elif isinstance(key[-1], KernelParticle): + elif isinstance(key[-1], ParticleSetView): key[-1].state = AllParcelsErrorCodes[type(error)] else: raise RuntimeError(f"{error}. Error could not be handled because particles was not part of the Field Sampling.") @@ -229,7 +229,7 @@ def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True): def __getitem__(self, key): self._check_velocitysampling() try: - if isinstance(key, KernelParticle): + if isinstance(key, ParticleSetView): return self.eval(key.time, key.z, key.lat, key.lon, key) else: return self.eval(*key) @@ -330,7 +330,7 @@ def eval(self, time: datetime, z, y, x, particles=None, applyConversion=True): def __getitem__(self, key): try: - if isinstance(key, KernelParticle): + if isinstance(key, ParticleSetView): return self.eval(key.time, key.z, key.lat, key.lon, key) else: return self.eval(*key) diff --git a/src/parcels/_core/index_search.py b/src/parcels/_core/index_search.py index a225621fc..c2dd18351 100644 --- a/src/parcels/_core/index_search.py +++ b/src/parcels/_core/index_search.py @@ -239,18 +239,20 @@ def uxgrid_point_in_cell(grid, y: np.ndarray, x: np.ndarray, yi: np.ndarray, xi: axis=-1, ) - # Get projection points onto element plane - # for the projection, all points are computed relative to v0 - r1 = np.squeeze(face_vertices[:, 1, :] - face_vertices[:, 0, :]) # (M,3) - r2 = np.squeeze(face_vertices[:, 2, :] - face_vertices[:, 0, :]) # (M,3) + # Get projection points onto element plane. Keep the leading + # dimension even for single-face queries so shapes remain (M,3). + r1 = face_vertices[:, 1, :] - face_vertices[:, 0, :] + r2 = face_vertices[:, 2, :] - face_vertices[:, 0, :] nhat = np.cross(r1, r2) norm = np.linalg.norm(nhat, axis=-1) + # Avoid division by zero for degenerate faces + norm = np.where(norm == 0.0, 1.0, norm) nhat = nhat / norm[:, None] # Calculate the component of the points in the direction of nhat - ptilde = points - np.squeeze(face_vertices[:, 0, :]) + ptilde = points - face_vertices[:, 0, :] pdotnhat = np.sum(ptilde * nhat, axis=-1) # Reconstruct points with normal component removed. - points = ptilde - pdotnhat[:, None] * nhat + np.squeeze(face_vertices[:, 0, :]) + points = ptilde - pdotnhat[:, None] * nhat + face_vertices[:, 0, :] else: nids = grid.uxgrid.face_node_connectivity[xi].values diff --git a/src/parcels/_core/particle.py b/src/parcels/_core/particle.py index 2587d3750..ba475dd72 100644 --- a/src/parcels/_core/particle.py +++ b/src/parcels/_core/particle.py @@ -11,7 +11,7 @@ from parcels._core.utils.time import TimeInterval from parcels._reprs import _format_list_items_multiline -__all__ = ["KernelParticle", "Particle", "ParticleClass", "Variable"] +__all__ = ["Particle", "ParticleClass", "ParticleSetView", "Variable"] _TO_WRITE_OPTIONS = [True, False, "once"] @@ -116,14 +116,27 @@ def add_variable(self, variable: Variable | list[Variable]): return ParticleClass(variables=self.variables + variable) -class KernelParticle: - """Simple class to be used in a kernel that links a particle (on the kernel level) to a particle dataset.""" +class ParticleSetView: + """Class to be used in a kernel that links a View of the ParticleSet (on the kernel level) to a ParticleSet.""" def __init__(self, data, index): self._data = data self._index = index def __getattr__(self, name): + # Return a proxy that behaves like the underlying numpy array but + # writes back into the parent arrays when sliced/modified. This + # enables constructs like `particles.dlon[mask] += vals` to update + # the parent arrays rather than temporary copies. + if name in self._data: + # If this ParticleSetView represents a single particle (integer + # index), return the underlying scalar directly to preserve + # user-facing semantics (e.g., `pset[0].time` should be a number). + if isinstance(self._index, (int, np.integer)): + return self._data[name][self._index] + if isinstance(self._index, np.ndarray) and self._index.ndim == 0: + return self._data[name][int(self._index)] + return ParticleSetViewArray(self._data, self._index, name) return self._data[name][self._index] def __setattr__(self, name, value): @@ -133,13 +146,269 @@ def __setattr__(self, name, value): self._data[name][self._index] = value def __getitem__(self, index): - self._index = index - return self + # normalize single-element tuple indexing (e.g., (inds,)) + if isinstance(index, tuple) and len(index) == 1: + index = index[0] + + base = self._index + new_index = np.zeros_like(base, dtype=bool) + + # Boolean mask (could be local-length or global-length) + if isinstance(index, (np.ndarray, list)) and np.asarray(index).dtype == bool: + arr = np.asarray(index) + if arr.size == base.size: + # global mask + new_index = arr + elif arr.size == int(np.sum(base)): + new_index[base] = arr + else: + raise ValueError( + f"Boolean index has incompatible length {arr.size} for selection of size {int(np.sum(base))}" + ) + return ParticleSetView(self._data, new_index) + + # Integer array/list, slice or single integer relative to the local view + # (boolean masks were handled above). Normalize and map to global + # particle indices for both boolean-base and integer-base `self._index`. + if isinstance(index, (np.ndarray, list, slice, int)): + # convert list/ndarray to ndarray, keep slice/int as-is + idx = np.asarray(index) if isinstance(index, (np.ndarray, list)) else index + if base.dtype == bool: + particle_idxs = np.flatnonzero(base) + sel = particle_idxs[idx] + else: + base_arr = np.asarray(base) + sel = base_arr[idx] + new_index[sel] = True + return ParticleSetView(self._data, new_index) + + # Fallback: try to assign directly (preserves previous behaviour for other index types) + try: + new_index[base] = index + return ParticleSetView(self._data, new_index) + except Exception as e: + raise TypeError(f"Unsupported index type for ParticleSetView.__getitem__: {type(index)!r}") from e def __len__(self): return len(self._index) +def _unwrap(other): + """Return ndarray for ParticleSetViewArray or the value unchanged.""" + return other.__array__() if isinstance(other, ParticleSetViewArray) else other + + +def _asarray(other): + """Return numpy array for ParticleSetViewArray, otherwise return argument.""" + return np.asarray(other.__array__()) if isinstance(other, ParticleSetViewArray) else other + + +class ParticleSetViewArray: + """Array-like proxy for a ParticleSetView that writes through to the parent arrays when mutated.""" + + def __init__(self, data, index, name): + self._data = data + self._index = index + self._name = name + + def __array__(self, dtype=None): + arr = self._data[self._name][self._index] + return arr.astype(dtype) if dtype is not None else arr + + def __repr__(self): + return repr(self.__array__()) + + def __len__(self): + return len(self.__array__()) + + def _to_global_index(self, subindex=None): + """Return a global index (boolean mask or integer indices) that + addresses the parent arrays. If `subindex` is provided it selects + within the current local view and maps back to the global index. + """ + base = self._index + if subindex is None: + return base + + # If subindex is a boolean array, support both local-length masks + # (length == base.sum()) and global-length masks (length == base.size). + if isinstance(subindex, (np.ndarray, list)) and np.asarray(subindex).dtype == bool: + arr = np.asarray(subindex) + if arr.size == base.size: + # already a global mask + return arr + if arr.size == int(np.sum(base)): + global_mask = np.zeros_like(base, dtype=bool) + global_mask[base] = arr + return global_mask + raise ValueError( + f"Boolean index has incompatible length {arr.size} for selection of size {int(np.sum(base))}" + ) + + # Handle tuple indexing where the first axis indexes particles + # and later axes index into the per-particle array shape (e.g. ei[:, igrid]) + if isinstance(subindex, tuple): + first, *rest = subindex + # map the first index (local selection) to global particle indices + if base.dtype == bool: + particle_idxs = np.flatnonzero(base) + first_arr = np.asarray(first) if isinstance(first, (np.ndarray, list)) else first + sel = particle_idxs[first_arr] + else: + base_arr = np.asarray(base) + sel = base_arr[first] + + # if rest contains a single int (e.g., column), return tuple index + if len(rest) == 1: + return (sel, rest[0]) + # return full tuple (sel, ...) for higher-dim cases + return tuple([sel] + rest) + + # If base is a boolean mask over the parent array and subindex is + # an integer or slice relative to the local view, map it to integer + # indices in the parent array. + if base.dtype == bool: + if isinstance(subindex, (slice, int)): + rel = np.flatnonzero(base)[subindex] + return rel + # If subindex is an integer/array selection (relative to the + # local view) map those to global integer indices. + arr = np.asarray(subindex) + if arr.dtype != bool: + particle_idxs = np.flatnonzero(base) + sel = particle_idxs[arr] + return sel + # Otherwise treat subindex as a boolean mask relative to the + # local view and expand to a global boolean mask. + global_mask = np.zeros_like(base, dtype=bool) + global_mask[base] = arr + return global_mask + + # If base is an array of integer indices + base_arr = np.asarray(base) + try: + return base_arr[subindex] + except Exception: + return base_arr[np.asarray(subindex, dtype=bool)] + + def __getitem__(self, subindex): + # Handle tuple indexing (e.g. [:, igrid]) by applying the tuple + # to the local selection first. This covers the common case + # `particles.ei[:, igrid]` where `ei` is a 2D parent array and the + # second index selects the grid index. + if isinstance(subindex, tuple): + local = self._data[self._name][self._index] + return local[subindex] + + new_index = self._to_global_index(subindex) + return ParticleSetViewArray(self._data, new_index, self._name) + + def __setitem__(self, subindex, value): + tgt = self._to_global_index(subindex) + self._data[self._name][tgt] = value + + # in-place ops must write back into the parent array + def __iadd__(self, other): + vals = self._data[self._name][self._index] + _unwrap(other) + self._data[self._name][self._index] = vals + return self + + def __isub__(self, other): + vals = self._data[self._name][self._index] - _unwrap(other) + self._data[self._name][self._index] = vals + return self + + def __imul__(self, other): + vals = self._data[self._name][self._index] * _unwrap(other) + self._data[self._name][self._index] = vals + return self + + # Provide simple numpy-like evaluation for binary ops by delegating to ndarray + def __add__(self, other): + return self.__array__() + _unwrap(other) + + def __sub__(self, other): + return self.__array__() - _unwrap(other) + + def __mul__(self, other): + return self.__array__() * _unwrap(other) + + def __truediv__(self, other): + return self.__array__() / _unwrap(other) + + def __floordiv__(self, other): + return self.__array__() // _unwrap(other) + + def __pow__(self, other): + return self.__array__() ** _unwrap(other) + + def __neg__(self): + return -self.__array__() + + def __pos__(self): + return +self.__array__() + + def __abs__(self): + return abs(self.__array__()) + + # Right-hand operations to handle cases like `scalar - ParticleSetViewArray` + def __radd__(self, other): + return _unwrap(other) + self.__array__() + + def __rsub__(self, other): + return _unwrap(other) - self.__array__() + + def __rmul__(self, other): + return _unwrap(other) * self.__array__() + + def __rtruediv__(self, other): + return _unwrap(other) / self.__array__() + + def __rfloordiv__(self, other): + return _unwrap(other) // self.__array__() + + def __rpow__(self, other): + return _unwrap(other) ** self.__array__() + + # Comparison operators should return plain numpy boolean arrays so that + # expressions like `mask = particles.gridID == gid` produce an ndarray + # usable for indexing (rather than another ParticleSetViewArray). + def __eq__(self, other): + left = np.asarray(self.__array__()) + right = _asarray(other) + return left == right + + def __ne__(self, other): + left = np.asarray(self.__array__()) + right = _asarray(other) + return left != right + + def __lt__(self, other): + left = np.asarray(self.__array__()) + right = _asarray(other) + return left < right + + def __le__(self, other): + left = np.asarray(self.__array__()) + right = _asarray(other) + return left <= right + + def __gt__(self, other): + left = np.asarray(self.__array__()) + right = _asarray(other) + return left > right + + def __ge__(self, other): + left = np.asarray(self.__array__()) + right = _asarray(other) + return left >= right + + # Allow attribute access like .dtype etc. by forwarding to the ndarray + def __getattr__(self, item): + arr = self.__array__() + return getattr(arr, item) + + def _assert_no_duplicate_variable_names(*, existing_vars: list[Variable], new_vars: list[Variable]): existing_names = {var.name for var in existing_vars} for var in new_vars: diff --git a/src/parcels/_core/particleset.py b/src/parcels/_core/particleset.py index 29c91d4ad..68375db85 100644 --- a/src/parcels/_core/particleset.py +++ b/src/parcels/_core/particleset.py @@ -11,7 +11,7 @@ from parcels._core.converters import _convert_to_flat_array from parcels._core.kernel import Kernel -from parcels._core.particle import KernelParticle, Particle, create_particle_data +from parcels._core.particle import Particle, ParticleSetView, create_particle_data from parcels._core.statuscodes import StatusCode from parcels._core.utils.time import ( TimeInterval, @@ -166,7 +166,7 @@ def __getattr__(self, name): def __getitem__(self, index): """Get a single particle by index.""" - return KernelParticle(self._data, index=index) + return ParticleSetView(self._data, index=index) def __setattr__(self, name, value): if name in ["_data"]: diff --git a/src/parcels/_core/uxgrid.py b/src/parcels/_core/uxgrid.py index 5e2f628a8..a1d45e796 100644 --- a/src/parcels/_core/uxgrid.py +++ b/src/parcels/_core/uxgrid.py @@ -103,7 +103,7 @@ def search(self, z, y, x, ei=None, tol=1e-6): if np.any(ei): indices = self.unravel_index(ei) fi = indices.get("FACE") - is_in_cell, coords = uxgrid_point_in_cell(self.uxgrid, y, x, fi, fi) + is_in_cell, coords = uxgrid_point_in_cell(self, y, x, fi, fi) y_check = y[is_in_cell == 0] x_check = x[is_in_cell == 0] zero_indices = np.where(is_in_cell == 0)[0] diff --git a/tests/test_particleset_execute.py b/tests/test_particleset_execute.py index 60be3da19..a176f369c 100644 --- a/tests/test_particleset_execute.py +++ b/tests/test_particleset_execute.py @@ -433,13 +433,6 @@ def PythonFail(particles, fieldset): # pragma: no cover [ ("Lat1", [0, 1]), ("Lat2", [2, 0]), - pytest.param( - "Lat1and2", - [2, 1], - marks=pytest.mark.xfail( - reason="Will be fixed alongside GH #2143 . Failing due to https://github.com/OceanParcels/Parcels/pull/2199#issuecomment-3285278876." - ), - ), ("Lat1then2", [2, 1]), ], )