Repository: OceanParcels/Parcels
Pull request #1862 (2025-02-14 07:33:20)

Adding more background to v4 development on the v4-docpage
I extended the description on the v4-docpage a bit to better explain the goals, process and timeline for Parcels v4 development


Patch:
diff --git a/docs/v4/index.md b/docs/v4/index.md
index 6dec52cd..3acc4b22 100644
--- a/docs/v4/index.md
+++ b/docs/v4/index.md
@@ -1,11 +1,21 @@
-# v4 development
+# Parcels v4 development
 
-This is a central hub to discuss the development of v4. As development progresses, some of the pages mentioned here will be incorporated into the main documentation.
+Supported by funding from the [WarmWorld](https://www.warmworld.de) [ELPHE](https://www.kooperation-international.de/foerderung/projekte/detail/info/warmworld-elphe-ermoeglichung-von-lagranian-particle-tracking-fuer-hochaufloesende-und-unstrukturierte-gitter) project and an [NWO Vici project](https://www.nwo.nl/en/researchprogrammes/nwo-talent-programme/projects-vici/vici-2022), the Parcels team is working on a major update to the Parcels codebase.
 
-You can think of this as a "living" document as we work towards the release of v4.
+The key goals of this update are
+
+1. to support `Fields` on unstructured grids;
+2. to allow for user-defined interpolation methods (somewhat similar to user-defined kernels);
+3. to make the codebase more modular, easier to extend, and more maintainable;
+4. to align Parcels more with other tools in the [Pangeo ecosystemand](https://www.pangeo.io/#ecosystem), particularly by leveraging `xarray` more; and
+5. to improve the performance of Parcels.
+
+The timeline for the release of Parcels v4 is not yet fixed, but we are aiming for a release of an 'alpha' version in September 2025. This v4-alpha will have support for unstructured grids and user-defined interpolation methods, but is not yet performance-optimised.
 
 Collaboration on v4 development is happening on the [Parcels v4 Project Board](https://github.com/orgs/OceanParcels/projects/5).
 
+The pages below provide further background on the development of Parcels v4. You can think of this page as a "living" document as we work towards the release of v4.
+
 ```{toctree}
 
 api
diff --git a/parcels/field.py b/parcels/field.py
index 43807a45..4fc13a56 100644
--- a/parcels/field.py
+++ b/parcels/field.py
@@ -27,7 +27,7 @@ from parcels._typing import (
     assert_valid_gridindexingtype,
     assert_valid_interp_method,
 )
-from parcels.tools._helpers import default_repr, field_repr, timedelta_to_float
+from parcels.tools._helpers import default_repr, deprecated_made_private, field_repr, timedelta_to_float
 from parcels.tools.converters import (
     TimeConverter,
     UnitConverter,
@@ -50,7 +50,7 @@ from .fieldfilebuffer import (
     DeferredNetcdfFileBuffer,
     NetcdfFileBuffer,
 )
-from .grid import CGrid, Grid, GridType, _calc_cell_areas
+from .grid import CGrid, Grid, GridType, _calc_cell_areas, _calc_cell_edge_sizes
 
 if TYPE_CHECKING:
     from ctypes import _Pointer as PointerType
@@ -353,6 +353,36 @@ class Field:
     def __repr__(self) -> str:
         return field_repr(self)
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def dataFiles(self):
+        return self._dataFiles
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_set(self):
+        return self._chunk_set
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def c_data_chunks(self):
+        return self._c_data_chunks
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def data_chunks(self):
+        return self._data_chunks
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def creation_log(self):
+        return self._creation_log
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def loaded_time_indices(self):
+        return self._loaded_time_indices
+
     @property
     def dimensions(self):
         return self._dimensions
@@ -401,6 +431,11 @@ class Field:
     def netcdf_engine(self):
         return self._netcdf_engine
 
+    @classmethod
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def get_dim_filenames(cls, *args, **kwargs):
+        return cls._get_dim_filenames(*args, **kwargs)
+
     @classmethod
     def _get_dim_filenames(cls, filenames, dim):
         if isinstance(filenames, str) or not isinstance(filenames, collections.abc.Iterable):
@@ -415,6 +450,11 @@ class Field:
         else:
             return filenames
 
+    @staticmethod
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def collect_timeslices(*args, **kwargs):
+        return Field._collect_timeslices(*args, **kwargs)
+
     @staticmethod
     def _collect_timeslices(
         timestamps, data_filenames, _grid_fb_class, dimensions, indices, netcdf_engine, netcdf_decodewarning=None
@@ -821,6 +861,10 @@ class Field:
             **kwargs,
         )
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def reshape(self, *args, **kwargs):
+        return self._reshape(*args, **kwargs)
+
     def _reshape(self, data, transpose=False):
         # Ensure that field data is the right data type
         if not isinstance(data, (np.ndarray, da.core.Array)):
@@ -919,6 +963,10 @@ class Field:
         if self.grid != field.grid:
             field.grid.depth_field = field
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def calc_cell_edge_sizes(self):
+        _calc_cell_edge_sizes(self.grid)
+
     def cell_areas(self):
         """Method to calculate cell sizes based on cell_edge_sizes.
 
@@ -926,12 +974,40 @@ class Field:
         """
         return _calc_cell_areas(self.grid)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def search_indices_vertical_z(self, *_):
+        raise NotImplementedError
+
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def search_indices_vertical_s(self, *args, **kwargs):
+        raise NotImplementedError
+
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def reconnect_bnd_indices(self, *args, **kwargs):
+        raise NotImplementedError
+
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def search_indices_rectilinear(self, *_):
+        raise NotImplementedError
+
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def search_indices_curvilinear(self, *_):
+        raise NotImplementedError
+
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def search_indices(self, *_):
+        raise NotImplementedError
+
     def _search_indices(self, time, z, y, x, ti=-1, particle=None, search2D=False):
         if self.grid._gtype in [GridType.RectilinearSGrid, GridType.RectilinearZGrid]:
             return _search_indices_rectilinear(self, time, z, y, x, ti, particle=particle, search2D=search2D)
         else:
             return _search_indices_curvilinear(self, time, z, y, x, ti, particle=particle, search2D=search2D)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def interpolator2D(self, *_):
+        raise NotImplementedError
+
     def _interpolator2D(self, ti, z, y, x, particle=None):
         """Impelement 2D interpolation with coordinate transformations as seen in Delandmeter and Van Sebille (2019), 10.5194/gmd-12-3571-2019.."""
         (_, eta, xsi, _, yi, xi) = self._search_indices(-1, z, y, x, particle=particle)
@@ -948,6 +1024,10 @@ class Field:
                 raise RuntimeError(self.interp_method + " is not implemented for 2D grids")
         return f(ctx)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def interpolator3D(self, *_):
+        raise NotImplementedError
+
     def _interpolator3D(self, ti, z, y, x, time, particle=None):
         """Impelement 3D interpolation with coordinate transformations as seen in Delandmeter and Van Sebille (2019), 10.5194/gmd-12-3571-2019.."""
         (zeta, eta, xsi, zi, yi, xi) = self._search_indices(time, z, y, x, ti, particle=particle)
@@ -980,6 +1060,10 @@ class Field:
             f1 = self.data[ti + 1, :]
             return f0 + (f1 - f0) * ((time - t0) / (t1 - t0))
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def spatial_interpolation(self, *args, **kwargs):
+        return self._spatial_interpolation(*args, **kwargs)
+
     def _spatial_interpolation(self, ti, z, y, x, time, particle=None):
         """Interpolate horizontal field values using a SciPy interpolator."""
         try:
@@ -1000,6 +1084,10 @@ class Field:
             e = add_note(e, f"Error interpolating field '{self.name}'.", before=True)
             raise e
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def time_index(self, *_):
+        raise NotImplementedError
+
     def _time_index(self, time):
         """Find the index in the time array associated with a given time.
 
@@ -1084,6 +1172,10 @@ class Field:
         else:
             return value
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def ccode_eval(self, *args, **kwargs):
+        return self._ccode_eval(*args, **kwargs)
+
     def _ccode_eval(self, var, t, z, y, x):
         self._check_velocitysampling()
         ccode_str = (
@@ -1093,15 +1185,31 @@ class Field:
         )
         return ccode_str
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def ccode_convert(self, *args, **kwargs):
+        return self._ccode_convert(*args, **kwargs)
+
     def _ccode_convert(self, _, z, y, x):
         return self.units.ccode_to_target(z, y, x)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def get_block_id(self, *args, **kwargs):
+        return self._get_block_id(*args, **kwargs)
+
     def _get_block_id(self, block):
         return np.ravel_multi_index(block, self.nchunks)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def get_block(self, *args, **kwargs):
+        return self._get_block(*args, **kwargs)
+
     def _get_block(self, bid):
         return np.unravel_index(bid, self.nchunks[1:])
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_setup(self, *args, **kwargs):
+        return self._chunk_setup(*args, **kwargs)
+
     def _chunk_setup(self):
         if isinstance(self.data, da.core.Array):
             chunks = self.data.chunks
@@ -1132,6 +1240,10 @@ class Field:
         self.grid.chunk_info = sum(self.grid.chunk_info, [])  # noqa: RUF017
         self._chunk_set = True
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_data(self, *args, **kwargs):
+        return self._chunk_data(*args, **kwargs)
+
     def _chunk_data(self):
         if not self._chunk_set:
             self._chunk_setup()
@@ -1302,6 +1414,10 @@ class Field:
         )
         dset.to_netcdf(filepath, unlimited_dims="time_counter")
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def rescale_and_set_minmax(self, *args, **kwargs):
+        return self._rescale_and_set_minmax(*args, **kwargs)
+
     def _rescale_and_set_minmax(self, data):
         data[np.isnan(data)] = 0
         if self._scaling_factor:
@@ -1312,6 +1428,10 @@ class Field:
             data[data > self.vmax] = 0
         return data
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def data_concatenate(self, *args, **kwargs):
+        return self._data_concatenate(*args, **kwargs)
+
     def _data_concatenate(self, data, data_to_concat, tindex):
         if data[tindex] is not None:
             if isinstance(data, np.ndarray):
@@ -1434,6 +1554,14 @@ class VectorField:
             and np.allclose(grid1.time_full, grid2.time_full)
         )
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.2.0
+    def dist(self, *args, **kwargs):
+        raise NotImplementedError
+
+    @deprecated_made_private  # TODO: Remove 6 months after v3.2.0
+    def jacobian(self, *args, **kwargs):
+        raise NotImplementedError
+
     def spatial_c_grid_interpolation2D(self, ti, z, y, x, time, particle=None, applyConversion=True):
         grid = self.U.grid
         (_, eta, xsi, zi, yi, xi) = self.U._search_indices(time, z, y, x, ti, particle=particle)
@@ -1923,6 +2051,10 @@ class VectorField:
         except tuple(AllParcelsErrorCodes.keys()) as error:
             return _deal_with_errors(error, key, vector_type=self.vector_type)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def ccode_eval(self, *args, **kwargs):
+        return self._ccode_eval(*args, **kwargs)
+
     def _ccode_eval(self, varU, varV, varW, U, V, W, t, z, y, x):
         ccode_str = ""
         if "3D" in self.vector_type:
diff --git a/parcels/fieldset.py b/parcels/fieldset.py
index 64167049..8a2e8ffb 100644
--- a/parcels/fieldset.py
+++ b/parcels/fieldset.py
@@ -14,7 +14,7 @@ from parcels.field import DeferredArray, Field, NestedField, VectorField
 from parcels.grid import Grid
 from parcels.gridset import GridSet
 from parcels.particlefile import ParticleFile
-from parcels.tools._helpers import fieldset_repr
+from parcels.tools._helpers import deprecated_made_private, fieldset_repr
 from parcels.tools.converters import TimeConverter, convert_xarray_time_units
 from parcels.tools.loggers import logger
 from parcels.tools.statuscodes import TimeExtrapolationError
@@ -63,6 +63,11 @@ class FieldSet:
     def particlefile(self):
         return self._particlefile
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def completed(self):
+        return self._completed
+
     @staticmethod
     def checkvaliddimensionsdict(dims):
         for d in dims:
@@ -243,6 +248,10 @@ class FieldSet:
             for f in vfield:
                 f.fieldset = self
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def add_UVfield(self, *args, **kwargs):
+        return self._add_UVfield(*args, **kwargs)
+
     def _add_UVfield(self):
         if not hasattr(self, "UV") and hasattr(self, "U") and hasattr(self, "V"):
             if isinstance(self.U, NestedField):
@@ -255,6 +264,10 @@ class FieldSet:
             else:
                 self.add_vector_field(VectorField("UVW", self.U, self.V, self.W))
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def check_complete(self):
+        return self._check_complete()
+
     def _check_complete(self):
         assert self.U, 'FieldSet does not have a Field named "U"'
         assert self.V, 'FieldSet does not have a Field named "V"'
@@ -332,6 +345,11 @@ class FieldSet:
                     f.grid._depth = depth_data if isinstance(depth_data, np.ndarray) else np.array(depth_data)
         self._completed = True
 
+    @classmethod
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def parse_wildcards(cls, *args, **kwargs):
+        return cls._parse_wildcards(*args, **kwargs)
+
     @classmethod
     def _parse_wildcards(cls, paths, filenames, var):
         if not isinstance(paths, list):
diff --git a/parcels/grid.py b/parcels/grid.py
index 560597c7..244afe14 100644
--- a/parcels/grid.py
+++ b/parcels/grid.py
@@ -7,6 +7,7 @@ import numpy as np
 import numpy.typing as npt
 
 from parcels._typing import Mesh, UpdateStatus, assert_valid_mesh
+from parcels.tools._helpers import deprecated_made_private
 from parcels.tools.converters import Geographic, GeographicPolar, TimeConverter, UnitConverter
 from parcels.tools.warnings import FieldSetWarning
 
@@ -152,6 +153,46 @@ class Grid:
     def cell_edge_sizes(self):
         return self._cell_edge_sizes
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def ti(self):
+        return self._ti
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def cstruct(self):
+        return self._cstruct
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def lat_flipped(self):
+        return self._lat_flipped
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def cgrid(self):
+        return self._cgrid
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def gtype(self):
+        return self._gtype
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def z4d(self):
+        return self._z4d
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def update_status(self):
+        return self._update_status
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def load_chunk(self):
+        return self._load_chunk
+
     @staticmethod
     def create_grid(
         lon: npt.ArrayLike,
@@ -186,6 +227,11 @@ class Grid:
         cstruct = CGrid(self._gtype, self._cgrid.value)
         return cstruct
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def child_ctypes_struct(self):
+        return self._child_ctypes_struct
+
     @property
     def _child_ctypes_struct(self):
         """Returns a ctypes struct object containing all relevant
@@ -240,6 +286,10 @@ class Grid:
             )
         return self._cstruct
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def check_zonal_periodic(self, *args, **kwargs):
+        return self._check_zonal_periodic(*args, **kwargs)
+
     def _check_zonal_periodic(self):
         if self.zonal_periodic or self.mesh == "flat" or self.lon.size == 1:
             return
@@ -248,6 +298,10 @@ class Grid:
         dx = np.where(dx > 180, dx - 360, dx)
         self._zonal_periodic = sum(dx) > 359.9
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def add_Sdepth_periodic_halo(self, *args, **kwargs):
+        return self._add_Sdepth_periodic_halo(*args, **kwargs)
+
     def _add_Sdepth_periodic_halo(self, zonal, meridional, halosize):
         if zonal:
             if len(self.depth.shape) == 3:
@@ -276,6 +330,10 @@ class Grid:
                 )
                 assert self.depth.shape[2] == self.ydim, "Third dim must be y."
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def computeTimeChunk(self, *args, **kwargs):
+        return self._computeTimeChunk(*args, **kwargs)
+
     def _computeTimeChunk(self, f, time, signdt):
         nextTime_loc = np.inf if signdt >= 0 else -np.inf
         periods = self.periods.value if isinstance(self.periods, c_int) else self.periods
@@ -350,22 +408,47 @@ class Grid:
                 nextTime_loc = self.time[0] + periods * (self.time_full[-1] - self.time_full[0])
         return nextTime_loc
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_not_loaded(self):
+        return self._chunk_not_loaded
+
     @property
     def _chunk_not_loaded(self):
         return 0
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_loading_requested(self):
+        return self._chunk_loading_requested
+
     @property
     def _chunk_loading_requested(self):
         return 1
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_loaded_touched(self):
+        return self._chunk_loaded_touched
+
     @property
     def _chunk_loaded_touched(self):
         return 2
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_deprecated(self):
+        return self._chunk_deprecated
+
     @property
     def _chunk_deprecated(self):
         return 3
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def chunk_loaded(self):
+        return self._chunk_loaded
+
     @property
     def _chunk_loaded(self):
         return [2, 3]
diff --git a/parcels/particledata.py b/parcels/particledata.py
index 3d384dbf..9f2eb5cc 100644
--- a/parcels/particledata.py
+++ b/parcels/particledata.py
@@ -5,6 +5,7 @@ from operator import attrgetter
 import numpy as np
 
 from parcels._compat import MPI, KMeans
+from parcels.tools._helpers import deprecated
 from parcels.tools.statuscodes import StatusCode
 
 
@@ -228,6 +229,12 @@ class ParticleData:
         """Return the length, in terms of 'number of elements, of a ParticleData instance."""
         return self._ncount
 
+    @deprecated(
+        "Use iter(...) instead, or just use the object in an iterator context (e.g. for p in particledata: ...)."
+    )  # TODO: Remove 6 months after v3.1.0 (or 9 months; doesn't contribute to code debt)
+    def iterator(self):
+        return iter(self)
+
     def __iter__(self):
         """Return an Iterator that allows for forward iteration over the elements in the ParticleData (e.g. `for p in pset:`)."""
         return ParticleDataIterator(self)
diff --git a/parcels/particlefile.py b/parcels/particlefile.py
index 752e8b8e..fb67f7f5 100644
--- a/parcels/particlefile.py
+++ b/parcels/particlefile.py
@@ -10,7 +10,7 @@ import zarr
 
 import parcels
 from parcels._compat import MPI
-from parcels.tools._helpers import default_repr, timedelta_to_float
+from parcels.tools._helpers import default_repr, deprecated, deprecated_made_private, timedelta_to_float
 from parcels.tools.warnings import FileWarning
 
 __all__ = ["ParticleFile"]
@@ -154,6 +154,36 @@ class ParticleFile:
     def time_origin(self):
         return self.particleset.time_origin
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def parcels_mesh(self):
+        return self._parcels_mesh
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def maxids(self):
+        return self._maxids
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def pids_written(self):
+        return self._pids_written
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def mpi_rank(self):
+        return self._mpi_rank
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def fill_value_map(self):
+        return self._fill_value_map
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def analytical(self):
+        return self._is_analytical
+
     def _create_variables_attribute_dict(self):
         """Creates the dictionary with variable attributes.
 
@@ -188,6 +218,21 @@ class ParticleFile:
 
         return attrs
 
+    @deprecated(
+        "ParticleFile.metadata is a dictionary. Use `ParticleFile.metadata['key'] = ...` or other dictionary methods instead."
+    )  # TODO: Remove 6 months after v3.1.0
+    def add_metadata(self, name, message):
+        """Add metadata to :class:`parcels.particleset.ParticleSet`.
+
+        Parameters
+        ----------
+        name : str
+            Name of the metadata variable
+        message : str
+            message to be written
+        """
+        self.metadata[name] = message
+
     def _convert_varout_name(self, var):
         if var == "depth":
             return "z"
@@ -196,6 +241,10 @@ class ParticleFile:
         else:
             return var
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def write_once(self, *args, **kwargs):
+        return self._write_once(*args, **kwargs)
+
     def _write_once(self, var):
         return self.particleset.particledata.ptype[var].to_write == "once"
 
diff --git a/parcels/particleset.py b/parcels/particleset.py
index 4e4ec818..adeddc44 100644
--- a/parcels/particleset.py
+++ b/parcels/particleset.py
@@ -27,7 +27,7 @@ from parcels.kernel import Kernel
 from parcels.particle import JITParticle, Variable
 from parcels.particledata import ParticleData, ParticleDataIterator
 from parcels.particlefile import ParticleFile
-from parcels.tools._helpers import particleset_repr, timedelta_to_float
+from parcels.tools._helpers import deprecated, deprecated_made_private, particleset_repr, timedelta_to_float
 from parcels.tools.converters import _get_cftime_calendars, convert_to_flat_array
 from parcels.tools.global_statics import get_package_dir
 from parcels.tools.loggers import logger
@@ -285,11 +285,62 @@ class ParticleSet:
 
         self._kernel = None
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def repeat_starttime(self):
+        return self._repeat_starttime
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def repeatlon(self):
+        return self._repeatlon
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def repeatlat(self):
+        return self._repeatlat
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def repeatdepth(self):
+        return self._repeatdepth
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def repeatpclass(self):
+        return self._repeatpclass
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def repeatkwargs(self):
+        return self._repeatkwargs
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def kernel(self):
+        return self._kernel
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def interaction_kernel(self):
+        return self._interaction_kernel
+
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def repeatpid(self):
+        return self._repeatpid
+
     def __del__(self):
         if self.particledata is not None and isinstance(self.particledata, ParticleData):
             del self.particledata
         self.particledata = None
 
+    @deprecated(
+        "Use iter(pset) instead, or just use the object in an iterator context (e.g. for p in pset: ...)."
+    )  # TODO: Remove 6 months after v3.1.0 (or 9 months; doesn't contribute to code debt)
+    def iterator(self):
+        return iter(self)
+
     def __iter__(self):
         return iter(self.particledata)
 
@@ -408,6 +459,10 @@ class ParticleSet:
         self._dirty_neighbor = True
         self.remove_indices(np.where(indices)[0])
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def active_particles_mask(self, *args, **kwargs):
+        return self._active_particles_mask(*args, **kwargs)
+
     def _active_particles_mask(self, time, dt):
         active_indices = (time - self.particledata.data["time"]) / dt >= 0
         non_err_indices = np.isin(self.particledata.data["state"], [StatusCode.Success, StatusCode.Evaluate])
@@ -415,6 +470,10 @@ class ParticleSet:
         self._active_particle_idx = np.where(active_indices)[0]
         return active_indices
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def compute_neighbor_tree(self, *args, **kwargs):
+        return self._compute_neighbor_tree(*args, **kwargs)
+
     def _compute_neighbor_tree(self, time, dt):
         active_mask = self._active_particles_mask(time, dt)
 
@@ -431,6 +490,10 @@ class ParticleSet:
         else:
             self._neighbor_tree.update_values(self._values, new_active_mask=active_mask)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def neighbors_by_index(self, *args, **kwargs):
+        return self._neighbors_by_index(*args, **kwargs)
+
     def _neighbors_by_index(self, particle_idx):
         neighbor_idx, distances = self._neighbor_tree.find_neighbors_by_idx(particle_idx)
         neighbor_idx = self._active_particle_idx[neighbor_idx]
@@ -441,6 +504,10 @@ class ParticleSet:
             self.particledata.data["horiz_dist"][neighbor_idx] = distances[1, mask]
         return ParticleDataIterator(self.particledata, subset=neighbor_idx)
 
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def neighbors_by_coor(self, *args, **kwargs):
+        return self._neighbors_by_coor(*args, **kwargs)
+
     def _neighbors_by_coor(self, coor):
         neighbor_idx = self._neighbor_tree.find_neighbors_by_coor(coor)
         neighbor_ids = self.particledata.data["id"][neighbor_idx]
@@ -572,6 +639,11 @@ class ParticleSet:
             **kwargs,
         )
 
+    @classmethod
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def monte_carlo_sample(self, *args, **kwargs):
+        return self._monte_carlo_sample(*args, **kwargs)
+
     @classmethod
     def _monte_carlo_sample(cls, start_field, size, mode="monte_carlo"):
         """Converts a starting field into a monte-carlo sample of lons and lats.
@@ -867,6 +939,11 @@ class ParticleSet:
         )
         return np.where(np.isin(self.particledata.data[variable_name], compare_values, invert=invert))[0]
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def error_particles(self):
+        return self._error_particles
+
     @property
     def _error_particles(self):
         """Get an iterator over all particles that are in an error state.
@@ -879,6 +956,11 @@ class ParticleSet:
         error_indices = self.data_indices("state", [StatusCode.Success, StatusCode.Evaluate], invert=True)
         return ParticleDataIterator(self.particledata, subset=error_indices)
 
+    @property
+    @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
+    def num_error_particles(self):
+        return self._num_error_particles
+
     @property
     def _num_error_particles(self):
         """Get the number of particles that are in an error state.


Test patch:
diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py
new file mode 100644
index 00000000..b364582e
--- /dev/null
+++ b/tests/test_deprecations.py
@@ -0,0 +1,413 @@
+import inspect
+from typing import Literal
+
+import numpy as np
+import pytest
+
+from parcels import Field, FieldSet, JITParticle, ParticleFile, ParticleSet, Variable
+from parcels.grid import (
+    CurvilinearGrid,
+    CurvilinearSGrid,
+    CurvilinearZGrid,
+    Grid,
+    RectilinearGrid,
+    RectilinearSGrid,
+    RectilinearZGrid,
+)
+from tests.utils import create_fieldset_unit_mesh
+
+Classes = Literal[
+    "Field",
+    "FieldSet",
+    "ParticleSet",
+    "Grid",
+    "RectilinearGrid",
+    "RectilinearZGrid",
+    "RectilinearSGrid",
+    "CurvilinearGrid",
+    "CurvilinearZGrid",
+    "CurvilinearSGrid",
+    "ParticleData",
+    "ParticleFile",
+]
+
+
+class Action:
+    """Utility class to help manage, document, and test deprecations."""
+
+    def __init__(
+        self,
+        class_: Classes,
+        name: str,
+        type_: Literal["read_only", "make_private", "remove"],
+        *,
+        skip_reason: str = "",
+    ):
+        if name.startswith("_"):
+            raise ValueError("name should not start with an underscore")
+
+        self.class_ = class_
+        self._raw_name = name
+        self.type_ = type_
+        self.skip_reason = skip_reason
+
+        if type_ == "read_only" and self.is_method:
+            raise ValueError("read_only attributes should not be methods")
+
+    @property
+    def public_name(self):
+        return self._raw_name.strip("()")
+
+    @property
+    def private_name(self):
+        if self.type_ == "make_private":
+            return f"_{self.public_name}"
+        return None
+
+    @property
+    def is_method(self):
+        if self._raw_name.endswith("()"):
+            return True
+        return False
+
+    @property
+    def skip(self):
+        return bool(self.skip_reason)
+
+    def __str__(self):
+        return f"{self.class_}.{self.public_name}"
+
+    def __repr__(self):
+        return f"Action(class_={self.class_!r}, name={self._raw_name!r}, type_={self.type_!r})"
+
+
+def test_testing_action_class():
+    """Testing the Action class used for testing."""
+    action = Action("MyClass", "my_attribute", "make_private")
+    assert not action.is_method
+    assert action.public_name == "my_attribute"
+    assert action.private_name == "_my_attribute"
+    assert action.class_ == "MyClass"
+    assert action.type_ == "make_private"
+
+    action = Action("Field", "my_attribute", "read_only")
+    assert not action.is_method
+    assert action.public_name == "my_attribute"
+    assert action.private_name is None
+    assert not action.is_method
+
+    action = Action("Field", "my_method()", "make_private")
+    assert action.is_method
+    assert action.public_name == "my_method"
+    assert action.private_name == "_my_method"
+
+    with pytest.raises(ValueError):  # Can't have underscore in name
+        Action("Field", "_my_attribute", "make_private")
+
+    with pytest.raises(ValueError):  # Can't have read-only method
+        Action("Field", "my_method()", "read_only")
+
+    action = Action("Field", "my_method()", "make_private", skip_reason="Reason")
+    assert action.skip
+
+
+# fmt: off
+actions = [
+    # 1709
+    Action("Field",           "dataFiles",                       "make_private"  ),
+    Action("Field",           "netcdf_engine",                   "read_only"     ),
+    Action("Field",           "loaded_time_indices",             "make_private"  ),
+    Action("Field",           "creation_log",                    "make_private"  ),
+    Action("Field",           "data_chunks",                     "make_private"  ),
+    Action("Field",           "c_data_chunks",                   "make_private"  ),
+    Action("Field",           "chunk_set",                       "make_private"  ),
+    Action("Field",           "cell_edge_sizes",                 "read_only"     ),
+    Action("Field",           "get_dim_filenames()",             "make_private"  ),
+    Action("Field",           "collect_timeslices()",            "make_private"  ),
+    Action("Field",           "reshape()",                       "make_private"  ),
+    Action("Field",           "calc_cell_edge_sizes()",          "make_private"  , skip_reason = "Moved to Grid"),
+    Action("Field",           "search_indices_vertical_z()",     "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "search_indices_vertical_s()",     "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "reconnect_bnd_indices()",         "make_private"  , skip_reason = "Moved to grid"),
+    Action("Field",           "search_indices_rectilinear()",    "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "search_indices_curvilinear()",    "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "search_indices()",                "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "interpolator2D()",                "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "interpolator3D()",                "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "spatial_interpolation()",         "make_private"  ),
+    Action("Field",           "time_index()",                    "make_private"  , skip_reason = "Removed implementation"),
+    Action("Field",           "ccode_eval()",                    "make_private"  ),
+    Action("Field",           "ccode_convert()",                 "make_private"  ),
+    Action("Field",           "get_block_id()",                  "make_private"  ),
+    Action("Field",           "get_block()",                     "make_private"  ),
+    Action("Field",           "chunk_setup()",                   "make_private"  ),
+    Action("Field",           "chunk_data()",                    "make_private"  ),
+    Action("Field",           "rescale_and_set_minmax()",        "make_private"  ),
+    Action("Field",           "data_concatenate()",              "make_private"  ),
+    Action("FieldSet",        "completed",                       "make_private"  ),
+    Action("FieldSet",        "particlefile",                    "read_only"     ),
+    Action("FieldSet",        "add_UVfield()",                   "make_private"  ),
+    Action("FieldSet",        "check_complete()",                "make_private"  ),
+    Action("FieldSet",        "parse_wildcards()",               "make_private"  ),
+
+    # 1713
+    Action("ParticleSet",      "repeat_starttime",               "make_private"  ),
+    Action("ParticleSet",      "repeatlon",                      "make_private"  ),
+    Action("ParticleSet",      "repeatlat",                      "make_private"  ),
+    Action("ParticleSet",      "repeatdepth",                    "make_private"  ),
+    Action("ParticleSet",      "repeatpclass",                   "make_private"  ),
+    Action("ParticleSet",      "repeatkwargs",                   "make_private"  ),
+    Action("ParticleSet",      "kernel",                         "make_private"  ),
+    Action("ParticleSet",      "interaction_kernel",             "make_private"  ),
+    Action("ParticleSet",      "repeatpid",                      "make_private"  ),
+    Action("ParticleSet",      "active_particles_mask()",        "make_private"  ),
+    Action("ParticleSet",      "compute_neighbor_tree()",        "make_private"  ),
+    Action("ParticleSet",      "neighbors_by_index()",           "make_private"  ),
+    Action("ParticleSet",      "neighbors_by_coor()",            "make_private"  ),
+    Action("ParticleSet",      "monte_carlo_sample()",           "make_private"  ),
+    Action("ParticleSet",      "error_particles",                "make_private"  ),
+    Action("ParticleSet",      "num_error_particles",            "make_private"  ),
+    Action("Grid",             "xi",                             "remove"        ),
+    Action("Grid",             "yi",                             "remove"        ),
+    Action("Grid",             "zi",                             "remove"        ),
+    Action("Grid",             "ti",                             "make_private"  ),
+    Action("Grid",             "lon",                            "read_only"     ),
+    Action("Grid",             "lat",                            "read_only"     ),
+    Action("Grid",             "time_origin",                    "read_only"     ),
+    Action("Grid",             "mesh",                           "read_only"     ),
+    Action("Grid",             "cstruct",                        "make_private"  ),
+    Action("Grid",             "cell_edge_sizes",                "read_only"     ),
+    Action("Grid",             "zonal_periodic",                 "read_only"     ),
+    Action("Grid",             "zonal_halo",                     "read_only"     ),
+    Action("Grid",             "meridional_halo",                "read_only"     ),
+    Action("Grid",             "lat_flipped",                    "make_private"  ),
+    Action("Grid",             "defer_load",                     "read_only"     ),
+    Action("Grid",             "lonlat_minmax",                  "read_only"     ),
+    Action("RectilinearGrid",  "lonlat_minmax",                  "read_only"     ),
+    Action("Grid",             "load_chunk",                     "make_private"  ),
+    Action("Grid",             "cgrid",                          "make_private"  ),
+    Action("Grid",             "child_ctypes_struct",            "make_private"  ),
+    Action("Grid",             "gtype",                          "make_private"  ),
+    Action("Grid",             "xdim",                           "read_only"     ),
+    Action("Grid",             "ydim",                           "read_only"     ),
+    Action("Grid",             "zdim",                           "read_only"     ),
+    Action("Grid",             "z4d",                            "make_private"  ),
+    Action("Grid",             "depth",                          "read_only"     ),
+    Action("Grid",             "check_zonal_periodic()",         "make_private"  ),
+    Action("Grid",             "add_Sdepth_periodic_halo()",     "make_private"  ),
+    Action("Grid",             "computeTimeChunk()",             "make_private"  ),
+    Action("Grid",             "update_status",                  "make_private"  ),
+    Action("Grid",             "chunk_not_loaded",               "make_private"  ),
+    Action("Grid",             "chunk_loading_requested",        "make_private"  ),
+    Action("Grid",             "chunk_loaded_touched",           "make_private"  ),
+    Action("Grid",             "chunk_deprecated",               "make_private"  ),
+    Action("Grid",             "chunk_loaded",                   "make_private"  ),
+    Action("RectilinearGrid",  "lon",                            "read_only"     ),
+    Action("RectilinearGrid",  "xdim",                           "read_only"     ),
+    Action("RectilinearGrid",  "lat",                            "read_only"     ),
+    Action("RectilinearGrid",  "ydim",                           "read_only"     ),
+    Action("RectilinearGrid",  "lat_flipped",                    "make_private"  ),
+    Action("RectilinearGrid",  "zonal_periodic",                 "read_only"     ),
+    Action("RectilinearGrid",  "zonal_halo",                     "read_only"     ),
+    Action("RectilinearGrid",  "meridional_halo",                "read_only"     ),
+    Action("RectilinearZGrid", "gtype",                          "make_private"  ),
+    Action("RectilinearZGrid", "depth",                          "read_only"     ),
+    Action("RectilinearZGrid", "zdim",                           "read_only"     ),
+    Action("RectilinearZGrid", "z4d",                            "make_private"  ),
+    Action("RectilinearSGrid", "gtype",                          "make_private"  ),
+    Action("RectilinearSGrid", "depth",                          "read_only"     ),
+    Action("RectilinearSGrid", "zdim",                           "read_only"     ),
+    Action("RectilinearSGrid", "z4d",                            "make_private"  ),
+    Action("RectilinearSGrid", "xdim",                           "read_only"     ),
+    Action("RectilinearSGrid", "ydim",                           "read_only"     ),
+    Action("RectilinearSGrid", "lat_flipped",                    "make_private"  ),
+    Action("CurvilinearGrid",  "lon",                            "read_only"     ),
+    Action("CurvilinearGrid",  "xdim",                           "read_only"     ),
+    Action("CurvilinearGrid",  "ydim",                           "read_only"     ),
+    Action("CurvilinearGrid",  "lat",                            "read_only"     ),
+    Action("CurvilinearGrid",  "zonal_periodic",                 "read_only"     ),
+    Action("CurvilinearGrid",  "zonal_halo",                     "read_only"     ),
+    Action("CurvilinearGrid",  "meridional_halo",                "read_only"     ),
+    Action("CurvilinearZGrid", "gtype",                          "make_private"  ),
+    Action("CurvilinearZGrid", "depth",                          "read_only"     ),
+    Action("CurvilinearZGrid", "zdim",                           "read_only"     ),
+    Action("CurvilinearZGrid", "z4d",                            "make_private"  ),
+    Action("CurvilinearSGrid", "gtype",                          "make_private"  ),
+    Action("CurvilinearSGrid", "depth",                          "read_only"     ),
+    Action("CurvilinearSGrid", "zdim",                           "read_only"     ),
+    Action("CurvilinearSGrid", "z4d",                            "make_private"  ),
+    Action("CurvilinearSGrid", "xdim",                           "read_only"     ),
+    Action("CurvilinearSGrid", "ydim",                           "read_only"     ),
+
+    # 1727
+    Action("ParticleSet",      "iterator()",                     "remove"        ),
+    Action("ParticleData",     "iterator()",                     "remove"        ),
+    Action("ParticleFile",     "add_metadata()",                 "remove"        ),
+    Action("ParticleFile",     "write_once()",                   "make_private"  ),
+    Action("ParticleFile",     "create_new_zarrfile",            "read_only"     ),
+    Action("ParticleFile",     "outputdt",                       "read_only"     ),
+    Action("ParticleFile",     "chunks",                         "read_only"     ),
+    Action("ParticleFile",     "particleset",                    "read_only"     ),
+    Action("ParticleFile",     "fname",                          "read_only"     ),
+    Action("ParticleFile",     "vars_to_write",                  "read_only"     ),
+    Action("ParticleFile",     "time_origin",                    "read_only"     ),
+    Action("ParticleFile",     "parcels_mesh",                   "make_private"  ),
+    Action("ParticleFile",     "maxids",                         "make_private"  ),
+    Action("ParticleFile",     "pids_written",                   "make_private"  ),
+    Action("ParticleFile",     "mpi_rank",                       "make_private"  ),
+    Action("ParticleFile",     "fill_value_map",                 "make_private"  ),
+    Action("ParticleFile",     "analytical",                     "make_private"  , skip_reason = "Was also renamed"),
+    Action("Grid",             "lon_grid_to_target()",           "remove"        ),
+    Action("Grid",             "lon_remapping",                  "remove"        ),
+    Action("Grid",             "lon_grid_to_source()",           "remove"        ),
+    Action("Grid",             "lon_particle_to_target()",       "remove"        ),
+    Action("Variable",         "name",                           "read_only"     ),
+    ]
+# fmt: on
+assert len({str(a) for a in actions}) == len(actions)  # Check that all actions are unique
+
+actions = list(filter(lambda action: not action.skip, actions))
+
+
+def create_test_data():
+    """Creates and returns the test data dictionary."""
+    fieldset = create_fieldset_unit_mesh()
+    field = fieldset.U
+
+    npart = 100
+    pset = ParticleSet(
+        fieldset,
+        lon=np.linspace(0, 1, npart, dtype=np.float32),
+        lat=np.linspace(1, 0, npart, dtype=np.float32),
+        pclass=JITParticle,
+    )
+
+    lon_g0 = np.linspace(0, 1000, 11, dtype=np.float32)
+    lat_g0 = np.linspace(0, 1000, 11, dtype=np.float32)
+    time_g0 = np.linspace(0, 1000, 2, dtype=np.float64)
+    grid = RectilinearZGrid(lon_g0, lat_g0, time=time_g0)
+
+    pfile = ParticleFile("test.zarr", pset, outputdt=1)
+
+    return {
+        "Field": {
+            "class": Field,
+            "object": field,
+        },
+        "FieldSet": {
+            "class": FieldSet,
+            "object": fieldset,
+        },
+        "ParticleSet": {
+            "class": ParticleSet,
+            "object": pset,
+        },
+        "Grid": {
+            "class": Grid,
+            "object": grid,
+        },
+        "RectilinearGrid": {
+            "class": RectilinearGrid,
+            "object": grid,  # not exactly right but good enough
+        },
+        "RectilinearZGrid": {
+            "class": RectilinearZGrid,
+            "object": grid,  # not exactly right but good enough
+        },
+        "RectilinearSGrid": {
+            "class": RectilinearSGrid,
+            "object": grid,  # not exactly right but good enough
+        },
+        "CurvilinearZGrid": {
+            "class": CurvilinearZGrid,
+            "object": grid,  # not exactly right but good enough
+        },
+        "CurvilinearGrid": {
+            "class": CurvilinearGrid,
+            "object": grid,  # not exactly right but good enough
+        },
+        "CurvilinearSGrid": {
+            "class": CurvilinearSGrid,
+            "object": grid,  # not exactly right but good enough
+        },
+        "ParticleFile": {
+            "class": ParticleFile,
+            "object": pfile,
+        },
+        "Variable": {
+            "class": Variable,
+            "object": Variable("test"),
+        },
+    }
+
+
+test_data = create_test_data()
+
+
+@pytest.mark.parametrize(
+    "private_attribute_action",
+    filter(lambda action: not action.is_method and action.type_ == "make_private", actions),
+    ids=str,
+)
+def test_private_attrib(private_attribute_action: Action):
+    """Checks that the public attribute is equivalent to the private attribute."""
+    action = private_attribute_action
+
+    obj = test_data[action.class_]["object"]
+
+    with pytest.raises(DeprecationWarning):
+        assert hasattr(obj, action.public_name)
+        assert hasattr(obj, action.private_name)
+        assert getattr(obj, action.public_name) is getattr(obj, action.private_name)
+
+
+@pytest.mark.parametrize(
+    "private_method_action",
+    filter(lambda action: action.is_method and action.type_ == "make_private", actions),
+    ids=str,
+)
+def test_private_method(private_method_action: Action):
+    """Looks at the source code to ensure that `public_method` calls `private_method`.
+
+    Looks for the string `.{method_name}(` in the source code of `public_method`.
+    """
+    action = private_method_action
+
+    class_ = test_data[action.class_]["class"]
+
+    public_method = getattr(class_, action.public_name)
+    private_method = getattr(class_, action.private_name)
+
+    assert callable(public_method)
+    assert callable(private_method)
+    assert f".{action.private_name}(" in inspect.getsource(public_method)
+
+
+@pytest.mark.parametrize(
+    "read_only_attribute_action",
+    filter(lambda action: not action.is_method and action.type_ == "read_only", actions),
+    ids=str,
+)
+def test_read_only_attr(read_only_attribute_action: Action):
+    """Tries to store a variable in the read-only attribute."""
+    action = read_only_attribute_action
+    obj = test_data[action.class_]["object"]
+
+    assert hasattr(obj, action.public_name)
+    with pytest.raises(AttributeError):
+        setattr(obj, action.public_name, None)
+
+
+@pytest.mark.parametrize(
+    "removed_attribute_action",
+    filter(lambda action: not action.is_method and action.type_ == "remove", actions),
+    ids=str,
+)
+def test_removed_attrib(removed_attribute_action: Action):
+    """Checks that attribute has been deleted."""
+    action = removed_attribute_action
+
+    obj = test_data[action.class_]["object"]
+
+    with pytest.raises(AttributeError):
+        getattr(obj, action.public_name)