Repository: OceanParcels/Parcels (language: python)

Issue:
Convert ParticleSet to structure of arrays
This means initialisation of particles is very cheap from Python, and shouldn't be any worse from the C kernel. This gives a massive speedup. See #665 for other discussion.

As part of this change, the semantics of accessing properties on a `ParticleSet` has changed: `pset[idx].property` becomes `pset.property[idx]`. It would be possible to rearrange `__getitem__` to return some kind of accessor, like the `ParticleAccessor` to make this backward-compatible, but I'm not sure of the efficiency implications of such a change. Speaking of which, I created `ParticleAccessor` before implementing `__getattr__` on `ParticleSet`. It might be worth ripping this back out since it's somewhat unneeded.

A few of the tests are (randomly) failing in JIT mode with segfaults. It looks like maybe something isn't being initialised correctly but it's being a little tricky to debug. I thought I'd just put the bulk of the work up here first.

There are a couple of other remaining tasks:
* get `ParticleFile` working again. I hacked this in another branch at a6684db, but that was before the writing to temporary dictionaries, so it'd probably be worth just responding to the few errors that pop up, rather than trying to merge this other change across. I'll do this after dealing with the JIT segfaults.
* test all tutorials/examples (I imagine things like particle plotting probably won't work)


Discussion / hints:
Thanks so much for all this development, @angus-g! I'd really like to try give it a spin in a real-life setup to investigate how much faster Parcels becomes!

As you've seen, I did quite some development over the last few weeks. I've today finally fixed the ParticleFile, so that works too

As I can see, there are two open issues:
- [ ] `print(ParticleSet)` is now very ugly
- [ ] there are Segmentation faults. I've now found why they happen: because `particle.xi` is not set to an array the size of the number of `Grids` (we need a `particle.xi` for each grid separately). For some reason, https://github.com/angus-g/parcels/blob/soa-storage/parcels/particle.py#L239-L254 is not called anymore?

The second open issue obviously is much more problematic than the first one. But we're almost there!
Thanks for rolling with this! In response to your second issue, you're correct that individual particles aren't initialised any more: the class is used as a structure to hold information about the required variables for which to create arrays. A lot of the work has been shifted to the particle set instead. I suppose this introduces a level of indirection that may make it more difficult to reason about what's going on.

This seems like the sort of thing that needs to be solved with more metaprogramming, so that we can treat particles as individual (for initialisation and printing), whilst having them stored in the more efficient format.
Even though we are now using [the fixed version](https://github.com/inducer/cgen/pull/26) of `cgen` (thanks @angus-g!),  the windows CI still breaks. Surprisingly, this seems to be related to printing to stdout. The error we are now getting is
```
./.github/scripts/windows_test.bat : WARNING: Casting field data to np.float32
At D:\a\_temp\cc668354-a74d-4222-8293-0beb2f6f0a93.ps1:4 char:1
+ ./.github/scripts/windows_test.bat
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (WARNING: Castin...a to np.float32:String) [], RemoteException
    + FullyQualifiedErrorId : NativeCommandError
```
Note that this `WARNING` is the first message printed to stdout. With https://github.com/OceanParcels/parcels/pull/678/commits/d835667589616af060fd9990292aaa7650f225c9, the same error appeared immediately after the `INFO Compiled JITParticleAdvectionRK4` message. 

I don't know enough about Windows why this now breaks, but the really strange thing is that it doesn't happen on `master`, so does seem related to `cgen` again?
That's an odd error, I'm not reproducing that locally. Particularly, all the JIT advection tests are passing for me, which suggests that the fixed `cgen` is working.

I'm only hitting the following error where compiling with `-fPIC` raises a warning on Windows:

```
tests/test_kernel_execution.py::test_execution_keep_cfiles_and_nocompilation_warnings[False] FAILED

================================== FAILURES ===================================
________ test_execution_keep_cfiles_and_nocompilation_warnings[False] _________

fieldset = <parcels.fieldset.FieldSet object at 0x000001EA2709F940>
delete_cfiles = False

    @pytest.mark.parametrize('delete_cfiles', [True, False])
    def test_execution_keep_cfiles_and_nocompilation_warnings(fieldset, delete_cfiles):
        pset = ParticleSet(fieldset, pclass=JITParticle, lon=[0.], lat=[0.])
        pset.execute(pset.Kernel(AdvectionRK4, delete_cfiles=delete_cfiles), endtime=1., dt=1.)
        cfile = pset.kernel.src_file
        logfile = pset.kernel.log_file
        del pset.kernel
        if delete_cfiles:
            assert not path.exists(cfile)
        else:
            assert path.exists(cfile)
            with open(logfile) as f:
>               assert 'warning' not in f.read(), 'Compilation WARNING in log file'
E               AssertionError: Compilation WARNING in log file
E               assert 'warning' not in 'C:\\Users\\Angus\\App...c4c_0.c -shared -m64\n'
E                 'warning' is contained here:
E                   C:\Users\Angus\AppData\Local\Temp\parcels-tmp\5fc35a3ed3c047645697155520b62c4c_0.c:1:0: warning: -fPIC ignored for target (all code is position independent)
E                 ?                                                                                         +++++++
E                    #include "parcels.h"
E                    ^
E                   Compiling: gcc -Wall -fPIC -IC:\Miniconda36-x64\envs\parcels\lib\site-packages\parcels-0.1.dev1+g0b49473-py3.6.egg\parcels\include -g -O3 -m64 -o C:\Users\Angus\AppData\Local\Temp\parcels-tmp\5fc35a3ed3c047645697155520b62c4c_0.dll C:\Users\Angus\AppData\Local\Temp\parcels-tmp\5fc35a3ed3c047645697155520b62c4c_0.c -shared -m64

tests\test_kernel_execution.py:280: AssertionError
```
Good call on the revert; it looks like anything written to stderr is considered an error in 2019? Weird!
Just pushed through a little change in the recovery kernel logic: we can handle all the particles with the `Repeat` state at once, which again removes a Python loop over all particles (e.g. in the case of the first execution step with deferred load, where all particles will be `Repeat`). The recovery kernels are still executed in a Python loop, which could be slow if there are many particles with errors.

I'm able to initialise and sample about 7 million particles on 0.1 deg global surface velocities, though the actual advection is letting me down for speed now.
@angus-g : I like the work you are recently doing on optimizing Parcels, and I would appreciate some talk via Slack, Mail or Skype to coordinate our optimization efforts a bit. I am preparing some branches at the moment for clean benchmarking of the source code and, afterwards, iterative hypothesis testing on some (code) optimization procedures and algorithmic changes. That benchmarking is also meant as an optimization guideline. Please contact me if you're in for a conversation, also on your goals and development- or research intents. Cheers, @CKehl 
@CKehl no problem, I’m happy to discuss via Slack, preferably
How can I find you on Slack ? Alternatively, you can find me by searching for my full name.
Thanks @erikvansebille and @CKehl!

Fix patch:
diff --git a/docs/_static/diagrams/ParticleSetHierarchy_basic_framework.drawio b/docs/_static/diagrams/ParticleSetHierarchy_basic_framework.drawio
deleted file mode 100644
index fa4f068c4..000000000
--- a/docs/_static/diagrams/ParticleSetHierarchy_basic_framework.drawio
+++ /dev/null
@@ -1,198 +0,0 @@
-<mxfile host="app.diagrams.net" modified="2020-10-07T14:58:39.317Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36" etag="vOmmNTbrrvNcN9W5fCr9" version="13.6.9" type="github">
-  <diagram id="ENzoWNGVRppfihYUDDF2" name="Page-1">
-    <mxGraphModel dx="1038" dy="580" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
-      <root>
-        <mxCell id="0" />
-        <mxCell id="1" parent="0" />
-        <mxCell id="TM2PD9wSft__4VRBbIbA-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;endArrow=none;endFill=0;startArrow=block;startFill=0;startSize=10;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-7" target="TM2PD9wSft__4VRBbIbA-9">
-          <mxGeometry relative="1" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;startArrow=block;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-9" target="TM2PD9wSft__4VRBbIbA-13">
-          <mxGeometry relative="1" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;startArrow=block;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-9" target="TM2PD9wSft__4VRBbIbA-11">
-          <mxGeometry relative="1" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;startArrow=block;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-11" target="TM2PD9wSft__4VRBbIbA-16">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="210" y="300" />
-              <mxPoint x="310" y="300" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;startArrow=block;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-13">
-          <mxGeometry relative="1" as="geometry">
-            <mxPoint x="310" y="330" as="targetPoint" />
-            <Array as="points">
-              <mxPoint x="435" y="300" />
-              <mxPoint x="310" y="300" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-6" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;startArrow=block;startFill=0;startSize=10;endArrow=none;endFill=0;entryX=0.25;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-18" target="TM2PD9wSft__4VRBbIbA-11">
-          <mxGeometry relative="1" as="geometry">
-            <mxPoint x="210" y="180" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-7" value="Collection" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="230" y="30" width="160" height="38" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-8" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-7">
-          <mxGeometry y="26" width="160" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-9" value="ParticleCollection" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="230" y="100" width="160" height="38" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-10" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-9">
-          <mxGeometry y="26" width="160" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-11" value="BaseParticleSet" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="130" y="180" width="160" height="38" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-12" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-11">
-          <mxGeometry y="26" width="160" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-13" value="ParticleCollectionSOA" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="299" y="180" width="260" height="100" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-14" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-13">
-          <mxGeometry y="26" width="260" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-15" value="get...() : ParticleAccessorSOA&lt;br&gt;get...() : ParticleCollectionIteratorSOA&lt;br&gt;__iter__() : ParticleCollectionIteratorSOA&lt;br&gt;__reversed__() : ParticleCollectionIteratorSOA" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-13">
-          <mxGeometry y="34" width="260" height="60" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-16" value="ParticleSetSOA" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="230" y="340" width="160" height="38" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-17" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-16">
-          <mxGeometry y="26" width="160" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-18" value="NDCluster" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="10" y="100" width="160" height="38" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-19" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-18">
-          <mxGeometry y="26" width="160" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-20" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;startArrow=block;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-26" target="TM2PD9wSft__4VRBbIbA-34">
-          <mxGeometry relative="1" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-21" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;startArrow=block;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-28" target="TM2PD9wSft__4VRBbIbA-30">
-          <mxGeometry relative="1" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-22" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.75;entryY=1;entryDx=0;entryDy=0;startArrow=diamondThin;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-34" target="TM2PD9wSft__4VRBbIbA-13">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="735" y="370" />
-              <mxPoint x="493" y="370" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-23" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.75;entryY=1;entryDx=0;entryDy=0;startArrow=diamondThin;startFill=0;startSize=10;endArrow=none;endFill=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-30" target="TM2PD9wSft__4VRBbIbA-13">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="965" y="370" />
-              <mxPoint x="493" y="370" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-24" value="" style="endArrow=classic;html=1;rounded=0;endFill=1;entryX=-0.006;entryY=0.7;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" target="TM2PD9wSft__4VRBbIbA-31">
-          <mxGeometry relative="1" as="geometry">
-            <mxPoint x="560" y="220" as="sourcePoint" />
-            <mxPoint x="550" y="219.5" as="targetPoint" />
-            <Array as="points">
-              <mxPoint x="590" y="220" />
-              <mxPoint x="590" y="350" />
-              <mxPoint x="850" y="350" />
-              <mxPoint x="850" y="220" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-25" value="" style="endArrow=classic;html=1;rounded=0;endFill=1;entryX=0;entryY=0.7;entryDx=0;entryDy=0;entryPerimeter=0;" edge="1" parent="1" target="TM2PD9wSft__4VRBbIbA-31">
-          <mxGeometry relative="1" as="geometry">
-            <mxPoint x="832" y="314" as="sourcePoint" />
-            <mxPoint x="855" y="190" as="targetPoint" />
-            <Array as="points">
-              <mxPoint x="850" y="314" />
-              <mxPoint x="850" y="220" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-26" value="BaseParticleCollectionIterator" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="630" y="100" width="210" height="38" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-27" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-26">
-          <mxGeometry y="26" width="210" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-28" value="BaseParticleSetAccessor" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="885" y="100" width="160" height="38" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-29" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-28">
-          <mxGeometry y="26" width="160" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-30" value="ParticleAccessorSOA" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="885" y="180" width="160" height="80" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-31" value="p : ParticleSetSOA" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-30">
-          <mxGeometry y="26" width="160" height="20" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-32" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-30">
-          <mxGeometry y="46" width="160" height="34" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-33" value="3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1.005;exitY=0.567;exitDx=0;exitDy=0;entryX=0;entryY=0.75;entryDx=0;entryDy=0;startArrow=diamondThin;startFill=0;startSize=10;endArrow=none;endFill=0;exitPerimeter=0;" edge="1" parent="1" source="TM2PD9wSft__4VRBbIbA-35" target="TM2PD9wSft__4VRBbIbA-30">
-          <mxGeometry relative="1" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-34" value="ParticleCollectionIteratorSOA" style="swimlane;fontStyle=2;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;rounded=0;shadow=0;strokeWidth=1;" vertex="1" parent="1">
-          <mxGeometry x="640" y="180" width="190" height="140" as="geometry">
-            <mxRectangle x="230" y="140" width="160" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-35" value="p : ParticleSetSOA&lt;br&gt;head : ParticleAccessorSOA&lt;br&gt;tail : ParticleAccessorSOA&lt;br&gt;current : ParticleAccessorSOA" style="text;html=1;align=left;verticalAlign=middle;resizable=0;points=[];autosize=1;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-34">
-          <mxGeometry y="26" width="190" height="60" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-36" value="" style="endArrow=classic;html=1;rounded=0;endFill=1;exitX=1;exitY=0.367;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="TM2PD9wSft__4VRBbIbA-34" source="TM2PD9wSft__4VRBbIbA-15">
-          <mxGeometry relative="1" as="geometry">
-            <mxPoint x="-45" y="56" as="sourcePoint" />
-            <mxPoint y="56" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-37" value="" style="line;html=1;strokeWidth=1;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-34">
-          <mxGeometry y="86" width="190" height="34" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-38" value="__next__() : ParticleAccessorSOA" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;" vertex="1" parent="TM2PD9wSft__4VRBbIbA-34">
-          <mxGeometry y="120" width="190" height="20" as="geometry" />
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-39" value="" style="endArrow=classic;html=1;rounded=0;endFill=1;" edge="1" parent="1">
-          <mxGeometry relative="1" as="geometry">
-            <mxPoint x="560" y="250" as="sourcePoint" />
-            <mxPoint x="640" y="250" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="TM2PD9wSft__4VRBbIbA-40" value="" style="endArrow=classic;html=1;rounded=0;endFill=1;" edge="1" parent="1">
-          <mxGeometry relative="1" as="geometry">
-            <mxPoint x="560" y="265" as="sourcePoint" />
-            <mxPoint x="640" y="265" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-      </root>
-    </mxGraphModel>
-  </diagram>
-</mxfile>
diff --git a/docs/_static/diagrams/ParticleSet_UML.drawio b/docs/_static/diagrams/ParticleSet_UML.drawio
deleted file mode 100644
index 2905dc2e5..000000000
--- a/docs/_static/diagrams/ParticleSet_UML.drawio
+++ /dev/null
@@ -1,599 +0,0 @@
-<mxfile host="app.diagrams.net" modified="2021-02-19T18:56:59.599Z" agent="5.0 (X11)" etag="J22Yctl83OLvW4y96np4" version="14.2.7" type="github">
-  <diagram name="Page-1" id="9f46799a-70d6-7492-0946-bef42562c5a5">
-    <mxGraphModel dx="1422" dy="2703" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" background="#ffffff" math="0" shadow="0">
-      <root>
-        <mxCell id="0" />
-        <mxCell id="1" parent="0" />
-        <mxCell id="Co4MXGZ7TRUsfddceolT-39" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;endArrow=diamondThin;endFill=0;endSize=16;" parent="1" source="78961159f06e98e8-17" target="78961159f06e98e8-56" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="200" y="20" />
-              <mxPoint x="360" y="20" />
-              <mxPoint x="360" y="600" />
-              <mxPoint x="480" y="600" />
-              <mxPoint x="480" y="660" />
-              <mxPoint x="500" y="660" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-48" value="&lt;div&gt;NEED TO BE OVERRIDEN&lt;/div&gt;&lt;div&gt;IN SPECIALIZED CLASS&lt;br&gt;&lt;/div&gt;" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="Co4MXGZ7TRUsfddceolT-39" vertex="1" connectable="0">
-          <mxGeometry x="0.4731" y="2" relative="1" as="geometry">
-            <mxPoint x="78" y="29" as="offset" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-67" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;endArrow=diamondThin;endFill=0;endSize=16;" parent="1" source="78961159f06e98e8-56" target="78961159f06e98e8-108" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="250" y="1220" />
-              <mxPoint x="123" y="1220" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-69" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endArrow=diamondThin;endFill=0;endSize=16;" parent="1" source="78961159f06e98e8-56" target="Co4MXGZ7TRUsfddceolT-51" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="250" y="1250" />
-              <mxPoint x="343" y="1250" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-71" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endArrow=diamondThin;endFill=0;endSize=16;" parent="1" source="78961159f06e98e8-56" target="Co4MXGZ7TRUsfddceolT-55" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="250" y="1260" />
-              <mxPoint x="563" y="1260" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-73" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endArrow=diamondThin;endFill=0;endSize=16;" parent="1" source="78961159f06e98e8-56" target="Co4MXGZ7TRUsfddceolT-59" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="250" y="1270" />
-              <mxPoint x="783" y="1270" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-75" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endArrow=diamondThin;endFill=0;endSize=16;dashed=1;" parent="1" source="78961159f06e98e8-56" target="Co4MXGZ7TRUsfddceolT-63" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="250" y="1280" />
-              <mxPoint x="1003" y="1280" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="78961159f06e98e8-56" value="BaseParticleSet (abstract)" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="400" y="690" width="200" height="268" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-63" value="+ collection: ParticleCollection" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-56" vertex="1">
-          <mxGeometry y="26" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-30" value="+ kernel: Kernel" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="78961159f06e98e8-56">
-          <mxGeometry y="52" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-31" value="+ fieldset: ref&amp;lt;Fieldset&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="78961159f06e98e8-56">
-          <mxGeometry y="78" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-32" value="+ repeat_arg: RepeatParameters" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="78961159f06e98e8-56">
-          <mxGeometry y="104" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-64" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="78961159f06e98e8-56" vertex="1">
-          <mxGeometry y="130" width="200" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-34" value="+ from_&amp;lt;variant&amp;gt;(...): Collection" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="78961159f06e98e8-56">
-          <mxGeometry y="138" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-37" value="+ execute(...): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="78961159f06e98e8-56">
-          <mxGeometry y="164" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-35" value="+ density(...): Field" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="78961159f06e98e8-56">
-          <mxGeometry y="190" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-36" value="+ plot(...): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="78961159f06e98e8-56">
-          <mxGeometry y="216" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-68" value="+ cstruct( ): pointer" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-56" vertex="1">
-          <mxGeometry y="242" width="200" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-33" value="" style="endArrow=block;dashed=1;endFill=0;endSize=12;html=1;edgeStyle=elbowEdgeStyle;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="78961159f06e98e8-56" source="78961159f06e98e8-63" target="78961159f06e98e8-68">
-          <mxGeometry width="160" relative="1" as="geometry">
-            <mxPoint x="260" y="-690" as="sourcePoint" />
-            <mxPoint x="210" y="150" as="targetPoint" />
-            <Array as="points">
-              <mxPoint x="-20" y="100" />
-              <mxPoint x="220" y="-340" />
-              <mxPoint x="220" y="-560" />
-              <mxPoint x="260" y="70" />
-              <mxPoint x="270" y="70" />
-              <mxPoint x="240" y="70" />
-              <mxPoint x="260" y="-360" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-45" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;endArrow=block;endFill=0;endSize=16;" parent="1" source="Co4MXGZ7TRUsfddceolT-12" target="Co4MXGZ7TRUsfddceolT-55" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="760" y="20" />
-              <mxPoint x="860" y="20" />
-              <mxPoint x="860" y="400" />
-              <mxPoint x="660" y="400" />
-              <mxPoint x="660" y="1160" />
-              <mxPoint x="580" y="1160" />
-            </Array>
-            <mxPoint x="680" y="1000" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-12" value="Parcels.Particle" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="670" y="35" width="180" height="355" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-13" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="26" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-14" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="52" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-15" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="78" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-16" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="104" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-17" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="130" width="180" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-18" value="+ gt(Particle): Bool" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="138" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-19" value="+ ge(Particle): Bool" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="164" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-20" value="+ lt(Particle): Bool" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="190" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-21" value="+ le(Particle): Bool" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="216" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-22" value="+ eq(Particle): Bool" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="242" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-23" value="+ ne(Particle): Bool" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="268" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-2" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="294" width="180" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-11" value="+ size(None): uint64" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-12" vertex="1">
-          <mxGeometry y="320" width="180" height="30" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-17" value="Collection (abstract)" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="50" y="35" width="300" height="918" as="geometry">
-            <mxRectangle x="90" y="83" width="100" height="26" as="alternateBounds" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="78961159f06e98e8-24" value="+ ncount: int" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="26" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-19" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="52" width="300" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-20" value="+ init(...): self" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="60" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-1" value="+ del( ): self" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="86" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-2" value="+ iterator( ): self" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="112" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-3" value="+ reverse( ): self" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="138" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-4" value="+ get(Collection): self" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="164" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-5" value="+ get(int32 index): ParticleAccessor" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="190" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-6" value="+ get(uint64 ID): ParticleAccessor" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="216" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-27" value="+ add(Collection): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="242" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-27" value="+ add(Particle): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="268" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-2" value="+ add(same_type): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="294" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-3" value="+ iadd(same_type): self" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="320" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-4" value="+ insert(any): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="346" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-5" value="+ push(Particle): int" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="372" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-7" value="+ append(Particle): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="398" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-8" value="+ sub(Collection): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" vertex="1" parent="78961159f06e98e8-17">
-          <mxGeometry y="424" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-6" value="+ remove_single(int32 index): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="450" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-7" value="+ remove_single(Particle): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="476" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-8" value="+ remove_single(uint64 ID): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="502" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-9" value="+ remove_multi(int32[] indices): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="528" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-10" value="+ remove_multi(Particle[]): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="554" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-11" value="+ remove_multi(uint64[] IDs): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="580" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-12" value="+ pop_single(any_numeric_unitary): Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="606" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-13" value="+ pop_multi(any_numeric_array): Particle[]" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="632" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-14" value="+ delete(int32 index): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="658" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-15" value="+ delete(uint64 ID): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="684" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-16" value="+ merge(same_type): self" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="710" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-17" value="+ split(int32[] indices): same_type" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="736" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-1" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="762" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-9" value="+ length(None): int" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="788" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-10" value="+ size(None): uint64" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="814" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="MUfdkFSpoXMzux8-uktr-1" value="+ toArray(None): ArrayColleaction" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="840" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="MUfdkFSpoXMzux8-uktr-2" value="+ isEmpty(None): Boolean" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="866" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="MUfdkFSpoXMzux8-uktr-3" value="+ clear(None): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontStyle=0" parent="78961159f06e98e8-17" vertex="1">
-          <mxGeometry y="892" width="300" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-18" value="ParticleCollection" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="380" y="35" width="260" height="295" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-19" value="+ processing_units: array" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="26" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-20" value="+ unit_centers: array[unit, dimensions]" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="52" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-21" value="+ offset: int32" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="78" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-21" value="+ particle_class: type" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="P7jvrvPm7dQxCzO7ZXYy-18">
-          <mxGeometry y="104" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-22" value="+ particle_type: numpy_struct" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="P7jvrvPm7dQxCzO7ZXYy-18">
-          <mxGeometry y="130" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-20" value="+ coordinate_type: &amp;lt;numeric_type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="P7jvrvPm7dQxCzO7ZXYy-18">
-          <mxGeometry y="156" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-22" value="+ data: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="182" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-23" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="208" width="260" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-24" value="+ cstruct( ): pointer" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="216" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="P7jvrvPm7dQxCzO7ZXYy-25" value="+ toDictionary( ): dict" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="242" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-24" value="+ set_var(string variable, &amp;lt;type&amp;gt; status): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="P7jvrvPm7dQxCzO7ZXYy-18" vertex="1">
-          <mxGeometry y="268" width="260" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-46" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;endArrow=block;endFill=0;endSize=16;" parent="1" source="78961159f06e98e8-30" target="78961159f06e98e8-108" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="1003" y="20" />
-              <mxPoint x="1140" y="20" />
-              <mxPoint x="1140" y="660" />
-              <mxPoint x="820" y="660" />
-              <mxPoint x="820" y="1010" />
-              <mxPoint x="100" y="1010" />
-            </Array>
-            <mxPoint x="1060" y="1010" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="78961159f06e98e8-30" value="&lt;div&gt;BaseParticleCollectionIterator&lt;br&gt;(abstract)&lt;/div&gt;" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=45;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="887.5" y="35" width="230" height="365" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-31" value="+ head: Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="45" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-32" value="+ tail: Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="71" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-33" value="+ current: Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="97" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-38" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="123" width="230" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-39" value="+ get(int32[] indices): Particle[]" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="131" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-40" value="+ get(int32 index): Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="157" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-42" value="+ get(uint64[] IDs): Particle[]" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="183" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-1" value="+ get(uint64 ID): Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="209" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-2" value="+ begin(None): Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="235" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-3" value="+ end(None): Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="261" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-4" value="+ next(None): Particle" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="287" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-5" value="+ reverse(None): Iterator" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="313" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-3" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-30" vertex="1">
-          <mxGeometry y="339" width="230" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-47" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;endSize=16;" parent="1" source="Co4MXGZ7TRUsfddceolT-41" target="Co4MXGZ7TRUsfddceolT-51" edge="1">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="790" y="980" />
-              <mxPoint x="400" y="980" />
-              <mxPoint x="400" y="1250" />
-              <mxPoint x="360" y="1250" />
-            </Array>
-            <mxPoint x="740" y="940" as="sourcePoint" />
-            <mxPoint x="680" y="1020" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-41" value="BaseParticleAccessor (abstract)" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="670" y="414" width="240" height="216" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-42" value="+ collection_ref: ref&amp;lt;ParticleCollection&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-41" vertex="1">
-          <mxGeometry y="26" width="240" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-43" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-41" vertex="1">
-          <mxGeometry y="52" width="240" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-44" value="+ update_next_dt(float64 value): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-41" vertex="1">
-          <mxGeometry y="60" width="240" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-25" value="+ delete( ): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="Co4MXGZ7TRUsfddceolT-41">
-          <mxGeometry y="86" width="240" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-27" value="+ set_state(StatusCode value): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="Co4MXGZ7TRUsfddceolT-41">
-          <mxGeometry y="112" width="240" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-26" value="+ get(string attribute): &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="Co4MXGZ7TRUsfddceolT-41">
-          <mxGeometry y="138" width="240" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-24" value="+ set(string attribute, &amp;lt;type&amp;gt; value): None" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="Co4MXGZ7TRUsfddceolT-41">
-          <mxGeometry y="164" width="240" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-28" value="+ toString( ): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" vertex="1" parent="Co4MXGZ7TRUsfddceolT-41">
-          <mxGeometry y="190" width="240" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-108" value="ParticleSet_AoS" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="20" y="1440" width="205" height="120" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-109" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-108" vertex="1">
-          <mxGeometry y="26" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-116" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="78961159f06e98e8-108" vertex="1">
-          <mxGeometry y="52" width="205" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="78961159f06e98e8-117" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-108" vertex="1">
-          <mxGeometry y="60" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-4" value="+ method(&amp;lt;type&amp;gt;): &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="78961159f06e98e8-108" vertex="1">
-          <mxGeometry y="86" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-51" value="ParticleSet_SoA" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="240" y="1440" width="205" height="120" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-52" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-51" vertex="1">
-          <mxGeometry y="26" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-53" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-51" vertex="1">
-          <mxGeometry y="52" width="205" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-54" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-51" vertex="1">
-          <mxGeometry y="60" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-5" value="+ method(&amp;lt;type&amp;gt;): &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-51" vertex="1">
-          <mxGeometry y="86" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-55" value="ParticleSet_LoA" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="460" y="1440" width="205" height="120" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-56" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-55" vertex="1">
-          <mxGeometry y="26" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-57" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-55" vertex="1">
-          <mxGeometry y="52" width="205" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-58" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-55" vertex="1">
-          <mxGeometry y="60" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-6" value="+ method(&amp;lt;type&amp;gt;): &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-55" vertex="1">
-          <mxGeometry y="86" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-59" value="ParticleSet_NodeList" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12" parent="1" vertex="1">
-          <mxGeometry x="680" y="1440" width="205" height="120" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-60" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-59" vertex="1">
-          <mxGeometry y="26" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-61" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-59" vertex="1">
-          <mxGeometry y="52" width="205" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-62" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-59" vertex="1">
-          <mxGeometry y="60" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-7" value="+ method(&amp;lt;type&amp;gt;): &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-59" vertex="1">
-          <mxGeometry y="86" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-63" value="ParticleSet_Tree" style="swimlane;html=1;fontStyle=1;align=center;verticalAlign=top;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;resizeParent=1;resizeLast=0;collapsible=1;marginBottom=0;swimlaneFillColor=#ffffff;rounded=0;shadow=0;comic=0;labelBackgroundColor=none;strokeWidth=1;fillColor=none;fontFamily=Verdana;fontSize=12;dashed=1;" parent="1" vertex="1">
-          <mxGeometry x="900" y="1440" width="205" height="120" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-64" value="+ &amp;lt;field&amp;gt;: &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-63" vertex="1">
-          <mxGeometry y="26" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-65" value="" style="line;html=1;strokeWidth=1;fillColor=none;align=left;verticalAlign=middle;spacingTop=-1;spacingLeft=3;spacingRight=3;rotatable=0;labelPosition=right;points=[];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-63" vertex="1">
-          <mxGeometry y="52" width="205" height="8" as="geometry" />
-        </mxCell>
-        <mxCell id="Co4MXGZ7TRUsfddceolT-66" value="+ toString(None): string" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-63" vertex="1">
-          <mxGeometry y="60" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="XpPb735-qe5b06PfUnhL-8" value="+ method(&amp;lt;type&amp;gt;): &amp;lt;type&amp;gt;" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;whiteSpace=wrap;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" parent="Co4MXGZ7TRUsfddceolT-63" vertex="1">
-          <mxGeometry y="86" width="205" height="26" as="geometry" />
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-19" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=diamondThin;endFill=0;endSize=16;" edge="1" parent="1" source="78961159f06e98e8-17" target="P7jvrvPm7dQxCzO7ZXYy-18">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="200" y="10" />
-              <mxPoint x="530" y="10" />
-            </Array>
-            <mxPoint x="585" y="410" as="sourcePoint" />
-            <mxPoint x="455" y="400" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-23" value="" style="endArrow=classic;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;edgeStyle=orthogonalEdgeStyle;entryX=0.75;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="Co4MXGZ7TRUsfddceolT-42" target="P7jvrvPm7dQxCzO7ZXYy-18">
-          <mxGeometry width="50" height="50" relative="1" as="geometry">
-            <mxPoint x="550" y="480" as="sourcePoint" />
-            <mxPoint x="600" y="430" as="targetPoint" />
-            <Array as="points">
-              <mxPoint x="655" y="453" />
-              <mxPoint x="655" y="10" />
-              <mxPoint x="575" y="10" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-29" value="" style="endArrow=block;dashed=1;endFill=0;endSize=12;html=1;edgeStyle=orthogonalEdgeStyle;elbow=vertical;rounded=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="P7jvrvPm7dQxCzO7ZXYy-18" target="78961159f06e98e8-63">
-          <mxGeometry width="160" relative="1" as="geometry">
-            <mxPoint x="530" y="390" as="sourcePoint" />
-            <mxPoint x="620" y="729" as="targetPoint" />
-            <Array as="points">
-              <mxPoint x="520" y="360" />
-              <mxPoint x="620" y="360" />
-              <mxPoint x="620" y="729" />
-            </Array>
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-38" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;endSize=16;" edge="1" parent="1" source="Co4MXGZ7TRUsfddceolT-41" target="78961159f06e98e8-108">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="790" y="980" />
-              <mxPoint x="400" y="980" />
-              <mxPoint x="400" y="1200" />
-              <mxPoint x="140" y="1200" />
-            </Array>
-            <mxPoint x="750" y="640" as="sourcePoint" />
-            <mxPoint x="690" y="1030" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-39" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;endSize=16;" edge="1" parent="1" source="Co4MXGZ7TRUsfddceolT-12" target="Co4MXGZ7TRUsfddceolT-59">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="760" y="20" />
-              <mxPoint x="860" y="20" />
-              <mxPoint x="860" y="400" />
-              <mxPoint x="660" y="400" />
-              <mxPoint x="660" y="1160" />
-              <mxPoint x="800" y="1160" />
-            </Array>
-            <mxPoint x="1040" y="75" as="sourcePoint" />
-            <mxPoint x="860" y="1480" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-40" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;endSize=16;dashed=1;" edge="1" parent="1" source="Co4MXGZ7TRUsfddceolT-12" target="Co4MXGZ7TRUsfddceolT-63">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="760" y="20" />
-              <mxPoint x="860" y="20" />
-              <mxPoint x="860" y="400" />
-              <mxPoint x="660" y="400" />
-              <mxPoint x="660" y="1160" />
-              <mxPoint x="1020" y="1160" />
-            </Array>
-            <mxPoint x="780" y="55" as="sourcePoint" />
-            <mxPoint x="600" y="1460" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-41" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;endSize=16;" edge="1" parent="1" source="78961159f06e98e8-30" target="Co4MXGZ7TRUsfddceolT-51">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="1003" y="20" />
-              <mxPoint x="1140" y="20" />
-              <mxPoint x="1140" y="660" />
-              <mxPoint x="820" y="660" />
-              <mxPoint x="820" y="1010" />
-              <mxPoint x="320" y="1010" />
-            </Array>
-            <mxPoint x="1122.5" y="85" as="sourcePoint" />
-            <mxPoint x="1180" y="1060" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-42" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;endSize=16;" edge="1" parent="1" source="78961159f06e98e8-30" target="Co4MXGZ7TRUsfddceolT-55">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="1003" y="20" />
-              <mxPoint x="1140" y="20" />
-              <mxPoint x="1140" y="660" />
-              <mxPoint x="820" y="660" />
-              <mxPoint x="820" y="1010" />
-              <mxPoint x="540" y="1010" />
-            </Array>
-            <mxPoint x="1182.5" y="145" as="sourcePoint" />
-            <mxPoint x="1240" y="1120" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-        <mxCell id="jW9BCVRJAd7_JdV0YTt1-43" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;endSize=16;" edge="1" parent="1" source="78961159f06e98e8-30" target="Co4MXGZ7TRUsfddceolT-59">
-          <mxGeometry relative="1" as="geometry">
-            <Array as="points">
-              <mxPoint x="1003" y="20" />
-              <mxPoint x="1140" y="20" />
-              <mxPoint x="1140" y="660" />
-              <mxPoint x="820" y="660" />
-              <mxPoint x="820" y="1010" />
-              <mxPoint x="770" y="1010" />
-            </Array>
-            <mxPoint x="1262.5" y="205" as="sourcePoint" />
-            <mxPoint x="1320" y="1180" as="targetPoint" />
-          </mxGeometry>
-        </mxCell>
-      </root>
-    </mxGraphModel>
-  </diagram>
-</mxfile>
diff --git a/docs/examples/example_moving_eddies.py b/docs/examples/example_moving_eddies.py
index 59430c037..24d47f2b9 100644
--- a/docs/examples/example_moving_eddies.py
+++ b/docs/examples/example_moving_eddies.py
@@ -168,7 +168,7 @@ def test_moving_eddies_fwdbwd(mode, mesh, tmpdir, npart=2):
 
     # Also include last timestep
     for var in ['lon', 'lat', 'depth', 'time']:
-        pset.collection.setallvardata(f"{var}", pset.collection.getvardata(f"{var}_nextloop"))
+        pset.particledata.setallvardata(f"{var}", pset.particledata.getvardata(f"{var}_nextloop"))
 
     assert np.allclose(pset.lon, lons)
     assert np.allclose(pset.lat, lats)
@@ -182,7 +182,7 @@ def test_moving_eddies_fieldset(mode, mesh, tmpdir):
     pset = moving_eddies_example(fieldset, outfile, 2, mode=mode)
     # Also include last timestep
     for var in ['lon', 'lat', 'depth', 'time']:
-        pset.collection.setallvardata(f"{var}", pset.collection.getvardata(f"{var}_nextloop"))
+        pset.particledata.setallvardata(f"{var}", pset.particledata.getvardata(f"{var}_nextloop"))
     if mesh == 'flat':
         assert (pset[0].lon < 2.2e5 and 1.1e5 < pset[0].lat < 1.2e5)
         assert (pset[1].lon < 2.2e5 and 3.7e5 < pset[1].lat < 3.8e5)
@@ -208,7 +208,7 @@ def test_moving_eddies_file(mode, mesh, tmpdir):
     pset = moving_eddies_example(fieldset, outfile, 2, mode=mode)
     # Also include last timestep
     for var in ['lon', 'lat', 'depth', 'time']:
-        pset.collection.setallvardata(f"{var}", pset.collection.getvardata(f"{var}_nextloop"))
+        pset.particledata.setallvardata(f"{var}", pset.particledata.getvardata(f"{var}_nextloop"))
     if mesh == 'flat':
         assert (pset[0].lon < 2.2e5 and 1.1e5 < pset[0].lat < 1.2e5)
         assert (pset[1].lon < 2.2e5 and 3.7e5 < pset[1].lat < 3.8e5)
diff --git a/docs/examples/example_stommel.py b/docs/examples/example_stommel.py
index c91741b6f..1a48c4454 100755
--- a/docs/examples/example_stommel.py
+++ b/docs/examples/example_stommel.py
@@ -5,28 +5,20 @@
 import numpy as np
 import pytest
 
-from parcels import (  # noqa
+from parcels import (
     AdvectionEE,
     AdvectionRK4,
     AdvectionRK45,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     ScipyParticle,
     Variable,
     timer,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
 method = {'RK4': AdvectionRK4, 'EE': AdvectionEE, 'RK45': AdvectionRK45}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def stommel_fieldset(xdim=200, ydim=200, grid_type='A'):
@@ -95,7 +87,7 @@ def simple_partition_function(coords, mpi_size=1):
 
 def stommel_example(npart=1, mode='jit', verbose=False, method=AdvectionRK4, grid_type='A',
                     outfile="StommelParticle.zarr", repeatdt=None, maxage=None, write_fields=True,
-                    custom_partition_function=False, pset_mode='soa'):
+                    custom_partition_function=False):
     timer.fieldset = timer.Timer('FieldSet', parent=timer.stommel)
     fieldset = stommel_fieldset(grid_type=grid_type)
     if write_fields:
@@ -120,12 +112,12 @@ class MyParticle(ParticleClass):
         age = Variable('age', dtype=np.float32, initial=0.)
 
     if custom_partition_function:
-        pset = pset_type[pset_mode]['pset'].from_line(fieldset, size=npart, pclass=MyParticle, repeatdt=repeatdt,
-                                                      start=(10e3, 5000e3), finish=(100e3, 5000e3), time=0,
-                                                      partition_function=simple_partition_function)
+        pset = ParticleSet.from_line(fieldset, size=npart, pclass=MyParticle, repeatdt=repeatdt,
+                                     start=(10e3, 5000e3), finish=(100e3, 5000e3), time=0,
+                                     partition_function=simple_partition_function)
     else:
-        pset = pset_type[pset_mode]['pset'].from_line(fieldset, size=npart, pclass=MyParticle, repeatdt=repeatdt,
-                                                      start=(10e3, 5000e3), finish=(100e3, 5000e3), time=0)
+        pset = ParticleSet.from_line(fieldset, size=npart, pclass=MyParticle, repeatdt=repeatdt,
+                                     start=(10e3, 5000e3), finish=(100e3, 5000e3), time=0)
 
     if verbose:
         print(f"Initial particle positions:\n{pset}")
@@ -146,15 +138,14 @@ class MyParticle(ParticleClass):
     return pset
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('grid_type', ['A', 'C'])
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
-def test_stommel_fieldset(pset_mode, mode, grid_type, tmpdir):
+def test_stommel_fieldset(mode, grid_type, tmpdir):
     timer.root = timer.Timer('Main')
     timer.stommel = timer.Timer('Stommel', parent=timer.root)
     outfile = tmpdir.join("StommelParticle")
-    psetRK4 = stommel_example(1, mode=mode, method=method['RK4'], grid_type=grid_type, outfile=outfile, write_fields=False, pset_mode=pset_mode)
-    psetRK45 = stommel_example(1, mode=mode, method=method['RK45'], grid_type=grid_type, outfile=outfile, write_fields=False, pset_mode=pset_mode)
+    psetRK4 = stommel_example(1, mode=mode, method=method['RK4'], grid_type=grid_type, outfile=outfile, write_fields=False)
+    psetRK45 = stommel_example(1, mode=mode, method=method['RK45'], grid_type=grid_type, outfile=outfile, write_fields=False)
     assert np.allclose(psetRK4.lon, psetRK45.lon, rtol=1e-3)
     assert np.allclose(psetRK4.lat, psetRK45.lat, rtol=1.1e-3)
     err_adv = np.abs(psetRK4.p_start - psetRK4.p)
@@ -185,8 +176,6 @@ def main(args=None):
                    help='repeatdt of the ParticleSet')
     p.add_argument('-a', '--maxage', default=None, type=int,
                    help='max age of the particles (after which particles are deleted)')
-    p.add_argument('-psm', '--pset_mode', choices=('soa', 'aos'), default='soa',
-                   help='max age of the particles (after which particles are deleted)')
     p.add_argument('-wf', '--write_fields', default=True,
                    help='Write the hydrodynamic fields to NetCDF')
     p.add_argument('-cpf', '--custom_partition_function', default=False,
diff --git a/docs/reference/particlefile.rst b/docs/reference/particlefile.rst
index 125840c7b..ef0104fb7 100644
--- a/docs/reference/particlefile.rst
+++ b/docs/reference/particlefile.rst
@@ -1,23 +1,9 @@
 Particlefile
 ============
 
-parcels.particlefile.baseparticlefile module
+parcels.particlefile module
 --------------------------------------------
 
-.. automodule:: parcels.particlefile.baseparticlefile
-    :members:
-    :show-inheritance: yes
-
-parcels.particlefile.particlefileaos module
--------------------------------------------
-
-.. automodule:: parcels.particlefile.particlefileaos
-    :members:
-    :show-inheritance: yes
-
-parcels.particlefile.particlefilesoa module
--------------------------------------------
-
-.. automodule:: parcels.particlefile.particlefilesoa
+.. automodule:: parcels.particlefile
     :members:
     :show-inheritance: yes
diff --git a/docs/reference/particles.rst b/docs/reference/particles.rst
index aa5ff4848..c5dd8aa65 100644
--- a/docs/reference/particles.rst
+++ b/docs/reference/particles.rst
@@ -11,20 +11,6 @@ parcels.particle module
 parcels.particleset module
 --------------------------
 
-.. automodule:: parcels.particleset.particlesetsoa
-    :members:
-    :show-inheritance:
-
-parcels.particleset.baseparticleset module
--------------------------------------------
-
-.. automodule:: parcels.particleset.baseparticleset
-    :members:
-    :show-inheritance:
-
-parcels.particleset.particlesetaos module
------------------------------------------
-
-.. automodule:: parcels.particleset.particlesetaos
+.. automodule:: parcels.particleset
     :members:
     :show-inheritance:
diff --git a/docs/reference/userdefined_kernels.rst b/docs/reference/userdefined_kernels.rst
index 6dbead60d..78f39d77e 100644
--- a/docs/reference/userdefined_kernels.rst
+++ b/docs/reference/userdefined_kernels.rst
@@ -1,23 +1,9 @@
 User-defined Kernels
 ====================
 
-parcels.kernel.basekernel module
+parcels.kernel module
 --------------------------------
 
-.. automodule:: parcels.kernel.basekernel
-    :members:
-    :undoc-members:
-
-parcels.kernel.kernelaos module
--------------------------------
-
-.. automodule:: parcels.kernel.kernelaos
-    :members:
-    :undoc-members:
-
-parcels.kernel.kernelsoa module
--------------------------------
-
-.. automodule:: parcels.kernel.kernelsoa
+.. automodule:: parcels.kernel
     :members:
     :undoc-members:
diff --git a/parcels/__init__.py b/parcels/__init__.py
index 1c9971b9b..e6a6c408e 100644
--- a/parcels/__init__.py
+++ b/parcels/__init__.py
@@ -4,7 +4,6 @@
 
 import parcels.rng as ParcelsRandom  # noqa
 from parcels.application_kernels import *  # noqa
-from parcels.collection import *  # noqa
 from parcels.compilation import *  # noqa
 from parcels.field import *  # noqa
 from parcels.fieldset import *  # noqa
diff --git a/parcels/collection/__init__.py b/parcels/collection/__init__.py
deleted file mode 100644
index 72e1dc818..000000000
--- a/parcels/collection/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .collectionaos import *  # noqa: F401
-from .collections import *  # noqa: F401
-from .collectionsoa import *  # noqa: F401
-from .iterators import *  # noqa: F401
diff --git a/parcels/collection/collectionaos.py b/parcels/collection/collectionaos.py
deleted file mode 100644
index 3cd7f11fd..000000000
--- a/parcels/collection/collectionaos.py
+++ /dev/null
@@ -1,1110 +0,0 @@
-from ctypes import c_void_p
-from operator import attrgetter  # NOQA
-
-import numpy as np
-
-from parcels.collection.collections import ParticleCollection
-from parcels.collection.iterators import (
-    BaseParticleAccessor,
-    BaseParticleCollectionIterable,
-    BaseParticleCollectionIterator,
-)
-from parcels.field import Field
-from parcels.particle import JITParticle, ScipyParticle  # noqa
-from parcels.tools.converters import convert_to_flat_array
-from parcels.tools.loggers import logger
-from parcels.tools.statuscodes import NotTestedError
-
-try:
-    from mpi4py import MPI
-except:
-    MPI = None
-if MPI:
-    try:
-        from sklearn.cluster import KMeans
-    except:
-        raise OSError(
-            'sklearn needs to be available if MPI is installed. '
-            'See https://docs.oceanparcels.org/en/latest/installation.html#installation-for-developers for more information'
-        )
-
-__all__ = ['ParticleCollectionAOS', 'ParticleCollectionIterableAOS', 'ParticleCollectionIteratorAOS']
-
-
-class ParticleCollectionAOS(ParticleCollection):
-    _data_c = None
-
-    def __init__(self, pclass, lon, lat, depth, time, lonlatdepth_dtype, pid_orig, partitions=None, ngrid=1, **kwargs):
-        """
-        Parameters
-        ----------
-        ngrid :
-            number of grids in the fieldset of the overarching ParticleSet - required for initialising the
-            field references of the ctypes-link of particles that are allocated
-        """
-        super(ParticleCollection, self).__init__()
-
-        assert pid_orig is not None, "particle IDs are None - incompatible with the collection. Invalid state."
-        pid = pid_orig + pclass.lastID
-
-        assert depth is not None, "particle's initial depth is None - incompatible with the collection. Invalid state."
-        assert lon.size == lat.size and lon.size == depth.size, (
-            'lon, lat, depth do not all have the same lenghts')
-
-        assert lon.size == time.size, (
-            'time and positions (lon, lat, depth) do not have the same lengths.')
-
-        # If partitions is false, the partitions are already initialised
-        if partitions is not None and partitions is not False:
-            self._pu_indicators = convert_to_flat_array(partitions)
-
-        for kwvar in kwargs:
-            if kwvar not in ['partition_function']:
-                assert lon.size == kwargs[kwvar].size, (
-                    f'{kwvar} and positions (lon, lat, depth) do nott have the same lengths.')
-
-        offset = np.max(pid) if (pid is not None) and len(pid) > 0 else -1
-        if MPI:
-            mpi_comm = MPI.COMM_WORLD
-            mpi_rank = mpi_comm.Get_rank()
-            mpi_size = mpi_comm.Get_size()
-
-            if lon.size < mpi_size and mpi_size > 1:
-                raise RuntimeError('Cannot initialise with fewer particles than MPI processors')
-
-            if mpi_size > 1:
-                if partitions is not False:
-                    if self._pu_indicators is None:
-                        if mpi_rank == 0:
-                            coords = np.vstack((lon, lat)).transpose()
-                            kmeans = KMeans(n_clusters=mpi_size, random_state=0).fit(coords)
-                            self._pu_indicators = kmeans.labels_
-                        else:
-                            self._pu_indicators = None
-                        self._pu_indicators = mpi_comm.bcast(self._pu_indicators, root=0)
-                    elif np.max(self._pu_indicators) >= mpi_size:
-                        raise RuntimeError('Particle partitions must vary between 0 and the number of mpi procs')
-                    lon = lon[self._pu_indicators == mpi_rank]
-                    lat = lat[self._pu_indicators == mpi_rank]
-                    time = time[self._pu_indicators == mpi_rank]
-                    depth = depth[self._pu_indicators == mpi_rank]
-                    pid = pid[self._pu_indicators == mpi_rank]
-                    for kwvar in kwargs:
-                        kwargs[kwvar] = kwargs[kwvar][self._pu_indicators == mpi_rank]
-                offset = MPI.COMM_WORLD.allreduce(offset, op=MPI.MAX)
-
-        pclass.setLastID(offset+1)
-
-        if lonlatdepth_dtype is None:
-            self._lonlatdepth_dtype = np.float32
-        else:
-            self._lonlatdepth_dtype = lonlatdepth_dtype
-        assert self._lonlatdepth_dtype in [np.float32, np.float64], \
-            'lon lat depth precision should be set to either np.float32 or np.float64'
-        pclass.set_lonlatdepth_dtype(self._lonlatdepth_dtype)
-        self._pclass = pclass
-
-        self._ptype = self._pclass.getPType()
-        self._data = np.empty(lon.shape[0], dtype=pclass)
-        initialised = set()
-
-        if self._ptype.uses_jit:
-            # Allocate underlying data for C-allocated particles
-            self._data_c = np.empty(lon.shape[0], dtype=self._ptype.dtype)
-
-        self._ncount = self._data.shape[0]
-
-        if lon is not None and lat is not None:
-            # Initialise from lists of lon/lat coordinates
-            assert self.ncount == len(lon) and self.ncount == len(lat), (
-                'Size of ParticleSet does not match length of lon and lat.')
-
-            for i in range(self.ncount):
-                self._data[i] = pclass(lon[i], lat[i], pid[i], ngrids=ngrid, depth=depth[i], cptr=self.cptr(i), time=time[i])
-                # Set other Variables if provided
-                for kwvar in kwargs:
-                    if isinstance(kwvar, Field):
-                        continue
-                    if kwvar in ['partition_function']:
-                        continue
-                    if not hasattr(self._data[i], kwvar):
-                        raise RuntimeError(f'Particle class does not have Variable {kwvar}')
-                    setattr(self._data[i], kwvar, kwargs[kwvar][i])
-                    if kwvar not in initialised:
-                        initialised.add(kwvar)
-
-            initialised |= {'lat', 'lat_nextloop', 'lon', 'lon_nextloop', 'depth', 'depth_nextloop', 'time', 'time_nextloop', 'id'}
-
-            for v in self._ptype.variables:
-                if v.name in initialised:
-                    continue
-
-                if isinstance(v.initial, Field):
-                    init_field = v.initial
-                    if init_field.grid.ti != 0:
-                        init_field.fieldset.computeTimeChunk(time[i], 0)
-                    for i in range(self.ncount):
-                        if (time[i] is None) or (np.isnan(time[i])):
-                            raise RuntimeError(f'Cannot initialise a Variable with a Field if no time provided (time-type: {type(time)} values: {time}). Add a "time=" to ParticleSet construction')
-                        setattr(self._data[i], v.name, init_field[time[i], depth[i], lat[i], lon[i]])
-                        logger.warning_once("Particle initialisation from field can be very slow as it is computed in scipy mode.")
-
-                if v not in initialised:
-                    initialised.add(v)
-        else:
-            raise ValueError("Latitude and longitude required for generating ParticleSet")
-        self._iterator = None
-        self._riterator = None
-
-    def __del__(self):
-        """Collection - Destructor."""
-        super().__del__()
-
-    def iterator(self):
-        self._iterator = ParticleCollectionIteratorAOS(self)
-        return self._iterator
-
-    def __iter__(self):
-        """Returns an Iterator that allows for forward iteration over the
-        elements in the ParticleCollection (e.g. `for p in pset:`).
-        """
-        return self.iterator()
-
-    def reverse_iterator(self):
-        self._riterator = ParticleCollectionIteratorAOS(self, True)
-        return self._riterator
-
-    def __reversed__(self):
-        """Returns an Iterator that allows for backwards iteration over
-        the elements in the ParticleCollection (e.g.
-        `for p in reversed(pset):`).
-        """
-        return self.reverse_iterator()
-
-    def __getitem__(self, index):
-        """
-        Access a particle in this collection using the fastest access
-        method for this collection - by its index.
-
-        Parameters
-        ----------
-        index : int
-            Index of the particle to access
-        """
-        return self.get_single_by_index(index)
-
-    def __getattr__(self, name):
-        """
-        Access a single property of all particles.
-        CAUTION: this function is not(!) in-place and is quite slow
-
-        Parameters
-        ----------
-        name : str
-            Name of the property to access
-        """
-        pdtype = None
-        for var in self._ptype.variables:
-            if name == var.name:
-                pdtype = var.dtype
-        if pdtype is None:
-            return None
-        result = np.zeros(self._ncount, dtype=pdtype)
-        for index in range(self._ncount):
-            if hasattr(self._data[index], name):
-                result[index] = getattr(self._data[index], name)
-        return result
-
-    @property
-    def data_c(self):
-        return self._data_c
-
-    @property
-    def particle_data(self):
-        """
-        'particle_data' is a reference to the actual barebone-storage of the particle data, and thus depends directly on the
-        specific collection in question. This property is just available for convenience and backward-compatibility, and
-        this returns the same as 'data'.
-        """
-        return self._data_c
-
-    def cptr(self, index):
-        if self._data_c is not None:
-            return self._data_c[index]
-        return None
-
-    def get_index_by_ID(self, id):
-        """
-        Provides a simple function to search / get the index for a particle of the requested ID.
-        Returns the particle's index.
-        """
-        raise NotTestedError
-        # super().get_single_by_ID(id)
-        # data_ids = np.array([p.id for p in self._data])
-        # index = np.where(data_ids == id)[0][0]
-        # return index
-
-    def get_indices_by_IDs(self, ids):
-        raise NotTestedError
-        # data_ids = np.array([p.id for p in self._data])
-        # AinB = np.in1d(ids, data_ids)
-        # if False in AinB:
-        #     logger.warning("Did not locate all requested IDs.")
-        # indices = np.nonzero(AinB)[0]
-        # return indices
-
-    def get_single_by_index(self, index):
-        """
-        This function gets a (particle) object from the collection based on its index within the collection. For
-        collections that are not based on random access (e.g. ordered lists, sets, trees), this function involves a
-        translation of the index into the specific object reference in the collection - or (if unavoidable) the
-        translation of the collection from a none-indexable, none-random-access structure into an indexable structure.
-        In cases where a get-by-index would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-ID.
-        """
-        super().get_single_by_index(index)
-
-        return self._data[index]
-
-    def get_single_by_object(self, particle_obj):
-        """
-        This function gets a (particle) object from the collection based on its actual object. For collections that
-        are random-access and based on indices (e.g. unordered list, vectors, arrays and dense matrices), this function
-        would involve a parsing of the whole list and translation of the object into an index in the collection - which
-        results in a significant performance malus.
-        In cases where a get-by-object would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-index or get-by-ID.
-
-        In this specific implementation, we cannot look for the object
-        directly, so we will look for one of its properties (the ID) that
-        has the nice property of being stored in an ordered list (if the
-        collection is sorted)
-        """
-        super().get_single_by_object(particle_obj)
-
-        return self.get_single_by_ID(particle_obj.id)
-
-    def get_single_by_ID(self, id):
-        """
-        This function gets a (particle) object from the collection based on the object's ID. For some collections,
-        this operation may involve a parsing of the whole list and translation of the object's ID into an index  or an
-        object reference in the collection - which results in a significant performance malus.
-        In cases where a get-by-ID would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-index.
-
-        This function uses binary search if we know the ID list to be sorted, and linear search otherwise. We assume
-        IDs are unique.
-        """
-        super().get_single_by_ID(id)
-
-        ids = np.array([p.id for p in self._data])
-        index = np.where(ids == id)[0][0]
-        return self.get_single_by_index(index)
-
-    def get_same(self, same_class):
-        """
-        This function gets particles from this collection that are themselves stored in another object of an equi-
-        structured ParticleCollection.
-        """
-        raise NotTestedError
-        # super().get_same(same_class)
-        # results = []
-        # for item in same_class:
-        #     index = np.where(self._data == item)[0]  # this will require implementing an equals(...) function to check between Particle and BaseParticleAccessor
-        #     if index.size != 0:
-        #         index = index[0]
-        #         results.append(self._data[index])
-        # if len(results) == 0:
-        #     results = None
-        # return results
-
-    def get_collection(self, pcollection):
-        """
-        This function gets particles from this collection that are themselves stored in a ParticleCollection, which
-        is differently structured than this one. That means the other-collection has to be re-formatted first in an
-        intermediary format.
-        """
-        raise NotTestedError
-        # super().get_collection(pcollection)
-        # if self._ncount <= 0:
-        #     return None
-        # ngrids = len(getattr(self._data[0], 'xi'))
-        # results = []
-        # for item in pcollection:
-        #     pdata_item = self._pclass(lon=item.lon, lat=item.lat, pid=item.pid, ngrids=ngrids, depth=item.depth, time=item.time)
-        #     index = np.where(self._data == pdata_item)[0]  # this will require implementing and equals(...) function check between Particle and BaseParticleAccessor
-        #     if index.size != 0:
-        #         index = index[0]
-        #         results.append(self._data[index])
-        # if len(results) == 0:
-        #     results = None
-        # return results
-
-    def get_multi_by_PyCollection_Particles(self, pycollectionp):
-        """
-        This function gets particles from this collection, which are themselves in common Python collections, such as
-        lists, dicts and numpy structures. We can either directly get the referred Particle instances (for internally-
-        ordered collections, e.g. ordered lists, sets, trees) or we may need to parse each instance for its index (for
-        random-access structures), which results in a considerable performance malus.
-
-        For collections where get-by-object incurs a performance malus, it is advisable to multi-get particles
-        by indices or IDs.
-        """
-        raise NotTestedError
-        # super().get_multi_by_PyCollection_Particles(pycollectionp)
-        # np_collection_p = np.array(pycollectionp, dtype=self._pclass)
-        # indices = np.in1d(np_collection_p, self._data).nonzero()[0]
-        # result = self._data[indices]
-        # if result.shape[0] <= 0:
-        #     result = None
-        # return result
-
-    def get_multi_by_indices(self, indices):
-        """
-        This function gets particles from this collection based on their indices. This works best for random-access
-        collections (e.g. numpy's ndarrays, dense matrices and dense arrays), whereas internally ordered collections
-        shall rather use a get-via-object-reference strategy.
-        """
-        raise NotTestedError
-        # super().get_multi_by_indices(indices)
-        # if type(indices) is dict:
-        #     indices = list(indices.values())
-        # return self._data[indices]
-
-    def get_multi_by_IDs(self, ids):
-        """
-        This function gets particles from this collection based on their IDs. For collections where this removal
-        strategy would require a collection transformation or by-ID parsing, it is advisable to rather apply a get-
-        by-objects or get-by-indices scheme.
-
-        Note that this implementation assumes that IDs of particles are strictly increasing with increasing index. So
-        a particle with a larger index will always have a larger ID as well. The assumption often holds for this
-        datastructure as new particles always get a larger ID than any existing particle (IDs are not recycled)
-        and their data are appended at the end of the list (largest index). This allows for the use of binary search
-        in the look-up. The collection maintains a `sorted` flag to indicate whether this assumption holds.
-        """
-        raise NotTestedError
-        # super().get_multi_by_IDs(ids)
-        # if type(ids) is dict:
-        #     ids = list(ids.values())
-        #
-        # if len(ids) == 0:
-        #     return None
-        #
-        # data_ids = np.array([p.id for p in self._data])
-        # indices = np.in1d(ids, data_ids)
-        # items_found = indices
-        # indices = np.nonzero(indices)[0]
-        # if False in items_found:
-        #     logger.warn("Failed to located a requested Particle")
-        # if len(indices) <= 0:
-        #     indices = None
-        # return self.get_multi_by_indices(indices)
-
-    def add_collection(self, pcollection):
-        """
-        Adds another, differently structured ParticleCollection to this collection. This is done by, for example,
-        appending/adding the items of the other collection to this collection.
-        """
-        # ==== first approach - still need to incorporate the MPI re-centering ==== #
-        raise NotTestedError
-        # super().add_collection(pcollection)
-        # ngrids = 0
-        # if self._ncount > 0:
-        #     ngrids = len(getattr(self._data[0], 'xi'))
-        # elif len(pcollection) > 0:
-        #     pd0 = pcollection[0]
-        #     ngrids = len(pd0['xi'])
-        # pd_cdata = None
-        # if self._ptype.uses_jit:
-        #     pd_cdata = np.array(len(pcollection), dtype=self._ptype.dtype)
-        # results = []
-        # for item_index, item in enumerate(pcollection):
-        #     pdata_item = self._pclass(lon=item.lon, lat=item.lat, pid=item.pid, ngrids=ngrids, depth=item.depth, time=item.time, cptr=pd_cdata[item_index])
-        #     results.append(pdata_item)
-        # self._data = np.concatenate([self._data, np.array(results, dtype=self._pclass)])
-        # if self._ptype.uses_jit:
-        #     self._data_c = np.concatenate([self._data_c, pd_cdata])
-        #     for p, pdata in zip(self._data, self._data_c):
-        #         p._cptr = pdata
-        # self._ncount = self._data.shape[0]
-        # return results
-
-    def add_single(self, particle_obj):
-        """
-        Adding a single Particle to the collection - either as a 'Particle; object in parcels itself, or
-        via its ParticleAccessor.
-        """
-        # ==== first approach - still need to incorporate the MPI re-centering ==== #
-        raise NotTestedError
-        # super().add_single(particle_obj)
-        # assert isinstance(particle_obj, ScipyParticle)
-        # self._data = np.concatenate([self._data, particle_obj])
-        # self._ncount = self._data.shape[0]
-        # if self._ptype.uses_jit and isinstance(particle_obj, JITParticle):
-        #     tmp_addr = self._data_c
-        #     prev_ncount = tmp_addr.shape[0]
-        #     self._data_c = np.array(self._ncount, dtype=self._ptype.dtype)
-        #     self._data_c[0:max(prev_ncount-1, 0)] = tmp_addr[:]
-        #     self._data_c[-1] = particle_obj._cptr
-        #     # particle_obj._cptr = self._data_c[-1]
-
-    def add_same(self, same_class):
-        """
-        Adds another, equi-structured ParticleCollection to this collection. This is done by concatenating
-        both collections. The fact that they are of the same ParticleCollection's derivative simplifies
-        parsing and concatenation.
-        """
-        super().add_same(same_class)
-
-        if same_class.ncount <= 0:
-            return
-
-        if self._ncount <= 0:
-            self._data = same_class._data
-            if same_class.ptype.uses_jit and self._ptype.uses_jit:
-                self._data_c = same_class._data_c
-            self._ncount = same_class.ncount
-            return
-
-        self._data = np.concatenate([self._data, same_class.data])
-        if self._ptype.uses_jit:
-            self._data_c = np.concatenate([self._data_c, same_class.data_c])
-            for p, pdata in zip(self._data, self._data_c):
-                p._cptr = pdata
-        self._ncount = self._data.shape[0]
-
-    def __iadd__(self, same_class):
-        """
-        Performs an incremental addition of the equi-structured ParticleCollections, such to allow
-
-        a += b,
-
-        with 'a' and 'b' begin the two equi-structured objects (or: 'b' being and individual object).
-        This operation is equal to an in-place addition of (an) element(s).
-        """
-        self.add_same(same_class)
-        return self
-
-    def insert(self, obj, index=None):
-        """
-        This function allows to 'insert' a Particle (as object or via its accessor) into this collection. This method
-        needs to be specified to each collection individually. Some collections (e.g. unordered list) allow to define
-        the index where the object is to be inserted. Some collections can optionally insert an object at a specific
-        position - at a significant speed- and memory malus cost (e.g. vectors, arrays, dense matrices). Some
-        collections that manage a specified indexing order internally (e.g. ordered lists, sets, trees), and thus
-        have no use for an 'index' parameter. For those collections with an internally-enforced order, the function
-        mapping equates to:
-
-        insert(obj) -> add_single(obj)
-
-        For AoS, insert with 'index==None', the function equates to 'add'. If 'index' is specified, split the array,
-        insert the item and splice the arrays.
-        """
-        raise NotTestedError
-        # if index is None:
-        #     self.add_single(obj)
-        # else:
-        #     assert isinstance(obj, ScipyParticle)
-        #     top_array = self._data[0:index-1]
-        #     bottom_array = self._data[index:]
-        #     splice_array = np.concatenate([top_array, obj])
-        #     self._data = np.concatenate([splice_array, bottom_array])
-        #     if self._ptype.uses_jit and isinstance(obj, JITParticle):
-        #         top_array = self._data_c[0:index-1]
-        #         bottom_array = self._data_c[index:]
-        #         splice_array = np.concatenate([top_array, obj._cptr])
-        #         self._data_c = np.concatenate([splice_array, bottom_array])
-        #     self._ncount = self._data.shape[0]
-
-    def push(self, particle_obj):
-        """
-        This function pushes a Particle (as object or via its accessor) to the end of a collection ('end' definition
-        depends on the specific collection itself). For collections with an inherent indexing order (e.g. ordered lists,
-        sets, trees), the function just includes the object at its pre-defined position (i.e. not necessarily at the
-        end). For the collections, the function mapping equates to:
-
-        int32 push(particle_obj) -> add_single(particle_obj); return -1;
-
-        This function further returns the index, at which position the Particle has been inserted. By definition,
-        the index is positive, thus: a return of '-1' indicates push failure, NOT the last position in the collection.
-        Furthermore, collections that do not work on an index-preserving manner also return '-1'.
-        """
-        raise NotTestedError
-        # return_index = self._ncount
-        # self.add_single(particle_obj)
-        # return return_index
-
-    def append(self, particle_obj):
-        """
-        This function appends a Particle (as object or via its accessor) to the end of a collection ('end' definition
-        depends on the specific collection itself). For collections with an inherent indexing order (e.g. ordered lists,
-        sets, trees), the function just includes the object at its pre-defined position (i.e. not necessarily at the
-        end). For the collections, the function mapping equates to:
-
-        append(particle_obj) -> add_single(particle_obj)
-
-        The function - in contrast to 'push' - does not return the index of the inserted object.
-        """
-        self.add_single(particle_obj)
-
-    def __delitem__(self, key):
-        """
-        This is the high-performance method to delete a specific object from this collection.
-        As the most-performant way depends on the specific collection in question, the function is abstract.
-
-        Highlight for the specific implementation:
-        The 'key' parameter should still be evaluated for being a single or a multi-entry delete, and needs to check
-        that it received the correct type of 'indexing' argument (i.e. index, id or iterator).
-
-        This should actually delete the item instead of just marking the particle as 'to be deleted'.
-        """
-        self.delete_by_index(key)
-
-    def delete_by_index(self, index):
-        """
-        This method deletes a particle from the  the collection based on its index. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection. The functional interpretation of the 'deleted' status
-        is handled by 'recovery' dictionary during simulation execution.
-        """
-        super().delete_by_index(index)
-        result = self.get_single_by_index(index)
-        self._data = np.delete(self._data, index)
-        if self.ptype.uses_jit:
-            self._data_c = np.delete(self._data_c, index)
-            # Update C-pointer on particles
-            for p, pdata in zip(self._data, self._data_c):
-                p._cptr = pdata
-
-        self._ncount -= 1
-        return result
-
-    def delete_by_ID(self, id):
-        """
-        This method deletes a particle from the  the collection based on its ID. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection. The functional interpretation of the 'deleted' status
-        is handled by 'recovery' dictionary during simulation execution.
-        """
-        raise NotTestedError
-        # super().delete_by_ID(id)
-        # index = self.get_index_by_ID(id)
-        # self.delete_by_index(index)
-
-    def remove_single_by_index(self, index):
-        """
-        This function removes a (particle) object from the collection based on its index within the collection. For
-        collections that are not based on random access (e.g. ordered lists, sets, trees), this function involves a
-        translation of the index into the specific object reference in the collection - or (if unavoidable) the
-        translation of the collection from a none-indexable, none-random-access structure into an indexable structure,
-        and then perform the removal.
-        In cases where a removal-by-index would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-object or remove-by-ID.
-        """
-        super().remove_single_by_index(index)
-        self.delete_by_index(index)
-
-    def remove_single_by_object(self, particle_obj):
-        """
-        This function removes a (particle) object from the collection based on its actual object. For collections that
-        are random-access and based on indices (e.g. unordered list, vectors, arrays and dense matrices), this function
-        would involves a parsing of the whole list and translation of the object into an index in the collection to
-        perform the removal - which results in a significant performance malus.
-        In cases where a removal-by-object would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-index or remove-by-ID.
-        """
-        super().remove_single_by_object(particle_obj)
-
-        # We cannot look for the object directly, so we will look for one of
-        # its properties that has the nice property of being stored in an
-        # ordered list
-        self.remove_single_by_ID(particle_obj.id)
-
-    def remove_single_by_ID(self, id):
-        """
-        This function removes a (particle) object from the collection based on the object's ID. For some collections,
-        this operation may involve a parsing of the whole list and translation of the object's ID into an index  or an
-        object reference in the collection in order to perform the removal - which results in a significant performance
-        malus.
-        In cases where a removal-by-ID would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-object or remove-by-index.
-        """
-        raise NotTestedError
-        # super().remove_single_by_ID(id)
-        # index = self.get_index_by_ID(id)
-        # self.remove_single_by_index(index)
-
-    def remove_same(self, same_class):
-        """
-        This function removes particles from this collection that are themselves stored in another object of an equi-
-        structured ParticleCollection. As the structures of both collections are the same, a more efficient M-in-N
-        removal can be applied without an in-between reformatting.
-        """
-        raise NotTestedError
-        # super().remove_same(same_class)
-        # indices = []
-        # indices = np.in1d(same_class.data, self._data)
-        # indices = None if len(indices) == 0 else np.nonzero(indices)[0]
-        #
-        # self._data = np.delete(self._data, indices)
-        # if self.ptype.uses_jit:
-        #     self._data_c = np.delete(self._data_c, indices)
-        #     for p, pdata in zip(self._data, self._data_c):
-        #         p._cptr = pdata
-        # self._ncount = self._data.shape[0]
-
-    def remove_collection(self, pcollection):
-        """
-        This function removes particles from this collection that are themselves stored in a ParticleCollection, which
-        is differently structured than this one. That means the removal first requires the removal-collection to be re-
-        formatted in an intermediary format, before executing the removal.
-        That said, this method should still be at least as efficient as a removal via common Python collections (i.e.
-        lists, dicts, numpy's nD arrays & dense arrays). Despite this, due to the reformatting, in some cases it may
-        be more efficient to remove items then rather by IDs oder indices.
-        """
-        raise NotTestedError
-        # super().remove_collection(pcollection)
-        #
-        # ids = [p.id for p in pcollection]
-        # data_ids = [p.id for p in self._data]
-        # indices = np.in1d(ids, data_ids)
-        # indices = None if len(indices) == 0 else np.nonzero(indices)[0]
-        #
-        # self._data = np.delete(self._data, indices)
-        # if self.ptype.uses_jit:
-        #     self._data_c = np.delete(self._data_c, indices)
-        #     for p, pdata in zip(self._data, self._data_c):
-        #         p._cptr = pdata
-        # self._ncount = self._data.shape[0]
-
-    def remove_multi_by_PyCollection_Particles(self, pycollectionp):
-        """
-        This function removes particles from this collection, which are themselves in common Python collections, such as
-        lists, dicts and numpy structures. In order to perform the removal, we can either directly remove the referred
-        Particle instances (for internally-ordered collections, e.g. ordered lists, sets, trees) or we may need to parse
-        each instance for its index (for random-access structures), which results in a considerable performance malus.
-
-        For collections where removal-by-object incurs a performance malus, it is advisable to multi-remove particles
-        by indices or IDs.
-        """
-        raise NotTestedError
-        # super().remove_multi_by_PyCollection_Particles(pycollectionp)
-        # npcollectionp = np.array(pycollectionp, dtype=self._pclass)
-        # npindices = np.in1d(npcollectionp, self._data)
-        # indices = None if len(npindices) == 0 else np.nonzero(npindices)[0]
-        # if indices is None:
-        #     return
-        #
-        # self._data = np.delete(self._data, indices)
-        # if self.ptype.uses_jit:
-        #     self._data_c = np.delete(self._data_c, indices)
-        #     for p, pdata in zip(self._data, self._data_c):
-        #         p._cptr = pdata
-        # self._ncount = self._data.shape[0]
-
-    def remove_multi_by_indices(self, indices):
-        """
-        This function removes particles from this collection based on their indices. This works best for random-access
-        collections (e.g. numpy's ndarrays, dense matrices and dense arrays), whereas internally ordered collections
-        shall rather use a removal-via-object-reference strategy.
-        """
-        super().remove_multi_by_indices(indices)
-        if type(indices) is dict:
-            indices = list(indices.values())
-
-        self._data = np.delete(self._data, indices)
-        if self.ptype.uses_jit:
-            self._data_c = np.delete(self._data_c, indices)
-            for p, pdata in zip(self._data, self._data_c):
-                p._cptr = pdata
-        self._ncount = self._data.shape[0]
-
-    def remove_multi_by_IDs(self, ids):
-        """
-        This function removes particles from this collection based on their IDs. For collections where this removal
-        strategy would require a collection transformation or by-ID parsing, it is advisable to rather apply a removal-
-        by-objects or removal-by-indices scheme.
-        """
-        raise NotTestedError
-        # super().remove_multi_by_IDs(ids)
-        # if type(ids) is dict:
-        #     ids = list(ids.values())
-        # if len(ids) == 0:
-        #     return
-        #
-        # # Use binary search if the collection is sorted, linear search otherwise
-        # data_ids = [p.id for p in self._data]
-        # indices = np.in1d(ids, data_ids)
-        # indices = None if len(indices) == 0 else np.nonzero(indices)[0]
-        # self.remove_multi_by_indices(indices)
-
-    def __isub__(self, other):
-        """
-        This method performs an incremental removal of the equi-structured ParticleCollections, such to allow
-
-        a -= b,
-
-        with 'a' and 'b' begin the two equi-structured objects (or: 'b' being and individual object).
-        This operation is equal to an in-place removal of (an) element(s).
-        """
-        raise NotTestedError
-        # if other is None:
-        #     return
-        # if type(other) is type(self):
-        #     self.remove_same(other)
-        # elif isinstance(other, ScipyParticle):
-        #     self.remove_single_by_object(other)
-        # else:
-        #     raise TypeError("Trying to do an incremental removal of an element of type %s, which is not supported." % type(other))
-        # return self
-
-    def pop_single_by_index(self, index):
-        """
-        Searches for Particle at index 'index', removes that Particle from the Collection and returns that Particle (or: ParticleAccessor).
-        If index is None, return last item (-1);
-        If index < 0: return from 'end' of collection.
-        If index is out of bounds, throws and OutOfRangeException.
-        If Particle cannot be retrieved, returns None.
-        """
-        raise NotTestedError
-        # super().pop_single_by_index(index)
-        # return self.delete_by_index(index)
-
-    def pop_single_by_ID(self, id):
-        """
-        Searches for Particle with ID 'id', removes that Particle from the Collection and returns that Particle (or: ParticleAccessor).
-        If Particle cannot be retrieved (e.g. because the ID is not available), returns None.
-        """
-        raise NotTestedError
-        # super().pop_single_by_ID(id)
-        # index = self.get_index_by_ID(id)
-        # return self.delete_by_index(index)
-
-    def pop_multi_by_indices(self, indices):
-        """
-        Searches for Particles with the indices registered in 'indices', removes the Particles from the Collection and returns the Particles (or: their ParticleAccessors).
-        If indices is None -> Particle cannot be retrieved -> Assert-Error and return None
-        If index is None, return last item (-1);
-        If index < 0: return from 'end' of collection.
-        If index in 'indices' is out of bounds, throws and OutOfRangeException.
-        If Particles cannot be retrieved, returns None.
-        """
-        raise NotTestedError
-        # super().pop_multi_by_indices(indices)
-        # results = self._data[indices]
-        # self.remove_multi_by_indices(indices)
-        # return results
-
-    def pop_multi_by_IDs(self, ids):
-        """
-        Searches for Particles with the IDs registered in 'ids', removes the Particles from the Collection and returns the Particles (or: their ParticleAccessors).
-        If Particles cannot be retrieved (e.g. because the IDs are not available), returns None.
-        """
-        raise NotTestedError
-        # super().pop_multi_by_IDs(ids)
-        # indices = self.get_indices_by_IDs(ids)
-        # return self.pop_multi_by_indices(indices)
-
-    def _clear_deleted_(self):
-        """
-        This (protected) function physically removes particles from the collection whose status is set to 'DELETE'.
-        It is the logical finalisation method of physically deleting particles that have been marked for deletion and
-        that have not otherwise been recovered.
-        This methods in heavily dependent on the actual collection type and should be implemented very specific
-        to the actual data structure, to remove objects 'the fastest way possible'.
-        """
-        raise NotTestedError
-        # data_states = [p.state for p in self._data]
-        # indices = np.where(data_states == StatusCode)
-        # indices = None if len(indices) == 0 else indices[0]
-        # indices = None if indices.size == 0 else indices
-        # if indices is None:
-        #     return
-        # if indices.size == 1:
-        #     indices = indices[0]
-        #     self.remove_single_by_index(indices)
-        # else:
-        #     self.remove_multi_by_indices(indices)
-
-    def merge(self, same_class=None):
-        """
-        This function merge two strictly equally-structured ParticleCollections into one. This can be, for example,
-        quite handy to merge two particle subsets that - due to continuous removal - become too small to be effective.
-
-        On the other hand, this function can also internally merge individual particles that are tagged by status as
-        being 'merged' (see the particle status for information on that).
-
-        In order to distinguish both use cases, we can evaluate the 'same_class' parameter. In cases where this is
-        'None', the merge operation semantically refers to an internal merge of individual particles - otherwise,
-        it performs a 2-collection merge.
-
-        Comment: the function can be simplified later by pre-evaluating the function parameter and then reference
-        the individual, specific functions for internal- or external merge.
-
-        The function shall return the merged ParticleCollection.
-        """
-        raise NotImplementedError
-
-    def split(self, indices=None):
-        """
-        This function splits this collection into two disect equi-structured collections. The reason for it can, for
-        example, be that the set exceeds a pre-defined maximum number of elements, which for performance reasons
-        mandates a split.
-
-        On the other hand, this function can also internally split individual particles that are tagged byt status as
-        to be 'split' (see the particle status for information on that).
-
-        In order to distinguish both use cases, we can evaluate the 'indices' parameter. In cases where this is
-        'None', the split operation semantically refers to an internal split of individual particles - otherwise,
-        it performs a collection-split.
-
-        Comment: the function can be simplified later by pre-evaluating the function parameter and then reference
-        the individual, specific functions for element- or collection split.
-
-        The function shall return the newly created or extended Particle collection, i.e. either the collection that
-        results from a collection split or this very collection, containing the newly-split particles.
-        """
-        raise NotImplementedError
-
-    def __sizeof__(self):
-        """
-        This function returns the size in actual bytes required in memory to hold the collection. Ideally and simply,
-        the size is computed as follows:
-
-        sizeof(self) = len(self) * sizeof(pclass)
-        """
-        return self._data.nbytes+self._data_c.nbytes
-
-    def clear(self):
-        """
-        This function physically removes all elements of the collection, yielding an empty collection as result of the
-        operation.
-        """
-        del self._data[:]
-        del self._data_c[:]
-        self._ncount = 0
-
-    def cstruct(self):
-        """Returns the ctypes mapping of the particle data. This depends on the specific structure in question."""
-        cstruct = self._data_c.ctypes.data_as(c_void_p)
-        return cstruct
-
-    def _to_write_particles(self, pd, time):
-        """We don't want to write a particle that is not started yet.
-        Particle will be written if particle.time is between time-dt/2 and time+dt (/2)
-        """
-        return np.array([i for i, p in enumerate(pd) if (((time - np.abs(p.dt/2) <= p.time < time + np.abs(p.dt))
-                                                         or (np.isnan(p.dt) and np.equal(time, p.time)))
-                                                         and np.isfinite(p.id))])
-
-    def getvardata(self, var, indices=None):
-        if indices is None:
-            return np.array([getattr(p, var) for p in self._data])
-        else:
-            try:
-                return np.array([getattr(p, var) for p in self._data[indices]])
-            except:  # Can occur for zero-length ParticleSets
-                return None
-
-    def setvardata(self, var, index, val):
-        if isinstance(index, (np.int64, int, np.int32)):
-            setattr(self._data[index], var, val)
-        else:
-            for i, v in zip(index, val):
-                setattr(self._data[i], var, v)
-
-    def setallvardata(self, var, val):
-        for i in range(len(self._data)):
-            setattr(self._data[i], var, val)
-
-    def toArray(self):
-        """This function converts (or: transforms; reformats; translates) this collection into an array-like structure
-        (e.g. Python list or numpy nD array) that can be addressed by index. In the common case of 'no ID recovery',
-        the global ID and the index match exactly.
-
-        While this function may be very convenient for may users, it is STRONGLY DISADVISED to use the function too
-        often, and the performance- and memory overhead malus may exceed any speed-up one could get from optimised
-        data structures - in fact, for large collections with an implicit-order structure (i.e. ordered lists, sets,
-        trees, etc.), this may be 'the most constly' function in any kind of simulation.
-
-        It can be - though - useful at the final stage of a simulation to dump the results to disk.
-        """
-        return self._data.tolist()
-
-    def set_variable_write_status(self, var, write_status):
-        """Method to set the write status of a Variable
-
-        Parameters
-        ----------
-        var :
-            Name of the variable (string)
-        write_status :
-            Write status of the variable (True, False or 'once')
-
-        """
-        if var in ['time', 'depth', 'lat', 'lon']:  # These are the variable names that are written for lon, lat and depth
-            var = var + '_nextloop'
-
-        var_changed = False
-        for v in self._ptype.variables:
-            if v.name == var and hasattr(v, 'to_write'):
-                v.to_write = write_status
-                var_changed = True
-        if var_changed:
-            for p in self._data:
-                attrib = getattr(p, var)
-                if hasattr(attrib, 'to_write'):
-                    attrib.to_write = write_status
-                setattr(p, var, attrib)
-        if not var_changed:
-            raise SyntaxError(f'Could not change the write status of {var}, because it is not a Variable name')
-
-
-class ParticleAccessorAOS(BaseParticleAccessor):
-    """Wrapper that provides access to particle data in the collection,
-    as if interacting with the particle itself.
-
-    Parameters
-    ----------
-    pcoll :
-        ParticleCollection that the represented particle
-        belongs to.
-    index :
-        The index at which the data for the represented
-        particle is stored in the corresponding data arrays
-        of the ParticleCollecion.
-    """
-
-    _index = 0
-
-    def __init__(self, pcoll, index):
-        """Initializes the ParticleAccessor to provide access to one
-        specific particle.
-        """
-        super().__init__(pcoll)
-        self._index = index
-
-    def __getattr__(self, name):
-        """
-        Get the value of an attribute of the particle.
-
-        Parameters
-        ----------
-        name : str
-            Name of the requested particle attribute.
-
-        Returns
-        -------
-        any
-            The value of the particle attribute in the underlying collection data array.
-        """
-        if name in BaseParticleAccessor.__dict__.keys():
-            result = super().__getattr__(name)
-        elif name in type(self).__dict__.keys():
-            result = object.__getattribute__(self, name)
-        else:
-            result = getattr(self._pcoll.data[self._index], name)
-        return result
-
-    def __setattr__(self, name, value):
-        """
-        Set the value of an attribute of the particle.
-
-        Parameters
-        ----------
-        name : str
-            Name of the particle attribute.
-        value : any
-            Value that will be assigned to the particle attribute in the underlying collection data array.
-        """
-        if name in BaseParticleAccessor.__dict__.keys():
-            super().__setattr__(name, value)
-        elif name in type(self).__dict__.keys():
-            object.__setattr__(self, name, value)
-        else:
-            setattr(self._pcoll.data[self._index], name, value)
-
-    def getPType(self):
-        return self._pcoll.data[self._index].getPType()
-
-    def __repr__(self):
-        return repr(self._pcoll.data[self._index])
-
-
-class ParticleCollectionIterableAOS(BaseParticleCollectionIterable):
-
-    def __init__(self, pcoll, reverse=False, subset=None):
-        super().__init__(pcoll, reverse, subset)
-
-    def __iter__(self):
-        return ParticleCollectionIteratorAOS(pcoll=self._pcoll_immutable, reverse=self._reverse, subset=self._subset)
-
-
-class ParticleCollectionIteratorAOS(BaseParticleCollectionIterator):
-    """Iterator for looping over the particles in the ParticleCollection.
-
-    Parameters
-    ----------
-    pcoll :
-        ParticleCollection that stores the particles.
-    reverse :
-        Flag to indicate reverse iteration (i.e. starting at
-        the largest index, instead of the smallest).
-    subset :
-        Subset of indices to iterate over, this allows the
-        creation of an iterator that represents part of the
-        collection.
-    """
-
-    def __init__(self, pcoll, reverse=False, subset=None):
-
-        if subset is not None:
-            if len(subset) > 0 and type(subset[0]) not in [int, np.int32, np.intp]:
-                raise TypeError("Iteration over a subset of particles in the"
-                                " particleset requires a list or numpy array"
-                                " of indices (of type int or np.int32).")
-            if reverse:
-                self._indices = subset.reverse()
-            else:
-                self._indices = subset
-            self.max_len = len(subset)
-        else:
-            self.max_len = len(pcoll)
-            if reverse:
-                self._indices = range(self.max_len - 1, -1, -1)
-            else:
-                self._indices = range(self.max_len)
-
-        self._reverse = reverse
-        self._pcoll = pcoll
-        self._prev_index = -1
-        self._index = 0
-        self._head = None
-        self._tail = None
-        if len(self._indices) > 0:
-            self._head = ParticleAccessorAOS(pcoll, self._indices[0])
-            self._tail = ParticleAccessorAOS(pcoll, self._indices[self.max_len - 1])
-        self.p = None
-
-    def __next__(self):
-        """Returns a ParticleAccessor for the next particle in the
-        ParticleSet.
-        """
-        if self._index < self.max_len:
-            self._prev_index = self._index
-            self._index += 1
-            return ParticleAccessorAOS(self._pcoll, self._indices[self._prev_index])
-        raise StopIteration
-
-    @property
-    def current(self):
-        if (self.max_len > self._prev_index) and (self._prev_index > -1):
-            return ParticleAccessorAOS(self._pcoll, self._indices[self._prev_index])
-        raise IndexError
-
-    def __repr__(self):
-        dir_str = 'Backward' if self._reverse else 'Forward'
-        return f"{dir_str} iteration at index {self._index} of {self.max_len}."
diff --git a/parcels/collection/collections.py b/parcels/collection/collections.py
deleted file mode 100644
index 85206bae8..000000000
--- a/parcels/collection/collections.py
+++ /dev/null
@@ -1,927 +0,0 @@
-from abc import ABC, abstractmethod
-
-import numpy as np
-
-from parcels.particle import ScipyParticle
-
-from .iterators import BaseParticleAccessor
-
-"""
-Author: Dr. Christian Kehl
-github relation: #913 (particleset_class_hierarchy)
-"""
-
-
-class Collection(ABC):
-    _ncount = -1
-    _iterator = None
-    _riterator = None
-
-    @abstractmethod
-    def __init__(self):
-        """
-        Collection - Constructor.
-
-        Initializes a collection by pre-allocating memory (where needed), initialising indexing structures
-        (where needed), initialising iterators and preparing the C-JIT-glue.
-        """
-        pass
-
-    @abstractmethod
-    def __del__(self):
-        """Collection - Destructor."""
-        pass
-
-    @property
-    def ncount(self):
-        return self._ncount
-
-    @abstractmethod
-    def __iter__(self):
-        """
-        This function represents a forward-iteration over the collection, the the sense it is called
-
-        for item in collection_object:
-            <do something>
-
-        In this sense, this function (if actually being implemented as such - which is not necessarily the sensible case
-        for every collection) would here return an iterator (either re-created or persistent; preferably created anew).
-
-        For iterating over a collection, it is possible to 'iterate' over an iterator, such that:
-
-        collection_iterator = collection_object.iterator()
-        for item in collection_iterator:
-            <do something>
-        """
-        pass
-
-    @abstractmethod
-    def iterator(self):
-        """
-        This function is an explicit object-return of a forward-iterator over this collection. If this iterator is
-        persistent or re-created upon call depends on the specific implementation of the '__iter__' function.
-
-        This function is an explicit forward to the Collection::__iter__() member function.
-        """
-        pass
-
-    @abstractmethod
-    def __reversed__(self):
-        """
-        This function represents a backward-iteration over the collection, the the sense it is called
-
-        for item in reversed(collection_object):
-            <do something>
-
-        In this sense, this function (if actually being implemented as such - which is not necessarily the sensible case
-        for every collection) would here return a reverse-iterator (either re-created or persistent; preferably created
-        anew).
-
-        For iterating over a collection, it is possible to 'iterate' over an iterator, such that:
-
-        collection_iterator = collection_object.reverse_iterator()
-        for item in collection_iterator:
-            <do something>
-        """
-        pass
-
-    @abstractmethod
-    def reverse_iterator(self):
-        """
-        This function is an explicit object-return of a backward-iterator over this collection. If this iterator is
-        persistent or re-created upon call depends on the specific implementation of the '__reversed__' function.
-
-        This function is an explicit forward to the Collection::__reversed__() member function.
-        """
-        pass
-
-    @abstractmethod
-    def __getitem__(self, item):
-        """
-        This function should return an item [accessor, as reference] at a certain 'position', in the sense of
-
-        item_ref = collection_object[<some_indexing_concept>]
-
-        The actual return and implementation of this will depend on the specific collection at hand. Ideally, this
-        function demands as 'key' an object that matches the most-performant access method, i.e.
-
-        ordered lists, sets, trees -> getitem(key = [int64, uint64] id) -> operates on a list-iterator
-        arrays, vectors, dense arrays & matrices -> getitem(key = [int, int32] index) -> operates on an index-managed iterator
-
-        Out of performance reasons, there should be no alternative getitem with other positional arguments than the
-        optimal one.
-        """
-        pass
-
-    def get(self, other):
-        """
-        This is a generic super-method to get one- or multiple Particles (via their object, their ParticleAccessor,
-        their ID or their index) from the collection. Ideally, it just discerns between the types of the 'other'
-        parameter, and then forwards the call to the related specific function.
-
-        Comment/Annotation:
-        Not all arguments have a sensible use-case in every datastructure, so some concrete classes may not implementat
-        all of them.
-        """
-        if other is None:
-            return
-        if type(other) is type(self):
-            return self.get_same(other)
-        elif isinstance(other, ParticleCollection):
-            return self.get_collection(other)
-        elif type(other) in [list, dict, np.ndarray]:
-            # multi-get routines - hard to discern at this point
-            if type(other) is not dict:
-                if len(other) == 0:
-                    return None
-                elif type(other[0]) in [int, np.int32, np.intp]:
-                    return self.get_multi_by_indices(other)
-                elif type(other[0]) in [np.int64, np.uint64]:
-                    return self.get_multi_by_IDs(other)
-                else:
-                    return self.get_multi_by_PyCollection_Particles(other)
-            else:
-                if len(list(other.values())) == 0:
-                    return None
-                if type(list(other.values())[0]) in [int, np.int32, np.intp]:
-                    return self.get_multi_by_indices(other)
-                elif type(list(other.values())[0]) in [np.int64, np.uint64]:
-                    return self.get_multi_by_IDs(other)
-                else:
-                    return self.get_multi_by_PyCollection_Particles(other)
-        elif type(other) in [int, np.int32, np.intp]:
-            return self.get_single_by_index(other)
-        elif type(other) in [np.int64, np.uint64]:
-            return self.get_single_by_ID(other)
-        else:
-            return self.get_single_by_object(other)
-
-    @abstractmethod
-    def get_single_by_index(self, index):
-        """
-        This function gets a (particle) object from the collection based on its index within the collection. For
-        collections that are not based on random access (e.g. ordered lists, sets, trees), this function involves a
-        translation of the index into the specific object reference in the collection - or (if unavoidable) the
-        translation of the collection from a none-indexable, none-random-access structure into an indexable structure.
-        In cases where a get-by-index would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-ID.
-        """
-        assert type(index) in [int, np.int32, np.intp], f"Trying to get a particle by index, but index {index} is not a 32-bit integer - invalid operation."
-
-    @abstractmethod
-    def get_single_by_object(self, particle_obj):
-        """
-        This function gets a (particle) object from the collection based on its actual object. For collections that
-        are random-access and based on indices (e.g. unordered list, vectors, arrays and dense matrices), this function
-        would involve a parsing of the whole list and translation of the object into an index in the collection - which
-        results in a significant performance malus.
-        In cases where a get-by-object would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-index or get-by-ID.
-        """
-        assert (isinstance(particle_obj, BaseParticleAccessor) or isinstance(particle_obj, ScipyParticle))
-
-    @abstractmethod
-    def get_single_by_ID(self, id):
-        """
-        This function gets a (particle) object from the collection based on the object's ID. For some collections,
-        this operation may involve a parsing of the whole list and translation of the object's ID into an index  or an
-        object reference in the collection - which results in a significant performance malus.
-        In cases where a get-by-ID would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-index.
-        """
-        assert type(id) in [np.int64, np.uint64], f"Trying to get a particle by ID, but ID {id} is not a 64-bit (signed or unsigned) iteger - invalid operation."
-
-    @abstractmethod
-    def get_same(self, same_class):
-        """
-        This function gets particles from this collection that are themselves stored in another object of an equi-
-        structured ParticleCollection.
-        """
-        assert same_class is not None, f"Trying to get another {type(self)} from this one, but the other one is None - invalid operation."
-        assert type(same_class) is type(self)
-
-    @abstractmethod
-    def get_collection(self, pcollection):
-        """
-        This function gets particles from this collection that are themselves stored in a ParticleCollection, which
-        is differently structured than this one. That means the other-collection has to be re-formatted first in an
-        intermediary format.
-        """
-        assert pcollection is not None, "Trying to get another particle collection from this one, but the other one is None - invalid operation."
-        assert isinstance(pcollection, ParticleCollection), "Trying to get another particle collection from this one, but the other is not of the type of 'ParticleCollection' - invalid operation."
-        assert type(pcollection) is not type(self)
-
-    @abstractmethod
-    def get_multi_by_PyCollection_Particles(self, pycollectionp):
-        """
-        This function gets particles from this collection, which are themselves in common Python collections, such as
-        lists, dicts and numpy structures. We can either directly get the referred Particle instances (for internally-
-        ordered collections, e.g. ordered lists, sets, trees) or we may need to parse each instance for its index (for
-        random-access structures), which results in a considerable performance malus.
-
-        For collections where get-by-object incurs a performance malus, it is advisable to multi-get particles
-        by indices or IDs.
-        """
-        assert type(pycollectionp) in [list, dict, np.ndarray], "Trying to get a collection of Particles, but their container is not a valid Python-collection - invalid operation."
-
-    @abstractmethod
-    def get_multi_by_indices(self, indices):
-        """
-        This function gets particles from this collection based on their indices. This works best for random-access
-        collections (e.g. numpy's ndarrays, dense matrices and dense arrays), whereas internally ordered collections
-        shall rather use a get-via-object-reference strategy.
-        """
-        assert indices is not None, "Trying to get particles by their collection indices, but the index list is None - invalid operation."
-        assert type(indices) in [list, dict, np.ndarray], "Trying to get particles by their IDs, but the ID container is not a valid Python-collection - invalid operation."
-        if type(indices) is not dict:
-            assert len(indices) == 0 or type(indices[0]) in [int, np.int32, np.intp], "Trying to get particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
-        else:
-            assert len(list(indices.values())) == 0 or type(list(indices.values())[0]) in [int, np.int32, np.intp], "Trying to get particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
-
-    @abstractmethod
-    def get_multi_by_IDs(self, ids):
-        """
-        This function gets particles from this collection based on their IDs. For collections where this removal
-        strategy would require a collection transformation or by-ID parsing, it is advisable to rather apply a get-
-        by-objects or get-by-indices scheme.
-        """
-        assert ids is not None, "Trying to get particles by their IDs, but the ID list is None - invalid operation."
-        assert type(ids) in [list, dict, np.ndarray], "Trying to get particles by their IDs, but the ID container is not a valid Python-collection - invalid operation."
-        if type(ids) is not dict:
-            assert len(ids) == 0 or type(ids[0]) in [np.int64, np.uint64], "Trying to get particles by their IDs, but the ID type in the Python collection is not a 64-bit (signed or unsigned) integer - invalid operation."
-        else:
-            assert len(list(ids.values())) == 0 or type(list(ids.values())[0]) in [np.int64, np.uint64], "Trying to get particles by their IDs, but the ID type in the Python collection is not a 64-bit (signed or unsigned) integer - invalid operation."
-
-    def __add__(self, other):
-        """
-        This is a generic super-method to add one- or multiple objects to this collection. Ideally, it just discerns
-        between the types of the 'other' parameter, and then forwards the call to the related specific function.
-
-        Comment/Annotation:
-        Functions for adding multiple objects are more specialised than just a for-each loop of single-item addition,
-        because certain data structures can add multiple objects in-bulk faster with specialised function than making a
-        roundtrip per-item add operation. Because of the sheer size of those containers and the resulting
-        performance demands, we need to make use of those specialised 'add' functions, where available.
-        """
-        if other is None:
-            return
-        if type(other) is type(self):
-            self.add_same(other)
-        elif isinstance(other, ParticleCollection):
-            self.add_collection(other)
-        else:
-            self.add_single(other)
-
-    @abstractmethod
-    def add_collection(self, pcollection):
-        """
-        Adds another, differently structured ParticleCollection to this collection. This is done by, for example,
-        appending/adding the items of the other collection to this collection.
-        """
-        assert pcollection is not None, "Trying to add another particle collection to this one, but the other one is None - invalid operation."
-        assert isinstance(pcollection, ParticleCollection), "Trying to add another particle collection to this one, but the other is not of the type of 'ParticleCollection' - invalid operation."
-        assert type(pcollection) is not type(self)
-
-    @abstractmethod
-    def add_single(self, particle_obj):
-        """
-        Adding a single Particle to the collection - either as a 'Particle; object in parcels itself, or
-        via its ParticleAccessor.
-        """
-        assert (isinstance(particle_obj, BaseParticleAccessor) or isinstance(particle_obj, ScipyParticle))
-
-    @abstractmethod
-    def add_same(self, same_class):
-        """
-        Adds another, equi-structured ParticleCollection to this collection. This is done by concatenating
-        both collections. The fact that they are of the same ParticleCollection's derivative simplifies
-        parsing and concatenation.
-        """
-        assert same_class is not None, f"Trying to add another {type(self)} to this one, but the other one is None - invalid operation."
-        assert type(same_class) is type(self)
-
-    @abstractmethod
-    def __iadd__(self, same_class):
-        """
-        Performs an incremental addition of the equi-structured ParticleCollections, such to allow
-
-        a += b,
-
-        with 'a' and 'b' begin the two equi-structured objects (or: 'b' being and individual object).
-        This operation is equal to an in-place addition of (an) element(s).
-        """
-        assert same_class is not None
-        assert type(same_class) is type(self), f"Trying to increment-add collection of type {type(same_class)} into collection of type {type(self)} - invalid operation."
-
-    @abstractmethod
-    def insert(self, obj, index=None):
-        """
-        This function allows to 'insert' a Particle (as object or via its accessor) into this collection. This method
-        needs to be specified to each collection individually. Some collections (e.g. unordered list) allow to define
-        the index where the object is to be inserted. Some collections can optionally insert an object at a specific
-        position - at a significant speed- and memory malus cost (e.g. vectors, arrays, dense matrices). Some
-        collections that manage a specified indexing order internally (e.g. ordered lists, sets, trees), and thus
-        have no use for an 'index' parameter. For those collections with an internally-enforced order, the function
-        mapping equates to:
-
-        insert(obj) -> add_single(obj)
-        """
-        pass
-
-    @abstractmethod
-    def push(self, particle_obj):
-        """
-        This function pushes a Particle (as object or via its accessor) to the end of a collection ('end' definition
-        depends on the specific collection itself). For collections with an inherent indexing order (e.g. ordered lists,
-        sets, trees), the function just includes the object at its pre-defined position (i.e. not necessarily at the
-        end). For the collections, the function mapping equates to:
-
-        int32 push(particle_obj) -> add_single(particle_obj); return -1;
-
-        This function further returns the index, at which position the Particle has been inserted. By definition,
-        the index is positive, thus: a return of '-1' indicates push failure, NOT the last position in the collection.
-        Furthermore, collections that do not work on an index-preserving manner also return '-1'.
-        """
-        pass
-
-    @abstractmethod
-    def append(self, particle_obj):
-        """
-        This function appends a Particle (as object or via its accessor) to the end of a collection ('end' definition
-        depends on the specific collection itself). For collections with an inherent indexing order (e.g. ordered lists,
-        sets, trees), the function just includes the object at its pre-defined position (i.e. not necessarily at the
-        end). For the collections, the function mapping equates to:
-
-        append(particle_obj) -> add_single(particle_obj)
-
-        The function - in contrast to 'push' - does not return the index of the inserted object.
-        """
-        pass
-
-    @abstractmethod
-    def __delitem__(self, key):
-        """
-        This is the high-performance method to delete a specific object from this collection.
-        As the most-performant way depends on the specific collection in question, the function is abstract.
-
-        Highlight for the specific implementation:
-        The 'key' parameter should still be evaluated for being a single or a multi-entry delete, and needs to check
-        that it received the correct type of 'indexing' argument (i.e. index, id or iterator).
-        """
-        pass
-
-    def delete(self, key):
-        """
-        This is the generic super-method to indicate obejct deletion of a specific object from this collection.
-
-        Comment/Annotation:
-        Functions for deleting multiple objects are more specialised than just a for-each loop of single-item deletion,
-        because certain data structures can delete multiple objects in-bulk faster with specialised function than making a
-        roundtrip per-item delete operation. Because of the sheer size of those containers and the resulting
-        performance demands, we need to make use of those specialised 'del' functions, where available.
-        """
-        if key is None:
-            return
-        if type(key) in [int, np.int32, np.intp]:
-            self.delete_by_index(key)
-        elif type(key) in [np.int64, np.uint64]:
-            self.delete_by_ID(key)
-
-    @abstractmethod
-    def delete_by_index(self, index):
-        """
-        This method deletes a particle from the  the collection based on its index. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection.
-        """
-        assert type(index) in [int, np.int32, np.intp], f"Trying to delete a particle by index, but index {index} is not a 32-bit integer - invalid operation."
-
-    @abstractmethod
-    def delete_by_ID(self, id):
-        """
-        This method deletes a particle from the  the collection based on its ID. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection.
-        """
-        assert type(id) in [np.int64, np.uint64], f"Trying to delete a particle by ID, but ID {id} is not a 64-bit (signed or unsigned) integer - invalid operation."
-
-    def __sub__(self, other):
-        """
-        This is a high-performance method to remove an object (via their object, their ParticleAccessor,
-        their ID or their index) from the collection. As the function applies to collections itself, it maps directly
-        to:
-
-        a-b -> a.remove(b)
-        """
-        if other is None:
-            return
-        if type(other) is type(self):
-            self.remove_same(other)
-        elif isinstance(other, ParticleCollection):
-            self.remove_collection(other)
-
-    def remove(self, other):
-        """
-        This is a generic super-method to remove one- or multiple Particles (via their object, their ParticleAccessor,
-        their ID or their index) from the collection. Ideally, it just discerns between the types of the 'other'
-        parameter, and then forwards the call to the related specific function.
-
-        Comment/Annotation:
-        Functions for removing multiple objects are more specialised than just a for-each loop of single-item removal,
-        because certain data structures can remove multiple objects faster with specialised function than making a
-        roundtrip per-item check-and-remove operation. Because of the sheer size of those containers and the resulting
-        performance demands, we need to make use of those specialised 'remove' functions, where available.
-        """
-        if other is None:
-            return
-        if type(other) is type(self):
-            self.remove_same(other)
-        elif isinstance(other, ParticleCollection):
-            self.remove_collection(other)
-        elif type(other) in [list, dict, np.ndarray]:
-            # multi-removal routines - hard to discern at this point
-            if type(other) is not dict:
-                if len(other) == 0:
-                    return
-                elif type(other[0]) in [int, np.int32, np.intp]:
-                    self.remove_multi_by_indices(other)
-                elif type(other[0]) in [np.int64, np.uint64]:
-                    self.remove_multi_by_IDs(other)
-                else:
-                    self.remove_multi_by_PyCollection_Particles(other)
-            else:
-                if len(list(other.values())) == 0:
-                    return
-                elif type(list(other.values())[0]) in [int, np.int32, np.intp]:
-                    self.remove_multi_by_indices(other)
-                elif type(list(other.values())[0]) in [np.int64, np.uint64]:
-                    self.remove_multi_by_IDs(other)
-                else:
-                    self.remove_multi_by_PyCollection_Particles(other)
-        elif type(other) in [int, np.int32, np.intp]:
-            self.remove_single_by_index(other)
-        elif type(other) in [np.int64, np.uint64]:
-            self.remove_single_by_ID(other)
-        else:
-            self.remove_single_by_object(other)
-
-    @abstractmethod
-    def remove_single_by_index(self, index):
-        """
-        This function removes a (particle) object from the collection based on its index within the collection. For
-        collections that are not based on random access (e.g. ordered lists, sets, trees), this function involves a
-        translation of the index into the specific object reference in the collection - or (if unavoidable) the
-        translation of the collection from a none-indexable, none-random-access structure into an indexable structure,
-        and then perform the removal.
-        In cases where a removal-by-index would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-object or remove-by-ID.
-        """
-        assert type(index) in [int, np.int32, np.intp], f"Trying to remove a particle by index, but index {index} is not a 32-bit integer - invalid operation."
-
-    @abstractmethod
-    def remove_single_by_object(self, particle_obj):
-        """
-        This function removes a (particle) object from the collection based on its actual object. For collections that
-        are random-access and based on indices (e.g. unordered list, vectors, arrays and dense matrices), this function
-        would involves a parsing of the whole list and translation of the object into an index in the collection to
-        perform the removal - which results in a significant performance malus.
-        In cases where a removal-by-object would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-index or remove-by-ID.
-        """
-        assert (isinstance(particle_obj, BaseParticleAccessor) or isinstance(particle_obj, ScipyParticle))
-
-    @abstractmethod
-    def remove_single_by_ID(self, id):
-        """
-        This function removes a (particle) object from the collection based on the object's ID. For some collections,
-        this operation may involve a parsing of the whole list and translation of the object's ID into an index  or an
-        object reference in the collection in order to perform the removal - which results in a significant performance
-        malus.
-        In cases where a removal-by-ID would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-object or remove-by-index.
-        """
-        assert type(id) in [np.int64, np.uint64], f"Trying to remove a particle by ID, but ID {id} is not a 64-bit (signed or unsigned) iteger - invalid operation."
-
-    @abstractmethod
-    def remove_same(self, same_class):
-        """
-        This function removes particles from this collection that are themselves stored in another object of an equi-
-        structured ParticleCollection. As the structures of both collections are the same, a more efficient M-in-N
-        removal can be applied without an in-between reformatting.
-        """
-        assert same_class is not None, f"Trying to remove another {type(self)} from this one, but the other one is None - invalid operation."
-        assert type(same_class) is type(self)
-
-    @abstractmethod
-    def remove_collection(self, pcollection):
-        """
-        This function removes particles from this collection that are themselves stored in a ParticleCollection, which
-        is differently structured than this one. Tht means the removal first requires the removal-collection to be re-
-        formatted in an intermediary format, before executing the removal.
-        That said, this method should still be at least as efficient as a removal via common Python collections (i.e.
-        lists, dicts, numpy's nD arrays & dense arrays). Despite this, due to the reformatting, in some cases it may
-        be more efficient to remove items then rather by IDs oder indices.
-        """
-        assert pcollection is not None, "Trying to remove another particle collection from this one, but the other one is None - invalid operation."
-        assert isinstance(pcollection, ParticleCollection), "Trying to remove another particle collection from this one, but the other is not of the type of 'ParticleCollection' - invalid operation."
-        assert type(pcollection) is not type(self)
-
-    @abstractmethod
-    def remove_multi_by_PyCollection_Particles(self, pycollectionp):
-        """
-        This function removes particles from this collection, which are themselves in common Python collections, such as
-        lists, dicts and numpy structures. In order to perform the removal, we can either directly remove the referred
-        Particle instances (for internally-ordered collections, e.g. ordered lists, sets, trees) or we may need to parse
-        each instance for its index (for random-access structures), which results in a considerable performance malus.
-
-        For collections where removal-by-object incurs a performance malus, it is advisable to multi-remove particles
-        by indices or IDs.
-        """
-        assert type(pycollectionp) in [list, dict, np.ndarray], "Trying to remove a collection of Particles, but their container is not a valid Python-collection - invalid operation."
-
-    @abstractmethod
-    def remove_multi_by_indices(self, indices):
-        """
-        This function removes particles from this collection based on their indices. This works best for random-access
-        collections (e.g. numpy's ndarrays, dense matrices and dense arrays), whereas internally ordered collections
-        shall rather use a removal-via-object-reference strategy.
-        """
-        assert indices is not None, "Trying to remove particles by their collection indices, but the index list is None - invalid operation."
-        assert type(indices) in [list, dict, np.ndarray], "Trying to remove particles by their indices, but the index container is not a valid Python-collection - invalid operation."
-        if type(indices) is not dict:
-            assert len(indices) == 0 or type(indices[0]) in [int, np.int32, np.intp], "Trying to remove particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
-        else:
-            assert len(list(indices.values())) == 0 or type(list(indices.values())[0]) in [int, np.int32, np.intp], "Trying to remove particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
-
-    @abstractmethod
-    def remove_multi_by_IDs(self, ids):
-        """
-        This function removes particles from this collection based on their IDs. For collections where this removal
-        strategy would require a collection transformation or by-ID parsing, it is advisable to rather apply a removal-
-        by-objects or removal-by-indices scheme.
-        """
-        assert ids is not None, "Trying to remove particles by their IDs, but the ID list is None - invalid operation."
-        assert type(ids) in [list, dict, np.ndarray], "Trying to remove particles by their IDs, but the ID container is not a valid Python-collection - invalid operation."
-        if type(ids) is not dict:
-            assert len(ids) == 0 or type(ids[0]) in [np.int64, np.uint64], "Trying to remove particles by their IDs, but the ID type in the Python collection is not a 64-bit (signed or unsigned) integer - invalid operation."
-        else:
-            assert len(list(ids.values())) == 0 or type(list(ids.values())[0]) in [np.int64, np.uint64], "Trying to remove particles by their IDs, but the ID type in the Python collection is not a 64-bit (signed or unsigned) integer - invalid operation."
-
-    @abstractmethod
-    def __isub__(self, other):
-        """
-        This method performs an incremental removal of the equi-structured ParticleCollections, such to allow
-
-        a -= b,
-
-        with 'a' and 'b' begin the two equi-structured objects (or: 'b' being and individual object).
-        This operation is equal to an in-place removal of (an) element(s).
-        """
-        pass
-
-    def pop(self, other):
-        """
-        This function pops a Particle (as object or via its accessor) from a collection.
-
-        This function removes the particle and then returns it.
-
-        Comment/Annotation:
-        Functions for popping multiple objects are more specialised than just a for-each loop of single-item pop,
-        because certain data structures can pop multiple objects faster with specialised function than making a
-        roundtrip per-item check-and-pop operation. Because of the sheer size of those containers and the resulting
-        performance demands, we need to make use of those specialised 'pop' functions, where available.
-        """
-        if other is None:
-            return
-        if type(other) in [int, np.int32, np.intp]:
-            return self.pop_single_by_index(other)
-        elif type(other) in [np.int64, np.uint64]:
-            return self.pop_single_by_ID(other)
-        elif type(other) in [list, dict, np.ndarray]:
-            # multi-removal routines - hard to discern at this point
-            if type(other) is not dict:
-                if len(other) == 0:
-                    return None
-                elif type(other[0]) in [int, np.int32, np.intp]:
-                    return self.pop_multi_by_indices(other)
-                elif type(other[0]) in [np.int64, np.uint64]:
-                    return self.pop_multi_by_IDs(other)
-            else:
-                if len(list(other.values())) == 0:
-                    return None
-                elif type(list(other.values())[0]) in [int, np.int32, np.intp]:
-                    return self.pop_multi_by_indices(other)
-                elif type(list(other.values())[0]) in [np.int64, np.uint64]:
-                    return self.pop_multi_by_IDs(other)
-
-    @abstractmethod
-    def pop_single_by_index(self, index):
-        """
-        Searches for Particle at index 'index', removes that Particle from the Collection and returns that Particle (or: ParticleAccessor).
-        If index is None, return last item (-1);
-        If index < 0: return from 'end' of collection.
-        If index is out of bounds, throws and OutOfRangeException.
-        If Particle cannot be retrieved, returns None.
-        """
-        assert type(index) in [int, np.int32, np.intp], f"Trying to pop a particle by index, but index {index} is not a 32-bit integer - invalid operation."
-        return None
-
-    @abstractmethod
-    def pop_single_by_ID(self, id):
-        """
-        Searches for Particle with ID 'id', removes that Particle from the Collection and returns that Particle (or: ParticleAccessor).
-        If Particle cannot be retrieved (e.g. because the ID is not available), returns None.
-        """
-        assert type(id) in [np.int64, np.uint64], f"Trying to pop a particle by ID, but ID {id} is not a 64-bit (signed or unsigned) iteger - invalid operation."
-        return None
-
-    @abstractmethod
-    def pop_multi_by_indices(self, indices):
-        """
-        Searches for Particles with the indices registered in 'indices', removes the Particles from the Collection and returns the Particles (or: their ParticleAccessors).
-        If indices is None -> Particle cannot be retrieved -> Assert-Error and return None
-        If index is None, return last item (-1);
-        If index < 0: return from 'end' of collection.
-        If index in 'indices' is out of bounds, throws and OutOfRangeException.
-        If Particles cannot be retrieved, returns None.
-        """
-        assert indices is not None, "Trying to pop particles by their collection indices, but the index list is None - invalid operation."
-        assert type(indices) in [list, dict, np.ndarray], "Trying to pop particles by their IDs, but the ID container is not a valid Python-collection - invalid operation."
-        if type(indices) is not dict:
-            assert len(indices) == 0 or type(indices[0]) in [int, np.int32, np.intp], "Trying to pop particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
-        else:
-            assert len(list(indices.values())) == 0 or type(list(indices.values())[0]) in [int, np.int32, np.intp], "Trying to pop particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
-        return None
-
-    @abstractmethod
-    def pop_multi_by_IDs(self, ids):
-        """
-        Searches for Particles with the IDs registered in 'ids', removes the Particles from the Collection and returns the Particles (or: their ParticleAccessors).
-        If Particles cannot be retrieved (e.g. because the IDs are not available), returns None.
-        """
-        assert ids is not None, "Trying to pop particles by their IDs, but the ID list is None - invalid operation."
-        assert type(ids) in [list, dict, np.ndarray], "Trying to pop particles by their IDs, but the ID container is not a valid Python-collection - invalid operation."
-        if type(ids) is not dict:
-            assert len(ids) == 0 or type(ids[0]) in [np.int64, np.uint64], "Trying to pop particles by their IDs, but the ID type in the Python collection is not a 64-bit (signed or unsigned) integer - invalid operation."
-        else:
-            assert len(list(ids.values())) == 0 or type(list(ids.values())[0]) in [np.int64, np.uint64], "Trying to pop particles by their IDs, but the ID type in the Python collection is not a 64-bit (signed or unsigned) integer - invalid operation."
-        return None
-
-    @abstractmethod
-    def _clear_deleted_(self):
-        """
-        This (protected) function physically removes particles from the collection whose status is set to 'DELETE'.
-        It is the logical finalisation method of physically deleting particles that have been marked for deletion and
-        that have not otherwise been recovered.
-        This methods in heavily dependent on the actual collection type and should be implemented very specific
-        to the actual data structure, to remove objects 'the fastest way possible'.
-        """
-        pass
-
-    @abstractmethod
-    def merge(self, same_class=None):
-        """
-        This function merge two strictly equally-structured ParticleCollections into one. This can be, for example,
-        quite handy to merge two particle subsets that - due to continuous removal - become too small to be effective.
-
-        On the other hand, this function can also internally merge individual particles that are tagged by status as
-        being 'merged' (see the particle status for information on that).
-
-        In order to distinguish both use cases, we can evaluate the 'same_class' parameter. In cases where this is
-        'None', the merge operation semantically refers to an internal merge of individual particles - otherwise,
-        it performs a 2-collection merge.
-
-        Comment: the function can be simplified later by pre-evaluating the function parameter and then reference
-        the individual, specific functions for internal- or external merge.
-
-        The function shall return the merged ParticleCollection.
-        """
-        return None
-
-    @abstractmethod
-    def split(self, indices=None):
-        """
-        This function splits this collection into two disect equi-structured collections. The reason for it can, for
-        example, be that the set exceeds a pre-defined maximum number of elements, which for performance reasons
-        mandates a split.
-
-        On the other hand, this function can also internally split individual particles that are tagged byt status as
-        to be 'split' (see the particle status for information on that).
-
-        In order to distinguish both use cases, we can evaluate the 'indices' parameter. In cases where this is
-        'None', the split operation semantically refers to an internal split of individual particles - otherwise,
-        it performs a collection-split.
-
-        Comment: the function can be simplified later by pre-evaluating the function parameter and then reference
-        the individual, specific functions for element- or collection split.
-
-        The function shall return the newly created or extended Particle collection, i.e. either the collection that
-        results from a collection split or this very collection, containing the newly-split particles.
-        """
-        return None
-
-    def __str__(self):
-        """
-        This function returns and informative string about the collection (i.e. the type of collection) and a summary
-        of its internal, distinct values.
-        """
-        return f"ParticleCollection - N: {self._ncount}"
-
-    @abstractmethod
-    def toArray(self):
-        """
-        This function converts (or: transforms; reformats; translates) this collection into an array-like structure
-        (e.g. Python list or numpy nD array) that can be addressed by index. In the common case of 'no ID recovery',
-        the global ID and the index match exactly.
-
-        While this function may be very convenient for may users, it is STRONGLY DISADVISED to use the function to
-        often, and the performance- and memory overhead malus may be exceed any speed-up one could get from optimised
-        data structures - in fact, for large collections with an implicit-order structure (i.e. ordered lists, sets,
-        trees, etc.), this may be 'the most constly' function in any kind of simulation.
-
-        It can be - though - useful at the final stage of a simulation to dump the results to disk.
-        """
-        pass
-
-    def __len__(self):
-        """This function returns the length, in terms of 'number of elements, of a collection."""
-        return self._ncount
-
-    @abstractmethod
-    def __sizeof__(self):
-        """
-        This function returns the size in actual bytes required in memory to hold the collection. Ideally and simply,
-        the size is computed as follows:
-
-        sizeof(self) = len(self) * sizeof(pclass)
-        """
-        pass
-
-    def empty(self):
-        """
-        This function retuns a boolean value, expressing if a collection is emoty (i.e. does not [anymore] contain any
-        elements) or not.
-        """
-        return (self._ncount < 1)
-
-    @abstractmethod
-    def clear(self):
-        """
-        This function physically removes all elements of the collection, yielding an empty collection as result of the
-        operation.
-        """
-        pass
-
-
-class ParticleCollection(Collection):
-    _pu_indicators = None  # formerly: partitions
-    _pu_centers = None
-    _offset = 0
-    _pclass = None
-    _ptype = None
-    _latlondepth_dtype = np.float32
-    _data = None  # formerly: particle_data
-
-    def __init__(self):
-        """Constructor for ParticleCollection.
-
-        Initializes a particle collection by pre-allocating memory (where needed), initialising indexing structures
-        (where needed), initialising iterators (if maintaining a persistent iterator) and preparing the C-JIT-glue.
-
-        Furthermore, initialising the PU distribution and its distribution metrics.
-        """
-        self._ncount = -1
-        self._pu_indicators = None  # formerly: partitions
-        self._pu_centers = None
-        self._offset = 0
-        self._pclass = None
-        self._ptype = None
-        self._latlondepth_dtype = np.float32
-        self._data = None  # formerly: particle_data
-        super().__init__()
-
-    def __del__(self):
-        """Destructor for ParticleCollection."""
-        pass
-
-    @property
-    def pu_indicators(self):
-        """
-        The 'pu_indicator' is an [array or dictionary]-of-indicators, where each indicator entry tells per item
-        (i.e. particle) in the collection to which processing unit (PU) in a parallelised setup it belongs to.
-        """
-        return self._pu_indicators
-
-    @property
-    def pu_centers(self):
-        """
-        The 'pu_centers" is an array of 2D/3D vectors storing the center of each cluster-of-particle partion that
-        is handled by the respective PU. Storing the centers allows us to only run the initial kMeans segmentation
-        once and then, on later particle additions, just (i) makes a closest-distance calculation, (ii) attaches the
-        new particle to the closest cluster and (iii) updates the new cluster center. The last part may require at some
-        point to merge overlaying clusters and them split them again in equi-sized partions.
-        """
-        return self._pu_centers
-
-    @property
-    def pclass(self):
-        """Stores the actual class type of the particles allocated and managed in this collection."""
-        return self._pclass
-
-    @property
-    def ptype(self):
-        """
-        'ptype' returns an instance of the particular type of class 'ParticleType' of the particle class of the particles
-        in this collection.
-
-        basically:
-        pytpe -> pclass().getPType()
-        """
-        return self._ptype
-
-    @property
-    def lonlatdepth_dtype(self):
-        """
-        'lonlatdepth_dtype' stores the numeric data type that is used to represent the lon, lat and depth of a particle.
-        This can be either 'float32' (default) or 'float64'
-        """
-        return self._lonlatdepth_dtype
-
-    @property
-    def data(self):
-        """
-        'data' is a reference to the actual barebone-storage of the particle data, and thus depends directly on the
-        specific collection in question.
-        """
-        return self._data
-
-    @property
-    def particle_data(self):
-        """
-        'particle_data' is a reference to the actual barebone-storage of the particle data, and thus depends directly on the
-        specific collection in question. This property is just available for convenience and backward-compatibility, and
-        this returns the same as 'data'.
-        """
-        return self._data
-
-    @abstractmethod
-    def cstruct(self):
-        """Returns the ctypes mapping of the particle data. This depends on the specific structure in question."""
-        pass
-
-    @abstractmethod
-    def __getattr__(self, name):
-        """
-        Access a single property of all particles.
-
-        Parameters
-        ----------
-        name : str
-            Name of the property to access
-
-
-        Notes
-        -----
-        This is a fallback implementation, and it is NOT efficient.
-        Specific datastructures may implement a more efficient variant.
-        """
-        for v in self.ptype.variables:
-            if v.name == name:
-                return np.array([getattr(p, name) for p in self], dtype=v.dtype)
-        if name in self.__dict__ and name[0] != '_':
-            return self.__dict__[name]
-        else:
-            return False
-
-    def has_write_once_variables(self):
-        for var in self.ptype.variables:
-            if var.to_write == 'once':
-                return True
-        return False
-
-    @abstractmethod
-    def getvardata(self, var, indices=None):
-        pass
-
-    @abstractmethod
-    def setvardata(self, var, index, val):
-        pass
-
-    @abstractmethod
-    def set_variable_write_status(self, var, write_status):
-        """
-        Method to set the write status of a Variable
-
-        This function depends on the specific collection in question and thus needs to be specified in specific
-         derivatives classes.
-
-        Parameters
-        ----------
-        var : str
-            Name of the variable
-        write_status : bool, str
-            Write status of the variable (True, False or 'once')
-        """
-        pass
diff --git a/parcels/collection/collectionsoa.py b/parcels/collection/collectionsoa.py
deleted file mode 100644
index 49467da79..000000000
--- a/parcels/collection/collectionsoa.py
+++ /dev/null
@@ -1,1042 +0,0 @@
-from bisect import bisect_left
-from ctypes import POINTER, Structure
-from operator import attrgetter
-
-import numpy as np
-
-from parcels.collection.collections import ParticleCollection
-from parcels.collection.iterators import (
-    BaseParticleAccessor,
-    BaseParticleCollectionIterable,
-    BaseParticleCollectionIterator,
-)
-from parcels.particle import JITParticle, ScipyParticle  # noqa
-from parcels.tools.loggers import logger
-from parcels.tools.statuscodes import NotTestedError
-
-try:
-    from mpi4py import MPI
-except:
-    MPI = None
-if MPI:
-    try:
-        from sklearn.cluster import KMeans
-    except:
-        KMeans = None
-
-
-def partitionParticlesMPI_default(coords, mpi_size=1):
-    """This function takes the coordinates of the particle starting
-    positions and returns which MPI process will process each
-    particle.
-
-    Input:
-
-    coords: numpy array with rows of [lon, lat] so that
-    coords.shape[0] is the number of particles and coords.shape[1] is 2.
-
-    mpi_size=1: the number of MPI processes.
-
-    Output:
-
-    mpiProcs: an integer array with values from 0 to mpi_size-1
-    specifying which MPI job will run which particles. len(mpiProcs)
-    must equal coords.shape[0]
-    """
-    if KMeans:
-        kmeans = KMeans(n_clusters=mpi_size, random_state=0).fit(coords)
-        mpiProcs = kmeans.labels_
-    else:  # assigning random labels if no KMeans (see https://github.com/OceanParcels/parcels/issues/1261)
-        logger.warning_once('sklearn needs to be available if MPI is installed. '
-                            'See https://docs.oceanparcels.org/en/latest/installation.html#installation-for-developers for more information')
-        mpiProcs = np.randint(0, mpi_size, size=coords.shape[0])
-
-    return mpiProcs
-
-
-class ParticleCollectionSOA(ParticleCollection):
-
-    def __init__(self, pclass, lon, lat, depth, time, lonlatdepth_dtype, pid_orig, ngrid=1, **kwargs):
-        """
-        Parameters
-        ----------
-        ngrid :
-            number of grids in the fieldset of the overarching ParticleSet - required for initialising the
-            field references of the ctypes-link of particles that are allocated
-        """
-        super(ParticleCollection, self).__init__()
-
-        assert pid_orig is not None, "particle IDs are None - incompatible with the collection. Invalid state."
-        pid = pid_orig + pclass.lastID
-
-        self._sorted = np.all(np.diff(pid) >= 0)
-
-        assert depth is not None, "particle's initial depth is None - incompatible with the collection. Invalid state."
-        assert lon.size == lat.size and lon.size == depth.size, (
-            'lon, lat, depth don''t all have the same lenghts')
-
-        assert lon.size == time.size, (
-            'time and positions (lon, lat, depth) don''t have the same lengths.')
-
-        # If a partitioning function for MPI runs has been passed into the
-        # particle creation with the "partition_function" kwarg, retrieve it here.
-        # If it has not, assign the default function, partitionParticlesMPI_defualt()
-        partition_function = kwargs.pop('partition_function', partitionParticlesMPI_default)
-
-        for kwvar in kwargs:
-            assert lon.size == kwargs[kwvar].size, (
-                f"{kwvar} and positions (lon, lat, depth) don't have the same lengths.")
-
-        offset = np.max(pid) if (pid is not None) and len(pid) > 0 else -1
-        if MPI:
-            mpi_comm = MPI.COMM_WORLD
-            mpi_rank = mpi_comm.Get_rank()
-            mpi_size = mpi_comm.Get_size()
-
-            if lon.size < mpi_size and mpi_size > 1:
-                raise RuntimeError('Cannot initialise with fewer particles than MPI processors')
-
-            if mpi_size > 1:
-                if partition_function is not False:
-                    if (self._pu_indicators is None) or (len(self._pu_indicators) != len(lon)):
-                        if mpi_rank == 0:
-                            coords = np.vstack((lon, lat)).transpose()
-                            self._pu_indicators = partition_function(coords, mpi_size=mpi_size)
-                        else:
-                            self._pu_indicators = None
-                        self._pu_indicators = mpi_comm.bcast(self._pu_indicators, root=0)
-                    elif np.max(self._pu_indicators) >= mpi_size:
-                        raise RuntimeError('Particle partitions must vary between 0 and the number of mpi procs')
-                    lon = lon[self._pu_indicators == mpi_rank]
-                    lat = lat[self._pu_indicators == mpi_rank]
-                    time = time[self._pu_indicators == mpi_rank]
-                    depth = depth[self._pu_indicators == mpi_rank]
-                    pid = pid[self._pu_indicators == mpi_rank]
-                    for kwvar in kwargs:
-                        kwargs[kwvar] = kwargs[kwvar][self._pu_indicators == mpi_rank]
-                offset = MPI.COMM_WORLD.allreduce(offset, op=MPI.MAX)
-
-        pclass.setLastID(offset+1)
-
-        if lonlatdepth_dtype is None:
-            self._lonlatdepth_dtype = np.float32
-        else:
-            self._lonlatdepth_dtype = lonlatdepth_dtype
-        assert self._lonlatdepth_dtype in [np.float32, np.float64], \
-            'lon lat depth precision should be set to either np.float32 or np.float64'
-        pclass.set_lonlatdepth_dtype(self._lonlatdepth_dtype)
-        self._pclass = pclass
-
-        self._ptype = pclass.getPType()
-        self._data = {}
-        initialised = set()
-
-        self._ncount = len(lon)
-
-        for v in self.ptype.variables:
-            if v.name in ['xi', 'yi', 'zi', 'ti']:
-                self._data[v.name] = np.empty((len(lon), ngrid), dtype=v.dtype)
-            else:
-                self._data[v.name] = np.empty(self._ncount, dtype=v.dtype)
-
-        if lon is not None and lat is not None:
-            # Initialise from lists of lon/lat coordinates
-            assert self.ncount == len(lon) and self.ncount == len(lat), (
-                'Size of ParticleSet does not match length of lon and lat.')
-
-            # mimic the variables that get initialised in the constructor
-            self._data['lat'][:] = lat
-            self._data['lat_nextloop'][:] = lat
-            self._data['lon'][:] = lon
-            self._data['lon_nextloop'][:] = lon
-            self._data['depth'][:] = depth
-            self._data['depth_nextloop'][:] = depth
-            self._data['time'][:] = time
-            self._data['time_nextloop'][:] = time
-            self._data['id'][:] = pid
-            self._data['obs_written'][:] = 0
-
-            # special case for exceptions which can only be handled from scipy
-            self._data['exception'] = np.empty(self.ncount, dtype=object)
-
-            initialised |= {'lat', 'lat_nextloop', 'lon', 'lon_nextloop', 'depth', 'depth_nextloop', 'time', 'time_nextloop', 'id', 'obs_written'}
-
-            # any fields that were provided on the command line
-            for kwvar, kwval in kwargs.items():
-                if not hasattr(pclass, kwvar):
-                    raise RuntimeError(f'Particle class does not have Variable {kwvar}')
-                self._data[kwvar][:] = kwval
-                initialised.add(kwvar)
-
-            # initialise the rest to their default values
-            for v in self.ptype.variables:
-                if v.name in initialised:
-                    continue
-
-                if isinstance(v.initial, attrgetter):
-                    self._data[v.name][:] = v.initial(self)
-                else:
-                    self._data[v.name][:] = v.initial
-
-                initialised.add(v.name)
-        else:
-            raise ValueError("Latitude and longitude required for generating ParticleSet")
-        self._iterator = None
-        self._riterator = None
-
-    def __del__(self):
-        """Collection - Destructor"""
-        super().__del__()
-
-    def iterator(self):
-        self._iterator = ParticleCollectionIteratorSOA(self)
-        return self._iterator
-
-    def __iter__(self):
-        """Returns an Iterator that allows for forward iteration over the
-        elements in the ParticleCollection (e.g. `for p in pset:`).
-        """
-        return self.iterator()
-
-    def reverse_iterator(self):
-        self._riterator = ParticleCollectionIteratorSOA(self, True)
-        return self._riterator
-
-    def __reversed__(self):
-        """Returns an Iterator that allows for backwards iteration over
-        the elements in the ParticleCollection (e.g.
-        `for p in reversed(pset):`).
-        """
-        return self.reverse_iterator()
-
-    def __getitem__(self, index):
-        """
-        Access a particle in this collection using the fastest access
-        method for this collection - by its index.
-
-        Parameters
-        ----------
-        index : int
-            Index of the particle to access
-        """
-        return self.get_single_by_index(index)
-
-    def __getattr__(self, name):
-        """
-        Access a single property of all particles.
-
-        Parameters
-        ----------
-        name : str
-            Name of the property to access
-        """
-        for v in self.ptype.variables:
-            if v.name == name and name in self._data:
-                return self._data[name]
-        return False
-
-    def get_single_by_index(self, index):
-        """
-        This function gets a (particle) object from the collection based on its index within the collection. For
-        collections that are not based on random access (e.g. ordered lists, sets, trees), this function involves a
-        translation of the index into the specific object reference in the collection - or (if unavoidable) the
-        translation of the collection from a none-indexable, none-random-access structure into an indexable structure.
-        In cases where a get-by-index would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-ID.
-        """
-        super().get_single_by_index(index)
-
-        return ParticleAccessorSOA(self, index)
-
-    def get_single_by_object(self, particle_obj):
-        """
-        This function gets a (particle) object from the collection based on its actual object. For collections that
-        are random-access and based on indices (e.g. unordered list, vectors, arrays and dense matrices), this function
-        would involve a parsing of the whole list and translation of the object into an index in the collection - which
-        results in a significant performance malus.
-        In cases where a get-by-object would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-index or get-by-ID.
-
-        In this specific implementation, we cannot look for the object
-        directly, so we will look for one of its properties (the ID) that
-        has the nice property of being stored in an ordered list (if the
-        collection is sorted)
-        """
-        super().get_single_by_object(particle_obj)
-
-        return self.get_single_by_ID(particle_obj.id)
-
-    def get_single_by_ID(self, id):
-        """
-        This function gets a (particle) object from the collection based on the object's ID. For some collections,
-        this operation may involve a parsing of the whole list and translation of the object's ID into an index  or an
-        object reference in the collection - which results in a significant performance malus.
-        In cases where a get-by-ID would result in a performance malus, it is highly-advisable to use a different
-        get function, e.g. get-by-index.
-
-        This function uses binary search if we know the ID list to be sorted, and linear search otherwise. We assume
-        IDs are unique.
-        """
-        super().get_single_by_ID(id)
-
-        # Use binary search if the collection is sorted, linear search otherwise
-        index = -1
-        if self._sorted:
-            index = bisect_left(self._data['id'], id)
-            if index == len(self._data['id']) or self._data['id'][index] != id:
-                raise ValueError(f"Trying to access a particle with a non-existing ID: {id}.")
-        else:
-            index = np.where(self._data['id'] == id)[0][0]
-
-        return self.get_single_by_index(index)
-
-    def get_same(self, same_class):
-        """
-        This function gets particles from this collection that are themselves stored in another object of an equi-
-        structured ParticleCollection.
-        """
-        super().get_same(same_class)
-        raise NotImplementedError
-
-    def get_collection(self, pcollection):
-        """
-        This function gets particles from this collection that are themselves stored in a ParticleCollection, which
-        is differently structured than this one. That means the other-collection has to be re-formatted first in an
-        intermediary format.
-        """
-        super().get_collection(pcollection)
-        raise NotImplementedError
-
-    def get_multi_by_PyCollection_Particles(self, pycollectionp):
-        """
-        This function gets particles from this collection, which are themselves in common Python collections, such as
-        lists, dicts and numpy structures. We can either directly get the referred Particle instances (for internally-
-        ordered collections, e.g. ordered lists, sets, trees) or we may need to parse each instance for its index (for
-        random-access structures), which results in a considerable performance malus.
-
-        For collections where get-by-object incurs a performance malus, it is advisable to multi-get particles
-        by indices or IDs.
-        """
-        super().get_multi_by_PyCollection_Particles(pycollectionp)
-        raise NotImplementedError
-
-    def get_multi_by_indices(self, indices):
-        """
-        This function gets particles from this collection based on their indices. This works best for random-access
-        collections (e.g. numpy's ndarrays, dense matrices and dense arrays), whereas internally ordered collections
-        shall rather use a get-via-object-reference strategy.
-        """
-        raise NotTestedError
-        # super().get_multi_by_indices(indices)
-        # if type(indices) is dict:
-        #     indices = list(indices.values())
-        # return ParticleCollectionIteratorSOA(self, subset=indices)
-
-    def get_multi_by_IDs(self, ids):
-        """
-        This function gets particles from this collection based on their IDs. For collections where this removal
-        strategy would require a collection transformation or by-ID parsing, it is advisable to rather apply a get-
-        by-objects or get-by-indices scheme.
-
-        Note that this implementation assumes that IDs of particles are strictly increasing with increasing index. So
-        a particle with a larger index will always have a larger ID as well. The assumption often holds for this
-        datastructure as new particles always get a larger ID than any existing particle (IDs are not recycled)
-        and their data are appended at the end of the list (largest index). This allows for the use of binary search
-        in the look-up. The collection maintains a `sorted` flag to indicate whether this assumption holds.
-        """
-        raise NotTestedError
-        # super().get_multi_by_IDs(ids)
-        # if type(ids) is dict:
-        #     ids = list(ids.values())
-        #
-        # if len(ids) == 0:
-        #     return None
-        #
-        # # Use binary search if the collection is sorted, linear search otherwise
-        # indices = np.empty(len(ids), dtype=np.int32)
-        # if self._sorted:
-        #     # This is efficient if len(ids) << self.len
-        #     sorted_ids = np.sort(np.array(ids))
-        #     indices = self._recursive_ID_lookup(0, len(self._data['id']), sorted_ids)
-        # else:
-        #     indices = np.where(np.in1d(self._data['id'], ids))[0]
-        #
-        # return self.get_multi_by_indices(indices)
-
-    def _recursive_ID_lookup(self, low, high, sublist):
-        """Identify the middle element of the sublist and perform binary
-        search on it.
-
-        Parameters
-        ----------
-        low :
-            Lowerbound on the indices to search for IDs.
-        high :
-            Upperbound on the indices to search for IDs.
-        sublist : list
-            Sublist of IDs to look for.
-        """
-        raise NotTestedError
-        # median = floor(len(sublist) / 2)
-        # index = bisect_left(self._data['id'][low:high], sublist[median])
-        # if len(sublist) == 1:
-        #     # edge case
-        #     if index == len(self._data['id']) or \
-        #        self._data['id'][index] != sublist[median]:
-        #         return np.array([])
-        #     return np.array([index])
-        #
-        # # The edge-cases have to be handled slightly differently
-        # if index == len(self._data['id']):
-        #     # Continue with the same bounds, but drop the median.
-        #     return self._recursive_ID_lookup(low, high, np.delete(sublist, median))
-        # elif self._data['id'][index] != sublist[median]:
-        #     # We can split, because we received the index that the median
-        #     # ID would have been inserted in, but we do not return the
-        #     # index and keep it in our search space.
-        #     left = self._recursive_ID_lookup(low, index, sublist[:median])
-        #     right = self._recursive_ID_lookup(index, high, sublist[median + 1:])
-        #     return np.concatenate((left, right))
-        #
-        # # Otherwise, we located the median, so we include it in our
-        # # result, and split the search space on it, without including it.
-        # left = self._recursive_ID_lookup(low, index, sublist[:median])
-        # right = self._recursive_ID_lookup(index + 1, high, sublist[median + 1:])
-        # return np.concatenate((left, np.array(index), right))
-
-    def add_collection(self, pcollection):
-        """
-        Adds another, differently structured ParticleCollection to this collection. This is done by, for example,
-        appending/adding the items of the other collection to this collection.
-        """
-        super().add_collection(pcollection)
-        raise NotImplementedError
-
-    def add_single(self, particle_obj):
-        """
-        Adding a single Particle to the collection - either as a 'Particle; object in parcels itself, or
-        via its ParticleAccessor.
-        """
-        super().add_single(particle_obj)
-        raise NotImplementedError
-
-    def add_same(self, same_class):
-        """
-        Adds another, equi-structured ParticleCollection to this collection. This is done by concatenating
-        both collections. The fact that they are of the same ParticleCollection's derivative simplifies
-        parsing and concatenation.
-        """
-        super().add_same(same_class)
-
-        if same_class.ncount == 0:
-            return
-
-        if self._ncount == 0:
-            self._data = same_class._data
-            self._ncount = same_class.ncount
-            return
-
-        # Determine order of concatenation and update the sorted flag
-        if self._sorted and same_class._sorted \
-           and self._data['id'][0] > same_class._data['id'][-1]:
-            for d in self._data:
-                self._data[d] = np.concatenate((same_class._data[d], self._data[d]))
-            self._ncount += same_class.ncount
-        else:
-            if not (same_class._sorted
-                    and self._data['id'][-1] < same_class._data['id'][0]):
-                self._sorted = False
-            for d in self._data:
-                self._data[d] = np.concatenate((self._data[d], same_class._data[d]))
-            self._ncount += same_class.ncount
-
-    def __iadd__(self, same_class):
-        """
-        Performs an incremental addition of the equi-structured ParticleCollections, such to allow
-
-        a += b,
-
-        with 'a' and 'b' begin the two equi-structured objects (or: 'b' being and individual object).
-        This operation is equal to an in-place addition of (an) element(s).
-        """
-        self.add_same(same_class)
-        return self
-
-    def insert(self, obj, index=None):
-        """
-        This function allows to 'insert' a Particle (as object or via its accessor) into this collection. This method
-        needs to be specified to each collection individually. Some collections (e.g. unordered list) allow to define
-        the index where the object is to be inserted. Some collections can optionally insert an object at a specific
-        position - at a significant speed- and memory malus cost (e.g. vectors, arrays, dense matrices). Some
-        collections that manage a specified indexing order internally (e.g. ordered lists, sets, trees), and thus
-        have no use for an 'index' parameter. For those collections with an internally-enforced order, the function
-        mapping equates to:
-
-        insert(obj) -> add_single(obj)
-        """
-        raise NotImplementedError
-
-    def push(self, particle_obj):
-        """
-        This function pushes a Particle (as object or via its accessor) to the end of a collection ('end' definition
-        depends on the specific collection itself). For collections with an inherent indexing order (e.g. ordered lists,
-        sets, trees), the function just includes the object at its pre-defined position (i.e. not necessarily at the
-        end). For the collections, the function mapping equates to:
-
-        int32 push(particle_obj) -> add_single(particle_obj); return -1;
-
-        This function further returns the index, at which position the Particle has been inserted. By definition,
-        the index is positive, thus: a return of '-1' indicates push failure, NOT the last position in the collection.
-        Furthermore, collections that do not work on an index-preserving manner also return '-1'.
-        """
-        raise NotImplementedError
-
-    def append(self, particle_obj):
-        """
-        This function appends a Particle (as object or via its accessor) to the end of a collection ('end' definition
-        depends on the specific collection itself). For collections with an inherent indexing order (e.g. ordered lists,
-        sets, trees), the function just includes the object at its pre-defined position (i.e. not necessarily at the
-        end). For the collections, the function mapping equates to:
-
-        append(particle_obj) -> add_single(particle_obj)
-
-        The function - in contrast to 'push' - does not return the index of the inserted object.
-        """
-        raise NotImplementedError
-
-    def __delitem__(self, key):
-        """
-        This is the high-performance method to delete a specific object from this collection.
-        As the most-performant way depends on the specific collection in question, the function is abstract.
-
-        Highlight for the specific implementation:
-        The 'key' parameter should still be evaluated for being a single or a multi-entry delete, and needs to check
-        that it received the correct type of 'indexing' argument (i.e. index, id or iterator).
-        """
-        self.delete_by_index(key)
-
-    def delete_by_index(self, index):
-        """
-        This method deletes a particle from the  the collection based on its index. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection.
-        """
-        raise NotTestedError
-        # super().delete_by_index(index)
-        #
-        # self._data['state'][index] = StatusCode.Delete
-
-    def delete_by_ID(self, id):
-        """
-        This method deletes a particle from the  the collection based on its ID. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection.
-        """
-        raise NotTestedError
-        # super().delete_by_ID(id)
-        #
-        # # Use binary search if the collection is sorted, linear search otherwise
-        # index = -1
-        # if self._sorted:
-        #     index = bisect_left(self._data['id'], id)
-        #     if index == len(self._data['id']) or \
-        #        self._data['id'][index] != id:
-        #         raise ValueError("Trying to delete a particle with a non-existing ID: %s." % id)
-        # else:
-        #     index = np.where(self._data['id'] == id)[0][0]
-        #
-        # self.delete_by_index(index)
-
-    def remove_single_by_index(self, index):
-        """
-        This function removes a (particle) object from the collection based on its index within the collection. For
-        collections that are not based on random access (e.g. ordered lists, sets, trees), this function involves a
-        translation of the index into the specific object reference in the collection - or (if unavoidable) the
-        translation of the collection from a none-indexable, none-random-access structure into an indexable structure,
-        and then perform the removal.
-        In cases where a removal-by-index would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-object or remove-by-ID.
-        """
-        super().remove_single_by_index(index)
-
-        for d in self._data:
-            self._data[d] = np.delete(self._data[d], index, axis=0)
-
-        self._ncount -= 1
-
-    def remove_single_by_object(self, particle_obj):
-        """
-        This function removes a (particle) object from the collection based on its actual object. For collections that
-        are random-access and based on indices (e.g. unordered list, vectors, arrays and dense matrices), this function
-        would involves a parsing of the whole list and translation of the object into an index in the collection to
-        perform the removal - which results in a significant performance malus.
-        In cases where a removal-by-object would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-index or remove-by-ID.
-        """
-        super().remove_single_by_object(particle_obj)
-
-        # We cannot look for the object directly, so we will look for one of
-        # its properties that has the nice property of being stored in an
-        # ordered list
-        self.remove_single_by_ID(particle_obj.id)
-
-    def remove_single_by_ID(self, id):
-        """
-        This function removes a (particle) object from the collection based on the object's ID. For some collections,
-        this operation may involve a parsing of the whole list and translation of the object's ID into an index  or an
-        object reference in the collection in order to perform the removal - which results in a significant performance
-        malus.
-        In cases where a removal-by-ID would result in a performance malus, it is highly-advisable to use a different
-        removal functions, e.g. remove-by-object or remove-by-index.
-        """
-        raise NotTestedError
-        # super().remove_single_by_ID(id)
-        #
-        # # Use binary search if the collection is sorted, linear search otherwise
-        # index = -1
-        # if self._sorted:
-        #     index = bisect_left(self._data['id'], id)
-        #     if index == len(self._data['id']) or \
-        #        self._data['id'][index] != id:
-        #         raise ValueError("Trying to remove a particle with a non-existing ID: %s." % id)
-        # else:
-        #     index = np.where(self._data['id'] == id)[0][0]
-        #
-        # self.remove_single_by_index(index)
-
-    def remove_same(self, same_class):
-        """
-        This function removes particles from this collection that are themselves stored in another object of an equi-
-        structured ParticleCollection. As the structures of both collections are the same, a more efficient M-in-N
-        removal can be applied without an in-between reformatting.
-        """
-        super().remove_same(same_class)
-        raise NotImplementedError
-
-    def remove_collection(self, pcollection):
-        """
-        This function removes particles from this collection that are themselves stored in a ParticleCollection, which
-        is differently structured than this one. Tht means the removal first requires the removal-collection to be re-
-        formatted in an intermediary format, before executing the removal.
-        That said, this method should still be at least as efficient as a removal via common Python collections (i.e.
-        lists, dicts, numpy's nD arrays & dense arrays). Despite this, due to the reformatting, in some cases it may
-        be more efficient to remove items then rather by IDs oder indices.
-        """
-        super().remove_collection(pcollection)
-        raise NotImplementedError
-
-    def remove_multi_by_PyCollection_Particles(self, pycollectionp):
-        """
-        This function removes particles from this collection, which are themselves in common Python collections, such as
-        lists, dicts and numpy structures. In order to perform the removal, we can either directly remove the referred
-        Particle instances (for internally-ordered collections, e.g. ordered lists, sets, trees) or we may need to parse
-        each instance for its index (for random-access structures), which results in a considerable performance malus.
-
-        For collections where removal-by-object incurs a performance malus, it is advisable to multi-remove particles
-        by indices or IDs.
-        """
-        super().remove_multi_by_PyCollection_Particles(pycollectionp)
-        raise NotImplementedError
-
-    def remove_multi_by_indices(self, indices):
-        """
-        This function removes particles from this collection based on their indices. This works best for random-access
-        collections (e.g. numpy's ndarrays, dense matrices and dense arrays), whereas internally ordered collections
-        shall rather use a removal-via-object-reference strategy.
-        """
-        super().remove_multi_by_indices(indices)
-        if type(indices) is dict:
-            indices = list(indices.values())
-
-        for d in self._data:
-            self._data[d] = np.delete(self._data[d], indices, axis=0)
-
-        self._ncount -= len(indices)
-
-    def remove_multi_by_IDs(self, ids):
-        """
-        This function removes particles from this collection based on their IDs. For collections where this removal
-        strategy would require a collection transformation or by-ID parsing, it is advisable to rather apply a removal-
-        by-objects or removal-by-indices scheme.
-        """
-        raise NotTestedError
-        # super().remove_multi_by_IDs(ids)
-        # if type(ids) is dict:
-        #     ids = list(ids.values())
-        #
-        # if len(ids) == 0:
-        #     return
-        #
-        # # Use binary search if the collection is sorted, linear search otherwise
-        # indices = np.empty(len(ids), dtype=np.int32)
-        # if self._sorted:
-        #     # This is efficient if len(ids) << self.len
-        #     sorted_ids = np.sort(np.array(ids))
-        #     indices = self._recursive_ID_lookup(0, len(self._data['id']), sorted_ids)
-        # else:
-        #     indices = np.where(np.in1d(self._data['id'], ids))[0]
-        #
-        # self.remove_multi_by_indices(indices)
-
-    def __isub__(self, other):
-        """
-        This method performs an incremental removal of the equi-structured ParticleCollections, such to allow
-
-        a -= b,
-
-        with 'a' and 'b' begin the two equi-structured objects (or: 'b' being and individual object).
-        This operation is equal to an in-place removal of (an) element(s).
-        """
-        raise NotTestedError
-        # if other is None:
-        #     return
-        # if type(other) is type(self):
-        #     self.remove_same(other)
-        # elif (isinstance(other, BaseParticleAccessor)
-        #       or isinstance(other, ScipyParticle)):
-        #     self.remove_single_by_object(other)
-        # else:
-        #     raise TypeError("Trying to do an incremental removal of an element of type %s, which is not supported." % type(other))
-        # return self
-
-    def pop_single_by_index(self, index):
-        """
-        Searches for Particle at index 'index', removes that Particle from the Collection and returns that Particle (or: ParticleAccessor).
-        If index is None, return last item (-1);
-        If index < 0: return from 'end' of collection.
-        If index is out of bounds, throws and OutOfRangeException.
-        If Particle cannot be retrieved, returns None.
-        """
-        super().pop_single_by_index(index)
-        raise NotImplementedError
-
-    def pop_single_by_ID(self, id):
-        """
-        Searches for Particle with ID 'id', removes that Particle from the Collection and returns that Particle (or: ParticleAccessor).
-        If Particle cannot be retrieved (e.g. because the ID is not available), returns None.
-        """
-        super().pop_single_by_ID(id)
-        raise NotImplementedError
-
-    def pop_multi_by_indices(self, indices):
-        """
-        Searches for Particles with the indices registered in 'indices', removes the Particles from the Collection and returns the Particles (or: their ParticleAccessors).
-        If indices is None -> Particle cannot be retrieved -> Assert-Error and return None
-        If index is None, return last item (-1);
-        If index < 0: return from 'end' of collection.
-        If index in 'indices' is out of bounds, throws and OutOfRangeException.
-        If Particles cannot be retrieved, returns None.
-        """
-        super().pop_multi_by_indices(indices)
-        raise NotImplementedError
-
-    def pop_multi_by_IDs(self, ids):
-        """
-        Searches for Particles with the IDs registered in 'ids', removes the Particles from the Collection and returns the Particles (or: their ParticleAccessors).
-        If Particles cannot be retrieved (e.g. because the IDs are not available), returns None.
-        """
-        super().pop_multi_by_IDs(ids)
-        raise NotImplementedError
-
-    def _clear_deleted_(self):
-        """
-        This (protected) function physically removes particles from the collection whose status is set to 'DELETE'.
-        It is the logical finalisation method of physically deleting particles that have been marked for deletion and
-        that have not otherwise been recovered.
-        This methods in heavily dependent on the actual collection type and should be implemented very specific
-        to the actual data structure, to remove objects 'the fastest way possible'.
-        """
-        raise NotImplementedError
-
-    def merge(self, same_class=None):
-        """
-        This function merge two strictly equally-structured ParticleCollections into one. This can be, for example,
-        quite handy to merge two particle subsets that - due to continuous removal - become too small to be effective.
-
-        On the other hand, this function can also internally merge individual particles that are tagged by status as
-        being 'merged' (see the particle status for information on that).
-
-        In order to distinguish both use cases, we can evaluate the 'same_class' parameter. In cases where this is
-        'None', the merge operation semantically refers to an internal merge of individual particles - otherwise,
-        it performs a 2-collection merge.
-
-        Comment: the function can be simplified later by pre-evaluating the function parameter and then reference
-        the individual, specific functions for internal- or external merge.
-
-        The function shall return the merged ParticleCollection.
-        """
-        raise NotImplementedError
-
-    def split(self, indices=None):
-        """
-        This function splits this collection into two disect equi-structured collections. The reason for it can, for
-        example, be that the set exceeds a pre-defined maximum number of elements, which for performance reasons
-        mandates a split.
-
-        On the other hand, this function can also internally split individual particles that are tagged byt status as
-        to be 'split' (see the particle status for information on that).
-
-        In order to distinguish both use cases, we can evaluate the 'indices' parameter. In cases where this is
-        'None', the split operation semantically refers to an internal split of individual particles - otherwise,
-        it performs a collection-split.
-
-        Comment: the function can be simplified later by pre-evaluating the function parameter and then reference
-        the individual, specific functions for element- or collection split.
-
-        The function shall return the newly created or extended Particle collection, i.e. either the collection that
-        results from a collection split or this very collection, containing the newly-split particles.
-        """
-        raise NotImplementedError
-
-    def __sizeof__(self):
-        """
-        This function returns the size in actual bytes required in memory to hold the collection. Ideally and simply,
-        the size is computed as follows:
-
-        sizeof(self) = len(self) * sizeof(pclass)
-        """
-        raise NotImplementedError
-
-    def clear(self):
-        """
-        This function physically removes all elements of the collection, yielding an empty collection as result of the
-        operation.
-        """
-        raise NotImplementedError
-
-    def cstruct(self):
-        """Returns the ctypes mapping of the particle data. This depends on the specific structure in question."""
-        class CParticles(Structure):
-            _fields_ = [(v.name, POINTER(np.ctypeslib.as_ctypes_type(v.dtype))) for v in self._ptype.variables]
-
-        def flatten_dense_data_array(vname):
-            data_flat = self._data[vname].view()
-            data_flat.shape = -1
-            return np.ctypeslib.as_ctypes(data_flat)
-
-        cdata = [flatten_dense_data_array(v.name) for v in self._ptype.variables]
-        cstruct = CParticles(*cdata)
-        return cstruct
-
-    def _to_write_particles(self, pd, time):
-        """We don't want to write a particle that is not started yet.
-        Particle will be written if particle.time is between time-dt/2 and time+dt (/2)
-        """
-        return np.where((np.less_equal(time - np.abs(pd['dt'] / 2), pd['time'], where=np.isfinite(pd['time']))
-                        & np.greater_equal(time + np.abs(pd['dt'] / 2), pd['time'], where=np.isfinite(pd['time']))
-                        | ((np.isnan(pd['dt'])) & np.equal(time, pd['time'], where=np.isfinite(pd['time']))))
-                        & (np.isfinite(pd['id']))
-                        & (np.isfinite(pd['time'])))[0]
-
-    def getvardata(self, var, indices=None):
-        if indices is None:
-            return self._data[var]
-        else:
-            try:
-                return self._data[var][indices]
-            except:  # Can occur for zero-length ParticleSets
-                return None
-
-    def setvardata(self, var, index, val):
-        self._data[var][index] = val
-
-    def setallvardata(self, var, val):
-        self._data[var][:] = val
-
-    def toArray(self):
-        """
-        This function converts (or: transforms; reformats; translates) this collection into an array-like structure
-        (e.g. Python list or numpy nD array) that can be addressed by index. In the common case of 'no ID recovery',
-        the global ID and the index match exactly.
-
-        While this function may be very convenient for may users, it is STRONGLY DISADVISED to use the function to
-        often, and the performance- and memory overhead malus may be exceed any speed-up one could get from optimised
-        data structures - in fact, for large collections with an implicit-order structure (i.e. ordered lists, sets,
-        trees, etc.), this may be 'the most constly' function in any kind of simulation.
-
-        It can be - though - useful at the final stage of a simulation to dump the results to disk.
-        """
-        raise NotImplementedError
-
-    def set_variable_write_status(self, var, write_status):
-        """Method to set the write status of a Variable
-
-        Parameters
-        ----------
-        var :
-            Name of the variable (string)
-        status :
-            Write status of the variable (True, False or 'once')
-        write_status :
-        """
-        var_changed = False
-        for v in self._ptype.variables:
-            if v.name == var:
-                v.to_write = write_status
-                var_changed = True
-        if not var_changed:
-            raise SyntaxError(f'Could not change the write status of {var}, because it is not a Variable name')
-
-
-class ParticleAccessorSOA(BaseParticleAccessor):
-    """Wrapper that provides access to particle data in the collection,
-    as if interacting with the particle itself.
-
-    Parameters
-    ----------
-    pcoll :
-        ParticleCollection that the represented particle
-        belongs to.
-    index :
-        The index at which the data for the represented
-        particle is stored in the corresponding data arrays
-        of the ParticleCollecion.
-    """
-
-    _index = 0
-
-    def __init__(self, pcoll, index):
-        """Initializes the ParticleAccessor to provide access to one
-        specific particle.
-        """
-        super().__init__(pcoll)
-        self._index = index
-
-    def __getattr__(self, name):
-        """
-        Get the value of an attribute of the particle.
-
-        Parameters
-        ----------
-        name : str
-            Name of the requested particle attribute.
-
-        Returns
-        -------
-        any
-            The value of the particle attribute in the underlying collection data array.
-        """
-        if name in BaseParticleAccessor.__dict__.keys():
-            result = super().__getattr__(name)
-        elif name in type(self).__dict__.keys():
-            result = object.__getattribute__(self, name)
-        else:
-            result = self._pcoll.data[name][self._index]
-        return result
-
-    def __setattr__(self, name, value):
-        """
-        Set the value of an attribute of the particle.
-
-        Parameters
-        ----------
-        name : str
-            Name of the particle attribute.
-        value : any
-            Value that will be assigned to the particle attribute in the underlying collection data array.
-        """
-        if name in BaseParticleAccessor.__dict__.keys():
-            super().__setattr__(name, value)
-        elif name in type(self).__dict__.keys():
-            object.__setattr__(self, name, value)
-        else:
-            self._pcoll.data[name][self._index] = value
-
-    def getPType(self):
-        return self._pcoll.ptype
-
-    def __repr__(self):
-        time_string = 'not_yet_set' if self.time is None or np.isnan(self.time) else f"{self.time:f}"
-        str = "P[%d](lon=%f, lat=%f, depth=%f, " % (self.id, self.lon, self.lat, self.depth)
-        for var in self._pcoll.ptype.variables:
-            if var.name in ['lon_nextloop', 'lat_nextloop', 'depth_nextloop', 'time_nextloop']:  # TODO check if time_nextloop is needed (or can work with time-dt?)
-                continue
-            if var.to_write is not False and var.name not in ['id', 'lon', 'lat', 'depth', 'time']:
-                str += f"{var.name}={getattr(self, var.name):f}, "
-        return str + f"time={time_string})"
-
-
-class ParticleCollectionIterableSOA(BaseParticleCollectionIterable):
-
-    def __init__(self, pcoll, reverse=False, subset=None):
-        super().__init__(pcoll, reverse, subset)
-
-    def __iter__(self):
-        return ParticleCollectionIteratorSOA(pcoll=self._pcoll_immutable, reverse=self._reverse, subset=self._subset)
-
-    def __len__(self):
-        """Implementation needed for particle-particle interaction"""
-        return len(self._subset)
-
-    def __getitem__(self, items):
-        """Implementation needed for particle-particle interaction"""
-        return ParticleAccessorSOA(self._pcoll_immutable, self._subset[items])
-
-
-class ParticleCollectionIteratorSOA(BaseParticleCollectionIterator):
-    """Iterator for looping over the particles in the ParticleCollection.
-
-    Parameters
-    ----------
-    pcoll :
-        ParticleCollection that stores the particles.
-    reverse :
-        Flag to indicate reverse iteration (i.e. starting at
-        the largest index, instead of the smallest).
-    subset :
-        Subset of indices to iterate over, this allows the
-        creation of an iterator that represents part of the
-        collection.
-    """
-
-    def __init__(self, pcoll, reverse=False, subset=None):
-
-        if subset is not None:
-            if len(subset) > 0 and type(subset[0]) not in [int, np.int32, np.intp]:
-                raise TypeError("Iteration over a subset of particles in the"
-                                " particleset requires a list or numpy array"
-                                " of indices (of type int or np.int32).")
-            if reverse:
-                self._indices = subset.reverse()
-            else:
-                self._indices = subset
-            self.max_len = len(subset)
-        else:
-            self.max_len = len(pcoll)
-            if reverse:
-                self._indices = range(self.max_len - 1, -1, -1)
-            else:
-                self._indices = range(self.max_len)
-
-        self._reverse = reverse
-        self._pcoll = pcoll
-        self._index = 0
-        self._head = None
-        self._tail = None
-        if len(self._indices) > 0:
-            self._head = ParticleAccessorSOA(pcoll, self._indices[0])
-            self._tail = ParticleAccessorSOA(pcoll,
-                                             self._indices[self.max_len - 1])
-        self.p = self._head
-
-    def __next__(self):
-        """Returns a ParticleAccessor for the next particle in the
-        ParticleSet.
-        """
-        if self._index < self.max_len:
-            self.p = ParticleAccessorSOA(self._pcoll,
-                                         self._indices[self._index])
-            self._index += 1
-            return self.p
-
-        raise StopIteration
-
-    @property
-    def current(self):
-        return self.p
-
-    def __repr__(self):
-        dir_str = 'Backward' if self._reverse else 'Forward'
-        return f"{dir_str} iteration at index {self._index} of {self.max_len}."
diff --git a/parcels/collection/iterators.py b/parcels/collection/iterators.py
deleted file mode 100644
index 42ca35afa..000000000
--- a/parcels/collection/iterators.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from abc import ABC, abstractmethod
-
-from parcels.tools.statuscodes import StatusCode
-
-
-class BaseParticleCollectionIterable(ABC):
-    _pcoll_immutable = None
-    _reverse = None
-    _subset = None
-
-    def __init__(self, pcoll, reverse=False, subset=None):
-        self._pcoll_immutable = pcoll
-        self._reverse = reverse
-        self._subset = subset
-
-    @abstractmethod
-    def __iter__(self):
-        pass
-
-
-class BaseParticleCollectionIterator(ABC):
-    """Interface for the ParticleCollection iterator. Provides the
-    ability to iterate over the particles in the ParticleCollection.
-    """
-
-    def ___init___(self):
-        self._head = None
-        self._tail = None
-        self._current = None
-
-    @abstractmethod
-    def __next__(self):
-        """Returns a ParticleAccessor for the next particle in the ParticleSet."""
-        pass
-
-    @property
-    def head(self):
-        """Returns a ParticleAccessor for the first particle in the ParticleSet."""
-        return self._head
-
-    @property
-    def tail(self):
-        """Returns a ParticleAccessor for the last particle in the ParticleSet."""
-        return self._tail
-
-    @property
-    def current(self):
-        """Returns a ParticleAccessor for the particle that the iteration currently at."""
-        return self._current
-
-    @abstractmethod
-    def __repr__(self):
-        """Represents the current position in the iteration."""
-        pass
-
-
-class BaseParticleAccessor(ABC):
-    """Interface for the ParticleAccessor. Implements a wrapper around particles to provide easy access."""
-
-    _pcoll = None
-
-    def __init__(self, pcoll):
-        """Initialize the ParticleAccessor object with at least a
-        reference to the ParticleSet it encapsulates.
-        """
-        self._pcoll = pcoll
-
-    def delete(self):
-        """Signal the underlying particle for deletion."""
-        self.state = StatusCode.Delete
-
-    def set_state(self, state):
-        """Syntactic sugar for changing the state of the underlying particle."""
-        self.state = state
-
-    def succeeded(self):
-        self.state = StatusCode.Success
-
-    def isComputed(self):
-        return self.state == StatusCode.Success
-
-    def reset_state(self):
-        self.state = StatusCode.Evaluate
-
-    @abstractmethod
-    def getPType(self):
-        return None
-
-    def __getattr__(self, name):
-        """The ParticleAccessor should provide an implementation of this
-        built-in function to allow accessing particle attributes in its
-        corresponding ParticleSet datastructure.
-        """
-        if name in ['_pcoll', ]:
-            return super().__getattribute__(name)
-        return None
-
-    def __setattr__(self, name, value):
-        """The ParticleAccessor should provide an implementation of this
-        built-in function to allow setting particle attributes in its
-        corresponding ParticleSet datastructure.
-        """
-        if name in ['_pcoll', ]:
-            super().__setattr__(name, value)
-
-    @abstractmethod
-    def __repr__(self):
-        """The ParticleAccessor should provide an implementation of this
-        built-in function that returns a string representation of the
-        Particle that it currently provides access to.
-        """
-        pass
diff --git a/parcels/interaction/__init__.py b/parcels/interaction/__init__.py
index 7adeba36c..2ce3ced6e 100644
--- a/parcels/interaction/__init__.py
+++ b/parcels/interaction/__init__.py
@@ -1,4 +1,1 @@
-from .baseinteractionkernel import BaseInteractionKernel  # noqa
-from .interactionkernelsoa import InteractionKernelSOA  # noqa
-
-__all__ = ["BaseInteractionKernel", "InteractionKernelSOA"]
+from .interactionkernel import InteractionKernel  # noqa
diff --git a/parcels/interaction/baseinteractionkernel.py b/parcels/interaction/baseinteractionkernel.py
deleted file mode 100644
index 057bb7060..000000000
--- a/parcels/interaction/baseinteractionkernel.py
+++ /dev/null
@@ -1,127 +0,0 @@
-import inspect
-from sys import version_info
-
-try:
-    from mpi4py import MPI
-except:
-    MPI = None
-
-from parcels.kernel.basekernel import BaseKernel
-
-__all__ = ['BaseInteractionKernel']
-
-
-class BaseInteractionKernel(BaseKernel):
-    """Base super class for Interaction Kernel objects that encapsulates
-    auto-generated code.
-
-    InteractionKernels do not implement ways to catch or recover from
-    errors caused during execution of the kernel function(s).
-    It is strongly recommended not to sample from fields inside an
-    InteractionKernel.
-    """
-
-    def __init__(self, fieldset, ptype, pyfunc=None, funcname=None,
-                 funccode=None, py_ast=None, funcvars=None,
-                 c_include="", delete_cfiles=True):
-        if MPI is not None and MPI.COMM_WORLD.Get_size() > 1:
-            raise NotImplementedError("InteractionKernels are not supported in an MPI environment. Please run your simulation outside MPI.")
-
-        if pyfunc is not None:
-            if isinstance(pyfunc, list):
-                funcname = ''.join([func.__name__ for func in pyfunc])
-            else:
-                funcname = pyfunc.__name__
-
-        super().__init__(
-            fieldset=fieldset, ptype=ptype, pyfunc=pyfunc, funcname=funcname,
-            funccode=funccode, py_ast=py_ast, funcvars=funcvars,
-            c_include=c_include, delete_cfiles=delete_cfiles)
-
-        if pyfunc is not None:
-            if isinstance(pyfunc, list):
-                self._pyfunc = pyfunc
-            else:
-                self._pyfunc = [pyfunc]
-
-        if self._ptype.uses_jit:
-            raise NotImplementedError("JIT mode is not supported for"
-                                      " InteractionKernels. Please run your"
-                                      " simulation in SciPy mode.")
-
-    @property
-    def _cache_key(self):
-        raise NotImplementedError
-
-    @staticmethod
-    def fix_indentation(string):
-        raise NotImplementedError
-
-    def check_fieldsets_in_kernels(self, pyfunc):
-        # Currently, the implemented interaction kernels do not impose
-        # any requirements on the fieldset
-        pass
-
-    def check_kernel_signature_on_version(self):
-        """
-        Returns numkernelargs.
-        Adaptation of this method in the BaseKernel that works with
-        lists of functions.
-        """
-        numkernelargs = []
-        if self._pyfunc is not None and isinstance(self._pyfunc, list):
-            for func in self._pyfunc:
-                if version_info[0] < 3:
-                    numkernelargs.append(
-                        len(inspect.getargspec(func).args)
-                    )
-                else:
-                    numkernelargs.append(
-                        len(inspect.getfullargspec(func).args)
-                    )
-        return numkernelargs
-
-    def remove_lib(self):
-        # Currently, no libs are generated/linked, so nothing has to be
-        # removed
-        pass
-
-    def get_kernel_compile_files(self):
-        raise NotImplementedError
-
-    def compile(self, compiler):
-        raise NotImplementedError
-
-    def load_lib(self):
-        raise NotImplementedError
-
-    def merge(self, kernel, kclass):
-        assert self.__class__ == kernel.__class__
-        funcname = self.funcname + kernel.funcname
-        # delete_cfiles = self.delete_cfiles and kernel.delete_cfiles
-        pyfunc = self._pyfunc + kernel._pyfunc
-        return kclass(self._fieldset, self._ptype, pyfunc=pyfunc,
-                      funcname=funcname)
-
-    def __add__(self, kernel):
-        if not isinstance(kernel, BaseInteractionKernel):
-            kernel = BaseInteractionKernel(self.fieldset, self.ptype, pyfunc=kernel)
-        return self.merge(kernel, BaseInteractionKernel)
-
-    def __radd__(self, kernel):
-        if not isinstance(kernel, BaseInteractionKernel):
-            kernel = BaseInteractionKernel(self.fieldset, self.ptype, pyfunc=kernel)
-        return kernel.merge(self, BaseInteractionKernel)
-
-    @staticmethod
-    def cleanup_remove_files(lib_file, all_files_array, delete_cfiles):
-        raise NotImplementedError
-
-    @staticmethod
-    def cleanup_unload_lib(lib):
-        raise NotImplementedError
-
-    def execute_jit(self, pset, endtime, dt):
-        raise NotImplementedError("JIT mode is not supported for"
-                                  " InteractionKernels. Please run your"
-                                  " simulation in SciPy mode.")
diff --git a/parcels/interaction/interactionkernelsoa.py b/parcels/interaction/interactionkernel.py
similarity index 61%
rename from parcels/interaction/interactionkernelsoa.py
rename to parcels/interaction/interactionkernel.py
index 7174a8d8b..ca4b3bad6 100644
--- a/parcels/interaction/interactionkernelsoa.py
+++ b/parcels/interaction/interactionkernel.py
@@ -1,6 +1,6 @@
-import math  # noqa
-import random  # noqa
+import inspect
 from collections import defaultdict
+from sys import version_info
 
 import numpy as np
 
@@ -9,16 +9,15 @@
 except:
     MPI = None
 
-import parcels.rng as ParcelsRandom  # noqa
 from parcels.field import NestedField, VectorField
-from parcels.interaction.baseinteractionkernel import BaseInteractionKernel
+from parcels.kernel import BaseKernel
 from parcels.tools.loggers import logger
 from parcels.tools.statuscodes import StatusCode
 
-__all__ = ['InteractionKernelSOA']
+__all__ = ['InteractionKernel']
 
 
-class InteractionKernelSOA(BaseInteractionKernel):
+class InteractionKernel(BaseKernel):
     """InteractionKernel object that encapsulates auto-generated code.
 
     InteractionKernels do not implement ways to catch or recover from
@@ -28,12 +27,41 @@ class InteractionKernelSOA(BaseInteractionKernel):
     """
 
     def __init__(self, fieldset, ptype, pyfunc=None, funcname=None,
-                 funccode=None, py_ast=None, funcvars=None, c_include="",
-                 delete_cfiles=True):
-        super().__init__(fieldset=fieldset, ptype=ptype, pyfunc=pyfunc,
-                         funcname=funcname, funccode=funccode, py_ast=py_ast,
-                         funcvars=funcvars, c_include=c_include,
-                         delete_cfiles=delete_cfiles)
+                 funccode=None, py_ast=None, funcvars=None,
+                 c_include="", delete_cfiles=True):
+        if MPI is not None and MPI.COMM_WORLD.Get_size() > 1:
+            raise NotImplementedError("InteractionKernels are not supported in an MPI environment. Please run your simulation outside MPI.")
+
+        if MPI is not None and MPI.COMM_WORLD.Get_size() > 1:
+            raise NotImplementedError("InteractionKernels are not supported in an MPI environment. Please run your simulation outside MPI.")
+
+        if pyfunc is not None:
+            if isinstance(pyfunc, list):
+                funcname = ''.join([func.__name__ for func in pyfunc])
+            else:
+                funcname = pyfunc.__name__
+
+        super().__init__(
+            fieldset=fieldset, ptype=ptype, pyfunc=pyfunc, funcname=funcname,
+            funccode=funccode, py_ast=py_ast, funcvars=funcvars,
+            c_include=c_include, delete_cfiles=delete_cfiles)
+
+        if pyfunc is not None:
+            if isinstance(pyfunc, list):
+                funcname = ''.join([func.__name__ for func in pyfunc])
+            else:
+                funcname = pyfunc.__name__
+
+        if pyfunc is not None:
+            if isinstance(pyfunc, list):
+                self._pyfunc = pyfunc
+            else:
+                self._pyfunc = [pyfunc]
+
+        if self._ptype.uses_jit:
+            raise NotImplementedError("JIT mode is not supported for"
+                                      " InteractionKernels. Please run your"
+                                      " simulation in SciPy mode.")
 
         for func in self._pyfunc:
             self.check_fieldsets_in_kernels(func)
@@ -47,10 +75,65 @@ def __init__(self, fieldset, ptype, pyfunc=None, funcname=None,
         # At this time, JIT mode is not supported for InteractionKernels,
         # so there is no need for any further "processing" of pyfunc's.
 
-    def execute_jit(self, pset, endtime, dt):
-        raise NotImplementedError("JIT mode is not supported for"
-                                  " InteractionKernels. Please run your"
-                                  " simulation in SciPy mode.")
+    @property
+    def _cache_key(self):
+        raise NotImplementedError
+
+    def check_fieldsets_in_kernels(self, pyfunc):
+        # Currently, the implemented interaction kernels do not impose
+        # any requirements on the fieldset
+        pass
+
+    def check_kernel_signature_on_version(self):
+        """
+        Returns numkernelargs.
+        Adaptation of this method in the BaseKernel that works with
+        lists of functions.
+        """
+        numkernelargs = []
+        if self._pyfunc is not None and isinstance(self._pyfunc, list):
+            for func in self._pyfunc:
+                if version_info[0] < 3:
+                    numkernelargs.append(
+                        len(inspect.getargspec(func).args)
+                    )
+                else:
+                    numkernelargs.append(
+                        len(inspect.getfullargspec(func).args)
+                    )
+        return numkernelargs
+
+    def remove_lib(self):
+        # Currently, no libs are generated/linked, so nothing has to be
+        # removed
+        pass
+
+    def get_kernel_compile_files(self):
+        raise NotImplementedError
+
+    def compile(self, compiler):
+        raise NotImplementedError
+
+    def load_lib(self):
+        raise NotImplementedError
+
+    def merge(self, kernel, kclass):
+        assert self.__class__ == kernel.__class__
+        funcname = self.funcname + kernel.funcname
+        # delete_cfiles = self.delete_cfiles and kernel.delete_cfiles
+        pyfunc = self._pyfunc + kernel._pyfunc
+        return kclass(self._fieldset, self._ptype, pyfunc=pyfunc,
+                      funcname=funcname)
+
+    def __add__(self, kernel):
+        if not isinstance(kernel, InteractionKernel):
+            kernel = InteractionKernel(self.fieldset, self.ptype, pyfunc=kernel)
+        return self.merge(kernel, InteractionKernel)
+
+    def __radd__(self, kernel):
+        if not isinstance(kernel, InteractionKernel):
+            kernel = InteractionKernel(self.fieldset, self.ptype, pyfunc=kernel)
+        return kernel.merge(self, InteractionKernel)
 
     def __del__(self):
         # Clean-up the in-memory dynamic linked libraries.
@@ -58,15 +141,18 @@ def __del__(self):
         # naming scheme which is required on Windows OS'es to deal with updates to a Parcels' kernel.)
         super().__del__()
 
-    def __add__(self, kernel):
-        if not isinstance(kernel, InteractionKernelSOA):
-            kernel = InteractionKernelSOA(self.fieldset, self.ptype, pyfunc=kernel)
-        return self.merge(kernel, InteractionKernelSOA)
+    @staticmethod
+    def cleanup_remove_files(lib_file, all_files_array, delete_cfiles):
+        raise NotImplementedError
 
-    def __radd__(self, kernel):
-        if not isinstance(kernel, InteractionKernelSOA):
-            kernel = InteractionKernelSOA(self.fieldset, self.ptype, pyfunc=kernel)
-        return kernel.merge(self, InteractionKernelSOA)
+    @staticmethod
+    def cleanup_unload_lib(lib):
+        raise NotImplementedError
+
+    def execute_jit(self, pset, endtime, dt):
+        raise NotImplementedError("JIT mode is not supported for"
+                                  " InteractionKernels. Please run your"
+                                  " simulation in SciPy mode.")
 
     def execute_python(self, pset, endtime, dt):
         """Performs the core update loop via Python.
@@ -129,7 +215,7 @@ def execute(self, pset, endtime, dt, output_file=None):
         It is strongly recommended not to sample from fields inside an
         InteractionKernel.
         """
-        pset.collection.state[:] = StatusCode.Evaluate
+        pset.particledata.state[:] = StatusCode.Evaluate
 
         if abs(dt) < 1e-6:
             logger.warning_once("'dt' is too small, causing numerical accuracy limit problems. Please chose a higher 'dt' and rather scale the 'time' axis of the field accordingly. (related issue #762)")
@@ -161,7 +247,7 @@ def execute(self, pset, endtime, dt, output_file=None):
                 if p.state == StatusCode.StopExecution:
                     return
                 if p.state == StatusCode.Repeat:
-                    p.set_state(StatusCode.Evaluate)
+                    p.state = StatusCode.Evaluate
                 elif p.state == StatusCode.Delete:
                     pass
                 else:
diff --git a/parcels/kernel/basekernel.py b/parcels/kernel.py
similarity index 64%
rename from parcels/kernel/basekernel.py
rename to parcels/kernel.py
index 1aae31d36..ce75b4c38 100644
--- a/parcels/kernel/basekernel.py
+++ b/parcels/kernel.py
@@ -1,8 +1,12 @@
 import functools
 import inspect
+import math  # noqa
+import random  # noqa
 import re
 import types
-from ast import FunctionDef
+from ast import FunctionDef, parse
+from copy import deepcopy
+from ctypes import byref, c_double, c_int
 from hashlib import md5
 from os import path, remove
 from sys import platform, version_info
@@ -13,55 +17,32 @@
 import numpy.ctypeslib as npct
 from numpy import ndarray
 
-from parcels.tools.loggers import logger
-
 try:
     from mpi4py import MPI
 except:
     MPI = None
 
+import parcels.rng as ParcelsRandom  # noqa
 from parcels.application_kernels.advection import AdvectionAnalytical, AdvectionRK4_3D
-
-# === import just necessary field classes to perform setup checks === #
+from parcels.compilation.codegenerator import ArrayKernelGenerator as KernelGenerator
+from parcels.compilation.codegenerator import LoopGenerator
 from parcels.field import Field, NestedField, VectorField
 from parcels.grid import GridCode
 from parcels.tools.global_statics import get_cache_dir
-from parcels.tools.statuscodes import StatusCode
-
-__all__ = ['BaseKernel']
-
+from parcels.tools.loggers import logger
+from parcels.tools.statuscodes import (
+    FieldOutOfBoundError,
+    FieldOutOfBoundSurfaceError,
+    FieldSamplingError,
+    StatusCode,
+    TimeExtrapolationError,
+)
 
-re_indent = re.compile(r"^(\s+)")
+__all__ = ['Kernel', 'BaseKernel']
 
 
 class BaseKernel:
-    """Base super class for base Kernel objects that encapsulates auto-generated code.
-
-    Parameters
-    ----------
-    fieldset : parcels.Fieldset
-        FieldSet object providing the field information (possibly None)
-    ptype :
-        PType object for the kernel particle
-    pyfunc :
-        (aggregated) Kernel function
-    funcname : str
-        function name
-    delete_cfiles : bool
-        Whether to delete the C-files after compilation in JIT mode (default is True)
-
-    Notes
-    -----
-    A Kernel is either created from a compiled <function ...> object
-    or the necessary information (funcname, funccode, funcvars) is provided.
-    The py_ast argument may be derived from the code string, but for
-    concatenation, the merged AST plus the new header definition is required.
-    """
-
-    _pyfunc = None
-    _fieldset = None
-    _ptype = None
-    funcname = None
+    """Superclass for 'normal' and Interactive Kernels"""
 
     def __init__(self, fieldset, ptype, pyfunc=None, funcname=None, funccode=None, py_ast=None, funcvars=None,
                  c_include="", delete_cfiles=True):
@@ -141,11 +122,159 @@ def _cache_key(self):
     def fix_indentation(string):
         """Fix indentation to allow in-lined kernel definitions."""
         lines = string.split('\n')
-        indent = re_indent.match(lines[0])
+        indent = re.compile(r"^(\s+)").match(lines[0])
         if indent:
             lines = [line.replace(indent.groups()[0], '', 1) for line in lines]
         return "\n".join(lines)
 
+    def remove_deleted(self, pset):
+        """Utility to remove all particles that signalled deletion."""
+        bool_indices = pset.particledata.state == StatusCode.Delete
+        indices = np.where(bool_indices)[0]
+        if len(indices) > 0 and self.fieldset.particlefile is not None:
+            self.fieldset.particlefile.write(pset, None, indices=indices)
+        pset.remove_indices(indices)
+
+
+class Kernel(BaseKernel):
+    """Kernel object that encapsulates auto-generated code.
+
+    Parameters
+    ----------
+    fieldset : parcels.Fieldset
+        FieldSet object providing the field information (possibly None)
+    ptype :
+        PType object for the kernel particle
+    pyfunc :
+        (aggregated) Kernel function
+    funcname : str
+        function name
+    delete_cfiles : bool
+        Whether to delete the C-files after compilation in JIT mode (default is True)
+
+    Notes
+    -----
+    A Kernel is either created from a compiled <function ...> object
+    or the necessary information (funcname, funccode, funcvars) is provided.
+    The py_ast argument may be derived from the code string, but for
+    concatenation, the merged AST plus the new header definition is required.
+    """
+
+    def __init__(self, fieldset, ptype, pyfunc=None, funcname=None, funccode=None, py_ast=None, funcvars=None,
+                 c_include="", delete_cfiles=True):
+        super().__init__(fieldset=fieldset, ptype=ptype, pyfunc=pyfunc, funcname=funcname, funccode=funccode,
+                         py_ast=py_ast, funcvars=funcvars, c_include=c_include, delete_cfiles=delete_cfiles)
+
+        # Derive meta information from pyfunc, if not given
+        self.check_fieldsets_in_kernels(pyfunc)
+
+        if funcvars is not None:
+            self.funcvars = funcvars
+        elif hasattr(pyfunc, '__code__'):
+            self.funcvars = list(pyfunc.__code__.co_varnames)
+        else:
+            self.funcvars = None
+        self.funccode = funccode or inspect.getsource(pyfunc.__code__)
+        # Parse AST if it is not provided explicitly
+        self.py_ast = py_ast or parse(BaseKernel.fix_indentation(self.funccode)).body[0]
+        if pyfunc is None:
+            # Extract user context by inspecting the call stack
+            stack = inspect.stack()
+            try:
+                user_ctx = stack[-1][0].f_globals
+                user_ctx['math'] = globals()['math']
+                user_ctx['ParcelsRandom'] = globals()['ParcelsRandom']
+                user_ctx['random'] = globals()['random']
+                user_ctx['StatusCode'] = globals()['StatusCode']
+            except:
+                logger.warning("Could not access user context when merging kernels")
+                user_ctx = globals()
+            finally:
+                del stack  # Remove cyclic references
+            # Compile and generate Python function from AST
+            py_mod = parse("")
+            py_mod.body = [self.py_ast]
+            exec(compile(py_mod, "<ast>", "exec"), user_ctx)
+            self._pyfunc = user_ctx[self.funcname]
+        else:
+            self._pyfunc = pyfunc
+
+        numkernelargs = self.check_kernel_signature_on_version()
+
+        assert numkernelargs == 3, \
+            'Since Parcels v2.0, kernels do only take 3 arguments: particle, fieldset, time !! AND !! Argument order in field interpolation is time, depth, lat, lon.'
+
+        self.name = f"{ptype.name}{self.funcname}"
+
+        # Generate the kernel function and add the outer loop
+        if self.ptype.uses_jit:
+            kernelgen = KernelGenerator(fieldset, ptype)
+            kernel_ccode = kernelgen.generate(deepcopy(self.py_ast),
+                                              self.funcvars)
+            self.field_args = kernelgen.field_args
+            self.vector_field_args = kernelgen.vector_field_args
+            fieldset = self.fieldset
+            for f in self.vector_field_args.values():
+                Wname = f.W.ccode_name if f.W else 'not_defined'
+                for sF_name, sF_component in zip([f.U.ccode_name, f.V.ccode_name, Wname], ['U', 'V', 'W']):
+                    if sF_name not in self.field_args:
+                        if sF_name != 'not_defined':
+                            self.field_args[sF_name] = getattr(f, sF_component)
+            self.const_args = kernelgen.const_args
+            loopgen = LoopGenerator(fieldset, ptype)
+            if path.isfile(self._c_include):
+                with open(self._c_include) as f:
+                    c_include_str = f.read()
+            else:
+                c_include_str = self._c_include
+            self.ccode = loopgen.generate(self.funcname, self.field_args, self.const_args,
+                                          kernel_ccode, c_include_str)
+
+            src_file_or_files, self.lib_file, self.log_file = self.get_kernel_compile_files()
+            if type(src_file_or_files) in (list, dict, tuple, np.ndarray):
+                self.dyn_srcs = src_file_or_files
+            else:
+                self.src_file = src_file_or_files
+
+    def __del__(self):
+        # Clean-up the in-memory dynamic linked libraries.
+        # This is not really necessary, as these programs are not that large, but with the new random
+        # naming scheme which is required on Windows OS'es to deal with updates to a Parcels' kernel.
+        try:
+            self.remove_lib()
+        except:
+            pass
+        self._fieldset = None
+        self.field_args = None
+        self.const_args = None
+        self.funcvars = None
+        self.funccode = None
+
+    @property
+    def ptype(self):
+        return self._ptype
+
+    @property
+    def pyfunc(self):
+        return self._pyfunc
+
+    @property
+    def fieldset(self):
+        return self._fieldset
+
+    @property
+    def c_include(self):
+        return self._c_include
+
+    @property
+    def _cache_key(self):
+        field_keys = ""
+        if self.field_args is not None:
+            field_keys = "-".join(
+                [f"{name}:{field.units.__class__.__name__}" for name, field in self.field_args.items()])
+        key = self.name + self.ptype._cache_key + field_keys + ('TIME:%f' % ostime())
+        return md5(key.encode('utf-8')).hexdigest()
+
     def add_scipy_positionupdate_kernels(self):
         # Adding kernels that set and update the coordinate changes
         def Setcoords(particle, fieldset, time):
@@ -205,7 +334,7 @@ def check_kernel_signature_on_version(self):
 
     def remove_lib(self):
         if self._lib is not None:
-            BaseKernel.cleanup_unload_lib(self._lib)
+            self.cleanup_unload_lib(self._lib)
             del self._lib
             self._lib = None
 
@@ -219,7 +348,7 @@ def remove_lib(self):
         if self.log_file is not None:
             all_files_array.append(self.log_file)
         if self.lib_file is not None and all_files_array is not None and self.delete_cfiles is not None:
-            BaseKernel.cleanup_remove_files(self.lib_file, all_files_array, self.delete_cfiles)
+            self.cleanup_remove_files(self.lib_file, all_files_array, self.delete_cfiles)
 
         # If file already exists, pull new names. This is necessary on a Windows machine, because
         # Python's ctype does not deal in any sort of manner well with dynamic linked libraries on this OS.
@@ -356,15 +485,6 @@ def cleanup_unload_lib(lib):
             except:
                 pass
 
-    def remove_deleted(self, pset):
-        """
-        Utility to remove all particles that signalled deletion.
-
-        This version is generally applicable to all structures and collections
-        """
-        indices = [i for i, p in enumerate(pset) if p.state == StatusCode.Delete]
-        pset.remove_indices(indices)
-
     def load_fieldset_jit(self, pset):
         """Updates the loaded fields of pset's fieldset according to the chunk information within their grids."""
         if pset.fieldset is not None:
@@ -397,13 +517,99 @@ def load_fieldset_jit(self, pset):
                 if not g.lat.flags.c_contiguous:
                     g.lat = np.array(g.lat, order='C')
 
+    def execute_jit(self, pset, endtime, dt):
+        """Invokes JIT engine to perform the core update loop."""
+        self.load_fieldset_jit(pset)
+
+        fargs = [byref(f.ctypes_struct) for f in self.field_args.values()]
+        fargs += [c_double(f) for f in self.const_args.values()]
+        particle_data = byref(pset.ctypes_struct)
+        return self._function(c_int(len(pset)), particle_data,
+                              c_double(endtime), c_double(dt), *fargs)
+
+    def execute_python(self, pset, endtime, dt):
+        """Performs the core update loop via Python."""
+        # sign of dt: { [0, 1]: forward simulation; -1: backward simulation }
+        sign_dt = np.sign(dt)
+
+        if self.fieldset is not None:
+            for f in self.fieldset.get_fields():
+                if isinstance(f, (VectorField, NestedField)):
+                    continue
+                f.data = np.array(f.data)
+
+        if not self.scipy_positionupdate_kernels_added:
+            self.add_scipy_positionupdate_kernels()
+            self.scipy_positionupdate_kernels_added = True
+
+        for p in pset:
+            self.evaluate_particle(p, endtime, sign_dt)
+
+    def execute(self, pset, endtime, dt):
+        """Execute this Kernel over a ParticleSet for several timesteps."""
+        pset.particledata.state[:] = StatusCode.Evaluate
+
+        if abs(dt) < 1e-6:
+            logger.warning_once("'dt' is too small, causing numerical accuracy limit problems. Please chose a higher 'dt' and rather scale the 'time' axis of the field accordingly. (related issue #762)")
+
+        if pset.fieldset is not None:
+            for g in pset.fieldset.gridset.grids:
+                if len(g.load_chunk) > g.chunk_not_loaded:  # not the case if a field in not called in the kernel
+                    g.load_chunk = np.where(g.load_chunk == g.chunk_loaded_touched,
+                                            g.chunk_deprecated, g.load_chunk)
+
+        # Execute the kernel over the particle set
+        if self.ptype.uses_jit:
+            self.execute_jit(pset, endtime, dt)
+        else:
+            self.execute_python(pset, endtime, dt)
+
+        # Remove all particles that signalled deletion
+        self.remove_deleted(pset)
+
+        # Identify particles that threw errors
+        n_error = pset.num_error_particles
+
+        while n_error > 0:
+            error_pset = pset.error_particles
+            # Apply recovery kernel
+            for p in error_pset:
+                if p.state == StatusCode.StopExecution:
+                    return
+                if p.state == StatusCode.Repeat:
+                    p.state = StatusCode.Evaluate
+                elif p.state == StatusCode.ErrorTimeExtrapolation:
+                    raise TimeExtrapolationError(p.time)
+                elif p.state == StatusCode.ErrorOutOfBounds:
+                    raise FieldOutOfBoundError(p.lon, p.lat, p.depth)
+                elif p.state == StatusCode.ErrorThroughSurface:
+                    raise FieldOutOfBoundSurfaceError(p.lon, p.lat, p.depth)
+                elif p.state == StatusCode.Error:
+                    raise FieldSamplingError(p.lon, p.lat, p.depth)
+                elif p.state == StatusCode.Delete:
+                    pass
+                else:
+                    logger.warning_once(f'Deleting particle {p.id} because of non-recoverable error')
+                    p.delete()
+
+            # Remove all particles that signalled deletion
+            self.remove_deleted(pset)   # Generalizable version!
+
+            # Execute core loop again to continue interrupted particles
+            if self.ptype.uses_jit:
+                self.execute_jit(pset, endtime, dt)
+            else:
+                self.execute_python(pset, endtime, dt)
+
+            n_error = pset.num_error_particles
+
     def evaluate_particle(self, p, endtime, sign_dt):
         """Execute the kernel evaluation of for an individual particle.
 
         Parameters
         ----------
         p :
-            object of (sub-)type (ScipyParticle, JITParticle) or (sub-)type of BaseParticleAccessor
+            object of (sub-)type (ScipyParticle, JITParticle)
         fieldset :
             fieldset of the containing ParticleSet (e.g. pset.fieldset)
         analytical :
@@ -434,12 +640,3 @@ def evaluate_particle(self, p, endtime, sign_dt):
 
             p.dt = pre_dt
         return p
-
-    def execute_jit(self, pset, endtime, dt):
-        pass
-
-    def execute_python(self, pset, endtime, dt):
-        pass
-
-    def execute(self, pset, endtime, dt, output_file=None):
-        pass
diff --git a/parcels/kernel/__init__.py b/parcels/kernel/__init__.py
deleted file mode 100644
index a0c062c43..000000000
--- a/parcels/kernel/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .basekernel import BaseKernel  # noqa
-from .kernelaos import KernelAOS  # noqa
-from .kernelsoa import KernelSOA  # noqa
-
-Kernel = KernelSOA
diff --git a/parcels/kernel/kernelaos.py b/parcels/kernel/kernelaos.py
deleted file mode 100644
index 5392d0e68..000000000
--- a/parcels/kernel/kernelaos.py
+++ /dev/null
@@ -1,181 +0,0 @@
-import inspect
-import math  # noqa
-import random  # noqa
-from ast import parse
-from copy import deepcopy
-from ctypes import byref, c_double, c_int
-from os import path
-
-import numpy as np
-
-try:
-    from mpi4py import MPI
-except:
-    MPI = None
-
-import parcels.rng as ParcelsRandom  # noqa
-from parcels.compilation.codegenerator import ObjectKernelGenerator as KernelGenerator
-from parcels.compilation.codegenerator import ParticleObjectLoopGenerator
-from parcels.field import NestedField, VectorField
-from parcels.kernel.basekernel import BaseKernel
-from parcels.tools.loggers import logger
-from parcels.tools.statuscodes import StatusCode  # noqa
-
-__all__ = ['KernelAOS']
-
-
-class KernelAOS(BaseKernel):
-    """Kernel object that encapsulates auto-generated code.
-
-    Parameters
-    ----------
-    fieldset :
-        FieldSet object providing the field information
-    ptype :
-        PType object for the kernel particle
-    delete_cfiles : bool
-        Whether to delete the C-files after compilation in JIT mode (default is True)
-
-    Notes
-    -----
-    A Kernel is either created from a compiled <function ...> object
-    or the necessary information (funcname, funccode, funcvars) is provided.
-    The py_ast argument may be derived from the code string, but for
-    concatenation, the merged AST plus the new header definition is required.
-    """
-
-    def __init__(self, fieldset, ptype, pyfunc=None, funcname=None,
-                 funccode=None, py_ast=None, funcvars=None, c_include="", delete_cfiles=True):
-        super().__init__(fieldset=fieldset, ptype=ptype, pyfunc=pyfunc, funcname=funcname, funccode=funccode, py_ast=py_ast, funcvars=funcvars, c_include=c_include, delete_cfiles=delete_cfiles)
-
-        # Derive meta information from pyfunc, if not given
-        self.check_fieldsets_in_kernels(pyfunc)
-
-        if funcvars is not None:
-            self.funcvars = funcvars
-        elif hasattr(pyfunc, '__code__'):
-            self.funcvars = list(pyfunc.__code__.co_varnames)
-        else:
-            self.funcvars = None
-        self.funccode = funccode or inspect.getsource(pyfunc.__code__)
-        # Parse AST if it is not provided explicitly
-        self.py_ast = py_ast or parse(BaseKernel.fix_indentation(self.funccode)).body[0]
-        if pyfunc is None:
-            # Extract user context by inspecting the call stack
-            stack = inspect.stack()
-            try:
-                user_ctx = stack[-1][0].f_globals
-                user_ctx['math'] = globals()['math']
-                user_ctx['ParcelsRandom'] = globals()['ParcelsRandom']
-                user_ctx['random'] = globals()['random']
-                user_ctx['StatusCode'] = globals()['StatusCode']
-            except:
-                logger.warning("Could not access user context when merging kernels")
-                user_ctx = globals()
-            finally:
-                del stack  # Remove cyclic references
-            # Compile and generate Python function from AST
-            py_mod = parse("")
-            py_mod.body = [self.py_ast]
-            exec(compile(py_mod, "<ast>", "exec"), user_ctx)
-            self._pyfunc = user_ctx[self.funcname]
-        else:
-            self._pyfunc = pyfunc
-
-        numkernelargs = self.check_kernel_signature_on_version()
-        assert numkernelargs == 3, \
-            'Since Parcels v2.0, kernels do only take 3 arguments: particle, fieldset, time !! AND !! Argument order in field interpolation is time, depth, lat, lon.'
-
-        self.name = f"{self._ptype.name}{self.funcname}"
-
-        # Generate the kernel function and add the outer loop
-        if self.ptype.uses_jit:
-            kernelgen = KernelGenerator(self.fieldset, ptype)
-            kernel_ccode = kernelgen.generate(deepcopy(self.py_ast), self.funcvars)
-            self.field_args = kernelgen.field_args
-            self.vector_field_args = kernelgen.vector_field_args
-            for f in self.vector_field_args.values():
-                Wname = f.W.ccode_name if f.W else 'not_defined'
-                for sF_name, sF_component in zip([f.U.ccode_name, f.V.ccode_name, Wname], ['U', 'V', 'W']):
-                    if sF_name not in self.field_args:
-                        if sF_name != 'not_defined':
-                            self.field_args[sF_name] = getattr(f, sF_component)
-            self.const_args = kernelgen.const_args
-            loopgen = ParticleObjectLoopGenerator(self.fieldset, ptype)
-            if path.isfile(c_include):
-                with open(c_include) as f:
-                    c_include_str = f.read()
-            else:
-                c_include_str = c_include
-            self.ccode = loopgen.generate(self.funcname, self.field_args, self.const_args,
-                                          kernel_ccode, c_include_str)
-
-            src_file_or_files, self.lib_file, self.log_file = self.get_kernel_compile_files()
-            if type(src_file_or_files) in (list, dict, tuple, np.ndarray):
-                self.dyn_srcs = src_file_or_files
-            else:
-                self.src_file = src_file_or_files
-
-    def execute_jit(self, pset, endtime, dt):
-        """Invokes JIT engine to perform the core update loop."""
-        self.load_fieldset_jit(pset)
-
-        fargs = []
-        if self.field_args is not None:
-            fargs += [byref(f.ctypes_struct) for f in self.field_args.values()]
-        if self.const_args is not None:
-            fargs += [c_double(f) for f in self.const_args.values()]
-
-        pdata = pset.ctypes_struct
-        if len(fargs) > 0:
-            self._function(c_int(len(pset)), pdata, c_double(endtime), c_double(dt), *fargs)
-        else:
-            self._function(c_int(len(pset)), pdata, c_double(endtime), c_double(dt))
-
-    def execute_python(self, pset, endtime, dt):
-        """Performs the core update loop via Python."""
-        # sign of dt: { [0, 1]: forward simulation; -1: backward simulation }
-        sign_dt = np.sign(dt)
-
-        if self.fieldset is not None:
-            for f in self.fieldset.get_fields():
-                if isinstance(f, (VectorField, NestedField)):
-                    continue
-                f.data = np.array(f.data)
-
-        if not self.scipy_positionupdate_kernels_added:
-            self.add_scipy_positionupdate_kernels()
-            self.scipy_positionupdate_kernels_added = True
-
-        for p in pset:
-            self.evaluate_particle(p, endtime, sign_dt)
-
-    def remove_deleted(self, pset):
-        """Utility to remove all particles that signalled deletion."""
-        indices = [i for i, p in enumerate(pset) if p.state == StatusCode.Delete]
-        if len(indices) > 0 and self.fieldset.particlefile is not None:
-            self.fieldset.particlefile.write(pset, None, indices=indices)
-        pset.remove_indices(indices)
-
-    def execute(self, pset, endtime, dt, output_file=None):
-        """Execute this Kernel over a ParticleSet for several timesteps."""
-        for p in pset:
-            p.reset_state()
-
-        if abs(dt) < 1e-6:
-            logger.warning_once("'dt' is too small, causing numerical accuracy limit problems. Please chose a higher 'dt' and rather scale the 'time' axis of the field accordingly. (related issue #762)")
-
-        if pset.fieldset is not None:
-            for g in pset.fieldset.gridset.grids:
-                if len(g.load_chunk) > g.chunk_not_loaded:  # not the case if a field in not called in the kernel
-                    g.load_chunk = np.where(g.load_chunk == g.chunk_loaded_touched,
-                                            g.chunk_deprecated, g.load_chunk)
-
-        # Execute the kernel over the particle set
-        if self.ptype.uses_jit:
-            self.execute_jit(pset, endtime, dt)
-        else:
-            self.execute_python(pset, endtime, dt)
-
-        # Remove all particles that signalled deletion
-        self.remove_deleted(pset)
diff --git a/parcels/kernel/kernelsoa.py b/parcels/kernel/kernelsoa.py
deleted file mode 100644
index 7fb1314cf..000000000
--- a/parcels/kernel/kernelsoa.py
+++ /dev/null
@@ -1,224 +0,0 @@
-import inspect
-import math  # noqa
-import random  # noqa
-from ast import parse
-from copy import deepcopy
-from ctypes import byref, c_double, c_int
-from os import path
-
-import numpy as np
-
-try:
-    from mpi4py import MPI
-except:
-    MPI = None
-
-import parcels.rng as ParcelsRandom  # noqa
-from parcels.compilation.codegenerator import ArrayKernelGenerator as KernelGenerator
-from parcels.compilation.codegenerator import LoopGenerator
-from parcels.field import NestedField, VectorField
-from parcels.kernel.basekernel import BaseKernel
-from parcels.tools.loggers import logger
-from parcels.tools.statuscodes import (
-    FieldOutOfBoundError,
-    FieldOutOfBoundSurfaceError,
-    FieldSamplingError,
-    StatusCode,
-    TimeExtrapolationError,
-)
-
-__all__ = ['KernelSOA']
-
-
-class KernelSOA(BaseKernel):
-    """Kernel object that encapsulates auto-generated code.
-
-    Parameters
-    ----------
-    fieldset :
-        FieldSet object providing the field information
-    ptype :
-        PType object for the kernel particle
-    delete_cfiles : bool
-        Whether to delete the C-files after compilation in JIT mode (default is True)
-
-    Notes
-    -----
-    A Kernel is either created from a compiled <function ...> object
-    or the necessary information (funcname, funccode, funcvars) is provided.
-    The py_ast argument may be derived from the code string, but for
-    concatenation, the merged AST plus the new header definition is required.
-    """
-
-    def __init__(self, fieldset, ptype, pyfunc=None, funcname=None,
-                 funccode=None, py_ast=None, funcvars=None, c_include="", delete_cfiles=True):
-        super().__init__(fieldset=fieldset, ptype=ptype, pyfunc=pyfunc, funcname=funcname, funccode=funccode, py_ast=py_ast, funcvars=funcvars, c_include=c_include, delete_cfiles=delete_cfiles)
-
-        # Derive meta information from pyfunc, if not given
-        self.check_fieldsets_in_kernels(pyfunc)
-
-        if funcvars is not None:
-            self.funcvars = funcvars
-        elif hasattr(pyfunc, '__code__'):
-            self.funcvars = list(pyfunc.__code__.co_varnames)
-        else:
-            self.funcvars = None
-        self.funccode = funccode or inspect.getsource(pyfunc.__code__)
-        # Parse AST if it is not provided explicitly
-        self.py_ast = py_ast or parse(BaseKernel.fix_indentation(self.funccode)).body[0]
-        if pyfunc is None:
-            # Extract user context by inspecting the call stack
-            stack = inspect.stack()
-            try:
-                user_ctx = stack[-1][0].f_globals
-                user_ctx['math'] = globals()['math']
-                user_ctx['ParcelsRandom'] = globals()['ParcelsRandom']
-                user_ctx['random'] = globals()['random']
-                user_ctx['StatusCode'] = globals()['StatusCode']
-            except:
-                logger.warning("Could not access user context when merging kernels")
-                user_ctx = globals()
-            finally:
-                del stack  # Remove cyclic references
-            # Compile and generate Python function from AST
-            py_mod = parse("")
-            py_mod.body = [self.py_ast]
-            exec(compile(py_mod, "<ast>", "exec"), user_ctx)
-            self._pyfunc = user_ctx[self.funcname]
-        else:
-            self._pyfunc = pyfunc
-
-        numkernelargs = self.check_kernel_signature_on_version()
-
-        assert numkernelargs == 3, \
-            'Since Parcels v2.0, kernels do only take 3 arguments: particle, fieldset, time !! AND !! Argument order in field interpolation is time, depth, lat, lon.'
-
-        self.name = f"{ptype.name}{self.funcname}"
-
-        # Generate the kernel function and add the outer loop
-        if self.ptype.uses_jit:
-            kernelgen = KernelGenerator(fieldset, ptype)
-            kernel_ccode = kernelgen.generate(deepcopy(self.py_ast),
-                                              self.funcvars)
-            self.field_args = kernelgen.field_args
-            self.vector_field_args = kernelgen.vector_field_args
-            fieldset = self.fieldset
-            for f in self.vector_field_args.values():
-                Wname = f.W.ccode_name if f.W else 'not_defined'
-                for sF_name, sF_component in zip([f.U.ccode_name, f.V.ccode_name, Wname], ['U', 'V', 'W']):
-                    if sF_name not in self.field_args:
-                        if sF_name != 'not_defined':
-                            self.field_args[sF_name] = getattr(f, sF_component)
-            self.const_args = kernelgen.const_args
-            loopgen = LoopGenerator(fieldset, ptype)
-            if path.isfile(self._c_include):
-                with open(self._c_include) as f:
-                    c_include_str = f.read()
-            else:
-                c_include_str = self._c_include
-            self.ccode = loopgen.generate(self.funcname, self.field_args, self.const_args,
-                                          kernel_ccode, c_include_str)
-
-            src_file_or_files, self.lib_file, self.log_file = self.get_kernel_compile_files()
-            if type(src_file_or_files) in (list, dict, tuple, np.ndarray):
-                self.dyn_srcs = src_file_or_files
-            else:
-                self.src_file = src_file_or_files
-
-    def execute_jit(self, pset, endtime, dt):
-        """Invokes JIT engine to perform the core update loop."""
-        self.load_fieldset_jit(pset)
-
-        fargs = [byref(f.ctypes_struct) for f in self.field_args.values()]
-        fargs += [c_double(f) for f in self.const_args.values()]
-        particle_data = byref(pset.ctypes_struct)
-        return self._function(c_int(len(pset)), particle_data,
-                              c_double(endtime), c_double(dt), *fargs)
-
-    def execute_python(self, pset, endtime, dt):
-        """Performs the core update loop via Python."""
-        # sign of dt: { [0, 1]: forward simulation; -1: backward simulation }
-        sign_dt = np.sign(dt)
-
-        if self.fieldset is not None:
-            for f in self.fieldset.get_fields():
-                if isinstance(f, (VectorField, NestedField)):
-                    continue
-                f.data = np.array(f.data)
-
-        if not self.scipy_positionupdate_kernels_added:
-            self.add_scipy_positionupdate_kernels()
-            self.scipy_positionupdate_kernels_added = True
-
-        for p in pset:
-            self.evaluate_particle(p, endtime, sign_dt)
-
-    def remove_deleted(self, pset):
-        """Utility to remove all particles that signalled deletion.
-
-        This deletion function is targetted to index-addressable, random-access array-collections.
-        """
-        # Indices marked for deletion.
-        bool_indices = pset.collection.state == StatusCode.Delete
-        indices = np.where(bool_indices)[0]
-        if len(indices) > 0 and self.fieldset.particlefile is not None:
-            self.fieldset.particlefile.write(pset, None, indices=indices)
-        pset.remove_indices(indices)
-
-    def execute(self, pset, endtime, dt, output_file=None):
-        """Execute this Kernel over a ParticleSet for several timesteps."""
-        pset.collection.state[:] = StatusCode.Evaluate
-
-        if abs(dt) < 1e-6:
-            logger.warning_once("'dt' is too small, causing numerical accuracy limit problems. Please chose a higher 'dt' and rather scale the 'time' axis of the field accordingly. (related issue #762)")
-
-        if pset.fieldset is not None:
-            for g in pset.fieldset.gridset.grids:
-                if len(g.load_chunk) > g.chunk_not_loaded:  # not the case if a field in not called in the kernel
-                    g.load_chunk = np.where(g.load_chunk == g.chunk_loaded_touched,
-                                            g.chunk_deprecated, g.load_chunk)
-
-        # Execute the kernel over the particle set
-        if self.ptype.uses_jit:
-            self.execute_jit(pset, endtime, dt)
-        else:
-            self.execute_python(pset, endtime, dt)
-
-        # Remove all particles that signalled deletion
-        self.remove_deleted(pset)
-
-        # Identify particles that threw errors
-        n_error = pset.num_error_particles
-
-        while n_error > 0:
-            error_pset = pset.error_particles
-            # Apply recovery kernel
-            for p in error_pset:
-                if p.state == StatusCode.StopExecution:
-                    return
-                if p.state == StatusCode.Repeat:
-                    p.reset_state()
-                elif p.state == StatusCode.ErrorTimeExtrapolation:
-                    raise TimeExtrapolationError(p.time)
-                elif p.state == StatusCode.ErrorOutOfBounds:
-                    raise FieldOutOfBoundError(p.lon, p.lat, p.depth)
-                elif p.state == StatusCode.ErrorThroughSurface:
-                    raise FieldOutOfBoundSurfaceError(p.lon, p.lat, p.depth)
-                elif p.state == StatusCode.Error:
-                    raise FieldSamplingError(p.lon, p.lat, p.depth)
-                elif p.state == StatusCode.Delete:
-                    pass
-                else:
-                    logger.warning_once(f'Deleting particle {p.id} because of non-recoverable error')
-                    p.delete()
-
-            # Remove all particles that signalled deletion
-            self.remove_deleted(pset)   # Generalizable version!
-
-            # Execute core loop again to continue interrupted particles
-            if self.ptype.uses_jit:
-                self.execute_jit(pset, endtime, dt)
-            else:
-                self.execute_python(pset, endtime, dt)
-
-            n_error = pset.num_error_particles
diff --git a/parcels/particledata.py b/parcels/particledata.py
new file mode 100644
index 000000000..28b8fe83d
--- /dev/null
+++ b/parcels/particledata.py
@@ -0,0 +1,483 @@
+from abc import ABC
+from ctypes import POINTER, Structure
+from operator import attrgetter
+
+import numpy as np
+
+from parcels.tools.loggers import logger
+from parcels.tools.statuscodes import StatusCode
+
+try:
+    from mpi4py import MPI
+except:
+    MPI = None
+if MPI:
+    try:
+        from sklearn.cluster import KMeans
+    except:
+        KMeans = None
+
+
+def partitionParticlesMPI_default(coords, mpi_size=1):
+    """This function takes the coordinates of the particle starting
+    positions and returns which MPI process will process each particle.
+
+    Input:
+
+    coords: numpy array with rows of [lon, lat] so that coords.shape[0] is the number of particles and coords.shape[1] is 2.
+
+    mpi_size=1: the number of MPI processes.
+
+    Output:
+    mpiProcs: an integer array with values from 0 to mpi_size-1 specifying which MPI job will run which particles. len(mpiProcs) must equal coords.shape[0]
+    """
+    if KMeans:
+        kmeans = KMeans(n_clusters=mpi_size, random_state=0).fit(coords)
+        mpiProcs = kmeans.labels_
+    else:  # assigning random labels if no KMeans (see https://github.com/OceanParcels/parcels/issues/1261)
+        logger.warning_once('sklearn needs to be available if MPI is installed. '
+                            'See https://docs.oceanparcels.org/en/latest/installation.html#installation-for-developers for more information')
+        mpiProcs = np.randint(0, mpi_size, size=coords.shape[0])
+
+    return mpiProcs
+
+
+class ParticleData(ABC):
+
+    def __init__(self, pclass, lon, lat, depth, time, lonlatdepth_dtype, pid_orig, ngrid=1, **kwargs):
+        """
+        Parameters
+        ----------
+        ngrid :
+            number of grids in the fieldset of the overarching ParticleSet - required for initialising the
+            field references of the ctypes-link of particles that are allocated
+        """
+        self._ncount = -1
+        self._pu_indicators = None
+        self._offset = 0
+        self._pclass = None
+        self._ptype = None
+        self._latlondepth_dtype = np.float32
+        self._data = None
+
+        assert pid_orig is not None, "particle IDs are None - incompatible with the ParticleData class. Invalid state."
+        pid = pid_orig + pclass.lastID
+
+        self._sorted = np.all(np.diff(pid) >= 0)
+
+        assert depth is not None, "particle's initial depth is None - incompatible with the ParticleData class. Invalid state."
+        assert lon.size == lat.size and lon.size == depth.size, ('lon, lat, depth don''t all have the same lenghts.')
+
+        assert lon.size == time.size, ('time and positions (lon, lat, depth) don''t have the same lengths.')
+
+        # If a partitioning function for MPI runs has been passed into the
+        # particle creation with the "partition_function" kwarg, retrieve it here.
+        # If it has not, assign the default function, partitionParticlesMPI_defualt()
+        partition_function = kwargs.pop('partition_function', partitionParticlesMPI_default)
+
+        for kwvar in kwargs:
+            assert lon.size == kwargs[kwvar].size, (
+                f"{kwvar} and positions (lon, lat, depth) don't have the same lengths.")
+
+        offset = np.max(pid) if (pid is not None) and len(pid) > 0 else -1
+        if MPI:
+            mpi_comm = MPI.COMM_WORLD
+            mpi_rank = mpi_comm.Get_rank()
+            mpi_size = mpi_comm.Get_size()
+
+            if lon.size < mpi_size and mpi_size > 1:
+                raise RuntimeError('Cannot initialise with fewer particles than MPI processors')
+
+            if mpi_size > 1:
+                if partition_function is not False:
+                    if (self._pu_indicators is None) or (len(self._pu_indicators) != len(lon)):
+                        if mpi_rank == 0:
+                            coords = np.vstack((lon, lat)).transpose()
+                            self._pu_indicators = partition_function(coords, mpi_size=mpi_size)
+                        else:
+                            self._pu_indicators = None
+                        self._pu_indicators = mpi_comm.bcast(self._pu_indicators, root=0)
+                    elif np.max(self._pu_indicators) >= mpi_size:
+                        raise RuntimeError('Particle partitions must vary between 0 and the number of mpi procs')
+                    lon = lon[self._pu_indicators == mpi_rank]
+                    lat = lat[self._pu_indicators == mpi_rank]
+                    time = time[self._pu_indicators == mpi_rank]
+                    depth = depth[self._pu_indicators == mpi_rank]
+                    pid = pid[self._pu_indicators == mpi_rank]
+                    for kwvar in kwargs:
+                        kwargs[kwvar] = kwargs[kwvar][self._pu_indicators == mpi_rank]
+                offset = MPI.COMM_WORLD.allreduce(offset, op=MPI.MAX)
+
+        pclass.setLastID(offset+1)
+
+        if lonlatdepth_dtype is None:
+            self._lonlatdepth_dtype = np.float32
+        else:
+            self._lonlatdepth_dtype = lonlatdepth_dtype
+        assert self._lonlatdepth_dtype in [np.float32, np.float64], \
+            'lon lat depth precision should be set to either np.float32 or np.float64'
+        pclass.set_lonlatdepth_dtype(self._lonlatdepth_dtype)
+        self._pclass = pclass
+
+        self._ptype = pclass.getPType()
+        self._data = {}
+        initialised = set()
+
+        self._ncount = len(lon)
+
+        for v in self.ptype.variables:
+            if v.name in ['xi', 'yi', 'zi', 'ti']:
+                self._data[v.name] = np.empty((len(lon), ngrid), dtype=v.dtype)
+            else:
+                self._data[v.name] = np.empty(self._ncount, dtype=v.dtype)
+
+        if lon is not None and lat is not None:
+            # Initialise from lists of lon/lat coordinates
+            assert self._ncount == len(lon) and self._ncount == len(lat), (
+                'Size of ParticleSet does not match length of lon and lat.')
+
+            # mimic the variables that get initialised in the constructor
+            self._data['lat'][:] = lat
+            self._data['lat_nextloop'][:] = lat
+            self._data['lon'][:] = lon
+            self._data['lon_nextloop'][:] = lon
+            self._data['depth'][:] = depth
+            self._data['depth_nextloop'][:] = depth
+            self._data['time'][:] = time
+            self._data['time_nextloop'][:] = time
+            self._data['id'][:] = pid
+            self._data['obs_written'][:] = 0
+
+            # special case for exceptions which can only be handled from scipy
+            self._data['exception'] = np.empty(self._ncount, dtype=object)
+
+            initialised |= {'lat', 'lat_nextloop', 'lon', 'lon_nextloop', 'depth', 'depth_nextloop', 'time', 'time_nextloop', 'id', 'obs_written'}
+
+            # any fields that were provided on the command line
+            for kwvar, kwval in kwargs.items():
+                if not hasattr(pclass, kwvar):
+                    raise RuntimeError(f'Particle class does not have Variable {kwvar}')
+                self._data[kwvar][:] = kwval
+                initialised.add(kwvar)
+
+            # initialise the rest to their default values
+            for v in self.ptype.variables:
+                if v.name in initialised:
+                    continue
+
+                if isinstance(v.initial, attrgetter):
+                    self._data[v.name][:] = v.initial(self)
+                else:
+                    self._data[v.name][:] = v.initial
+
+                initialised.add(v.name)
+        else:
+            raise ValueError("Latitude and longitude required for generating ParticleSet")
+
+    def __del__(self):
+        pass
+
+    @property
+    def pu_indicators(self):
+        """
+        The 'pu_indicator' is an [array or dictionary]-of-indicators, where each indicator entry tells per item
+        (i.e. particle) in the ParticleData instance to which processing unit (PU) in a parallelised setup it belongs to.
+        """
+        return self._pu_indicators
+
+    @property
+    def pclass(self):
+        """Class type of the particles allocated and managed in this ParticleData instance."""
+        return self._pclass
+
+    @property
+    def ptype(self):
+        """
+        'ptype' returns an instance of the particular type of class 'ParticleType' of the particle class of the particles
+        in this ParticleData instance.
+
+        basically:
+        pytpe -> pclass().getPType()
+        """
+        return self._ptype
+
+    @property
+    def lonlatdepth_dtype(self):
+        """
+        'lonlatdepth_dtype' stores the numeric data type that is used to represent the lon, lat and depth of a particle.
+        This can be either 'float32' (default) or 'float64'
+        """
+        return self._lonlatdepth_dtype
+
+    @property
+    def data(self):
+        """'data' is a reference to the actual barebone-storage of the particle data."""
+        return self._data
+
+    def __len__(self):
+        """Return the length, in terms of 'number of elements, of a ParticleData instance."""
+        return self._ncount
+
+    def iterator(self):
+        return ParticleDataIterator(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 self.iterator()
+
+    def __getitem__(self, index):
+        """Get a particle object from the ParticleData instance based on its index."""
+        return self.get_single_by_index(index)
+
+    def __getattr__(self, name):
+        """
+        Access a single property of all particles.
+
+        Parameters
+        ----------
+        name : str
+            Name of the property to access
+        """
+        for v in self.ptype.variables:
+            if v.name == name and name in self._data:
+                return self._data[name]
+        return False
+
+    def get_single_by_index(self, index):
+        """Get a particle object from the ParticleData instance based on its index."""
+        assert type(index) in [int, np.int32, np.intp], f"Trying to get a particle by index, but index {index} is not a 32-bit integer - invalid operation."
+        return ParticleDataAccessor(self, index)
+
+    def add_same(self, same_class):
+        """Add another ParticleData instance to this ParticleData instance. This is done by concatenating both instances."""
+        assert same_class is not None, f"Trying to add another {type(self)} to this one, but the other one is None - invalid operation."
+        assert type(same_class) is type(self)
+
+        if same_class._ncount == 0:
+            return
+
+        if self._ncount == 0:
+            self._data = same_class._data
+            self._ncount = same_class._ncount
+            return
+
+        # Determine order of concatenation and update the sorted flag
+        if self._sorted and same_class._sorted \
+           and self._data['id'][0] > same_class._data['id'][-1]:
+            for d in self._data:
+                self._data[d] = np.concatenate((same_class._data[d], self._data[d]))
+            self._ncount += same_class._ncount
+        else:
+            if not (same_class._sorted
+                    and self._data['id'][-1] < same_class._data['id'][0]):
+                self._sorted = False
+            for d in self._data:
+                self._data[d] = np.concatenate((self._data[d], same_class._data[d]))
+            self._ncount += same_class._ncount
+
+    def __iadd__(self, instance):
+        """Perform an incremental addition of ParticleData instances, such to allow a += b."""
+        self.add_same(instance)
+        return self
+
+    def remove_single_by_index(self, index):
+        """Remove a particle from the ParticleData instance based on its index."""
+        assert type(index) in [int, np.int32, np.intp], f"Trying to remove a particle by index, but index {index} is not a 32-bit integer - invalid operation."
+
+        for d in self._data:
+            self._data[d] = np.delete(self._data[d], index, axis=0)
+
+        self._ncount -= 1
+
+    def remove_multi_by_indices(self, indices):
+        """Remove particles from the ParticleData instance based on their indices."""
+        assert indices is not None, "Trying to remove particles by their ParticleData instance indices, but the index list is None - invalid operation."
+        assert type(indices) in [list, dict, np.ndarray], "Trying to remove particles by their indices, but the index container is not a valid Python-collection - invalid operation."
+        if type(indices) is not dict:
+            assert len(indices) == 0 or type(indices[0]) in [int, np.int32, np.intp], "Trying to remove particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
+        else:
+            assert len(list(indices.values())) == 0 or type(list(indices.values())[0]) in [int, np.int32, np.intp], "Trying to remove particles by their index, but the index type in the Python collection is not a 32-bit integer - invalid operation."
+        if type(indices) is dict:
+            indices = list(indices.values())
+
+        for d in self._data:
+            self._data[d] = np.delete(self._data[d], indices, axis=0)
+
+        self._ncount -= len(indices)
+
+    def cstruct(self):
+        """Return the ctypes mapping of the particle data."""
+        class CParticles(Structure):
+            _fields_ = [(v.name, POINTER(np.ctypeslib.as_ctypes_type(v.dtype))) for v in self._ptype.variables]
+
+        def flatten_dense_data_array(vname):
+            data_flat = self._data[vname].view()
+            data_flat.shape = -1
+            return np.ctypeslib.as_ctypes(data_flat)
+
+        cdata = [flatten_dense_data_array(v.name) for v in self._ptype.variables]
+        cstruct = CParticles(*cdata)
+        return cstruct
+
+    def _to_write_particles(self, pd, time):
+        """Return the Particles that need to be written at time: if particle.time is between time-dt/2 and time+dt (/2)"""
+        return np.where((np.less_equal(time - np.abs(pd['dt'] / 2), pd['time'], where=np.isfinite(pd['time']))
+                        & np.greater_equal(time + np.abs(pd['dt'] / 2), pd['time'], where=np.isfinite(pd['time']))
+                        | ((np.isnan(pd['dt'])) & np.equal(time, pd['time'], where=np.isfinite(pd['time']))))
+                        & (np.isfinite(pd['id']))
+                        & (np.isfinite(pd['time'])))[0]
+
+    def getvardata(self, var, indices=None):
+        if indices is None:
+            return self._data[var]
+        else:
+            try:
+                return self._data[var][indices]
+            except:  # Can occur for zero-length ParticleSets
+                return None
+
+    def setvardata(self, var, index, val):
+        self._data[var][index] = val
+
+    def setallvardata(self, var, val):
+        self._data[var][:] = val
+
+    def set_variable_write_status(self, var, write_status):
+        """Method to set the write status of a Variable
+
+        Parameters
+        ----------
+        var :
+            Name of the variable (string)
+        write_status :
+            Write status of the variable (True, False or 'once')
+        """
+        var_changed = False
+        for v in self._ptype.variables:
+            if v.name == var:
+                v.to_write = write_status
+                var_changed = True
+        if not var_changed:
+            raise SyntaxError(f'Could not change the write status of {var}, because it is not a Variable name')
+
+
+class ParticleDataAccessor(ABC):
+    """Wrapper that provides access to particle data, as if interacting with the particle itself.
+
+    Parameters
+    ----------
+    pcoll :
+        ParticleData that the particle belongs to.
+    index :
+        The index at which the data for the particle is stored in the corresponding data arrays of the ParticleData instance.
+    """
+
+    _pcoll = None
+    _index = 0
+
+    def __init__(self, pcoll, index):
+        """Initializes the ParticleDataAccessor to provide access to one specific particle."""
+        self._pcoll = pcoll
+        self._index = index
+
+    def __getattr__(self, name):
+        """
+        Get the value of an attribute of the particle.
+
+        Parameters
+        ----------
+        name : str
+            Name of the requested particle attribute.
+
+        Returns
+        -------
+        any
+            The value of the particle attribute in the underlying data array.
+        """
+        if name in self.__dict__.keys():
+            result = self.__getattribute__(name)
+        elif name in type(self).__dict__.keys():
+            result = object.__getattribute__(self, name)
+        else:
+            result = self._pcoll.data[name][self._index]
+        return result
+
+    def __setattr__(self, name, value):
+        """
+        Set the value of an attribute of the particle.
+
+        Parameters
+        ----------
+        name : str
+            Name of the particle attribute.
+        value : any
+            Value that will be assigned to the particle attribute in the underlying data array.
+        """
+        if name in self.__dict__.keys():
+            self.__setattr__(name, value)
+        elif name in type(self).__dict__.keys():
+            object.__setattr__(self, name, value)
+        else:
+            self._pcoll.data[name][self._index] = value
+
+    def getPType(self):
+        return self._pcoll.ptype
+
+    def __repr__(self):
+        time_string = 'not_yet_set' if self.time is None or np.isnan(self.time) else f"{self.time:f}"
+        str = "P[%d](lon=%f, lat=%f, depth=%f, " % (self.id, self.lon, self.lat, self.depth)
+        for var in self._pcoll.ptype.variables:
+            if var.name in ['lon_nextloop', 'lat_nextloop', 'depth_nextloop', 'time_nextloop']:  # TODO check if time_nextloop is needed (or can work with time-dt?)
+                continue
+            if var.to_write is not False and var.name not in ['id', 'lon', 'lat', 'depth', 'time']:
+                str += f"{var.name}={getattr(self, var.name):f}, "
+        return str + f"time={time_string})"
+
+    def delete(self):
+        """Signal the particle for deletion."""
+        self.state = StatusCode.Delete
+
+
+class ParticleDataIterator(ABC):
+    """Iterator for looping over the particles in the ParticleData.
+
+    Parameters
+    ----------
+    pcoll :
+        ParticleData that stores the particles.
+    subset :
+        Subset of indices to iterate over.
+    """
+
+    def __init__(self, pcoll, subset=None):
+
+        if subset is not None:
+            if len(subset) > 0 and type(subset[0]) not in [int, np.int32, np.intp]:
+                raise TypeError("Iteration over a subset of particles in the particleset requires a list or numpy array of indices (of type int or np.int32).")
+            self._indices = subset
+            self.max_len = len(subset)
+        else:
+            self.max_len = len(pcoll)
+            self._indices = range(self.max_len)
+
+        self._pcoll = pcoll
+        self._index = 0
+
+    def __len__(self):
+        return len(self._indices)
+
+    def __getitem__(self, items):
+        return ParticleDataAccessor(self._pcoll, self._indices[items])
+
+    def __iter__(self):
+        """Return the iterator itself."""
+        return self
+
+    def __next__(self):
+        """Return a ParticleDataAccessor for the next particle in the ParticleData instance."""
+        if self._index < self.max_len:
+            self.p = ParticleDataAccessor(self._pcoll, self._indices[self._index])
+            self._index += 1
+            return self.p
+
+        raise StopIteration
diff --git a/parcels/particlefile/baseparticlefile.py b/parcels/particlefile.py
similarity index 88%
rename from parcels/particlefile/baseparticlefile.py
rename to parcels/particlefile.py
index 2aa822384..bcc38fae6 100644
--- a/parcels/particlefile/baseparticlefile.py
+++ b/parcels/particlefile.py
@@ -1,6 +1,6 @@
 """Module controlling the writing of ParticleSets to Zarr file."""
 import os
-from abc import ABC, abstractmethod
+from abc import ABC
 from datetime import timedelta as delta
 
 import numpy as np
@@ -19,7 +19,7 @@
     raise OSError('Parcels version can not be retrieved. Have you run ''python setup.py install''?')
 
 
-__all__ = ['BaseParticleFile']
+__all__ = ['ParticleFile']
 
 
 def _set_calendar(origin_calendar):
@@ -29,7 +29,7 @@ def _set_calendar(origin_calendar):
         return origin_calendar
 
 
-class BaseParticleFile(ABC):
+class ParticleFile(ABC):
     """Initialise trajectory output.
 
     Parameters
@@ -49,7 +49,7 @@ class BaseParticleFile(ABC):
 
     Returns
     -------
-    BaseParticleFile
+    ParticleFile
         ParticleFile object that can be used to write particle data to file
     """
 
@@ -68,12 +68,12 @@ def __init__(self, name, particleset, outputdt=np.infty, chunks=None, create_new
         if self.particleset.fieldset is not None:
             self.parcels_mesh = self.particleset.fieldset.gridset.grids[0].mesh
         self.time_origin = self.particleset.time_origin
-        self.lonlatdepth_dtype = self.particleset.collection.lonlatdepth_dtype
+        self.lonlatdepth_dtype = self.particleset.particledata.lonlatdepth_dtype
         self.maxids = 0
         self.pids_written = {}
         self.create_new_zarrfile = create_new_zarrfile
         self.vars_to_write = {}
-        for var in self.particleset.collection.ptype.variables:
+        for var in self.particleset.particledata.ptype.variables:
             if var.to_write:
                 self.vars_to_write[var.name] = var.dtype
         self.mpi_rank = MPI.COMM_WORLD.Get_rank() if MPI else 0
@@ -81,7 +81,7 @@ def __init__(self, name, particleset, outputdt=np.infty, chunks=None, create_new
         self.analytical = False  # Flag to indicate if ParticleFile is used for analytical trajectories
 
         # Reset obs_written of each particle, in case new ParticleFile created for a ParticleSet
-        particleset.collection.setallvardata('obs_written', 0)
+        particleset.particledata.setallvardata('obs_written', 0)
 
         self.metadata = {"feature_type": "trajectory", "Conventions": "CF-1.6/CF-1.7",
                          "ncei_template_version": "NCEI_NetCDF_Trajectory_Template_v2.0",
@@ -112,11 +112,6 @@ def __init__(self, name, particleset, outputdt=np.infty, chunks=None, create_new
             else:
                 self.fname = name if extension in ['.zarr'] else "%s.zarr" % name
 
-    @abstractmethod
-    def _reserved_var_names(self):
-        """Returns the reserved dimension names not to be written just once."""
-        pass
-
     def _create_variables_attribute_dict(self):
         """Creates the dictionary with variable attributes.
 
@@ -150,7 +145,7 @@ def _create_variables_attribute_dict(self):
             attrs['time']['calendar'] = 'standard' if self.time_origin.calendar == 'np_datetime64' else self.time_origin.calendar
 
         for vname in self.vars_to_write:
-            if vname not in self._reserved_var_names():
+            if vname not in ['time', 'lat', 'lon', 'depth', 'id']:
                 attrs[vname] = {"_FillValue": self.fill_value_map[self.vars_to_write[vname]],
                                 "long_name": "",
                                 "standard_name": vname,
@@ -179,7 +174,7 @@ def _convert_varout_name(self, var):
             return var
 
     def write_once(self, var):
-        return self.particleset.collection.ptype[var].to_write == 'once'
+        return self.particleset.particledata.ptype[var].to_write == 'once'
 
     def _extend_zarr_dims(self, Z, store, dtype, axis):
         if axis == 1:
@@ -209,21 +204,21 @@ def write(self, pset, time, indices=None):
         """
         time = time.total_seconds() if isinstance(time, delta) else time
 
-        if pset.collection._ncount == 0:
+        if pset.particledata._ncount == 0:
             logger.warning("ParticleSet is empty on writing as array at time %g" % time)
             return
 
-        indices_to_write = pset.collection._to_write_particles(pset.collection._data, time) if indices is None else indices
+        indices_to_write = pset.particledata._to_write_particles(pset.particledata._data, time) if indices is None else indices
 
         if len(indices_to_write) > 0:
-            pids = pset.collection.getvardata('id', indices_to_write)
+            pids = pset.particledata.getvardata('id', indices_to_write)
             to_add = sorted(set(pids) - set(self.pids_written.keys()))
             for i, pid in enumerate(to_add):
                 self.pids_written[pid] = self.maxids + i
             ids = np.array([self.pids_written[p] for p in pids], dtype=int)
             self.maxids = len(self.pids_written)
 
-            once_ids = np.where(pset.collection.getvardata('obs_written', indices_to_write) == 0)[0]
+            once_ids = np.where(pset.particledata.getvardata('obs_written', indices_to_write) == 0)[0]
             if len(once_ids) > 0:
                 ids_once = ids[once_ids]
                 indices_to_write_once = indices_to_write[once_ids]
@@ -248,11 +243,11 @@ def write(self, pset, time, indices=None):
                     if varout not in ['trajectory']:  # because 'trajectory' is written as coordinate
                         if self.write_once(var):
                             data = np.full((arrsize[0],), self.fill_value_map[self.vars_to_write[var]], dtype=self.vars_to_write[var])
-                            data[ids_once] = pset.collection.getvardata(var, indices_to_write_once)
+                            data[ids_once] = pset.particledata.getvardata(var, indices_to_write_once)
                             dims = ["trajectory"]
                         else:
                             data = np.full(arrsize, self.fill_value_map[self.vars_to_write[var]], dtype=self.vars_to_write[var])
-                            data[ids, 0] = pset.collection.getvardata(var, indices_to_write)
+                            data[ids, 0] = pset.particledata.getvardata(var, indices_to_write)
                             dims = ["trajectory", "obs"]
                         ds[varout] = xr.DataArray(data=data, dims=dims, attrs=attrs[varout])
                         ds[varout].encoding['chunks'] = self.chunks[0] if self.write_once(var) else self.chunks
@@ -265,20 +260,20 @@ def write(self, pset, time, indices=None):
                 else:
                     store = zarr.DirectoryStore(self.fname)
                 Z = zarr.group(store=store, overwrite=False)
-                obs = pset.collection.getvardata('obs_written', indices_to_write)
+                obs = pset.particledata.getvardata('obs_written', indices_to_write)
                 for var in self.vars_to_write:
                     varout = self._convert_varout_name(var)
                     if self.maxids > Z[varout].shape[0]:
                         self._extend_zarr_dims(Z[varout], store, dtype=self.vars_to_write[var], axis=0)
                     if self.write_once(var):
                         if len(once_ids) > 0:
-                            Z[varout].vindex[ids_once] = pset.collection.getvardata(var, indices_to_write_once)
+                            Z[varout].vindex[ids_once] = pset.particledata.getvardata(var, indices_to_write_once)
                     else:
                         if max(obs) >= Z[varout].shape[1]:
                             self._extend_zarr_dims(Z[varout], store, dtype=self.vars_to_write[var], axis=1)
-                        Z[varout].vindex[ids, obs] = pset.collection.getvardata(var, indices_to_write)
+                        Z[varout].vindex[ids, obs] = pset.particledata.getvardata(var, indices_to_write)
 
-            pset.collection.setvardata('obs_written', indices_to_write, obs+1)
+            pset.particledata.setvardata('obs_written', indices_to_write, obs+1)
 
     def write_latest_locations(self, pset, time):
         """Write the current (latest) particle locations to zarr file.
@@ -293,6 +288,6 @@ def write_latest_locations(self, pset, time):
             Time at which to write ParticleSet. Note that typically this would be pset.time_nextloop
         """
         for var in ['lon', 'lat', 'depth', 'time']:
-            pset.collection.setallvardata(f"{var}", pset.collection.getvardata(f"{var}_nextloop"))
+            pset.particledata.setallvardata(f"{var}", pset.particledata.getvardata(f"{var}_nextloop"))
 
         self.write(pset, time)
diff --git a/parcels/particlefile/__init__.py b/parcels/particlefile/__init__.py
deleted file mode 100644
index a994ce0b1..000000000
--- a/parcels/particlefile/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Module controlling the writing of ParticleSets to NetCDF file."""
-
-from .baseparticlefile import BaseParticleFile  # noqa: F401
-from .baseparticlefile import _set_calendar  # noqa: F401
-from .particlefileaos import ParticleFileAOS  # noqa: F401
-from .particlefilesoa import ParticleFileSOA  # noqa: F401
-
-ParticleFile = ParticleFileSOA
diff --git a/parcels/particlefile/particlefileaos.py b/parcels/particlefile/particlefileaos.py
deleted file mode 100644
index fbc4c894a..000000000
--- a/parcels/particlefile/particlefileaos.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Module controlling the writing of ParticleSets to NetCDF file"""
-import numpy as np
-
-from parcels.particlefile.baseparticlefile import BaseParticleFile
-
-__all__ = ['ParticleFileAOS']
-
-
-class ParticleFileAOS(BaseParticleFile):
-    """Initialise trajectory output.
-
-    Parameters
-    ----------
-    name : str
-        Basename of the output file. This can also be a Zarr store.
-    particleset :
-        ParticleSet to output
-    outputdt :
-        Interval which dictates the update frequency of file output
-        while ParticleFile is given as an argument of ParticleSet.execute()
-        It is either a timedelta object or a positive double.
-    chunks :
-        Tuple (trajs, obs) to control the size of chunks in the zarr output.
-
-    Returns
-    -------
-    ParticleFileAOS
-        ParticleFile object that can be used to write particle data to file
-    """
-
-    def __init__(self, name, particleset, outputdt=np.infty, chunks=None, write_ondelete=False):
-        super().__init__(
-            name=name, particleset=particleset, outputdt=outputdt, chunks=chunks)
-
-    def _reserved_var_names(self):
-        """Returns the reserved dimension names not to be written just once."""
-        return ['time', 'lat', 'lon', 'depth', 'id']
diff --git a/parcels/particlefile/particlefilesoa.py b/parcels/particlefile/particlefilesoa.py
deleted file mode 100644
index bafdab27d..000000000
--- a/parcels/particlefile/particlefilesoa.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""Module controlling the writing of ParticleSets to NetCDF file"""
-import numpy as np
-
-from parcels.particlefile.baseparticlefile import BaseParticleFile
-
-__all__ = ['ParticleFileSOA']
-
-
-class ParticleFileSOA(BaseParticleFile):
-    """Initialise trajectory output.
-
-    Parameters
-    ----------
-    name : str
-        Basename of the output file.  This can also be a Zarr store.
-    particleset :
-        ParticleSet to output
-    outputdt :
-        Interval which dictates the update frequency of file output
-        while ParticleFile is given as an argument of ParticleSet.execute()
-        It is either a timedelta object or a positive double.
-    chunks :
-        Tuple (trajs, obs) to control the size of chunks in the zarr output.
-
-    Returns
-    -------
-    ParticleFileSOA
-        ParticleFile object that can be used to write particle data to file
-
-    """
-
-    def __init__(self, name, particleset, outputdt=np.infty, chunks=None, write_ondelete=False):
-        super().__init__(
-            name=name, particleset=particleset, outputdt=outputdt, chunks=chunks)
-
-    def _reserved_var_names(self):
-        """Returns the reserved dimension names not to be written just once."""
-        return ['time', 'lat', 'lon', 'depth', 'id']
diff --git a/parcels/particleset/particlesetsoa.py b/parcels/particleset.py
similarity index 56%
rename from parcels/particleset/particlesetsoa.py
rename to parcels/particleset.py
index 815c405aa..e0fb8cb15 100644
--- a/parcels/particleset/particlesetsoa.py
+++ b/parcels/particleset.py
@@ -1,16 +1,31 @@
 import sys
+import time as time_module
+from abc import ABC
 from copy import copy
 from datetime import date, datetime
 from datetime import timedelta as delta
+from os import path
 
+import cftime
 import numpy as np
 import xarray as xr
+from tqdm import tqdm
 
-from parcels.collection.collectionsoa import ParticleCollectionIterableSOA  # noqa
-from parcels.collection.collectionsoa import ParticleCollectionIteratorSOA  # noqa
-from parcels.collection.collectionsoa import ParticleCollectionSOA
+try:
+    from mpi4py import MPI
+except:
+    MPI = None
+
+try:
+    from pykdtree.kdtree import KDTree
+except:
+    KDTree = None
+
+from parcels.application_kernels.advection import AdvectionRK4
+from parcels.compilation.codecompiler import GNUCompiler
+from parcels.field import NestedField
 from parcels.grid import CurvilinearGrid, GridCode
-from parcels.interaction.interactionkernelsoa import InteractionKernelSOA
+from parcels.interaction.interactionkernel import InteractionKernel
 from parcels.interaction.neighborsearch import (
     BruteFlatNeighborSearch,
     BruteSphericalNeighborSearch,
@@ -18,24 +33,15 @@
     KDTreeFlatNeighborSearch,
 )
 from parcels.kernel import Kernel
-from parcels.particle import JITParticle, ScipyParticle, Variable  # noqa
+from parcels.particle import JITParticle, Variable
+from parcels.particledata import ParticleData, ParticleDataIterator
 from parcels.particlefile import ParticleFile
-from parcels.particleset.baseparticleset import BaseParticleSet
 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
 from parcels.tools.statuscodes import StatusCode
 
-try:
-    from mpi4py import MPI
-except:
-    MPI = None
-# == comment CK: prevents us from adding KDTree as 'mandatory' dependency == #
-try:
-    from pykdtree.kdtree import KDTree
-except:
-    KDTree = None
-
-__all__ = ['ParticleSetSOA']
+__all__ = ['ParticleSet']
 
 
 def _convert_to_reltime(time):
@@ -45,8 +51,8 @@ def _convert_to_reltime(time):
     return False
 
 
-class ParticleSetSOA(BaseParticleSet):
-    """Container class for storing particle and executing kernel over them.
+class ParticleSet(ABC):
+    """Class for storing particle and executing kernel over them.
 
     Please note that this currently only supports fixed size particle sets, meaning that the particle set only
     holds the particles defined on construction. Individual particles can neither be added nor deleted individually,
@@ -89,7 +95,17 @@ class ParticleSetSOA(BaseParticleSet):
     def __init__(self, fieldset=None, pclass=JITParticle, lon=None, lat=None,
                  depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None,
                  pid_orig=None, interaction_distance=None, periodic_domain_zonal=None, **kwargs):
-        super().__init__()
+        self.particledata = None
+        self.repeat_starttime = None
+        self.repeatlon = None
+        self.repeatlat = None
+        self.repeatdepth = None
+        self.repeatpclass = None
+        self.repeatkwargs = None
+        self.kernel = None
+        self.interaction_kernel = None
+        self.fieldset = None
+        self.time_origin = None
 
         # ==== first: create a new subclass of the pclass that includes the required variables ==== #
         # ==== see dynamic-instantiation trick here: https://www.python-course.eu/python3_classes_and_type.php ==== #
@@ -195,7 +211,7 @@ def ArrayClass_init(self, *args, **kwargs):
         # it is set to True here.
         self._dirty_neighbor = True
 
-        self._collection = ParticleCollectionSOA(
+        self.particledata = ParticleData(
             _pclass, lon=lon, lat=lat, depth=depth, time=time,
             lonlatdepth_dtype=lonlatdepth_dtype, pid_orig=pid_orig,
             ngrid=ngrids, **kwargs)
@@ -238,83 +254,188 @@ def ArrayClass_init(self, *args, **kwargs):
             if len(time) > 0 and time[0] is None:
                 self.repeat_starttime = time[0]
             else:
-                if self._collection.data['time'][0] and not np.allclose(self._collection.data['time'], self._collection.data['time'][0]):
+                if self.particledata.data['time'][0] and not np.allclose(self.particledata.data['time'], self.particledata.data['time'][0]):
                     raise ValueError('All Particle.time should be the same when repeatdt is not None')
-                self.repeat_starttime = copy(self._collection.data['time'][0])
-            self.repeatlon = copy(self._collection.data['lon'])
-            self.repeatlat = copy(self._collection.data['lat'])
-            self.repeatdepth = copy(self._collection.data['depth'])
+                self.repeat_starttime = copy(self.particledata.data['time'][0])
+            self.repeatlon = copy(self.particledata.data['lon'])
+            self.repeatlat = copy(self.particledata.data['lat'])
+            self.repeatdepth = copy(self.particledata.data['depth'])
             for kwvar in kwargs:
                 if kwvar not in ['partition_function']:
-                    self.repeatkwargs[kwvar] = copy(self._collection.data[kwvar])
+                    self.repeatkwargs[kwvar] = copy(self.particledata.data[kwvar])
 
         if self.repeatdt:
-            if MPI and self._collection.pu_indicators is not None:
+            if MPI and self.particledata.pu_indicators is not None:
                 mpi_comm = MPI.COMM_WORLD
                 mpi_rank = mpi_comm.Get_rank()
-                self.repeatpid = pid_orig[self._collection.pu_indicators == mpi_rank]
+                self.repeatpid = pid_orig[self.particledata.pu_indicators == mpi_rank]
 
         self.kernel = None
 
     def __del__(self):
-        super().__del__()
+        if self.particledata is not None and isinstance(self.particledata, ParticleData):
+            del self.particledata
+        self.particledata = None
 
-    def _set_particle_vector(self, name, value):
-        """Set attributes of all particles to new values.
+    def iterator(self):
+        return self.particledata.iterator()
+
+    def __iter__(self):
+        """Allows for more intuitive iteration over a particleset, while in reality iterating over the particles in the ParticleData instance."""
+        return self.iterator()
+
+    def __getattr__(self, name):
+        """
+        Access a single property of all particles.
 
         Parameters
         ----------
         name : str
-            Name of the attribute (str).
-        value : any
-            New value to set the attribute of the particles to.
+            Name of the property
         """
-        self.collection._data[name][:] = value
+        for v in self.particledata.ptype.variables:
+            if v.name == name:
+                return getattr(self.particledata, name)
+        if name in self.__dict__ and name[0] != '_':
+            return self.__dict__[name]
+        else:
+            return False
+
+    def __getitem__(self, index):
+        """Get a single particle by index."""
+        return self.particledata.get_single_by_index(index)
+
+    @staticmethod
+    def lonlatdepth_dtype_from_field_interp_method(field):
+        if isinstance(field, NestedField):
+            for f in field:
+                if f.interp_method == 'cgrid_velocity':
+                    return np.float64
+        else:
+            if field.interp_method == 'cgrid_velocity':
+                return np.float64
+        return np.float32
+
+    def cstruct(self):
+        cstruct = self.particledata.cstruct()
+        return cstruct
+
+    @property
+    def ctypes_struct(self):
+        return self.cstruct()
+
+    def __create_progressbar(self, starttime, endtime):
+        pbar = tqdm(total=abs(endtime - starttime), file=sys.stdout)
+        pbar.prevtime = starttime
+        return pbar
+
+    @property
+    def size(self):
+        # ==== to change at some point - len and size are different things ==== #
+        return len(self.particledata)
+
+    def __repr__(self):
+        return "\n".join([str(p) for p in self])
+
+    def __len__(self):
+        return len(self.particledata)
 
-    def _impute_release_times(self, default):
-        """Set attribute 'time' to default if encountering NaN values.
+    def __sizeof__(self):
+        return sys.getsizeof(self.particledata)
+
+    def add(self, particles):
+        """Add particles to the ParticleSet. Note that this is an
+        incremental add, the particles will be added to the ParticleSet
+        on which this function is called.
 
         Parameters
         ----------
-        default :
-            Default release time.
+        particles :
+            Another ParticleSet containing particles to add to this one.
 
         Returns
         -------
         type
-            Minimum and maximum release times.
+            The current ParticleSet
 
         """
-        if np.any(np.isnan(self._collection.data['time'])):
-            self._collection.data['time'][np.isnan(self._collection.data['time'])] = default
-            self._collection.data['time_nextloop'][np.isnan(self._collection.data['time_nextloop'])] = default
-        return np.min(self._collection.data['time_nextloop']), np.max(self._collection.data['time_nextloop'])
+        if isinstance(particles, type(self)):
+            particles = particles.particledata
+        self.particledata += particles
+        # Adding particles invalidates the neighbor search structure.
+        self._dirty_neighbor = True
+        return self
 
-    def data_indices(self, variable_name, compare_values, invert=False):
-        """Get the indices of all particles where the value of `variable_name` equals (one of) `compare_values`.
+    def __iadd__(self, particles):
+        """Add particles to the ParticleSet.
+
+        Note that this is an incremental add, the particles will be added to the ParticleSet
+        on which this function is called.
 
         Parameters
         ----------
-        variable_name : str
-            Name of the variable to check.
-        compare_values :
-            Value or list of values to compare to.
-        invert :
-            Whether to invert the selection. I.e., when True,
-            return all indices that do not equal (one of)
-            `compare_values`. (Default value = False)
+        particles :
+            Another ParticleSet containing particles to add to this one.
 
         Returns
         -------
-        np.ndarray
-            Numpy array of indices that satisfy the test.
-
+        type
+            The current ParticleSet
         """
-        compare_values = np.array([compare_values, ]) if type(compare_values) not in [list, dict, np.ndarray] else compare_values
-        return np.where(np.isin(self._collection.data[variable_name], compare_values, invert=invert))[0]
+        self.add(particles)
+        return self
 
-    def indexed_subset(self, indices):
-        return ParticleCollectionIterableSOA(self._collection, subset=indices)
+    def remove_indices(self, indices):
+        """Method to remove particles from the ParticleSet, based on their `indices`."""
+        # Removing particles invalidates the neighbor search structure.
+        self._dirty_neighbor = True
+        if type(indices) in [int, np.int32, np.intp]:
+            self.particledata.remove_single_by_index(indices)
+        else:
+            self.particledata.remove_multi_by_indices(indices)
+
+    def remove_booleanvector(self, indices):
+        """Method to remove particles from the ParticleSet, based on an array of booleans."""
+        # Removing particles invalidates the neighbor search structure.
+        self._dirty_neighbor = True
+        self.remove_indices(np.where(indices)[0])
+
+    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])
+        active_indices = np.logical_and(active_indices, non_err_indices)
+        self._active_particle_idx = np.where(active_indices)[0]
+        return active_indices
+
+    def compute_neighbor_tree(self, time, dt):
+        active_mask = self.active_particles_mask(time, dt)
+
+        self._values = np.vstack((
+            self.particledata.data['depth'],
+            self.particledata.data['lat'],
+            self.particledata.data['lon'],
+        ))
+        if self._dirty_neighbor:
+            self._neighbor_tree.rebuild(self._values, active_mask=active_mask)
+            self._dirty_neighbor = False
+        else:
+            self._neighbor_tree.update_values(self._values, new_active_mask=active_mask)
+
+    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]
+        mask = (neighbor_idx != particle_idx)
+        neighbor_idx = neighbor_idx[mask]
+        if 'horiz_dist' in self.particledata._ptype.variables:
+            self.particledata.data['vert_dist'][neighbor_idx] = distances[0, mask]
+            self.particledata.data['horiz_dist'][neighbor_idx] = distances[1, mask]
+        return ParticleDataIterator(self.particledata, subset=neighbor_idx)
+
+    def neighbors_by_coor(self, coor):
+        neighbor_idx = self._neighbor_tree.find_neighbors_by_coor(coor)
+        neighbor_ids = self.particledata.data['id'][neighbor_idx]
+        return neighbor_ids
 
     def populate_indices(self):
         """Pre-populate guesses of particle xi/yi indices using a kdtree.
@@ -337,60 +458,80 @@ def populate_indices(self):
                 tree_data = np.stack((grid.lon.flat, grid.lat.flat), axis=-1)
                 tree = KDTree(tree_data)
                 # stack all the particle positions for a single query
-                pts = np.stack((self._collection.data['lon'], self._collection.data['lat']), axis=-1)
+                pts = np.stack((self.particledata.data['lon'], self.particledata.data['lat']), axis=-1)
                 # query datatype needs to match tree datatype
                 _, idx = tree.query(pts.astype(tree_data.dtype))
                 yi, xi = np.unravel_index(idx, grid.lon.shape)
 
-                self._collection.data['xi'][:, i] = xi
-                self._collection.data['yi'][:, i] = yi
+                self.particledata.data['xi'][:, i] = xi
+                self.particledata.data['yi'][:, i] = yi
 
-    @property
-    def error_particles(self):
-        """Get an iterator over all particles that are in an error state.
+    @classmethod
+    def from_list(cls, fieldset, pclass, lon, lat, depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None, **kwargs):
+        """Initialise the ParticleSet from lists of lon and lat.
 
-        Returns
-        -------
-        iterator
-            Collection iterator over error particles.
+        Parameters
+        ----------
+        fieldset :
+            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
+        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
+            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
+        lon :
+            List of initial longitude values for particles
+        lat :
+            List of initial latitude values for particles
+        depth :
+            Optional list of initial depth values for particles. Default is 0m
+        time :
+            Optional list of start time values for particles. Default is fieldset.U.time[0]
+        repeatdt :
+            Optional interval (in seconds) on which to repeat the release of the ParticleSet (Default value = None)
+        lonlatdepth_dtype :
+            Floating precision for lon, lat, depth particle coordinates.
+            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
+            and np.float64 if the interpolation method is 'cgrid_velocity'
+            Other Variables can be initialised using further arguments (e.g. v=... for a Variable named 'v')
+        **kwargs :
+            Keyword arguments passed to the particleset constructor.
         """
-        error_indices = self.data_indices('state', [StatusCode.Success, StatusCode.Evaluate], invert=True)
-        return ParticleCollectionIterableSOA(self._collection, subset=error_indices)
-
-    def active_particles_mask(self, time, dt):
-        active_indices = (time - self._collection.data['time'])/dt >= 0
-        non_err_indices = np.isin(self._collection.data['state'], [StatusCode.Success, StatusCode.Evaluate])
-        active_indices = np.logical_and(active_indices, non_err_indices)
-        self._active_particle_idx = np.where(active_indices)[0]
-        return active_indices
+        return cls(fieldset=fieldset, pclass=pclass, lon=lon, lat=lat, depth=depth, time=time, repeatdt=repeatdt, lonlatdepth_dtype=lonlatdepth_dtype, **kwargs)
 
-    @property
-    def num_error_particles(self):
-        """Get the number of particles that are in an error state.
-
-        Returns
-        -------
-        int
-            Number of error particles.
-        """
-        return np.sum(np.isin(
-            self._collection.data['state'],
-            [StatusCode.Success, StatusCode.Evaluate], invert=True))
+    @classmethod
+    def from_line(cls, fieldset, pclass, start, finish, size, depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None, **kwargs):
+        """Create a particleset in the shape of a line (according to a cartesian grid).
 
-    def __getitem__(self, index):
-        """Get a single particle by index."""
-        return self._collection.get_single_by_index(index)
+        Initialise the ParticleSet from start/finish coordinates with equidistant spacing
+        Note that this method uses simple numpy.linspace calls and does not take into account
+        great circles, so may not be a exact on a globe
 
-    def cstruct(self):
-        """'cstruct' returns the ctypes mapping of the combined collections cstruct and the fieldset cstruct.
-        This depends on the specific structure in question.
+        Parameters
+        ----------
+        fieldset :
+            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
+        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
+            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
+        start :
+            Starting point for initialisation of particles on a straight line.
+        finish :
+            End point for initialisation of particles on a straight line.
+        size :
+            Initial size of particle set
+        depth :
+            Optional list of initial depth values for particles. Default is 0m
+        time :
+            Optional start time value for particles. Default is fieldset.U.time[0]
+        repeatdt :
+            Optional interval (in seconds) on which to repeat the release of the ParticleSet (Default value = None)
+        lonlatdepth_dtype :
+            Floating precision for lon, lat, depth particle coordinates.
+            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
+            and np.float64 if the interpolation method is 'cgrid_velocity'
         """
-        cstruct = self._collection.cstruct()
-        return cstruct
-
-    @property
-    def ctypes_struct(self):
-        return self.cstruct()
+        lon = np.linspace(start[0], finish[0], size)
+        lat = np.linspace(start[1], finish[1], size)
+        if type(depth) in [int, float]:
+            depth = [depth] * size
+        return cls(fieldset=fieldset, pclass=pclass, lon=lon, lat=lat, depth=depth, time=time, repeatdt=repeatdt, lonlatdepth_dtype=lonlatdepth_dtype, **kwargs)
 
     @classmethod
     def monte_carlo_sample(cls, start_field, size, mode='monte_carlo'):
@@ -398,14 +539,13 @@ def monte_carlo_sample(cls, start_field, size, mode='monte_carlo'):
 
         Parameters
         ----------
-        start_field :
+        start_field : parcels.field.Field
             mod:`parcels.fieldset.Field` object for initialising particles stochastically (horizontally)  according to the presented density field.
         size :
 
         mode :
              (Default value = 'monte_carlo')
 
-
         Returns
         -------
         list of float
@@ -451,6 +591,37 @@ def monte_carlo_sample(cls, start_field, size, mode='monte_carlo'):
         else:
             raise NotImplementedError(f'Mode {mode} not implemented. Please use "monte carlo" algorithm instead.')
 
+    @classmethod
+    def from_field(cls, fieldset, pclass, start_field, size, mode='monte_carlo', depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None):
+        """Initialise the ParticleSet randomly drawn according to distribution from a field.
+
+        Parameters
+        ----------
+        fieldset : parcels.fieldset.FieldSet
+            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
+        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
+            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
+        start_field : parcels.field.Field
+            Field for initialising particles stochastically (horizontally)  according to the presented density field.
+        size :
+            Initial size of particle set
+        mode :
+            Type of random sampling. Currently only 'monte_carlo' is implemented (Default value = 'monte_carlo')
+        depth :
+            Optional list of initial depth values for particles. Default is 0m
+        time :
+            Optional start time value for particles. Default is fieldset.U.time[0]
+        repeatdt :
+            Optional interval (in seconds) on which to repeat the release of the ParticleSet (Default value = None)
+        lonlatdepth_dtype :
+            Floating precision for lon, lat, depth particle coordinates.
+            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
+            and np.float64 if the interpolation method is 'cgrid_velocity'
+        """
+        lon, lat = cls.monte_carlo_sample(start_field, size, mode)
+
+        return cls(fieldset=fieldset, pclass=pclass, lon=lon, lat=lat, depth=depth, time=time, lonlatdepth_dtype=lonlatdepth_dtype, repeatdt=repeatdt)
+
     @classmethod
     def from_particlefile(cls, fieldset, pclass, filename, restart=True, restarttime=None, repeatdt=None, lonlatdepth_dtype=None, **kwargs):
         """Initialise the ParticleSet from a zarr ParticleFile.
@@ -533,114 +704,6 @@ def from_particlefile(cls, fieldset, pclass, filename, restart=True, restarttime
                    depth=vars['depth'], time=vars['time'], pid_orig=vars['id'],
                    lonlatdepth_dtype=lonlatdepth_dtype, repeatdt=repeatdt, **kwargs)
 
-    def compute_neighbor_tree(self, time, dt):
-        active_mask = self.active_particles_mask(time, dt)
-
-        self._values = np.vstack((
-            self._collection.data['depth'],
-            self._collection.data['lat'],
-            self._collection.data['lon'],
-        ))
-        if self._dirty_neighbor:
-            self._neighbor_tree.rebuild(self._values, active_mask=active_mask)
-            self._dirty_neighbor = False
-        else:
-            self._neighbor_tree.update_values(self._values, new_active_mask=active_mask)
-
-    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]
-        mask = (neighbor_idx != particle_idx)
-        neighbor_idx = neighbor_idx[mask]
-        if 'horiz_dist' in self._collection._ptype.variables:
-            self._collection.data['vert_dist'][neighbor_idx] = distances[0, mask]
-            self._collection.data['horiz_dist'][neighbor_idx] = distances[1, mask]
-        return ParticleCollectionIterableSOA(self._collection, subset=neighbor_idx)
-
-    def neighbors_by_coor(self, coor):
-        neighbor_idx = self._neighbor_tree.find_neighbors_by_coor(coor)
-        neighbor_ids = self._collection.data['id'][neighbor_idx]
-        return neighbor_ids
-
-    @property
-    def size(self):
-        # ==== to change at some point - len and size are different things ==== #
-        return len(self._collection)
-
-    def __repr__(self):
-        return "\n".join([str(p) for p in self])
-
-    def __len__(self):
-        return len(self._collection)
-
-    def __sizeof__(self):
-        return sys.getsizeof(self._collection)
-
-    def __iadd__(self, particles):
-        """Add particles to the ParticleSet.
-
-        Note that this is an incremental add, the particles will be added to the ParticleSet
-        on which this function is called.
-
-        Parameters
-        ----------
-        particles :
-            Another ParticleSet containing particles to add
-            to this one.
-
-        Returns
-        -------
-        type
-            The current ParticleSet
-        """
-        self.add(particles)
-        return self
-
-    def __iter__(self):
-        return super().__iter__()
-
-    def iterator(self):
-        return super().iterator()
-
-    def add(self, particles):
-        """Add particles to the ParticleSet. Note that this is an
-        incremental add, the particles will be added to the ParticleSet
-        on which this function is called.
-
-        Parameters
-        ----------
-        particles :
-            Another ParticleSet containing particles to add to this one.
-
-        Returns
-        -------
-        type
-            The current ParticleSet
-
-        """
-        if isinstance(particles, BaseParticleSet):
-            particles = particles.collection
-        self._collection += particles
-        # Adding particles invalidates the neighbor search structure.
-        self._dirty_neighbor = True
-        return self
-
-    def remove_indices(self, indices):
-        """Method to remove particles from the ParticleSet, based on their `indices`."""
-        # Removing particles invalidates the neighbor search structure.
-        self._dirty_neighbor = True
-        if type(indices) in [int, np.int32, np.intp]:
-            self._collection.remove_single_by_index(indices)
-        else:
-            self._collection.remove_multi_by_indices(indices)
-
-    def remove_booleanvector(self, indices):
-        """Method to remove particles from the ParticleSet, based on an array of booleans."""
-        # Removing particles invalidates the neighbor search structure.
-        self._dirty_neighbor = True
-        self.remove_indices(np.where(indices)[0])
-
     def density(self, field_name=None, particle_val=None, relative=False, area_scale=False):
         """Calculate 2D particle density field from ParticleSet particle locations.
 
@@ -669,7 +732,7 @@ def density(self, field_name=None, particle_val=None, relative=False, area_scale
 
         k = Kernel(
             self.fieldset,
-            self._collection.ptype,
+            self.particledata.ptype,
             funcname="search_kernel",
             funcvars=["particle", "fieldset", "time", "x"],
             funccode=f_str,
@@ -677,7 +740,7 @@ def density(self, field_name=None, particle_val=None, relative=False, area_scale
         self.execute(pyfunc=k, runtime=0)
 
         if isinstance(particle_val, str):
-            particle_val = self._collection._data[particle_val]
+            particle_val = self.particledata._data[particle_val]
         else:
             particle_val = particle_val if particle_val else np.ones(self.size)
         density = np.zeros((field.grid.lat.size, field.grid.lon.size), dtype=np.float32)
@@ -720,14 +783,14 @@ def Kernel(self, pyfunc, c_include="", delete_cfiles=True):
         if isinstance(pyfunc, list):
             return Kernel.from_list(
                 self.fieldset,
-                self.collection.ptype,
+                self.particledata.ptype,
                 pyfunc,
                 c_include=c_include,
                 delete_cfiles=delete_cfiles,
             )
         return Kernel(
             self.fieldset,
-            self.collection.ptype,
+            self.particledata.ptype,
             pyfunc=pyfunc,
             c_include=c_include,
             delete_cfiles=delete_cfiles,
@@ -736,12 +799,72 @@ def Kernel(self, pyfunc, c_include="", delete_cfiles=True):
     def InteractionKernel(self, pyfunc_inter):
         if pyfunc_inter is None:
             return None
-        return InteractionKernelSOA(self.fieldset, self.collection.ptype, pyfunc=pyfunc_inter)
+        return InteractionKernel(self.fieldset, self.particledata.ptype, pyfunc=pyfunc_inter)
 
     def ParticleFile(self, *args, **kwargs):
         """Wrapper method to initialise a :class:`parcels.particlefile.ParticleFile` object from the ParticleSet."""
         return ParticleFile(*args, particleset=self, **kwargs)
 
+    def _set_particle_vector(self, name, value):
+        """Set attributes of all particles to new values.
+
+        Parameters
+        ----------
+        name : str
+            Name of the attribute (str).
+        value : any
+            New value to set the attribute of the particles to.
+        """
+        self.particledata._data[name][:] = value
+
+    def data_indices(self, variable_name, compare_values, invert=False):
+        """Get the indices of all particles where the value of `variable_name` equals (one of) `compare_values`.
+
+        Parameters
+        ----------
+        variable_name : str
+            Name of the variable to check.
+        compare_values :
+            Value or list of values to compare to.
+        invert :
+            Whether to invert the selection. I.e., when True,
+            return all indices that do not equal (one of)
+            `compare_values`. (Default value = False)
+
+        Returns
+        -------
+        np.ndarray
+            Numpy array of indices that satisfy the test.
+
+        """
+        compare_values = np.array([compare_values, ]) if type(compare_values) not in [list, dict, np.ndarray] else compare_values
+        return np.where(np.isin(self.particledata.data[variable_name], compare_values, invert=invert))[0]
+
+    @property
+    def error_particles(self):
+        """Get an iterator over all particles that are in an error state.
+
+        Returns
+        -------
+        iterator
+            ParticleDataIterator over error particles.
+        """
+        error_indices = self.data_indices('state', [StatusCode.Success, StatusCode.Evaluate], invert=True)
+        return ParticleDataIterator(self.particledata, subset=error_indices)
+
+    @property
+    def num_error_particles(self):
+        """Get the number of particles that are in an error state.
+
+        Returns
+        -------
+        int
+            Number of error particles.
+        """
+        return np.sum(np.isin(
+            self.particledata.data['state'],
+            [StatusCode.Success, StatusCode.Evaluate], invert=True))
+
     def set_variable_write_status(self, var, write_status):
         """Method to set the write status of a Variable.
 
@@ -752,4 +875,218 @@ def set_variable_write_status(self, var, write_status):
         write_status :
             Write status of the variable (True, False or 'once')
         """
-        self._collection.set_variable_write_status(var, write_status)
+        self.particledata.set_variable_write_status(var, write_status)
+
+    def execute(self, pyfunc=AdvectionRK4, pyfunc_inter=None, endtime=None, runtime=None, dt=1.,
+                output_file=None, verbose_progress=None, postIterationCallbacks=None, callbackdt=None):
+        """Execute a given kernel function over the particle set for multiple timesteps.
+
+        Optionally also provide sub-timestepping
+        for particle output.
+
+        Parameters
+        ----------
+        pyfunc :
+            Kernel function to execute. This can be the name of a
+            defined Python function or a :class:`parcels.kernel.Kernel` object.
+            Kernels can be concatenated using the + operator (Default value = AdvectionRK4)
+        endtime :
+            End time for the timestepping loop.
+            It is either a datetime object or a positive double. (Default value = None)
+        runtime :
+            Length of the timestepping loop. Use instead of endtime.
+            It is either a timedelta object or a positive double. (Default value = None)
+        dt :
+            Timestep interval to be passed to the kernel.
+            It is either a timedelta object or a double.
+            Use a negative value for a backward-in-time simulation. (Default value = 1.)
+        output_file :
+            mod:`parcels.particlefile.ParticleFile` object for particle output (Default value = None)
+        verbose_progress : bool
+            Boolean for providing a progress bar for the kernel execution loop. (Default value = None)
+        postIterationCallbacks :
+            Optional) Array of functions that are to be called after each iteration (post-process, non-Kernel) (Default value = None)
+        callbackdt :
+            Optional, in conjecture with 'postIterationCallbacks) timestep inverval to (latestly) interrupt the running kernel and invoke post-iteration callbacks from 'postIterationCallbacks' (Default value = None)
+        pyfunc_inter :
+             (Default value = None)
+        """
+        # check if pyfunc has changed since last compile. If so, recompile
+        if self.kernel is None or (self.kernel.pyfunc is not pyfunc and self.kernel is not pyfunc):
+            # Generate and store Kernel
+            if isinstance(pyfunc, Kernel):
+                self.kernel = pyfunc
+            else:
+                self.kernel = self.Kernel(pyfunc)
+            # Prepare JIT kernel execution
+            if self.particledata.ptype.uses_jit:
+                self.kernel.remove_lib()
+                cppargs = ['-DDOUBLE_COORD_VARIABLES'] if self.particledata.lonlatdepth_dtype else None
+                self.kernel.compile(compiler=GNUCompiler(cppargs=cppargs, incdirs=[path.join(get_package_dir(), 'include'), "."]))
+                self.kernel.load_lib()
+
+        # Set up the interaction kernel(s) if not set and given.
+        if self.interaction_kernel is None and pyfunc_inter is not None:
+            if isinstance(pyfunc_inter, InteractionKernel):
+                self.interaction_kernel = pyfunc_inter
+            else:
+                self.interaction_kernel = self.InteractionKernel(pyfunc_inter)
+
+        # Convert all time variables to seconds
+        if isinstance(endtime, delta):
+            raise RuntimeError('endtime must be either a datetime or a double')
+        if isinstance(endtime, datetime):
+            endtime = np.datetime64(endtime)
+        elif isinstance(endtime, cftime.datetime):
+            endtime = self.time_origin.reltime(endtime)
+        if isinstance(endtime, np.datetime64):
+            if self.time_origin.calendar is None:
+                raise NotImplementedError('If fieldset.time_origin is not a date, execution endtime must be a double')
+            endtime = self.time_origin.reltime(endtime)
+        if isinstance(runtime, delta):
+            runtime = runtime.total_seconds()
+        if isinstance(dt, delta):
+            dt = dt.total_seconds()
+        if dt > 0 and dt <= 1e-6:
+            raise ValueError('Time step dt is too small')
+        outputdt = output_file.outputdt if output_file else np.infty
+        if isinstance(outputdt, delta):
+            outputdt = outputdt.total_seconds()
+        if isinstance(callbackdt, delta):
+            callbackdt = callbackdt.total_seconds()
+
+        assert runtime is None or runtime >= 0, 'runtime must be positive'
+        assert outputdt is None or outputdt >= 0, 'outputdt must be positive'
+
+        if runtime is not None and endtime is not None:
+            raise RuntimeError('Only one of (endtime, runtime) can be specified')
+
+        mintime, maxtime = self.fieldset.gridset.dimrange('time_full') if self.fieldset is not None else (0, 1)
+
+        default_release_time = mintime if dt >= 0 else maxtime
+        if np.any(np.isnan(self.particledata.data['time'])):
+            self.particledata.data['time'][np.isnan(self.particledata.data['time'])] = default_release_time
+            self.particledata.data['time_nextloop'][np.isnan(self.particledata.data['time_nextloop'])] = default_release_time
+        min_rt = np.min(self.particledata.data['time_nextloop'])
+        max_rt = np.max(self.particledata.data['time_nextloop'])
+
+        # Derive _starttime and endtime from arguments or fieldset defaults
+        _starttime = min_rt if dt >= 0 else max_rt
+        if self.repeatdt is not None and self.repeat_starttime is None:
+            self.repeat_starttime = _starttime
+        if runtime is not None:
+            endtime = _starttime + runtime * np.sign(dt)
+        elif endtime is None:
+            mintime, maxtime = self.fieldset.gridset.dimrange('time_full')
+            endtime = maxtime if dt >= 0 else mintime
+
+        if (abs(endtime-_starttime) < 1e-5 or runtime == 0) and dt == 0:
+            raise RuntimeError("dt and runtime are zero, or endtime is equal to Particle.time. "
+                               "ParticleSet.execute() will not do anything.")
+
+        self._set_particle_vector('dt', dt)
+
+        if callbackdt is None:
+            interupt_dts = [np.infty, outputdt]
+            if self.repeatdt is not None:
+                interupt_dts.append(self.repeatdt)
+            callbackdt = np.min(np.array(interupt_dts))
+        time = _starttime
+        if self.repeatdt:
+            next_prelease = self.repeat_starttime + (abs(time - self.repeat_starttime) // self.repeatdt + 1) * self.repeatdt * np.sign(dt)
+        else:
+            next_prelease = np.infty if dt > 0 else - np.infty
+        if output_file:
+            next_output = time + dt
+        else:
+            next_output = time + np.infty if dt > 0 else - np.infty
+        next_callback = time + callbackdt if dt > 0 else time - callbackdt
+        next_input = self.fieldset.computeTimeChunk(time, np.sign(dt)) if self.fieldset is not None else np.inf
+
+        tol = 1e-12
+        if verbose_progress is None:
+            walltime_start = time_module.time()
+        if verbose_progress:
+            pbar = self.__create_progressbar(_starttime, endtime)
+
+        while (time < endtime and dt > 0) or (time > endtime and dt < 0) or dt == 0:
+            time_at_startofloop = time
+            if verbose_progress is None and time_module.time() - walltime_start > 10:
+                # Showing progressbar if runtime > 10 seconds
+                if output_file:
+                    logger.info(f'Output files are stored in {output_file.fname}.')
+                pbar = self.__create_progressbar(_starttime, endtime)
+                verbose_progress = True
+
+            if dt > 0:
+                next_time = min(next_prelease, next_input, next_output, next_callback, endtime)
+            else:
+                next_time = max(next_prelease, next_input, next_output, next_callback, endtime)
+
+            # If we don't perform interaction, only execute the normal kernel efficiently.
+            if self.interaction_kernel is None:
+                self.kernel.execute(self, endtime=next_time, dt=dt)
+            # Interaction: interleave the interaction and non-interaction kernel for each time step.
+            # E.g. Normal -> Inter -> Normal -> Inter if endtime-time == 2*dt
+            else:
+                cur_time = time
+                while (cur_time < next_time and dt > 0) or (cur_time > next_time and dt < 0) or dt == 0:
+                    if dt > 0:
+                        cur_end_time = min(cur_time+dt, next_time)
+                    else:
+                        cur_end_time = max(cur_time+dt, next_time)
+                    self.kernel.execute(self, endtime=cur_end_time, dt=dt)
+                    self.interaction_kernel.execute(self, endtime=cur_end_time, dt=dt)
+                    cur_time += dt
+                    if dt == 0:
+                        break
+            # End of interaction specific code
+            time = next_time
+
+            if abs(time - next_output) < tol or dt == 0:
+                for fld in self.fieldset.get_fields():
+                    if hasattr(fld, 'to_write') and fld.to_write:
+                        if fld.grid.tdim > 1:
+                            raise RuntimeError('Field writing during execution only works for Fields with one snapshot in time')
+                        fldfilename = str(output_file.fname).replace('.zarr', '_%.4d' % fld.to_write)
+                        fld.write(fldfilename)
+                        fld.to_write += 1
+
+            if abs(time - next_output) < tol:
+                if output_file:
+                    if output_file.analytical:  # output analytical solution at later time
+                        output_file.write_latest_locations(self, time)
+                    else:
+                        output_file.write(self, time_at_startofloop)
+                if np.isfinite(outputdt):
+                    next_output += outputdt * np.sign(dt)
+
+            # ==== insert post-process here to also allow for memory clean-up via external func ==== #
+            if abs(time-next_callback) < tol:
+                if postIterationCallbacks is not None:
+                    for extFunc in postIterationCallbacks:
+                        extFunc()
+                next_callback += callbackdt * np.sign(dt)
+
+            if abs(time-next_prelease) < tol:
+                pset_new = self.__class__(
+                    fieldset=self.fieldset, time=time, lon=self.repeatlon,
+                    lat=self.repeatlat, depth=self.repeatdepth,
+                    pclass=self.repeatpclass,
+                    lonlatdepth_dtype=self.particledata.lonlatdepth_dtype,
+                    partition_function=False, pid_orig=self.repeatpid, **self.repeatkwargs)
+                for p in pset_new:
+                    p.dt = dt
+                self.add(pset_new)
+                next_prelease += self.repeatdt * np.sign(dt)
+
+            if time != endtime:
+                next_input = self.fieldset.computeTimeChunk(time, dt)
+            if dt == 0:
+                break
+            if verbose_progress:
+                pbar.update(abs(time - pbar.prevtime))
+                pbar.prevtime = time
+
+        if verbose_progress:
+            pbar.close()
diff --git a/parcels/particleset/__init__.py b/parcels/particleset/__init__.py
deleted file mode 100644
index cbdaa1b3d..000000000
--- a/parcels/particleset/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from .baseparticleset import BaseParticleSet  # noqa
-from .particlesetaos import ParticleSetAOS  # noqa
-from .particlesetsoa import ParticleSetSOA  # noqa
-
-# ParticleSet is an alias for ParticleSetSOA, i.e. the default
-# implementation for storing particles is the Structure of Arrays
-# approach.
-ParticleSet = ParticleSetSOA
diff --git a/parcels/particleset/baseparticleset.py b/parcels/particleset/baseparticleset.py
deleted file mode 100644
index 1c2ebb494..000000000
--- a/parcels/particleset/baseparticleset.py
+++ /dev/null
@@ -1,563 +0,0 @@
-import sys
-import time as time_module
-from abc import ABC, abstractmethod
-from datetime import datetime
-from datetime import timedelta as delta
-from os import path
-
-import cftime
-import numpy as np
-from tqdm import tqdm
-
-from parcels.application_kernels.advection import AdvectionRK4
-from parcels.collection.collections import ParticleCollection
-from parcels.compilation.codecompiler import GNUCompiler
-from parcels.field import NestedField
-from parcels.interaction.baseinteractionkernel import BaseInteractionKernel
-from parcels.kernel.basekernel import BaseKernel as Kernel
-from parcels.tools.global_statics import get_package_dir
-from parcels.tools.loggers import logger
-from parcels.tools.statuscodes import StatusCode
-
-
-class NDCluster(ABC):
-    """Interface."""
-
-
-class BaseParticleSet(NDCluster):
-    """Base ParticleSet."""
-
-    _collection = None
-    kernel = None
-    interaction_kernel = None
-    fieldset = None
-    time_origin = None
-    repeat_starttime = None
-    repeatlon = None
-    repeatlat = None
-    repeatdepth = None
-    repeatpclass = None
-    repeatkwargs = None
-
-    def __init__(self, fieldset=None, pclass=None, lon=None, lat=None,
-                 depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None, pid_orig=None, **kwargs):
-        self._collection = None
-        self.repeat_starttime = None
-        self.repeatlon = None
-        self.repeatlat = None
-        self.repeatdepth = None
-        self.repeatpclass = None
-        self.repeatkwargs = None
-        self.kernel = None
-        self.interaction_kernel = None
-        self.fieldset = None
-        self.time_origin = None
-
-    def __del__(self):
-        if self._collection is not None and isinstance(self._collection, ParticleCollection):
-            del self._collection
-        self._collection = None
-
-    def iterator(self):
-        return self._collection.iterator()
-
-    def __iter__(self):
-        """Allows for more intuitive iteration over a particleset, while in reality iterating over the particles in the collection."""
-        return self.iterator()
-
-    def __getattr__(self, name):
-        """
-        Access a single property of all particles.
-
-        Parameters
-        ----------
-        name : str
-            Name of the property
-        """
-        for v in self._collection.ptype.variables:
-            if v.name == name:
-                return getattr(self._collection, name)
-        if name in self.__dict__ and name[0] != '_':
-            return self.__dict__[name]
-        else:
-            return False
-
-    @staticmethod
-    def lonlatdepth_dtype_from_field_interp_method(field):
-        if isinstance(field, NestedField):
-            for f in field:
-                if f.interp_method == 'cgrid_velocity':
-                    return np.float64
-        else:
-            if field.interp_method == 'cgrid_velocity':
-                return np.float64
-        return np.float32
-
-    @property
-    def collection(self):
-        return self._collection
-
-    @abstractmethod
-    def cstruct(self):
-        """
-        'cstruct' returns the ctypes mapping of the combined collections cstruct and the fieldset cstruct.
-        This depends on the specific structure in question.
-        """
-        pass
-
-    def __create_progressbar(self, starttime, endtime):
-        pbar = tqdm(total=abs(endtime - starttime), file=sys.stdout)
-        pbar.prevtime = starttime
-        return pbar
-
-    @classmethod
-    def from_list(cls, fieldset, pclass, lon, lat, depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None, **kwargs):
-        """Initialise the ParticleSet from lists of lon and lat.
-
-        Parameters
-        ----------
-        fieldset :
-            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
-        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
-            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
-        lon :
-            List of initial longitude values for particles
-        lat :
-            List of initial latitude values for particles
-        depth :
-            Optional list of initial depth values for particles. Default is 0m
-        time :
-            Optional list of start time values for particles. Default is fieldset.U.time[0]
-        repeatdt :
-            Optional interval (in seconds) on which to repeat the release of the ParticleSet (Default value = None)
-        lonlatdepth_dtype :
-            Floating precision for lon, lat, depth particle coordinates.
-            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
-            and np.float64 if the interpolation method is 'cgrid_velocity'
-            Other Variables can be initialised using further arguments (e.g. v=... for a Variable named 'v')
-        **kwargs :
-            Keyword arguments passed to the particleset constructor.
-        """
-        return cls(fieldset=fieldset, pclass=pclass, lon=lon, lat=lat, depth=depth, time=time, repeatdt=repeatdt, lonlatdepth_dtype=lonlatdepth_dtype, **kwargs)
-
-    @classmethod
-    def from_line(cls, fieldset, pclass, start, finish, size, depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None, **kwargs):
-        """Create a particleset in the shape of a line (according to a cartesian grid).
-
-        Initialise the ParticleSet from start/finish coordinates with equidistant spacing
-        Note that this method uses simple numpy.linspace calls and does not take into account
-        great circles, so may not be a exact on a globe
-
-        Parameters
-        ----------
-        fieldset :
-            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
-        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
-            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
-        start :
-            Starting point for initialisation of particles on a straight line.
-        finish :
-            End point for initialisation of particles on a straight line.
-        size :
-            Initial size of particle set
-        depth :
-            Optional list of initial depth values for particles. Default is 0m
-        time :
-            Optional start time value for particles. Default is fieldset.U.time[0]
-        repeatdt :
-            Optional interval (in seconds) on which to repeat the release of the ParticleSet (Default value = None)
-        lonlatdepth_dtype :
-            Floating precision for lon, lat, depth particle coordinates.
-            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
-            and np.float64 if the interpolation method is 'cgrid_velocity'
-        """
-        lon = np.linspace(start[0], finish[0], size)
-        lat = np.linspace(start[1], finish[1], size)
-        if type(depth) in [int, float]:
-            depth = [depth] * size
-        return cls(fieldset=fieldset, pclass=pclass, lon=lon, lat=lat, depth=depth, time=time, repeatdt=repeatdt, lonlatdepth_dtype=lonlatdepth_dtype, **kwargs)
-
-    @classmethod
-    @abstractmethod
-    def monte_carlo_sample(cls, start_field, size, mode='monte_carlo'):
-        """Converts a starting field into a monte-carlo sample of lons and lats.
-
-        Parameters
-        ----------
-        start_field : parcels.field.Field
-            mod:`parcels.fieldset.Field` object for initialising particles stochastically (horizontally)  according to the presented density field.
-        size :
-
-        mode :
-             (Default value = 'monte_carlo')
-
-        Returns
-        -------
-        list of float
-            A list of longitude values.
-        list of float
-            A list of latitude values.
-        """
-        pass
-
-    @classmethod
-    def from_field(cls, fieldset, pclass, start_field, size, mode='monte_carlo', depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None):
-        """Initialise the ParticleSet randomly drawn according to distribution from a field.
-
-        Parameters
-        ----------
-        fieldset : parcels.fieldset.FieldSet
-            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
-        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
-            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
-        start_field : parcels.field.Field
-            Field for initialising particles stochastically (horizontally)  according to the presented density field.
-        size :
-            Initial size of particle set
-        mode :
-            Type of random sampling. Currently only 'monte_carlo' is implemented (Default value = 'monte_carlo')
-        depth :
-            Optional list of initial depth values for particles. Default is 0m
-        time :
-            Optional start time value for particles. Default is fieldset.U.time[0]
-        repeatdt :
-            Optional interval (in seconds) on which to repeat the release of the ParticleSet (Default value = None)
-        lonlatdepth_dtype :
-            Floating precision for lon, lat, depth particle coordinates.
-            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
-            and np.float64 if the interpolation method is 'cgrid_velocity'
-        """
-        lon, lat = cls.monte_carlo_sample(start_field, size, mode)
-
-        return cls(fieldset=fieldset, pclass=pclass, lon=lon, lat=lat, depth=depth, time=time, lonlatdepth_dtype=lonlatdepth_dtype, repeatdt=repeatdt)
-
-    @classmethod
-    @abstractmethod
-    def from_particlefile(cls, fieldset, pclass, filename, restart=True, restarttime=None, repeatdt=None, lonlatdepth_dtype=None, **kwargs):
-        """Initialise the ParticleSet from a netcdf ParticleFile.
-        This creates a new ParticleSet based on locations of all particles written
-        in a netcdf ParticleFile at a certain time. Particle IDs are preserved if restart=True
-
-        Parameters
-        ----------
-        fieldset :
-            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
-        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
-            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
-        filename : str
-            Name of the particlefile from which to read initial conditions
-        restart : bool
-            Boolean to signal if pset is used for a restart (default is True).
-            In that case, Particle IDs are preserved.
-        restarttime :
-            time at which the Particles will be restarted. Default is the last time written.
-            Alternatively, restarttime could be a time value (including np.datetime64) or
-            a callable function such as np.nanmin. The last is useful when running with dt < 0.
-        repeatdt :
-            Optional interval (in seconds) on which to repeat the release of the ParticleSet (Default value = None)
-        lonlatdepth_dtype :
-            Floating precision for lon, lat, depth particle coordinates.
-            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
-            and np.float64 if the interpolation method is 'cgrid_velocity'
-        **kwargs :
-            Keyword arguments passed to the particleset constructor.
-        """
-        pass
-
-    def density(self, field_name=None, particle_val=None, relative=False, area_scale=False):
-        """Calculate 2D particle density field from ParticleSet particle locations.
-
-        Parameters
-        ----------
-        field_name : str, optional
-            Name of the field from the fieldset to calculate the histogram on.
-            Defaults to using "U".
-        particle_val :
-            Optional numpy-array of values to weigh each particle with,
-            or string name of particle variable to use weigh particles with.
-            Default is None, resulting in a value of 1 for each particle
-        relative : bool
-            Whether the density is scaled by the total weight of all particles.
-            Default is False
-        area_scale : bool
-            Whether the density is scaled by the area (in m^2) of each grid cell.
-            Default is False
-        """
-        pass
-
-    @abstractmethod
-    def Kernel(self, pyfunc, c_include="", delete_cfiles=True):
-        """Wrapper method to convert a `pyfunc` into a :class:`parcels.kernel.Kernel` object.
-
-        Conversion is based on `fieldset` and `ptype` of the ParticleSet.
-
-        Parameters
-        ----------
-        delete_cfiles : bool
-            Whether to delete the C-files after compilation in JIT mode (default is True)
-        pyfunc :
-
-        c_include :
-             (Default value = "")
-        """
-        pass
-
-    def InteractionKernel(self, pyfunc_inter):
-        raise NotImplementedError("Particle set type is not compatible with interaction.")
-
-    @abstractmethod
-    def ParticleFile(self, *args, **kwargs):
-        """Wrapper method to initialise a :class:`parcels.particlefile.ParticleFile` object from the ParticleSet."""
-        pass
-
-    @abstractmethod
-    def _set_particle_vector(self, name, value):
-        """Set attributes of all particles to new values.
-
-        This is a fallback implementation, it might be slow.
-
-        Parameters
-        ----------
-        name : str
-            Name of the attribute (str).
-        value :
-            New value to set the attribute of the particles to.
-        """
-        for p in self:
-            setattr(p, name, value)
-
-    @property
-    @abstractmethod
-    def error_particles(self):
-        """Get an iterator over all particles that are in an error state.
-
-        This is a fallback implementation, it might be slow.
-
-
-        Returns
-        -------
-        iterator
-            Collection iterator over error particles.
-
-        """
-        error_indices = [
-            i for i, p in enumerate(self)
-            if p.state not in [StatusCode.Success, StatusCode.Evaluate]]
-        return self.collection.get_multi_by_indices(indices=error_indices)
-
-    @property
-    def num_error_particles(self):
-        """Get the number of particles that are in an error state."""
-        return len([True if p.state not in [StatusCode.Success, StatusCode.Evaluate] else None for p in self])
-
-    def execute(self, pyfunc=AdvectionRK4, pyfunc_inter=None, endtime=None, runtime=None, dt=1.,
-                output_file=None, verbose_progress=None, postIterationCallbacks=None, callbackdt=None):
-        """Execute a given kernel function over the particle set for multiple timesteps.
-
-        Optionally also provide sub-timestepping
-        for particle output.
-
-        Parameters
-        ----------
-        pyfunc :
-            Kernel function to execute. This can be the name of a
-            defined Python function or a :class:`parcels.kernel.Kernel` object.
-            Kernels can be concatenated using the + operator (Default value = AdvectionRK4)
-        endtime :
-            End time for the timestepping loop.
-            It is either a datetime object or a positive double. (Default value = None)
-        runtime :
-            Length of the timestepping loop. Use instead of endtime.
-            It is either a timedelta object or a positive double. (Default value = None)
-        dt :
-            Timestep interval to be passed to the kernel.
-            It is either a timedelta object or a double.
-            Use a negative value for a backward-in-time simulation. (Default value = 1.)
-        output_file :
-            mod:`parcels.particlefile.ParticleFile` object for particle output (Default value = None)
-        verbose_progress : bool
-            Boolean for providing a progress bar for the kernel execution loop. (Default value = None)
-        postIterationCallbacks :
-            Optional) Array of functions that are to be called after each iteration (post-process, non-Kernel) (Default value = None)
-        callbackdt :
-            Optional, in conjecture with 'postIterationCallbacks) timestep inverval to (latestly) interrupt the running kernel and invoke post-iteration callbacks from 'postIterationCallbacks' (Default value = None)
-        pyfunc_inter :
-             (Default value = None)
-        """
-        # check if pyfunc has changed since last compile. If so, recompile
-        if self.kernel is None or (self.kernel.pyfunc is not pyfunc and self.kernel is not pyfunc):
-            # Generate and store Kernel
-            if isinstance(pyfunc, Kernel):
-                self.kernel = pyfunc
-            else:
-                self.kernel = self.Kernel(pyfunc)
-            # Prepare JIT kernel execution
-            if self.collection.ptype.uses_jit:
-                self.kernel.remove_lib()
-                cppargs = ['-DDOUBLE_COORD_VARIABLES'] if self.collection.lonlatdepth_dtype else None
-                self.kernel.compile(compiler=GNUCompiler(cppargs=cppargs, incdirs=[path.join(get_package_dir(), 'include'), "."]))
-                self.kernel.load_lib()
-
-        # Set up the interaction kernel(s) if not set and given.
-        if self.interaction_kernel is None and pyfunc_inter is not None:
-            if isinstance(pyfunc_inter, BaseInteractionKernel):
-                self.interaction_kernel = pyfunc_inter
-            else:
-                self.interaction_kernel = self.InteractionKernel(pyfunc_inter)
-
-        # Convert all time variables to seconds
-        if isinstance(endtime, delta):
-            raise RuntimeError('endtime must be either a datetime or a double')
-        if isinstance(endtime, datetime):
-            endtime = np.datetime64(endtime)
-        elif isinstance(endtime, cftime.datetime):
-            endtime = self.time_origin.reltime(endtime)
-        if isinstance(endtime, np.datetime64):
-            if self.time_origin.calendar is None:
-                raise NotImplementedError('If fieldset.time_origin is not a date, execution endtime must be a double')
-            endtime = self.time_origin.reltime(endtime)
-        if isinstance(runtime, delta):
-            runtime = runtime.total_seconds()
-        if isinstance(dt, delta):
-            dt = dt.total_seconds()
-        if dt > 0 and dt <= 1e-6:
-            raise ValueError('Time step dt is too small')
-        outputdt = output_file.outputdt if output_file else np.infty
-        if isinstance(outputdt, delta):
-            outputdt = outputdt.total_seconds()
-        if isinstance(callbackdt, delta):
-            callbackdt = callbackdt.total_seconds()
-
-        assert runtime is None or runtime >= 0, 'runtime must be positive'
-        assert outputdt is None or outputdt >= 0, 'outputdt must be positive'
-
-        if runtime is not None and endtime is not None:
-            raise RuntimeError('Only one of (endtime, runtime) can be specified')
-
-        mintime, maxtime = self.fieldset.gridset.dimrange('time_full') if self.fieldset is not None else (0, 1)
-
-        default_release_time = mintime if dt >= 0 else maxtime
-        min_rt, max_rt = self._impute_release_times(default_release_time)
-
-        # Derive _starttime and endtime from arguments or fieldset defaults
-        _starttime = min_rt if dt >= 0 else max_rt
-        if self.repeatdt is not None and self.repeat_starttime is None:
-            self.repeat_starttime = _starttime
-        if runtime is not None:
-            endtime = _starttime + runtime * np.sign(dt)
-        elif endtime is None:
-            mintime, maxtime = self.fieldset.gridset.dimrange('time_full')
-            endtime = maxtime if dt >= 0 else mintime
-
-        if (abs(endtime-_starttime) < 1e-5 or runtime == 0) and dt == 0:
-            raise RuntimeError("dt and runtime are zero, or endtime is equal to Particle.time. "
-                               "ParticleSet.execute() will not do anything.")
-
-        self._set_particle_vector('dt', dt)
-
-        if callbackdt is None:
-            interupt_dts = [np.infty, outputdt]
-            if self.repeatdt is not None:
-                interupt_dts.append(self.repeatdt)
-            callbackdt = np.min(np.array(interupt_dts))
-        time = _starttime
-        if self.repeatdt:
-            next_prelease = self.repeat_starttime + (abs(time - self.repeat_starttime) // self.repeatdt + 1) * self.repeatdt * np.sign(dt)
-        else:
-            next_prelease = np.infty if dt > 0 else - np.infty
-        if output_file:
-            next_output = time + dt
-        else:
-            next_output = time + np.infty if dt > 0 else - np.infty
-        next_callback = time + callbackdt if dt > 0 else time - callbackdt
-        next_input = self.fieldset.computeTimeChunk(time, np.sign(dt)) if self.fieldset is not None else np.inf
-
-        tol = 1e-12
-        if verbose_progress is None:
-            walltime_start = time_module.time()
-        if verbose_progress:
-            pbar = self.__create_progressbar(_starttime, endtime)
-
-        while (time < endtime and dt > 0) or (time > endtime and dt < 0) or dt == 0:
-            time_at_startofloop = time
-            if verbose_progress is None and time_module.time() - walltime_start > 10:
-                # Showing progressbar if runtime > 10 seconds
-                if output_file:
-                    logger.info(f'Output files are stored in {output_file.fname}.')
-                pbar = self.__create_progressbar(_starttime, endtime)
-                verbose_progress = True
-
-            if dt > 0:
-                next_time = min(next_prelease, next_input, next_output, next_callback, endtime)
-            else:
-                next_time = max(next_prelease, next_input, next_output, next_callback, endtime)
-
-            # If we don't perform interaction, only execute the normal kernel efficiently.
-            if self.interaction_kernel is None:
-                self.kernel.execute(self, endtime=next_time, dt=dt, output_file=output_file)
-            # Interaction: interleave the interaction and non-interaction kernel for each time step.
-            # E.g. Normal -> Inter -> Normal -> Inter if endtime-time == 2*dt
-            else:
-                cur_time = time
-                while (cur_time < next_time and dt > 0) or (cur_time > next_time and dt < 0) or dt == 0:
-                    if dt > 0:
-                        cur_end_time = min(cur_time+dt, next_time)
-                    else:
-                        cur_end_time = max(cur_time+dt, next_time)
-                    self.kernel.execute(
-                        self, endtime=cur_end_time, dt=dt, output_file=output_file)
-                    self.interaction_kernel.execute(
-                        self, endtime=cur_end_time, dt=dt, output_file=output_file)
-                    cur_time += dt
-                    if dt == 0:
-                        break
-            # End of interaction specific code
-            time = next_time
-
-            if abs(time - next_output) < tol or dt == 0:
-                for fld in self.fieldset.get_fields():
-                    if hasattr(fld, 'to_write') and fld.to_write:
-                        if fld.grid.tdim > 1:
-                            raise RuntimeError('Field writing during execution only works for Fields with one snapshot in time')
-                        fldfilename = str(output_file.fname).replace('.zarr', '_%.4d' % fld.to_write)
-                        fld.write(fldfilename)
-                        fld.to_write += 1
-
-            if abs(time - next_output) < tol:
-                if output_file:
-                    if output_file.analytical:  # output analytical solution at later time
-                        output_file.write_latest_locations(self, time)
-                    else:
-                        output_file.write(self, time_at_startofloop)
-                if np.isfinite(outputdt):
-                    next_output += outputdt * np.sign(dt)
-
-            # ==== insert post-process here to also allow for memory clean-up via external func ==== #
-            if abs(time-next_callback) < tol:
-                if postIterationCallbacks is not None:
-                    for extFunc in postIterationCallbacks:
-                        extFunc()
-                next_callback += callbackdt * np.sign(dt)
-
-            if abs(time-next_prelease) < tol:
-                pset_new = self.__class__(
-                    fieldset=self.fieldset, time=time, lon=self.repeatlon,
-                    lat=self.repeatlat, depth=self.repeatdepth,
-                    pclass=self.repeatpclass,
-                    lonlatdepth_dtype=self.collection.lonlatdepth_dtype,
-                    partition_function=False, pid_orig=self.repeatpid, **self.repeatkwargs)
-                for p in pset_new:
-                    p.dt = dt
-                self.add(pset_new)
-                next_prelease += self.repeatdt * np.sign(dt)
-
-            if time != endtime:
-                next_input = self.fieldset.computeTimeChunk(time, dt)
-            if dt == 0:
-                break
-            if verbose_progress:
-                pbar.update(abs(time - pbar.prevtime))
-                pbar.prevtime = time
-
-        if verbose_progress:
-            pbar.close()
diff --git a/parcels/particleset/particlesetaos.py b/parcels/particleset/particlesetaos.py
deleted file mode 100644
index 54b15d559..000000000
--- a/parcels/particleset/particlesetaos.py
+++ /dev/null
@@ -1,784 +0,0 @@
-import sys
-from copy import copy
-from ctypes import c_void_p
-from datetime import date, datetime
-from datetime import timedelta as delta
-
-import numpy as np
-import xarray as xr
-
-from parcels.collection.collectionaos import (  # NOQA
-    ParticleCollectionAOS,
-    ParticleCollectionIterableAOS,
-    ParticleCollectionIteratorAOS,
-)
-from parcels.field import NestedField
-from parcels.grid import GridCode
-from parcels.kernel.kernelaos import KernelAOS
-from parcels.particle import JITParticle, ScipyParticle, Variable  # NOQA
-from parcels.particlefile.particlefileaos import ParticleFileAOS
-from parcels.particleset.baseparticleset import BaseParticleSet
-from parcels.tools.converters import _get_cftime_calendars, convert_to_flat_array
-from parcels.tools.loggers import logger
-from parcels.tools.statuscodes import StatusCode  # NOQA
-
-try:
-    from mpi4py import MPI
-except:
-    MPI = None
-
-__all__ = ['ParticleSetAOS']
-
-
-def _convert_to_reltime(time):
-    """Check to determine if the value of the time parameter needs to be
-    converted to a relative value (relative to the time_origin).
-    """
-    if isinstance(time, np.datetime64) or (hasattr(time, 'calendar') and time.calendar in _get_cftime_calendars()):
-        return True
-    return False
-
-
-class ParticleSetAOS(BaseParticleSet):
-    """Container class for storing particle and executing kernel over them.
-
-    Parameters
-    ----------
-    fieldset :
-        mod:`parcels.fieldset.FieldSet` object from which to sample velocity.
-        While fieldset=None is supported, this will throw a warning as it breaks most Parcels functionality
-    pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
-        Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
-    lon :
-        List of initial longitude values for particles
-    lat :
-        List of initial latitude values for particles
-    depth :
-        Optional list of initial depth values for particles. Default is 0m
-    time :
-        Optional list of initial time values for particles. Default is fieldset.U.grid.time[0]
-    repeatdt : datetime.timedelta or float, optional
-        Optional interval on which to repeat the release of the ParticleSet. Either timedelta object, or float in seconds.
-    lonlatdepth_dtype :
-        Floating precision for lon, lat, depth particle coordinates.
-        It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
-        and np.float64 if the interpolation method is 'cgrid_velocity'
-    pid_orig :
-        Optional list of (offsets for) the particle IDs
-    partitions :
-        List of cores on which to distribute the particles for MPI runs. Default: None, in which case particles
-        are distributed automatically on the processors
-
-        Other Variables can be initialised using further arguments (e.g. v=... for a Variable named 'v')
-    """
-
-    def __init__(self, fieldset=None, pclass=JITParticle, lon=None, lat=None, depth=None, time=None, repeatdt=None, lonlatdepth_dtype=None, pid_orig=None, **kwargs):
-        super().__init__()
-
-        # ==== first: create a new subclass of the pclass that includes the required variables ==== #
-        # ==== see dynamic-instantiation trick here: https://www.python-course.eu/python3_classes_and_type.php ==== #
-        class_name = "Object"+pclass.__name__
-        object_class = None
-        if class_name not in dir():
-            def ObjectScipyClass_init(self, *args, **kwargs):
-                fieldset = kwargs.get('fieldset', None)
-                ngrids = kwargs.get('ngrids', None)
-                if type(self).ngrids.initial < 0:
-                    numgrids = ngrids
-                    if numgrids is None and fieldset is not None:
-                        numgrids = fieldset.gridset.size
-                    assert numgrids is not None, "Neither fieldsets nor number of grids are specified - exiting."
-                    type(self).ngrids.initial = numgrids
-                self.ngrids = type(self).ngrids.initial
-                if self.ngrids >= 0:
-                    for index in ['xi', 'yi', 'zi', 'ti']:
-                        if index != 'ti':
-                            setattr(self, index, np.zeros(self.ngrids, dtype=np.int32))
-                        else:
-                            setattr(self, index, -1*np.ones(self.ngrids, dtype=np.int32))
-                super(type(self), self).__init__(*args, **kwargs)
-
-            def ObjectJITClass_init(self, *args, **kwargs):
-                super(type(self), self).__init__(*args, **kwargs)  # needs to go first cause it initialises the space for all other variables
-                fieldset = kwargs.get('fieldset', None)
-                ngrids = kwargs.get('ngrids', None)
-                if type(self).ngrids.initial < 0:
-                    numgrids = ngrids
-                    if numgrids is None and fieldset is not None:
-                        numgrids = fieldset.gridset.size
-                    assert numgrids is not None, "Neither fieldsets nor number of grids are specified - exiting."
-                    type(self).ngrids.initial = numgrids
-                self.ngrids = type(self).ngrids.initial
-                if self.ngrids >= 0:
-                    for index in ['xi', 'yi', 'zi', 'ti']:
-                        if index != 'ti':
-                            setattr(self, index, np.zeros((self.ngrids), dtype=np.int32))
-                        else:
-                            setattr(self, index, -1*np.ones((self.ngrids), dtype=np.int32))
-                        setattr(self, index+'p', getattr(self, index).ctypes.data_as(c_void_p))  # without direct (!) prior recast, throws error that the dtype (here: int32) has no ctypes-property
-                        setattr(self, 'c'+index, getattr(self, index+'p').value)
-
-            def ObjectClass_del_forward(self):
-                super(type(self), self).__del__()
-
-            def ObjectClass_repr_forward(self):
-                return super(type(self), self).__repr__()
-
-            def ObjectClass_eq_forward(self, other):
-                return super(type(self), self).__eq__(other)
-
-            def ObjectClass_ne_forward(self, other):
-                return super(type(self), self).__ne__(other)
-
-            def ObjectClass_lt_forward(self, other):
-                return super(type(self), self).__lt__(other)
-
-            def ObjectClass_le_forward(self, other):
-                return super(type(self), self).__le__(other)
-
-            def ObjectClass_gt_forward(self, other):
-                return super(type(self), self).__gt__(other)
-
-            def ObjectClass_ge_forward(self, other):
-                return super(type(self), self).__ge__(other)
-
-            def ObjectClass_sizeof_forward(self):
-                return super(type(self), self).__sizeof__()
-
-            object_scipy_class_vdict = {"ngrids": Variable('ngrids', dtype=np.int32, to_write=False, initial=-1),
-                                        "xi": np.ndarray((1,), dtype=np.int32),
-                                        "yi": np.ndarray((1,), dtype=np.int32),
-                                        "zi": np.ndarray((1,), dtype=np.int32),
-                                        "ti": np.ndarray((1,), dtype=np.int32),
-                                        "__init__": ObjectScipyClass_init,
-                                        "__del__": ObjectClass_del_forward,
-                                        "__repr__": ObjectClass_repr_forward,
-                                        "__eq__": ObjectClass_eq_forward,
-                                        "__ne__": ObjectClass_ne_forward,
-                                        "__lt__": ObjectClass_lt_forward,
-                                        "__le__": ObjectClass_le_forward,
-                                        "__gt__": ObjectClass_gt_forward,
-                                        "__ge__": ObjectClass_ge_forward,
-                                        "__sizeof__": ObjectClass_sizeof_forward
-                                        }
-
-            object_jit_class_vdict = {"ngrids": Variable('ngrids', dtype=np.int32, to_write=False, initial=-1),
-                                      "_cptr": None,
-                                      "xi": np.ndarray((1,), dtype=np.int32),
-                                      "yi": np.ndarray((1,), dtype=np.int32),
-                                      "zi": np.ndarray((1,), dtype=np.int32),
-                                      "ti": np.ndarray((1,), dtype=np.int32),
-                                      "xip": c_void_p,
-                                      "yip": c_void_p,
-                                      "zip": c_void_p,
-                                      "tip": c_void_p,
-                                      "cxi": Variable('cxi', dtype=np.dtype(c_void_p), to_write=False),
-                                      "cyi": Variable('cyi', dtype=np.dtype(c_void_p), to_write=False),
-                                      "czi": Variable('czi', dtype=np.dtype(c_void_p), to_write=False),
-                                      "cti": Variable('cti', dtype=np.dtype(c_void_p), to_write=False),
-                                      "__init__": ObjectJITClass_init,
-                                      "__del__": ObjectClass_del_forward,
-                                      "__repr__": ObjectClass_repr_forward,
-                                      "__eq__": ObjectClass_eq_forward,
-                                      "__ne__": ObjectClass_ne_forward,
-                                      "__lt__": ObjectClass_lt_forward,
-                                      "__le__": ObjectClass_le_forward,
-                                      "__gt__": ObjectClass_gt_forward,
-                                      "__ge__": ObjectClass_ge_forward,
-                                      "__sizeof__": ObjectClass_sizeof_forward
-                                      }
-            object_class = None
-            if issubclass(pclass, JITParticle):
-                object_class = type("Object" + pclass.__name__, (pclass, ), object_jit_class_vdict)
-            elif issubclass(pclass, ScipyParticle):
-                object_class = type("Object" + pclass.__name__, (pclass,), object_scipy_class_vdict)
-            if object_class is None:
-                raise TypeError("ParticleSetAOS: Given Particle base class is invalid - derive from either ScipyParticle or JITParticle.")
-        else:
-            object_class = locals()[class_name]
-        # ==== dynamic re-classing completed ==== #
-        _pclass = object_class
-
-        self.fieldset = fieldset
-        if self.fieldset is None:
-            logger.warning_once("No FieldSet provided in ParticleSet generation. "
-                                "This breaks most Parcels functionality")
-        else:
-            self.fieldset.check_complete()
-        partitions = kwargs.pop('partitions', None)
-
-        lon = np.empty(shape=0) if lon is None else convert_to_flat_array(lon)
-        lat = np.empty(shape=0) if lat is None else convert_to_flat_array(lat)
-
-        if isinstance(pid_orig, (type(None), type(False))):
-            pid_orig = np.arange(lon.size)
-
-        if depth is None:
-            mindepth = self.fieldset.gridset.dimrange('depth')[0] if self.fieldset is not None else 0
-            depth = np.ones(lon.size) * mindepth
-        else:
-            depth = convert_to_flat_array(depth)
-        assert lon.size == lat.size and lon.size == depth.size, (
-            'lon, lat, depth don''t all have the same lenghts')
-
-        time = convert_to_flat_array(time)
-        time = np.repeat(time, lon.size) if time.size == 1 else time
-
-        if time.size > 0 and type(time[0]) in [datetime, date]:
-            time = np.array([np.datetime64(t) for t in time])
-        self.time_origin = fieldset.time_origin if self.fieldset is not None else 0
-        if time.size > 0 and isinstance(time[0], np.timedelta64) and not self.time_origin:
-            raise NotImplementedError('If fieldset.time_origin is not a date, time of a particle must be a double')
-        time = np.array([self.time_origin.reltime(t) if _convert_to_reltime(t) else t for t in time])
-        assert lon.size == time.size, (
-            'time and positions (lon, lat, depth) don''t have the same lengths.')
-
-        if lonlatdepth_dtype is None:
-            if fieldset is not None:
-                lonlatdepth_dtype = self.lonlatdepth_dtype_from_field_interp_method(fieldset.U)
-            else:
-                lonlatdepth_dtype = np.float32
-        assert lonlatdepth_dtype in [np.float32, np.float64], \
-            'lon lat depth precision should be set to either np.float32 or np.float64'
-
-        for kwvar in kwargs:
-            if kwvar not in ['partition_function']:
-                kwargs[kwvar] = convert_to_flat_array(kwargs[kwvar])
-                assert lon.size == kwargs[kwvar].size, (
-                    f"{kwvar} and positions (lon, lat, depth) don't have the same lengths.")
-
-        self.repeatdt = repeatdt.total_seconds() if isinstance(repeatdt, delta) else repeatdt
-        if self.repeatdt:
-            if self.repeatdt <= 0:
-                raise 'Repeatdt should be > 0'
-            if time[0] and not np.allclose(time, time[0]):
-                raise 'All Particle.time should be the same when repeatdt is not None'
-            self.repeatpclass = pclass
-            self.repeatkwargs = kwargs
-
-        ngrids = fieldset.gridset.size if fieldset is not None else 0
-        self._collection = ParticleCollectionAOS(_pclass, lon=lon, lat=lat, depth=depth, time=time, lonlatdepth_dtype=lonlatdepth_dtype, pid_orig=pid_orig, partitions=partitions, ngrid=ngrids, **kwargs)
-
-        if self.repeatdt:
-            if len(time) > 0 and time[0] is None:
-                self.repeat_starttime = time[0]
-            else:
-                collect_time = self._collection.time
-                if collect_time[0] and not np.allclose(collect_time, collect_time[0]):
-                    raise ValueError('All Particle.time should be the same when repeatdt is not None')
-                self.repeat_starttime = copy(collect_time[0])
-            self.repeatlon = copy(self._collection.lon)
-            self.repeatlat = copy(self._collection.lat)
-            self.repeatdepth = copy(self._collection.depth)
-            for kwvar in kwargs:
-                if kwvar not in ['partition_function']:
-                    self.repeatkwargs[kwvar] = copy(getattr(self._collection, kwvar))
-
-        if self.repeatdt:
-            if MPI and self._collection.pu_indicators is not None:
-                mpi_comm = MPI.COMM_WORLD
-                mpi_rank = mpi_comm.Get_rank()
-                self.repeatpid = pid_orig[self._collection.pu_indicators == mpi_rank]
-
-        self.kernel = None
-
-    def __del__(self):
-        super().__del__()
-
-    def delete(self, key):
-        """This is the generic super-method to indicate obejct deletion of a specific object from this collection.
-
-        Comment/Annotation:
-        Functions for deleting multiple objects are more specialised than just a for-each loop of single-item deletion,
-        because certain data structures can delete multiple objects in-bulk faster with specialised function than making a
-        roundtrip per-item delete operation. Because of the sheer size of those containers and the resulting
-        performance demands, we need to make use of those specialised 'del' functions, where available.
-        """
-        if key is None:
-            return
-        if type(key) in [int, np.int32, np.intp]:
-            self.delete_by_index(key)
-        elif type(key) in [np.int64, np.uint64]:
-            self.delete_by_ID(key)
-
-    def delete_by_index(self, index):
-        """This method deletes a particle from the  the collection based on its index. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection.
-        """
-        self._collection[index].state = StatusCode.Delete
-
-    def delete_by_ID(self, id):
-        """This method deletes a particle from the  the collection based on its ID. It does not return the deleted item.
-        Semantically, the function appears similar to the 'remove' operation. That said, the function in OceanParcels -
-        instead of directly deleting the particle - just raises the 'deleted' status flag for the indexed particle.
-        In result, the particle still remains in the collection.
-        """
-        p = self._collection.get_single_by_ID(id)
-        p.state = StatusCode.Delete
-
-    def _set_particle_vector(self, name, value):
-        """Set attributes of all particles to new values.
-
-        Parameters
-        ----------
-        name : str
-            Name of the attribute.
-        value :
-            New value to set the attribute of the particles to.
-        """
-        [setattr(p, name, value) for p in self._collection.data]
-
-    def _impute_release_times(self, default):
-        """Set attribute 'time' to default if encountering NaN values.
-
-        Parameters
-        ----------
-        default :
-            Default release time.
-
-        Returns
-        -------
-        type
-            Minimum and maximum release times.
-
-        """
-        max_rt = None
-        min_rt = None
-        for p in self:
-            if np.isnan(p.time):
-                p.time = default
-            if max_rt is None or max_rt < p.time:
-                max_rt = p.time
-            if min_rt is None or min_rt > p.time:
-                min_rt = p.time
-        return min_rt, max_rt
-
-    def data_indices(self, variable_name, compare_values, invert=False):
-        """Get the indices of all particles where the value of
-        `variable_name` equals (one of) `compare_values`.
-
-        Parameters
-        ----------
-        variable_name : str
-            Name of the variable to check.
-        compare_values :
-            Value or list of values to compare to.
-        invert :
-            Whether to invert the selection. I.e., when True,
-            return all indices that do not equal (one of)
-            `compare_values`. (Default value = False)
-
-        Returns
-        -------
-        np.ndarray
-            Numpy array of indices that satisfy the test.
-        """
-        compare_values = np.array([compare_values, ]) if type(compare_values) not in [list, dict, np.ndarray] else compare_values
-        result = []
-        if not invert:
-            result = [i for i, p in enumerate(self._collection.data) if getattr(p, variable_name) in compare_values]
-        else:
-            result = [i for i, p in enumerate(self._collection.data) if getattr(p, variable_name) not in compare_values]
-        return np.array(result)
-
-    def indexed_subset(self, indices):
-        return self._collection.data[indices]
-
-    def populate_indices(self):
-        """Pre-populate guesses of particle xi/yi indices using a kdtree.
-
-        This is only intended for curvilinear grids, where the initial index search
-        may be quite expensive.
-        """
-        raise NotImplementedError()
-
-    @property
-    def particle_data(self):
-        return self._collection.particle_data
-
-    @property
-    def error_particles(self):
-        """Get an iterator over all particles that are in an error state.
-
-        Returns
-        -------
-        iterator
-            Collection iterator over error particles.
-        """
-        error_indices = [
-            i for i, p in enumerate(self)
-            if p.state not in [StatusCode.Success, StatusCode.Evaluate]]
-        return self._collection.get_multi_by_indices(indices=error_indices)
-
-    @property
-    def num_error_particles(self):
-        """Get the number of particles that are in an error state.
-
-        Returns
-        -------
-        int
-            Number of error particles.
-        """
-        return np.sum([True for p in self._collection if p.state not in [StatusCode.Success, StatusCode.Evaluate]])
-
-    def __iter__(self):
-        return super().__iter__()
-
-    def iterator(self):
-        return super().iterator()
-
-    def __getitem__(self, index):
-        """Get a single particle by index."""
-        return self._collection.get_single_by_index(index)
-
-    def __getattr__(self, name):
-        """
-        Access a single property of all particles.
-
-        Parameters
-        ----------
-        name : str
-            Name of the property to access.
-        """
-        for v in self._collection.ptype.variables:
-            if v.name == name:
-                return getattr(self._collection, name)
-        if name in self.__dict__ and name[0] != '_':
-            return self.__dict__[name]
-        else:
-            return False
-
-    @property
-    def size(self):
-        return len(self._collection)
-
-    def __repr__(self):
-        return "\n".join([str(p) for p in self])
-
-    def __len__(self):
-        return len(self._collection)
-
-    def __sizeof__(self):
-        return sys.getsizeof(self._collection)
-
-    def cstruct(self):
-        return self._collection.cstruct()
-
-    @property
-    def ctypes_struct(self):
-        return self.cstruct()
-
-    @property
-    def ptype(self):
-        return self._collection.ptype
-
-    @staticmethod
-    def lonlatdepth_dtype_from_field_interp_method(field):
-        if isinstance(field, NestedField):
-            for f in field:
-                if f.interp_method == 'cgrid_velocity':
-                    return np.float64
-        else:
-            if field.interp_method == 'cgrid_velocity':
-                return np.float64
-        return np.float32
-
-    @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.
-
-        Parameters
-        ----------
-        start_field :
-            mod:`parcels.fieldset.Field` object for initialising particles stochastically (horizontally)  according to the presented density field.
-
-            returns list(lon), list(lat)
-        size :
-
-        mode :
-             (Default value = 'monte_carlo')
-
-        Returns
-        -------
-        list of float
-            A list of longitude values.
-        list of float
-            A list of latitude values.
-        """
-        if mode == 'monte_carlo':
-            data = start_field.data if isinstance(start_field.data, np.ndarray) else np.array(start_field.data)
-            if start_field.interp_method == 'cgrid_tracer':
-                p_interior = np.squeeze(data[0, 1:, 1:])
-            else:  # if A-grid
-                d = data
-                p_interior = (d[0, :-1, :-1] + d[0, 1:, :-1] + d[0, :-1, 1:] + d[0, 1:, 1:])/4.
-                p_interior = np.where(d[0, :-1, :-1] == 0, 0, p_interior)
-                p_interior = np.where(d[0, 1:, :-1] == 0, 0, p_interior)
-                p_interior = np.where(d[0, 1:, 1:] == 0, 0, p_interior)
-                p_interior = np.where(d[0, :-1, 1:] == 0, 0, p_interior)
-            p = np.reshape(p_interior, (1, p_interior.size))
-            inds = np.random.choice(p_interior.size, size, replace=True, p=p[0] / np.sum(p))
-            xsi = np.random.uniform(size=len(inds))
-            eta = np.random.uniform(size=len(inds))
-            j, i = np.unravel_index(inds, p_interior.shape)
-            grid = start_field.grid
-            lon, lat = ([], [])
-            if grid.gtype in [GridCode.RectilinearZGrid, GridCode.RectilinearSGrid]:
-                lon = grid.lon[i] + xsi * (grid.lon[i + 1] - grid.lon[i])
-                lat = grid.lat[j] + eta * (grid.lat[j + 1] - grid.lat[j])
-            else:
-                lons = np.array([grid.lon[j, i], grid.lon[j, i+1], grid.lon[j+1, i+1], grid.lon[j+1, i]])
-                if grid.mesh == 'spherical':
-                    lons[1:] = np.where(lons[1:] - lons[0] > 180, lons[1:]-360, lons[1:])
-                    lons[1:] = np.where(-lons[1:] + lons[0] > 180, lons[1:]+360, lons[1:])
-                lon = (1-xsi)*(1-eta) * lons[0] +\
-                    xsi*(1-eta) * lons[1] +\
-                    xsi*eta * lons[2] +\
-                    (1-xsi)*eta * lons[3]
-                lat = (1-xsi)*(1-eta) * grid.lat[j, i] +\
-                    xsi*(1-eta) * grid.lat[j, i+1] +\
-                    xsi*eta * grid.lat[j+1, i+1] +\
-                    (1-xsi)*eta * grid.lat[j+1, i]
-            return list(lon), list(lat)
-        else:
-            raise NotImplementedError(f'Mode {mode} not implemented. Please use "monte carlo" algorithm instead.')
-
-    @classmethod
-    def from_particlefile(cls, fieldset, pclass, filename, restart=True, restarttime=None, repeatdt=None, lonlatdepth_dtype=None, **kwargs):
-        """Initialise the ParticleSet from a zarr ParticleFile.
-        This creates a new ParticleSet based on locations of all particles written
-        in a zarr ParticleFile at a certain time. Particle IDs are preserved if restart=True
-
-        Parameters
-        ----------
-        fieldset :
-            mod:`parcels.fieldset.FieldSet` object from which to sample velocity
-        pclass : parcels.particle.JITParticle or parcels.particle.ScipyParticle
-            Particle class. May be a particle class as defined in parcels, or a subclass defining a custom particle.
-        filename : str
-            Name of the particlefile from which to read initial conditions
-        restart : bool
-            BSignal if pset is used for a restart (default is True).
-            In that case, Particle IDs are preserved.
-        restarttime :
-            time at which the Particles will be restarted. Default is the last time written.
-            Alternatively, restarttime could be a time value (including np.datetime64) or
-            a callable function such as np.nanmin. The last is useful when running with dt < 0.
-        repeatdt : datetime.timedelta or float, optional
-            Optional interval on which to repeat the release of the ParticleSet. Either timedelta object, or float in seconds.
-        lonlatdepth_dtype :
-            Floating precision for lon, lat, depth particle coordinates.
-            It is either np.float32 or np.float64. Default is np.float32 if fieldset.U.interp_method is 'linear'
-            and np.float64 if the interpolation method is 'cgrid_velocity'
-        **kwargs :
-            Keyword arguments passed to the particleset constructor.
-        """
-        if repeatdt is not None:
-            logger.warning(f'Note that the `repeatdt` argument is not retained from {filename}, and that '
-                           'setting a new repeatdt will start particles from the _new_ particle '
-                           'locations.')
-
-        pfile = xr.open_zarr(str(filename))
-        pfile_vars = [v for v in pfile.data_vars]
-
-        vars = {}
-        to_write = {}
-        for v in pclass.getPType().variables:
-            if v.name in pfile_vars:
-                vars[v.name] = np.ma.filled(pfile.variables[v.name], np.nan)
-            elif v.name not in ['xi', 'yi', 'zi', 'ti', 'dt', 'depth', 'id', 'obs_written', 'state'] \
-                    and v.to_write:
-                raise RuntimeError(f'Variable {v.name} is in pclass but not in the particlefile')
-            to_write[v.name] = v.to_write
-        vars['depth'] = np.ma.filled(pfile.variables['z'], np.nan)
-        vars['id'] = np.ma.filled(pfile.variables['trajectory'], np.nan)
-
-        if isinstance(vars['time'][0, 0], np.timedelta64):
-            vars['time'] = np.array([t/np.timedelta64(1, 's') for t in vars['time']])
-
-        if restarttime is None:
-            restarttime = np.nanmax(vars['time'])
-        elif callable(restarttime):
-            restarttime = restarttime(vars['time'])
-        else:
-            restarttime = restarttime
-
-        inds = np.where(vars['time'] == restarttime)
-        for v in vars:
-            if to_write[v] is True:
-                vars[v] = vars[v][inds]
-            elif to_write[v] == 'once':
-                vars[v] = vars[v][inds[0]]
-            if v not in ['lon', 'lat', 'depth', 'time', 'id']:
-                kwargs[v] = vars[v]
-
-        if restart:
-            pclass.setLastID(0)  # reset to zero offset
-        else:
-            vars['id'] = None
-
-        return cls(fieldset=fieldset, pclass=pclass, lon=vars['lon'], lat=vars['lat'],
-                   depth=vars['depth'], time=vars['time'], pid_orig=vars['id'],
-                   lonlatdepth_dtype=lonlatdepth_dtype, repeatdt=repeatdt, **kwargs)
-
-    def __iadd__(self, particles):
-        """Add particles to the ParticleSet.
-
-        Note that this is an incremental add, the particles will be added to the ParticleSet
-        on which this function is called.
-
-        Parameters
-        ----------
-        particles :
-            Another ParticleSet containing particles to add
-            to this one.
-
-        Returns
-        -------
-        type
-            The current ParticleSet
-        """
-        self.add(particles)
-        return self
-
-    def add(self, particles):
-        """Add particles to the ParticleSet. Note that this is an
-        incremental add, the particles will be added to the ParticleSet
-        on which this function is called.
-
-        Parameters
-        ----------
-        particles :
-            Another ParticleSet containing particles to add
-            to this one.
-
-        Returns
-        -------
-        type
-            The current ParticleSet
-
-        """
-        if isinstance(particles, BaseParticleSet):
-            particles = particles.collection
-        self._collection += particles
-        return self
-
-    def remove_indices(self, indices):
-        """Method to remove particles from the ParticleSet, based on their `indices`."""
-        if type(indices) in [int, np.int32, np.intp]:
-            self._collection.remove_single_by_index(indices)
-        else:
-            self._collection.remove_multi_by_indices(indices)
-
-    def remove_booleanvector(self, indices):
-        """Method to remove particles from the ParticleSet, based on an array of booleans."""
-        # indices = np.where(indices)[0]
-        indices = np.nonzero(indices)[0]
-        self.remove_indices(indices)
-
-    def density(self, field_name=None, particle_val=None, relative=False, area_scale=False):
-        """Calculate 2D particle density field from ParticleSet particle locations.
-
-        Parameters
-        ----------
-        field_name : str, optional
-            Name of the field from the fieldset to calculate the histogram on.
-            Defaults to using "U".
-        particle_val :
-            Optional numpy-array of values to weigh each particle with,
-            or string name of particle variable to use weigh particles with.
-            Default is None, resulting in a value of 1 for each particle
-        relative : bool
-            Whether the density is scaled by the total weight of all particles.
-            Default is False
-        area_scale : bool
-            Whether the density is scaled by the area (in m^2) of each grid cell.
-            Default is False
-        """
-        field_name = field_name if field_name else "U"
-        sampling_name = "UV" if field_name in ["U", "V"] else field_name
-        field = getattr(self.fieldset, field_name)
-
-        f_str = (f"def search_kernel(particle, fieldset, time):\n"
-                 f"    x = fieldset.{sampling_name}[time, particle.depth, particle.lat, particle.lon]")
-
-        k = KernelAOS(
-            self.fieldset,
-            self._collection.ptype,
-            funcname="search_kernel",
-            funcvars=["particle", "fieldset", "time", "x"],
-            funccode=f_str,
-        )
-        self.execute(pyfunc=k, runtime=0)
-
-        if isinstance(particle_val, str):
-            particle_val = [getattr(p, particle_val) for p in self._collection]
-        else:
-            particle_val = particle_val if particle_val else np.ones(self.size)
-        density = np.zeros((field.grid.lat.size, field.grid.lon.size), dtype=np.float32)
-
-        for i, p in enumerate(self):
-            try:  # breaks if either p.xi, p.yi, p.zi, p.ti do not exist (in scipy) or field not in fieldset
-                if p.ti[field.igrid] < 0:  # xi, yi, zi, ti, not initialised
-                    raise 'error'
-                xi = p.xi[field.igrid]
-                yi = p.yi[field.igrid]
-            except:
-                _, _, _, xi, yi, _ = field.search_indices(p.lon, p.lat, p.depth, 0, 0, search2D=True)
-            density[yi, xi] += particle_val[i]
-
-        if relative:
-            density /= np.sum(particle_val)
-
-        if area_scale:
-            density /= field.cell_areas()
-
-        return density
-
-    def Kernel(self, pyfunc, c_include="", delete_cfiles=True):
-        """Wrapper method to convert a `pyfunc` into a :class:`parcels.kernel.Kernel` object
-        based on `fieldset` and `ptype` of the ParticleSet.
-
-        Parameters
-        ----------
-        pyfunc : function or list of functions
-            Python function to convert into kernel. If a list of functions is provided,
-            the functions will be converted to kernels and combined into a single kernel.
-        delete_cfiles : bool
-            Whether to delete the C-files after compilation in JIT mode (default is True).
-        """
-        if isinstance(pyfunc, list):
-            return KernelAOS.from_list(
-                self.fieldset,
-                self.collection.ptype,
-                pyfunc,
-                c_include=c_include,
-                delete_cfiles=delete_cfiles,
-            )
-        return KernelAOS(
-            self.fieldset,
-            self.collection.ptype,
-            pyfunc=pyfunc,
-            c_include=c_include,
-            delete_cfiles=delete_cfiles,
-        )
-
-    def ParticleFile(self, *args, **kwargs):
-        """Wrapper method to initialise a :class:`parcels.particlefile.ParticleFile`
-        object from the ParticleSet.
-        """
-        return ParticleFileAOS(*args, particleset=self, **kwargs)
-
-    def set_variable_write_status(self, var, write_status):
-        """Method to set the write status of a Variable.
-
-        Parameters
-        ----------
-        var :
-            Name of the variable (string)
-        write_status :
-            Write status of the variable (True, False or 'once')
-        """
-        self._collection.set_variable_write_status(var, write_status)
diff --git a/parcels/tools/statuscodes.py b/parcels/tools/statuscodes.py
index 3cfd76614..bb7790258 100644
--- a/parcels/tools/statuscodes.py
+++ b/parcels/tools/statuscodes.py
@@ -93,13 +93,6 @@ def __init__(self, particle, fieldset=None, msg=None):
         super().__init__(message)
 
 
-class NotTestedError(Exception):
-    """Raised when the function is not tested. Code is commented."""
-
-    def __init__(self):
-        super().__init__("Method has not been tested. Code has been commented out until unit tests are available.")
-
-
 def parse_particletime(time, fieldset):
     if fieldset is not None and fieldset.time_origin:
         time = fieldset.time_origin.fulltime(time)


Test patch:
diff --git a/tests/test_advection.py b/tests/test_advection.py
index 34273576d..6f2fd33bf 100644
--- a/tests/test_advection.py
+++ b/tests/test_advection.py
@@ -5,7 +5,7 @@
 import pytest
 import xarray as xr
 
-from parcels import (  # noqa
+from parcels import (
     AdvectionAnalytical,
     AdvectionDiffusionEM,
     AdvectionDiffusionM1,
@@ -16,22 +16,13 @@
     Field,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     ScipyParticle,
     StatusCode,
     Variable,
-    logger,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 kernel = {'EE': AdvectionEE, 'RK4': AdvectionRK4, 'RK45': AdvectionRK45, 'AA': AdvectionAnalytical,
           'AdvDiffEM': AdvectionDiffusionEM, 'AdvDiffM1': AdvectionDiffusionM1}
 
@@ -70,9 +61,8 @@ def depth_fixture(zdim=2):
     return depth(zdim=zdim)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_advection_zonal(lon, lat, depth, pset_mode, mode, npart=10):
+def test_advection_zonal(lon, lat, depth, mode, npart=10):
     """Particles at high latitude move geographically faster due to the pole correction in `GeographicPolar`."""
     data2D = {'U': np.ones((lon.size, lat.size), dtype=np.float32),
               'V': np.zeros((lon.size, lat.size), dtype=np.float32)}
@@ -82,42 +72,40 @@ def test_advection_zonal(lon, lat, depth, pset_mode, mode, npart=10):
     fieldset2D = FieldSet.from_data(data2D, dimensions, mesh='spherical', transpose=True)
     assert fieldset2D.U.creation_log == 'from_data'
 
-    pset2D = pset_type[pset_mode]['pset'](fieldset2D, pclass=ptype[mode],
-                                          lon=np.zeros(npart) + 20.,
-                                          lat=np.linspace(0, 80, npart))
+    pset2D = ParticleSet(fieldset2D, pclass=ptype[mode],
+                         lon=np.zeros(npart) + 20.,
+                         lat=np.linspace(0, 80, npart))
     pset2D.execute(AdvectionRK4, runtime=delta(hours=2), dt=delta(seconds=30))
     assert (np.diff(pset2D.lon) > 1.e-4).all()
 
     dimensions['depth'] = depth
     fieldset3D = FieldSet.from_data(data3D, dimensions, mesh='spherical', transpose=True)
-    pset3D = pset_type[pset_mode]['pset'](fieldset3D, pclass=ptype[mode],
-                                          lon=np.zeros(npart) + 20.,
-                                          lat=np.linspace(0, 80, npart),
-                                          depth=np.zeros(npart) + 10.)
+    pset3D = ParticleSet(fieldset3D, pclass=ptype[mode],
+                         lon=np.zeros(npart) + 20.,
+                         lat=np.linspace(0, 80, npart),
+                         depth=np.zeros(npart) + 10.)
     pset3D.execute(AdvectionRK4, runtime=delta(hours=2), dt=delta(seconds=30))
     assert (np.diff(pset3D.lon) > 1.e-4).all()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_advection_meridional(lon, lat, pset_mode, mode, npart=10):
+def test_advection_meridional(lon, lat, mode, npart=10):
     """Particles at high latitude move geographically faster due to the pole correction in `GeographicPolar`."""
     data = {'U': np.zeros((lon.size, lat.size), dtype=np.float32),
             'V': np.ones((lon.size, lat.size), dtype=np.float32)}
     dimensions = {'lon': lon, 'lat': lat}
     fieldset = FieldSet.from_data(data, dimensions, mesh='spherical', transpose=True)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(-60, 60, npart),
-                                        lat=np.linspace(0, 30, npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(-60, 60, npart),
+                       lat=np.linspace(0, 30, npart))
     delta_lat = np.diff(pset.lat)
     pset.execute(AdvectionRK4, runtime=delta(hours=2), dt=delta(seconds=30))
     assert np.allclose(np.diff(pset.lat), delta_lat, rtol=1.e-4)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
-def test_advection_3D(pset_mode, mode, npart=11):
+def test_advection_3D(mode, npart=11):
     """Flat 2D zonal flow that increases linearly with depth from 0 m/s to 1 m/s."""
     xdim = ydim = zdim = 2
     dimensions = {'lon': np.linspace(0., 1e4, xdim, dtype=np.float32),
@@ -128,20 +116,19 @@ def test_advection_3D(pset_mode, mode, npart=11):
     data['U'][:, :, 0] = 0.
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.zeros(npart),
-                                        lat=np.zeros(npart) + 1e2,
-                                        depth=np.linspace(0, 1, npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.zeros(npart),
+                       lat=np.zeros(npart) + 1e2,
+                       depth=np.linspace(0, 1, npart))
     time = delta(hours=2).total_seconds()
     pset.execute(AdvectionRK4, runtime=time, dt=delta(seconds=30))
     assert np.allclose(pset.depth*pset.time, pset.lon, atol=1.e-1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
 @pytest.mark.parametrize('direction', ['up', 'down'])
 @pytest.mark.parametrize('wErrorThroughSurface', [True, False])
-def test_advection_3D_outofbounds(pset_mode, mode, direction, wErrorThroughSurface):
+def test_advection_3D_outofbounds(mode, direction, wErrorThroughSurface):
     xdim = ydim = zdim = 2
     dimensions = {'lon': np.linspace(0., 1, xdim, dtype=np.float32),
                   'lat': np.linspace(0., 1, ydim, dtype=np.float32),
@@ -170,7 +157,7 @@ def SubmergeParticle(particle, fieldset, time):
         kernels.append(SubmergeParticle)
     kernels.append(DeleteParticle)
 
-    pset = pset_type[pset_mode]['pset'](fieldset=fieldset, pclass=ptype[mode], lon=0.5, lat=0.5, depth=0.9)
+    pset = ParticleSet(fieldset=fieldset, pclass=ptype[mode], lon=0.5, lat=0.5, depth=0.9)
     pset.execute(kernels, runtime=11., dt=1)
 
     if direction == 'up' and wErrorThroughSurface:
@@ -194,33 +181,30 @@ def periodicBC(particle, fieldset, time):
     particle.lat = math.fmod(particle.lat, 1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_advection_periodic_zonal(pset_mode, mode, xdim=100, ydim=100, halosize=3):
+def test_advection_periodic_zonal(mode, xdim=100, ydim=100, halosize=3):
     fieldset = periodicfields(xdim, ydim, uvel=1., vvel=0.)
     fieldset.add_periodic_halo(zonal=True, halosize=halosize)
     assert len(fieldset.U.lon) == xdim + 2 * halosize
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
     pset.execute(AdvectionRK4 + pset.Kernel(periodicBC), runtime=delta(hours=20), dt=delta(seconds=30))
     assert abs(pset.lon[0] - 0.15) < 0.1
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_advection_periodic_meridional(pset_mode, mode, xdim=100, ydim=100):
+def test_advection_periodic_meridional(mode, xdim=100, ydim=100):
     fieldset = periodicfields(xdim, ydim, uvel=0., vvel=1.)
     fieldset.add_periodic_halo(meridional=True)
     assert len(fieldset.U.lat) == ydim + 10  # default halo size is 5 grid points
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
     pset.execute(AdvectionRK4 + pset.Kernel(periodicBC), runtime=delta(hours=20), dt=delta(seconds=30))
     assert abs(pset.lat[0] - 0.15) < 0.1
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_advection_periodic_zonal_meridional(pset_mode, mode, xdim=100, ydim=100):
+def test_advection_periodic_zonal_meridional(mode, xdim=100, ydim=100):
     fieldset = periodicfields(xdim, ydim, uvel=1., vvel=1.)
     fieldset.add_periodic_halo(zonal=True, meridional=True)
     assert len(fieldset.U.lat) == ydim + 10  # default halo size is 5 grid points
@@ -228,19 +212,17 @@ def test_advection_periodic_zonal_meridional(pset_mode, mode, xdim=100, ydim=100
     assert np.allclose(np.diff(fieldset.U.lat), fieldset.U.lat[1]-fieldset.U.lat[0], rtol=0.001)
     assert np.allclose(np.diff(fieldset.U.lon), fieldset.U.lon[1]-fieldset.U.lon[0], rtol=0.001)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.4], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.4], lat=[0.5])
     pset.execute(AdvectionRK4 + pset.Kernel(periodicBC), runtime=delta(hours=20), dt=delta(seconds=30))
     assert abs(pset.lon[0] - 0.05) < 0.1
     assert abs(pset.lat[0] - 0.15) < 0.1
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('u', [-0.3, np.array(0.2)])
 @pytest.mark.parametrize('v', [0.2, np.array(1)])
 @pytest.mark.parametrize('w', [None, -0.2, np.array(0.7)])
-def test_length1dimensions(pset_mode, mode, u, v, w):
-    logger.info(f"mode: {mode} pset_mode {pset_mode}")
+def test_length1dimensions(mode, u, v, w):
     (lon, xdim) = (np.linspace(-10, 10, 21), 21) if isinstance(u, np.ndarray) else (0, 1)
     (lat, ydim) = (np.linspace(-15, 15, 31), 31) if isinstance(v, np.ndarray) else (-4, 1)
     (depth, zdim) = (np.linspace(-5, 5, 11), 11) if (isinstance(w, np.ndarray) and w is not None) else (3, 1)
@@ -267,7 +249,7 @@ def test_length1dimensions(pset_mode, mode, u, v, w):
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat')
 
     x0, y0, z0 = 2, 8, -4
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=x0, lat=y0, depth=z0)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=x0, lat=y0, depth=z0)
     pfunc = AdvectionRK4 if w is None else AdvectionRK4_3D
     kernel = pset.Kernel(pfunc)
     pset.execute(kernel, runtime=5, dt=1)
@@ -301,7 +283,6 @@ def fieldset_stationary(xdim=100, ydim=100, maxtime=delta(hours=6)):
     return FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('method, rtol, diffField', [
     ('EE', 1e-2, False),
@@ -309,7 +290,7 @@ def fieldset_stationary(xdim=100, ydim=100, maxtime=delta(hours=6)):
     ('AdvDiffM1', 1e-2, True),
     ('RK4', 1e-5, False),
     ('RK45', 1e-5, False)])
-def test_stationary_eddy(pset_mode, fieldset_stationary, mode, method, rtol, diffField, npart=1):
+def test_stationary_eddy(fieldset_stationary, mode, method, rtol, diffField, npart=1):
     fieldset = fieldset_stationary
     if diffField:
         fieldset.add_field(Field('Kh_zonal', np.zeros(fieldset.U.data.shape), grid=fieldset.U.grid))
@@ -324,7 +305,7 @@ class RK45Particles(ptype[mode]):
         next_dt = Variable('next_dt', dtype=np.float32, initial=dt)
 
     pclass = RK45Particles if method == 'RK45' else ptype[mode]
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass, lon=lon, lat=lat)
+    pset = ParticleSet(fieldset, pclass=pclass, lon=lon, lat=lat)
     pset.execute(pset.Kernel(kernel[method], delete_cfiles=False), dt=dt, endtime=endtime)
 
     exp_lon = [truth_stationary(x, y, pset[0].time)[0] for x, y, in zip(lon, lat)]
@@ -333,9 +314,8 @@ class RK45Particles(ptype[mode]):
     assert np.allclose(pset.lat, exp_lat, rtol=rtol)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_stationary_eddy_vertical(pset_mode, mode, npart=1):
+def test_stationary_eddy_vertical(mode, npart=1):
     lon = np.linspace(12000, 21000, npart)
     lat = np.linspace(10000, 20000, npart)
     depth = np.linspace(12500, 12500, npart)
@@ -354,7 +334,7 @@ def test_stationary_eddy_vertical(pset_mode, mode, npart=1):
     data = {'U': fld1, 'V': fldzero, 'W': fld2}
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=lon, lat=lat, depth=depth)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=lon, lat=lat, depth=depth)
     pset.execute(AdvectionRK4_3D, dt=dt, endtime=endtime)
     exp_lon = [truth_stationary(x, z, pset[0].time)[0] for x, z, in zip(lon, depth)]
     exp_depth = [truth_stationary(x, z, pset[0].time)[1] for x, z, in zip(lon, depth)]
@@ -366,7 +346,7 @@ def test_stationary_eddy_vertical(pset_mode, mode, npart=1):
     data = {'U': fldzero, 'V': fld2, 'W': fld1}
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=lon, lat=lat, depth=depth)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=lon, lat=lat, depth=depth)
     pset.execute(AdvectionRK4_3D, dt=dt, endtime=endtime)
     exp_depth = [truth_stationary(z, y, pset[0].time)[0] for z, y, in zip(depth, lat)]
     exp_lat = [truth_stationary(z, y, pset[0].time)[1] for z, y, in zip(depth, lat)]
@@ -397,7 +377,6 @@ def fieldset_moving(xdim=100, ydim=100, maxtime=delta(hours=6)):
     return FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('method, rtol, diffField', [
     ('EE', 1e-2, False),
@@ -405,7 +384,7 @@ def fieldset_moving(xdim=100, ydim=100, maxtime=delta(hours=6)):
     ('AdvDiffM1', 1e-2, True),
     ('RK4', 1e-5, False),
     ('RK45', 1e-5, False)])
-def test_moving_eddy(pset_mode, fieldset_moving, mode, method, rtol, diffField, npart=1):
+def test_moving_eddy(fieldset_moving, mode, method, rtol, diffField, npart=1):
     fieldset = fieldset_moving
     if diffField:
         fieldset.add_field(Field('Kh_zonal', np.zeros(fieldset.U.data.shape), grid=fieldset.U.grid))
@@ -420,7 +399,7 @@ class RK45Particles(ptype[mode]):
         next_dt = Variable('next_dt', dtype=np.float32, initial=dt)
 
     pclass = RK45Particles if method == 'RK45' else ptype[mode]
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass, lon=lon, lat=lat)
+    pset = ParticleSet(fieldset, pclass=pclass, lon=lon, lat=lat)
     pset.execute(kernel[method], dt=dt, endtime=endtime)
 
     exp_lon = [truth_moving(x, y, pset.time)[0] for x, y, in zip(lon, lat)]
@@ -455,7 +434,6 @@ def fieldset_decaying(xdim=100, ydim=100, maxtime=delta(hours=6)):
     return FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('method, rtol, diffField', [
     ('EE', 1e-2, False),
@@ -464,7 +442,7 @@ def fieldset_decaying(xdim=100, ydim=100, maxtime=delta(hours=6)):
     ('RK4', 1e-5, False),
     ('RK45', 1e-5, False),
     ('AA', 1e-3, False)])
-def test_decaying_eddy(pset_mode, fieldset_decaying, mode, method, rtol, diffField, npart=1):
+def test_decaying_eddy(fieldset_decaying, mode, method, rtol, diffField, npart=1):
     fieldset = fieldset_decaying
     if method == 'AA':
         if mode == 'jit':
@@ -487,7 +465,7 @@ class RK45Particles(ptype[mode]):
         next_dt = Variable('next_dt', dtype=np.float32, initial=dt)
 
     pclass = RK45Particles if method == 'RK45' else ptype[mode]
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass, lon=lon, lat=lat)
+    pset = ParticleSet(fieldset, pclass=pclass, lon=lon, lat=lat)
     pset.execute(kernel[method], dt=dt, endtime=endtime)
 
     exp_lon = [truth_decaying(x, y, pset.time)[0] for x, y, in zip(lon, lat)]
@@ -496,15 +474,14 @@ class RK45Particles(ptype[mode]):
     assert np.allclose(pset.lat, exp_lat, rtol=rtol)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_analyticalAgrid(pset_mode, mode):
+def test_analyticalAgrid(mode):
     lon = np.arange(0, 15, dtype=np.float32)
     lat = np.arange(0, 15, dtype=np.float32)
     U = np.ones((lat.size, lon.size), dtype=np.float32)
     V = np.ones((lat.size, lon.size), dtype=np.float32)
     fieldset = FieldSet.from_data({'U': U, 'V': V}, {'lon': lon, 'lat': lat}, mesh='flat')
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=1, lat=1)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=1, lat=1)
     failed = False
     try:
         pset.execute(AdvectionAnalytical, runtime=1)
@@ -513,13 +490,12 @@ def test_analyticalAgrid(pset_mode, mode):
     assert failed
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy'])  # JIT not implemented
 @pytest.mark.parametrize('u', [1, -0.2, -0.3, 0])
 @pytest.mark.parametrize('v', [1, -0.3, 0, -1])
 @pytest.mark.parametrize('w', [None, 1, -0.3, 0, -1])
 @pytest.mark.parametrize('direction', [1, -1])
-def test_uniform_analytical(pset_mode, mode, u, v, w, direction, tmpdir):
+def test_uniform_analytical(mode, u, v, w, direction, tmpdir):
     lon = np.arange(0, 15, dtype=np.float32)
     lat = np.arange(0, 15, dtype=np.float32)
     if w is not None:
@@ -537,7 +513,7 @@ def test_uniform_analytical(pset_mode, mode, u, v, w, direction, tmpdir):
     fieldset.V.interp_method = 'cgrid_velocity'
 
     x0, y0, z0 = 6.1, 6.2, 20
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=x0, lat=y0, depth=z0)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=x0, lat=y0, depth=z0)
 
     outfile_path = tmpdir.join("uniformanalytical.zarr")
     outfile = pset.ParticleFile(name=outfile_path, outputdt=1, chunks=(1, 1))
diff --git a/tests/test_diffusion.py b/tests/test_diffusion.py
index 1abce45eb..dda28ed34 100644
--- a/tests/test_diffusion.py
+++ b/tests/test_diffusion.py
@@ -4,29 +4,21 @@
 import pytest
 from scipy import stats
 
-from parcels import (  # noqa
+from parcels import (
     AdvectionDiffusionEM,
     AdvectionDiffusionM1,
     DiffusionUniformKh,
     Field,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
     ParcelsRandom,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     RectilinearZGrid,
     ScipyParticle,
     Variable,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def zeros_fieldset(mesh='spherical', xdim=200, ydim=100, mesh_conversion=1):
@@ -42,8 +34,7 @@ def zeros_fieldset(mesh='spherical', xdim=200, ydim=100, mesh_conversion=1):
 
 @pytest.mark.parametrize('mesh', ['spherical', 'flat'])
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_fieldKh_Brownian(mesh, mode, pset_mode, xdim=200, ydim=100, kh_zonal=100, kh_meridional=50):
+def test_fieldKh_Brownian(mesh, mode, xdim=200, ydim=100, kh_zonal=100, kh_meridional=50):
     mesh_conversion = 1/1852./60 if mesh == 'spherical' else 1
     fieldset = zeros_fieldset(mesh=mesh, xdim=xdim, ydim=ydim, mesh_conversion=mesh_conversion)
 
@@ -54,7 +45,7 @@ def test_fieldKh_Brownian(mesh, mode, pset_mode, xdim=200, ydim=100, kh_zonal=10
     runtime = delta(days=1)
 
     ParcelsRandom.seed(1234)
-    pset = pset_type[pset_mode]['pset'](fieldset=fieldset, pclass=ptype[mode], lon=np.zeros(npart), lat=np.zeros(npart))
+    pset = ParticleSet(fieldset=fieldset, pclass=ptype[mode], lon=np.zeros(npart), lat=np.zeros(npart))
     pset.execute(pset.Kernel(DiffusionUniformKh), runtime=runtime, dt=delta(hours=1))
 
     expected_std_lon = np.sqrt(2*kh_zonal*mesh_conversion**2*runtime.total_seconds())
@@ -72,10 +63,9 @@ def test_fieldKh_Brownian(mesh, mode, pset_mode, xdim=200, ydim=100, kh_zonal=10
 
 @pytest.mark.parametrize('mesh', ['spherical', 'flat'])
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('kernel', [AdvectionDiffusionM1,
                                     AdvectionDiffusionEM])
-def test_fieldKh_SpatiallyVaryingDiffusion(mesh, mode, pset_mode, kernel, xdim=200, ydim=100):
+def test_fieldKh_SpatiallyVaryingDiffusion(mesh, mode, kernel, xdim=200, ydim=100):
     """Test advection-diffusion kernels on a non-uniform diffusivity field with a linear gradient in one direction."""
     mesh_conversion = 1/1852./60 if mesh == 'spherical' else 1
     fieldset = zeros_fieldset(mesh=mesh, xdim=xdim, ydim=ydim, mesh_conversion=mesh_conversion)
@@ -93,7 +83,7 @@ def test_fieldKh_SpatiallyVaryingDiffusion(mesh, mode, pset_mode, kernel, xdim=2
     runtime = delta(days=1)
 
     ParcelsRandom.seed(1636)
-    pset = pset_type[pset_mode]['pset'](fieldset=fieldset, pclass=ptype[mode], lon=np.zeros(npart), lat=np.zeros(npart))
+    pset = ParticleSet(fieldset=fieldset, pclass=ptype[mode], lon=np.zeros(npart), lat=np.zeros(npart))
     pset.execute(pset.Kernel(kernel), runtime=runtime, dt=delta(hours=1))
 
     lats = pset.lat
@@ -105,9 +95,8 @@ def test_fieldKh_SpatiallyVaryingDiffusion(mesh, mode, pset_mode, kernel, xdim=2
 
 
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('lambd', [1, 5])
-def test_randomexponential(mode, pset_mode, lambd, npart=1000):
+def test_randomexponential(mode, lambd, npart=1000):
     fieldset = zeros_fieldset()
 
     # Rate parameter for random.expovariate
@@ -116,8 +105,8 @@ def test_randomexponential(mode, pset_mode, lambd, npart=1000):
     # Set random seed
     ParcelsRandom.seed(1234)
 
-    pset = pset_type[pset_mode]['pset'](fieldset=fieldset, pclass=ptype[mode],
-                                        lon=np.zeros(npart), lat=np.zeros(npart), depth=np.zeros(npart))
+    pset = ParticleSet(fieldset=fieldset, pclass=ptype[mode],
+                       lon=np.zeros(npart), lat=np.zeros(npart), depth=np.zeros(npart))
 
     def vertical_randomexponential(particle, fieldset, time):
         # Kernel for random exponential variable in depth direction
@@ -131,10 +120,9 @@ def vertical_randomexponential(particle, fieldset, time):
 
 
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mu', [0.8*np.pi, np.pi])
 @pytest.mark.parametrize('kappa', [2, 4])
-def test_randomvonmises(mode, pset_mode, mu, kappa, npart=10000):
+def test_randomvonmises(mode, mu, kappa, npart=10000):
     fieldset = zeros_fieldset()
 
     # Parameters for random.vonmisesvariate
@@ -146,7 +134,7 @@ def test_randomvonmises(mode, pset_mode, mu, kappa, npart=10000):
 
     class AngleParticle(ptype[mode]):
         angle = Variable('angle')
-    pset = pset_type[pset_mode]['pset'](fieldset=fieldset, pclass=AngleParticle, lon=np.zeros(npart), lat=np.zeros(npart), depth=np.zeros(npart))
+    pset = ParticleSet(fieldset=fieldset, pclass=AngleParticle, lon=np.zeros(npart), lat=np.zeros(npart), depth=np.zeros(npart))
 
     def vonmises(particle, fieldset, time):
         particle.angle = ParcelsRandom.vonmisesvariate(fieldset.mu, fieldset.kappa)
diff --git a/tests/test_fieldset.py b/tests/test_fieldset.py
index 9e069194e..0a13b15bb 100644
--- a/tests/test_fieldset.py
+++ b/tests/test_fieldset.py
@@ -18,13 +18,7 @@
     AdvectionRK4_3D,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
     ParticleSet,
-    ParticleSetAOS,
-    ParticleSetSOA,
     RectilinearZGrid,
     ScipyParticle,
     TimeExtrapolationError,
@@ -40,10 +34,7 @@
     _get_cftime_datetimes,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def generate_fieldset(xdim, ydim, zdim=1, tdim=1):
@@ -342,12 +333,11 @@ def test_add_duplicate_field(dupobject):
     assert error_thrown
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('fieldtype', ['normal', 'vector'])
-def test_add_field_after_pset(pset_mode, fieldtype):
+def test_add_field_after_pset(fieldtype):
     data, dimensions = generate_fieldset(100, 100)
     fieldset = FieldSet.from_data(data, dimensions)
-    pset = pset_type[pset_mode]['pset'](fieldset, ScipyParticle, lon=0, lat=0)  # noqa ; to trigger fieldset.check_complete
+    pset = ParticleSet(fieldset, ScipyParticle, lon=0, lat=0)  # noqa ; to trigger fieldset.check_complete
     field1 = Field('field1', fieldset.U.data, lon=fieldset.U.lon, lat=fieldset.U.lat)
     field2 = Field('field2', fieldset.U.data, lon=fieldset.U.lon, lat=fieldset.U.lat)
     vfield = VectorField('vfield', field1, field2)
@@ -549,9 +539,8 @@ def addConst(particle, fieldset, time):
     particle.lon = particle.lon + fieldset.movewest + fieldset.moveeast
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_fieldset_constant(pset_mode, mode):
+def test_fieldset_constant(mode):
     data, dimensions = generate_fieldset(100, 100)
     fieldset = FieldSet.from_data(data, dimensions)
     westval = -0.2
@@ -560,15 +549,14 @@ def test_fieldset_constant(pset_mode, mode):
     fieldset.add_constant('moveeast', eastval)
     assert fieldset.movewest == westval
 
-    pset = pset_type[pset_mode]['pset'].from_line(fieldset, size=1, pclass=ptype[mode], start=(0.5, 0.5), finish=(0.5, 0.5))
+    pset = ParticleSet.from_line(fieldset, size=1, pclass=ptype[mode], start=(0.5, 0.5), finish=(0.5, 0.5))
     pset.execute(pset.Kernel(addConst), dt=1, runtime=1)
     assert abs(pset.lon[0] - (0.5 + westval + eastval)) < 1e-4
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('swapUV', [False, True])
-def test_vector_fields(pset_mode, mode, swapUV):
+def test_vector_fields(mode, swapUV):
     lon = np.linspace(0., 10., 12, dtype=np.float32)
     lat = np.linspace(0., 10., 10, dtype=np.float32)
     U = np.ones((10, 12), dtype=np.float32)
@@ -581,7 +569,7 @@ def test_vector_fields(pset_mode, mode, swapUV):
         UV = VectorField('UV', fieldset.V, fieldset.U)
         fieldset.add_vector_field(UV)
 
-    pset = pset_type[pset_mode]['pset'].from_line(fieldset, size=1, pclass=ptype[mode], start=(0.5, 0.5), finish=(0.5, 0.5))
+    pset = ParticleSet.from_line(fieldset, size=1, pclass=ptype[mode], start=(0.5, 0.5), finish=(0.5, 0.5))
     pset.execute(AdvectionRK4, dt=1, runtime=2)
     if swapUV:
         assert abs(pset.lon[0] - .5) < 1e-9
@@ -591,9 +579,8 @@ def test_vector_fields(pset_mode, mode, swapUV):
         assert abs(pset.lat[0] - .5) < 1e-9
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_add_second_vector_field(pset_mode, mode):
+def test_add_second_vector_field(mode):
     lon = np.linspace(0., 10., 12, dtype=np.float32)
     lat = np.linspace(0., 10., 10, dtype=np.float32)
     U = np.ones((10, 12), dtype=np.float32)
@@ -615,15 +602,14 @@ def SampleUV2(particle, fieldset, time):
         particle_dlon += u * particle.dt  # noqa
         particle_dlat += v * particle.dt  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=0.5, lat=0.5)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=0.5, lat=0.5)
     pset.execute(AdvectionRK4+pset.Kernel(SampleUV2), dt=1, runtime=2)
 
     assert abs(pset.lon[0] - 2.5) < 1e-9
     assert abs(pset.lat[0] - .5) < 1e-9
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_fieldset_write(pset_mode, tmpdir):
+def test_fieldset_write(tmpdir):
     filepath = tmpdir.join("fieldset_write.zarr")
     xdim, ydim = 3, 4
     lon = np.linspace(0., 10., xdim, dtype=np.float32)
@@ -642,7 +628,7 @@ def UpdateU(particle, fieldset, time):
         fieldset.U.data[particle.ti, particle.yi, particle.xi] += 1
         fieldset.U.grid.time[0] = time
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ScipyParticle, lon=5, lat=5)
+    pset = ParticleSet(fieldset, pclass=ScipyParticle, lon=5, lat=5)
     ofile = pset.ParticleFile(name=filepath, outputdt=2.)
     pset.execute(UpdateU, dt=1, runtime=10, output_file=ofile)
 
@@ -652,14 +638,13 @@ def UpdateU(particle, fieldset, time):
     assert np.allclose(fieldset.U.data, da['U'].values, atol=1.)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('time_periodic', [4*86400.0, False])
 @pytest.mark.parametrize('dt', [-3600, 3600])
 @pytest.mark.parametrize('chunksize', [False, 'auto', {'time': ('time_counter', 1), 'lat': ('y', 32), 'lon': ('x', 32)}])
 @pytest.mark.parametrize('with_GC', [False, True])
 @pytest.mark.skipif(sys.platform.startswith("win"), reason="skipping windows test as windows memory leaks (#787)")
-def test_from_netcdf_memory_containment(pset_mode, mode, time_periodic, dt, chunksize, with_GC):
+def test_from_netcdf_memory_containment(mode, time_periodic, dt, chunksize, with_GC):
     if time_periodic and dt < 0:
         return True  # time_periodic does not work in backward-time mode
     if chunksize == 'auto':
@@ -708,7 +693,7 @@ def periodicBoundaryConditions(particle, fieldset, time):
     postProcessFuncs = [perflog.advance, ]
     if with_GC:
         postProcessFuncs.append(perIterGC)
-    pset = pset_type[pset_mode]['pset'](fieldset=fieldset, pclass=ptype[mode], lon=[0.5, ], lat=[0.5, ])
+    pset = ParticleSet(fieldset=fieldset, pclass=ptype[mode], lon=[0.5, ], lat=[0.5, ])
     mem_0 = process.memory_info().rss
     mem_exhausted = False
     try:
@@ -723,12 +708,11 @@ def periodicBoundaryConditions(particle, fieldset, time):
     assert not mem_exhausted
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('time_periodic', [4*86400.0, False])
 @pytest.mark.parametrize('chunksize', [False, 'auto', {'lat': ('y', 32), 'lon': ('x', 32)}, {'time': ('time_counter', 1), 'lat': ('y', 32), 'lon': ('x', 32)}])
 @pytest.mark.parametrize('deferLoad', [True, False])
-def test_from_netcdf_chunking(pset_mode, mode, time_periodic, chunksize, deferLoad):
+def test_from_netcdf_chunking(mode, time_periodic, chunksize, deferLoad):
     fnameU = path.join(path.dirname(__file__), 'test_data', 'perlinfieldsU.nc')
     fnameV = path.join(path.dirname(__file__), 'test_data', 'perlinfieldsV.nc')
     ufiles = [fnameU, ] * 4
@@ -740,7 +724,7 @@ def test_from_netcdf_chunking(pset_mode, mode, time_periodic, chunksize, deferLo
     dimensions = {'lon': 'nav_lon', 'lat': 'nav_lat'}
 
     fieldset = FieldSet.from_netcdf(files, variables, dimensions, timestamps=timestamps, time_periodic=time_periodic, deferred_load=deferLoad, allow_time_extrapolation=True if time_periodic in [False, None] else False, chunksize=chunksize)
-    pset = pset_type[pset_mode]['pset'].from_line(fieldset, size=1, pclass=ptype[mode], start=(0.5, 0.5), finish=(0.5, 0.5))
+    pset = ParticleSet.from_line(fieldset, size=1, pclass=ptype[mode], start=(0.5, 0.5), finish=(0.5, 0.5))
     pset.execute(AdvectionRK4, dt=1, runtime=1)
 
 
@@ -774,12 +758,11 @@ def test_timestamps(datetype, tmpdir):
         assert np.allclose(fieldset3.U.data, fieldset4.U.data)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('use_xarray', [True, False])
 @pytest.mark.parametrize('time_periodic', [86400., False])
 @pytest.mark.parametrize('dt_sign', [-1, 1])
-def test_periodic(pset_mode, mode, use_xarray, time_periodic, dt_sign):
+def test_periodic(mode, use_xarray, time_periodic, dt_sign):
     lon = np.array([0, 1], dtype=np.float32)
     lat = np.array([0, 1], dtype=np.float32)
     depth = np.array([0, 1], dtype=np.float32)
@@ -834,7 +817,7 @@ class MyParticle(ptype[mode]):
         v2 = Variable('v2', dtype=np.float32, initial=0.)
         d = Variable('d', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, pclass=MyParticle, lon=[0.5], lat=[0.5], depth=[0.5])
+    pset = ParticleSet.from_list(fieldset, pclass=MyParticle, lon=[0.5], lat=[0.5], depth=[0.5])
     pset.execute(AdvectionRK4_3D + pset.Kernel(sampleTemp), runtime=delta(hours=51), dt=delta(hours=dt_sign*1))
 
     if time_periodic is not False:
@@ -850,9 +833,8 @@ class MyParticle(ptype[mode]):
     assert np.allclose(pset.d[0], 1.)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('fail', [False, pytest.param(True, marks=pytest.mark.xfail(strict=True))])
-def test_fieldset_defer_loading_with_diff_time_origin(pset_mode, tmpdir, fail, filename='test_parcels_defer_loading'):
+def test_fieldset_defer_loading_with_diff_time_origin(tmpdir, fail, filename='test_parcels_defer_loading'):
     filepath = tmpdir.join(filename)
     data0, dims0 = generate_fieldset(10, 10, 1, 10)
     dims0['time'] = np.arange(0, 10, 1) * 3600
@@ -874,15 +856,14 @@ def test_fieldset_defer_loading_with_diff_time_origin(pset_mode, tmpdir, fail, f
     fieldset_out.write(filepath)
     fieldset = FieldSet.from_parcels(filepath, extra_fields={'W': 'W'})
     assert fieldset.U.creation_log == 'from_parcels'
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, pclass=JITParticle, lon=[0.5], lat=[0.5], depth=[0.5],
-                                                  time=[datetime.datetime(2018, 4, 20, 1)])
+    pset = ParticleSet.from_list(fieldset, pclass=JITParticle, lon=[0.5], lat=[0.5], depth=[0.5],
+                                 time=[datetime.datetime(2018, 4, 20, 1)])
     pset.execute(AdvectionRK4_3D, runtime=delta(hours=4), dt=delta(hours=1))
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('zdim', [2, 8])
 @pytest.mark.parametrize('scale_fac', [0.2, 4, 1])
-def test_fieldset_defer_loading_function(pset_mode, zdim, scale_fac, tmpdir, filename='test_parcels_defer_loading'):
+def test_fieldset_defer_loading_function(zdim, scale_fac, tmpdir, filename='test_parcels_defer_loading'):
     filepath = tmpdir.join(filename)
     data0, dims0 = generate_fieldset(3, 3, zdim, 10)
     data0['U'][:, 0, :, :] = np.nan  # setting first layer to nan, which will be changed to zero (and all other layers to 1)
@@ -915,7 +896,7 @@ def compute(fieldset):
     assert isinstance(fieldset.U.data, da.core.Array)
     assert np.allclose(fieldset.U.data, scale_fac*(zdim-1.)/zdim)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, JITParticle, 0, 0)
+    pset = ParticleSet(fieldset, JITParticle, 0, 0)
 
     def DoNothing(particle, fieldset, time):
         pass
@@ -943,7 +924,7 @@ class SampleParticle(JITParticle):
         v_kernel = Variable('v_kernel', dtype=np.float32, initial=0.)
         u_scipy = Variable('u_scipy', dtype=np.float32, initial=0.)
 
-    pset = pset_type['soa']['pset'](fieldset, pclass=SampleParticle, time=[0, time2], lon=[0.5, 0.5], lat=[0.5, 0.5], depth=[0.5, 0.5])
+    pset = ParticleSet(fieldset, pclass=SampleParticle, time=[0, time2], lon=[0.5, 0.5], lat=[0.5, 0.5], depth=[0.5, 0.5])
 
     if time2 > 1:
         failed = False
@@ -958,9 +939,8 @@ class SampleParticle(JITParticle):
         assert isinstance(fieldset.U.data, da.core.Array)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('tdim', [10, None])
-def test_fieldset_from_xarray(pset_mode, tdim):
+def test_fieldset_from_xarray(tdim):
     def generate_dataset(xdim, ydim, zdim=1, tdim=1):
         lon = np.linspace(0., 12, xdim, dtype=np.float32)
         lat = np.linspace(0., 12, ydim, dtype=np.float32)
@@ -992,7 +972,7 @@ def generate_dataset(xdim, ydim, zdim=1, tdim=1):
     fieldset = FieldSet.from_xarray_dataset(ds, variables, dimensions, mesh='flat')
     assert fieldset.U.creation_log == 'from_xarray_dataset'
 
-    pset = pset_type[pset_mode]['pset'](fieldset, JITParticle, 0, 0, depth=20)
+    pset = ParticleSet(fieldset, JITParticle, 0, 0, depth=20)
 
     pset.execute(AdvectionRK4, dt=1, runtime=10)
     if tdim == 10:
@@ -1001,20 +981,18 @@ def generate_dataset(xdim, ydim, zdim=1, tdim=1):
         assert np.allclose(pset.lon_nextloop[0], 5.0) and np.allclose(pset.lat_nextloop[0], 10)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_fieldset_frompop(pset_mode, mode):
+def test_fieldset_frompop(mode):
     filenames = path.join(path.join(path.dirname(__file__), 'test_data'), 'POPtestdata_time.nc')
     variables = {'U': 'U', 'V': 'V', 'W': 'W', 'T': 'T'}
     dimensions = {'lon': 'lon', 'lat': 'lat', 'time': 'time'}
 
     fieldset = FieldSet.from_pop(filenames, variables, dimensions, mesh='flat')
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, ptype[mode], lon=[3, 5, 1], lat=[3, 5, 1])
+    pset = ParticleSet.from_list(fieldset, ptype[mode], lon=[3, 5, 1], lat=[3, 5, 1])
     pset.execute(AdvectionRK4, runtime=3, dt=1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_fieldset_from_data_gridtypes(pset_mode, xdim=20, ydim=10, zdim=4):
+def test_fieldset_from_data_gridtypes(xdim=20, ydim=10, zdim=4):
     """Simple test for fieldset initialisation from data."""
     lon = np.linspace(0., 10., xdim, dtype=np.float32)
     lat = np.linspace(0., 10., ydim, dtype=np.float32)
@@ -1031,7 +1009,7 @@ def test_fieldset_from_data_gridtypes(pset_mode, xdim=20, ydim=10, zdim=4):
 
     # Rectilinear Z grid
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat')
-    pset = pset_type[pset_mode]['pset'](fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
+    pset = ParticleSet(fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
     pset.execute(AdvectionRK4, runtime=1.5, dt=.5)
     plon = pset.lon
     plat = pset.lat
@@ -1042,7 +1020,7 @@ def test_fieldset_from_data_gridtypes(pset_mode, xdim=20, ydim=10, zdim=4):
     # Rectilinear S grid
     dimensions['depth'] = depth_s
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat')
-    pset = pset_type[pset_mode]['pset'](fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
+    pset = ParticleSet(fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
     pset.execute(AdvectionRK4, runtime=1.5, dt=.5)
     assert np.allclose(plon, pset.lon)
     assert np.allclose(plat, pset.lat)
@@ -1052,7 +1030,7 @@ def test_fieldset_from_data_gridtypes(pset_mode, xdim=20, ydim=10, zdim=4):
     dimensions['lat'] = latm
     dimensions['depth'] = depth
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat')
-    pset = pset_type[pset_mode]['pset'](fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
+    pset = ParticleSet(fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
     pset.execute(AdvectionRK4, runtime=1.5, dt=.5)
     assert np.allclose(plon, pset.lon)
     assert np.allclose(plat, pset.lat)
@@ -1060,17 +1038,16 @@ def test_fieldset_from_data_gridtypes(pset_mode, xdim=20, ydim=10, zdim=4):
     # Curvilinear S grid
     dimensions['depth'] = depth_s
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat')
-    pset = pset_type[pset_mode]['pset'](fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
+    pset = ParticleSet(fieldset, ScipyParticle, [0, 0], [0, 0], [0, .4])
     pset.execute(AdvectionRK4, runtime=1.5, dt=.5)
     assert np.allclose(plon, pset.lon)
     assert np.allclose(plat, pset.lat)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('direction', [1, -1])
 @pytest.mark.parametrize('time_extrapolation', [True, False])
-def test_deferredload_simplefield(pset_mode, mode, direction, time_extrapolation, tmpdir, tdim=10):
+def test_deferredload_simplefield(mode, direction, time_extrapolation, tmpdir, tdim=10):
     filename = tmpdir.join("simplefield_deferredload.nc")
     data = np.zeros((tdim, 2, 2))
     for ti in range(tdim):
@@ -1084,7 +1061,7 @@ def test_deferredload_simplefield(pset_mode, mode, direction, time_extrapolation
 
     class SamplingParticle(ptype[mode]):
         p = Variable('p')
-    pset = pset_type[pset_mode]['pset'](fieldset, SamplingParticle, lon=0.5, lat=0.5)
+    pset = ParticleSet(fieldset, SamplingParticle, lon=0.5, lat=0.5)
 
     def SampleU(particle, fieldset, time):
         particle.p, tmp = fieldset.UV[particle]
diff --git a/tests/test_fieldset_sampling.py b/tests/test_fieldset_sampling.py
index 3f720a523..5dc48bf8b 100644
--- a/tests/test_fieldset_sampling.py
+++ b/tests/test_fieldset_sampling.py
@@ -5,29 +5,21 @@
 import pytest
 import xarray as xr
 
-from parcels import (  # noqa
+from parcels import (
     AdvectionRK4,
     AdvectionRK4_3D,
     Field,
     FieldSet,
     Geographic,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
     NestedField,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     ScipyParticle,
     StatusCode,
     Variable,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def pclass(mode):
@@ -149,19 +141,17 @@ def test_fieldset_sample_eval(fieldset, xdim=60, ydim=60):
     assert np.allclose(u_s, lat, rtol=1e-7)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_fieldset_polar_with_halo(fieldset_geometric_polar, pset_mode, mode):
+def test_fieldset_polar_with_halo(fieldset_geometric_polar, mode):
     fieldset_geometric_polar.add_periodic_halo(zonal=5)
-    pset = pset_type[pset_mode]['pset'](fieldset_geometric_polar, pclass=pclass(mode), lon=0, lat=0)
+    pset = ParticleSet(fieldset_geometric_polar, pclass=pclass(mode), lon=0, lat=0)
     pset.execute(runtime=1)
     assert pset.lon[0] == 0.
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('zdir', [-1, 1])
-def test_verticalsampling(pset_mode, mode, zdir):
+def test_verticalsampling(mode, zdir):
     dims = (4, 2, 2)
     dimensions = {'lon': np.linspace(0., 1., dims[2], dtype=np.float32),
                   'lat': np.linspace(0., 1., dims[1], dtype=np.float32),
@@ -169,14 +159,13 @@ def test_verticalsampling(pset_mode, mode, zdir):
     data = {'U': np.zeros(dims, dtype=np.float32),
             'V': np.zeros(dims, dtype=np.float32)}
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat')
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=0, lat=0, depth=0.7*zdir)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=0, lat=0, depth=0.7*zdir)
     pset.execute(AdvectionRK4, dt=1., runtime=1.)
     assert pset[0].zi == [2]
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_from_field(pset_mode, mode, xdim=10, ydim=20, npart=10000):
+def test_pset_from_field(mode, xdim=10, ydim=20, npart=10000):
     np.random.seed(123456)
     dimensions = {'lon': np.linspace(0., 1., xdim, dtype=np.float32),
                   'lat': np.linspace(0., 1., ydim, dtype=np.float32)}
@@ -193,14 +182,13 @@ def test_pset_from_field(pset_mode, mode, xdim=10, ydim=20, npart=10000):
                       lat=np.linspace(-1./(ydim*2), 1.+1./(ydim*2), ydim+1, dtype=np.float32), transpose=True)
 
     fieldset.add_field(densfield)
-    pset = pset_type[pset_mode]['pset'].from_field(fieldset, size=npart, pclass=pclass(mode), start_field=fieldset.start)
+    pset = ParticleSet.from_field(fieldset, size=npart, pclass=pclass(mode), start_field=fieldset.start)
     pdens = np.histogram2d(pset.lon, pset.lat, bins=[np.linspace(0., 1., xdim+1), np.linspace(0., 1., ydim+1)])[0]
     assert np.allclose(pdens/sum(pdens.flatten()), startfield/sum(startfield.flatten()), atol=1e-2)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_nearest_neighbour_interpolation2D(pset_mode, mode, k_sample_p, npart=81):
+def test_nearest_neighbour_interpolation2D(mode, k_sample_p, npart=81):
     dims = (2, 2)
     dimensions = {'lon': np.linspace(0., 1., dims[0], dtype=np.float32),
                   'lat': np.linspace(0., 1., dims[1], dtype=np.float32)}
@@ -211,15 +199,14 @@ def test_nearest_neighbour_interpolation2D(pset_mode, mode, k_sample_p, npart=81
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
     fieldset.P.interp_method = 'nearest'
     xv, yv = np.meshgrid(np.linspace(0., 1.0, int(np.sqrt(npart))), np.linspace(0., 1.0, int(np.sqrt(npart))))
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten())
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten())
     pset.execute(k_sample_p, endtime=1, dt=1)
     assert np.allclose(pset.p[(pset.lon < 0.5) & (pset.lat > 0.5)], 1.0, rtol=1e-5)
     assert np.allclose(pset.p[(pset.lon > 0.5) | (pset.lat < 0.5)], 0.0, rtol=1e-5)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_nearest_neighbour_interpolation3D(pset_mode, mode, k_sample_p, npart=81):
+def test_nearest_neighbour_interpolation3D(mode, k_sample_p, npart=81):
     dims = (2, 2, 2)
     dimensions = {'lon': np.linspace(0., 1., dims[0], dtype=np.float32),
                   'lat': np.linspace(0., 1., dims[1], dtype=np.float32),
@@ -232,22 +219,20 @@ def test_nearest_neighbour_interpolation3D(pset_mode, mode, k_sample_p, npart=81
     fieldset.P.interp_method = 'nearest'
     xv, yv = np.meshgrid(np.linspace(0, 1.0, int(np.sqrt(npart))), np.linspace(0, 1.0, int(np.sqrt(npart))))
     # combine a pset at 0m with pset at 1m, as meshgrid does not do 3D
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
-                                        depth=np.zeros(npart))
-    pset2 = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
-                                         depth=np.ones(npart))
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
+                       depth=np.zeros(npart))
+    pset2 = ParticleSet(fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
+                        depth=np.ones(npart))
     pset.add(pset2)
-
     pset.execute(k_sample_p, endtime=1, dt=1)
     assert np.allclose(pset.p[(pset.lon < 0.5) & (pset.lat > 0.5) & (pset.depth > 0.5)], 1.0, rtol=1e-5)
     assert np.allclose(pset.p[(pset.lon > 0.5) | (pset.lat < 0.5) & (pset.depth < 0.5)], 0.0, rtol=1e-5)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('withDepth', [True, False])
 @pytest.mark.parametrize('arrtype', ['ones', 'rand'])
-def test_inversedistance_nearland(pset_mode, mode, withDepth, arrtype, k_sample_p, npart=81):
+def test_inversedistance_nearland(mode, withDepth, arrtype, k_sample_p, npart=81):
     dims = (4, 4, 6) if withDepth else (4, 6)
     dimensions = {'lon': np.linspace(0., 1., dims[-1], dtype=np.float32),
                   'lat': np.linspace(0., 1., dims[-2], dtype=np.float32)}
@@ -266,11 +251,11 @@ def test_inversedistance_nearland(pset_mode, mode, withDepth, arrtype, k_sample_
     fieldset.P.interp_method = 'linear_invdist_land_tracer'
 
     xv, yv = np.meshgrid(np.linspace(0.1, 0.9, int(np.sqrt(npart))), np.linspace(0.1, 0.9, int(np.sqrt(npart))))
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
-                                        depth=np.zeros(npart))
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
+                       depth=np.zeros(npart))
     if withDepth:
-        pset2 = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
-                                             depth=np.ones(npart))
+        pset2 = ParticleSet(fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten(),
+                            depth=np.ones(npart))
         pset.add(pset2)
     pset.execute(k_sample_p, endtime=1, dt=1)
     if arrtype == 'rand':
@@ -287,12 +272,11 @@ def test_inversedistance_nearland(pset_mode, mode, withDepth, arrtype, k_sample_
     assert success
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('boundaryslip', ['freeslip', 'partialslip'])
 @pytest.mark.parametrize('withW', [False, True])
 @pytest.mark.parametrize('withT', [False, True])
-def test_partialslip_nearland_zonal(pset_mode, mode, boundaryslip, withW, withT, npart=20):
+def test_partialslip_nearland_zonal(mode, boundaryslip, withW, withT, npart=20):
     dims = (3, 9, 3)
     U = 0.1*np.ones(dims, dtype=np.float32)
     U[:, 0, :] = np.nan
@@ -318,8 +302,8 @@ def test_partialslip_nearland_zonal(pset_mode, mode, boundaryslip, withW, withT,
         data = {'U': U, 'V': V}
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', interp_method=boundaryslip)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=np.zeros(npart),
-                                        lat=np.linspace(0.1, 3.9, npart), depth=np.zeros(npart))
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=np.zeros(npart),
+                       lat=np.linspace(0.1, 3.9, npart), depth=np.zeros(npart))
     kernel = AdvectionRK4_3D if withW else AdvectionRK4
     pset.execute(kernel, endtime=2, dt=1)
     if boundaryslip == 'partialslip':
@@ -336,11 +320,10 @@ def test_partialslip_nearland_zonal(pset_mode, mode, boundaryslip, withW, withT,
             assert np.allclose([p.depth for p in pset], 0.1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('boundaryslip', ['freeslip', 'partialslip'])
 @pytest.mark.parametrize('withW', [False, True])
-def test_partialslip_nearland_meridional(pset_mode, mode, boundaryslip, withW, npart=20):
+def test_partialslip_nearland_meridional(mode, boundaryslip, withW, npart=20):
     dims = (1, 1, 9)
     U = np.zeros(dims, dtype=np.float32)
     U[:, :, 0] = np.nan
@@ -360,8 +343,8 @@ def test_partialslip_nearland_meridional(pset_mode, mode, boundaryslip, withW, n
         interp_method = {'U': boundaryslip, 'V': boundaryslip}
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', interp_method=interp_method)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lat=np.zeros(npart),
-                                        lon=np.linspace(0.1, 3.9, npart), depth=np.zeros(npart))
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lat=np.zeros(npart),
+                       lon=np.linspace(0.1, 3.9, npart), depth=np.zeros(npart))
     kernel = AdvectionRK4_3D if withW else AdvectionRK4
     pset.execute(kernel, endtime=2, dt=1)
     if boundaryslip == 'partialslip':
@@ -378,10 +361,9 @@ def test_partialslip_nearland_meridional(pset_mode, mode, boundaryslip, withW, n
             assert np.allclose([p.depth for p in pset], 0.1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('boundaryslip', ['freeslip', 'partialslip'])
-def test_partialslip_nearland_vertical(pset_mode, mode, boundaryslip, npart=20):
+def test_partialslip_nearland_vertical(mode, boundaryslip, npart=20):
     dims = (9, 1, 1)
     U = 0.1*np.ones(dims, dtype=np.float32)
     U[0, :, :] = np.nan
@@ -393,8 +375,8 @@ def test_partialslip_nearland_vertical(pset_mode, mode, boundaryslip, npart=20):
     data = {'U': U, 'V': V}
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', interp_method={'U': boundaryslip, 'V': boundaryslip})
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=np.zeros(npart), lat=np.zeros(npart),
-                                        depth=np.linspace(0.1, 3.9, npart))
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=np.zeros(npart), lat=np.zeros(npart),
+                       depth=np.linspace(0.1, 3.9, npart))
     pset.execute(AdvectionRK4, endtime=2, dt=1)
     if boundaryslip == 'partialslip':
         assert np.allclose([p.lon for p in pset if p.depth >= 0.5 and p.depth <= 3.5], 0.1)
@@ -406,10 +388,9 @@ def test_partialslip_nearland_vertical(pset_mode, mode, boundaryslip, npart=20):
         assert np.allclose([p.lat for p in pset], 0.1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('lat_flip', [False, True])
-def test_fieldset_sample_particle(pset_mode, mode, k_sample_uv, lat_flip, npart=120):
+def test_fieldset_sample_particle(mode, k_sample_uv, lat_flip, npart=120):
     """Sample the fieldset using an array of particles.
 
     Note that the low tolerances (1.e-6) are due to the first-order
@@ -429,71 +410,67 @@ def test_fieldset_sample_particle(pset_mode, mode, k_sample_uv, lat_flip, npart=
 
     lon = np.linspace(-170, 170, npart)
     lat = np.linspace(-80, 80, npart)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
     pset.execute(pset.Kernel(k_sample_uv), endtime=1., dt=1.)
     assert np.allclose(pset.v, lon, rtol=1e-6)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
     pset.execute(pset.Kernel(k_sample_uv), endtime=1., dt=1.)
     assert np.allclose(pset.u, lat, rtol=1e-6)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_fieldset_sample_geographic(fieldset_geometric, pset_mode, mode, k_sample_uv, npart=120):
+def test_fieldset_sample_geographic(fieldset_geometric, mode, k_sample_uv, npart=120):
     """Sample a fieldset with conversion to geographic units (degrees)."""
     fieldset = fieldset_geometric
     lon = np.linspace(-170, 170, npart)
     lat = np.linspace(-80, 80, npart)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
     pset.execute(pset.Kernel(k_sample_uv), endtime=1., dt=1.)
     assert np.allclose(pset.v, lon, rtol=1e-6)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
     pset.execute(pset.Kernel(k_sample_uv), endtime=1., dt=1.)
     assert np.allclose(pset.u, lat, rtol=1e-6)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_fieldset_sample_geographic_noconvert(fieldset_geometric, pset_mode, mode, k_sample_uv_noconvert, npart=120):
+def test_fieldset_sample_geographic_noconvert(fieldset_geometric, mode, k_sample_uv_noconvert, npart=120):
     """Sample a fieldset without conversion to geographic units."""
     fieldset = fieldset_geometric
     lon = np.linspace(-170, 170, npart)
     lat = np.linspace(-80, 80, npart)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
     pset.execute(pset.Kernel(k_sample_uv_noconvert), endtime=1., dt=1.)
     assert np.allclose(pset.v, lon * 1000 * 1.852 * 60, rtol=1e-6)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
     pset.execute(pset.Kernel(k_sample_uv_noconvert), endtime=1., dt=1.)
     assert np.allclose(pset.u, lat * 1000 * 1.852 * 60, rtol=1e-6)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_fieldset_sample_geographic_polar(fieldset_geometric_polar, pset_mode, mode, k_sample_uv, npart=120):
+def test_fieldset_sample_geographic_polar(fieldset_geometric_polar, mode, k_sample_uv, npart=120):
     """Sample a fieldset with conversion to geographic units and a pole correction."""
     fieldset = fieldset_geometric_polar
     lon = np.linspace(-170, 170, npart)
     lat = np.linspace(-80, 80, npart)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=lon, lat=np.zeros(npart) + 70.)
     pset.execute(pset.Kernel(k_sample_uv), endtime=1., dt=1.)
     assert np.allclose(pset.v, lon, rtol=1e-6)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lat=lat, lon=np.zeros(npart) - 45.)
     pset.execute(pset.Kernel(k_sample_uv), endtime=1., dt=1.)
     # Note: 1.e-2 is a very low rtol, so there seems to be a rather
     # large sampling error for the JIT correction.
     assert np.allclose(pset.u, lat, rtol=1e-2)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_meridionalflow_spherical(pset_mode, mode, xdim=100, ydim=200):
+def test_meridionalflow_spherical(mode, xdim=100, ydim=200):
     """Create uniform NORTHWARD flow on spherical earth and advect particles.
 
     As flow is so simple, it can be directly compared to analytical solution.
@@ -509,7 +486,7 @@ def test_meridionalflow_spherical(pset_mode, mode, xdim=100, ydim=200):
     lonstart = [0, 45]
     latstart = [0, 45]
     runtime = delta(hours=24)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=lonstart, lat=latstart)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=lonstart, lat=latstart)
     pset.execute(pset.Kernel(AdvectionRK4), runtime=runtime, dt=delta(hours=1))
 
     assert pset.lat[0] - (latstart[0] + runtime.total_seconds() * maxvel / 1852 / 60) < 1e-4
@@ -518,9 +495,8 @@ def test_meridionalflow_spherical(pset_mode, mode, xdim=100, ydim=200):
     assert pset.lon[1] - lonstart[1] < 1e-4
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_zonalflow_spherical(pset_mode, mode, k_sample_p, xdim=100, ydim=200):
+def test_zonalflow_spherical(mode, k_sample_p, xdim=100, ydim=200):
     """Create uniform EASTWARD flow on spherical earth and advect particles.
 
     As flow is so simple, it can be directly compared to analytical solution
@@ -539,7 +515,7 @@ def test_zonalflow_spherical(pset_mode, mode, k_sample_p, xdim=100, ydim=200):
     lonstart = [0, 45]
     latstart = [0, 45]
     runtime = delta(hours=24)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=lonstart, lat=latstart)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=lonstart, lat=latstart)
     pset.execute(pset.Kernel(AdvectionRK4) + k_sample_p,
                  runtime=runtime, dt=delta(hours=1))
 
@@ -553,9 +529,8 @@ def test_zonalflow_spherical(pset_mode, mode, k_sample_p, xdim=100, ydim=200):
     assert abs(pset.p[1] - p_fld) < 1e-4
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_random_field(pset_mode, mode, k_sample_p, xdim=20, ydim=20, npart=100):
+def test_random_field(mode, k_sample_p, xdim=20, ydim=20, npart=100):
     """Sampling test that tests for overshoots by sampling a field of random numbers between 0 and 1."""
     np.random.seed(123456)
     dimensions = {'lon': np.linspace(0., 1., xdim, dtype=np.float32),
@@ -566,17 +541,16 @@ def test_random_field(pset_mode, mode, k_sample_p, xdim=20, ydim=20, npart=100):
             'start': np.ones((xdim, ydim), dtype=np.float32)}
 
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat', transpose=True)
-    pset = pset_type[pset_mode]['pset'].from_field(fieldset, size=npart, pclass=pclass(mode),
-                                                   start_field=fieldset.start)
+    pset = ParticleSet.from_field(fieldset, size=npart, pclass=pclass(mode),
+                                  start_field=fieldset.start)
     pset.execute(k_sample_p, endtime=1., dt=1.0)
     sampled = pset.p
     assert (sampled >= 0.).all()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('allow_time_extrapolation', [True, False])
-def test_sampling_out_of_bounds_time(pset_mode, mode, allow_time_extrapolation, k_sample_p,
+def test_sampling_out_of_bounds_time(mode, allow_time_extrapolation, k_sample_p,
                                      xdim=10, ydim=10, tdim=10):
     dimensions = {'lon': np.linspace(0., 1., xdim, dtype=np.float32),
                   'lat': np.linspace(0., 1., ydim, dtype=np.float32),
@@ -587,7 +561,7 @@ def test_sampling_out_of_bounds_time(pset_mode, mode, allow_time_extrapolation,
 
     fieldset = FieldSet.from_data(data, dimensions, mesh='flat',
                                   allow_time_extrapolation=allow_time_extrapolation, transpose=True)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=-1.0)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=-1.0)
     if allow_time_extrapolation:
         pset.execute(k_sample_p, endtime=-0.9, dt=0.1)
         assert np.allclose(pset.p, 0.0, rtol=1e-5)
@@ -595,19 +569,19 @@ def test_sampling_out_of_bounds_time(pset_mode, mode, allow_time_extrapolation,
         with pytest.raises(RuntimeError):
             pset.execute(k_sample_p, endtime=-0.9, dt=0.1)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=0)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=0)
     pset.execute(k_sample_p, runtime=0.1, dt=0.1)
     assert np.allclose(pset.p, 0.0, rtol=1e-5)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=0.5)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=0.5)
     pset.execute(k_sample_p, runtime=0.1, dt=0.1)
     assert np.allclose(pset.p, 0.5, rtol=1e-5)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=1.0)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=1.0)
     pset.execute(k_sample_p, runtime=0.1, dt=0.1)
     assert np.allclose(pset.p, 1.0, rtol=1e-5)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=2.0)
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0.5], lat=[0.5], time=2.0)
     if allow_time_extrapolation:
         pset.execute(k_sample_p, runtime=0.1, dt=0.1)
         assert np.allclose(pset.p, 1.0, rtol=1e-5)
@@ -616,11 +590,10 @@ def test_sampling_out_of_bounds_time(pset_mode, mode, allow_time_extrapolation,
             pset.execute(k_sample_p, runtime=0.1, dt=0.1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
 @pytest.mark.parametrize('npart', [1, 10])
 @pytest.mark.parametrize('chs', [False, 'auto', {'lat': ('y', 10), 'lon': ('x', 10)}])
-def test_sampling_multigrids_non_vectorfield_from_file(pset_mode, mode, npart, tmpdir, chs, filename='test_subsets'):
+def test_sampling_multigrids_non_vectorfield_from_file(mode, npart, tmpdir, chs, filename='test_subsets'):
     xdim, ydim = 100, 200
     filepath = tmpdir.join(filename)
     U = Field('U', np.zeros((ydim, xdim), dtype=np.float32),
@@ -658,8 +631,7 @@ def test_sampling_multigrids_non_vectorfield_from_file(pset_mode, mode, npart, t
     class TestParticle(ptype[mode]):
         sample_var = Variable('sample_var', initial=0.)
 
-    pset = pset_type[pset_mode]['pset'].from_line(fieldset, pclass=TestParticle, start=[0.3, 0.3], finish=[0.7, 0.7],
-                                                  size=npart)
+    pset = ParticleSet.from_line(fieldset, pclass=TestParticle, start=[0.3, 0.3], finish=[0.7, 0.7], size=npart)
 
     def test_sample(particle, fieldset, time):
         particle.sample_var += fieldset.B[time, fieldset.sample_depth, particle.lat, particle.lon]
@@ -668,33 +640,22 @@ def test_sample(particle, fieldset, time):
     pset.execute(kernels, runtime=10, dt=1)
     assert np.allclose(pset.sample_var, 10.0)
     if mode == 'jit':
-        if pset_mode == 'soa':
-            assert len(pset.xi.shape) == 2
-            assert pset.xi.shape[0] == len(pset.lon)
-            assert pset.xi.shape[1] == fieldset.gridset.size
-            assert np.all(pset.xi >= 0)
-            assert np.all(pset.xi[:, fieldset.B.igrid] < xdim * 4)
-            assert np.all(pset.xi[:, 0] < xdim)
-            assert pset.yi.shape[0] == len(pset.lon)
-            assert pset.yi.shape[1] == fieldset.gridset.size
-            assert np.all(pset.yi >= 0)
-            assert np.all(pset.yi[:, fieldset.B.igrid] < ydim * 3)
-            assert np.all(pset.yi[:, 0] < ydim)
-        elif pset_mode == 'aos':
-            assert np.alltrue([[pxi > 0 for pxi in p.xi] for p in pset])
-            assert np.alltrue([len(p.xi) == fieldset.gridset.size for p in pset])
-            assert np.alltrue([p.xi[fieldset.B.igrid] < xdim * 4 for p in pset])
-            assert np.alltrue([p.xi[0] < xdim for p in pset])
-            assert np.alltrue([[pyi > 0 for pyi in p.yi] for p in pset])
-            assert np.alltrue([len(p.yi) == fieldset.gridset.size for p in pset])
-            assert np.alltrue([p.yi[fieldset.B.igrid] < ydim * 3 for p in pset])
-            assert np.alltrue([p.yi[0] < ydim for p in pset])
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
+        assert len(pset.xi.shape) == 2
+        assert pset.xi.shape[0] == len(pset.lon)
+        assert pset.xi.shape[1] == fieldset.gridset.size
+        assert np.all(pset.xi >= 0)
+        assert np.all(pset.xi[:, fieldset.B.igrid] < xdim * 4)
+        assert np.all(pset.xi[:, 0] < xdim)
+        assert pset.yi.shape[0] == len(pset.lon)
+        assert pset.yi.shape[1] == fieldset.gridset.size
+        assert np.all(pset.yi >= 0)
+        assert np.all(pset.yi[:, fieldset.B.igrid] < ydim * 3)
+        assert np.all(pset.yi[:, 0] < ydim)
+
+
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
 @pytest.mark.parametrize('npart', [1, 10])
-def test_sampling_multigrids_non_vectorfield(pset_mode, mode, npart):
+def test_sampling_multigrids_non_vectorfield(mode, npart):
     xdim, ydim = 100, 200
     U = Field('U', np.zeros((ydim, xdim), dtype=np.float32),
               lon=np.linspace(0., 1., xdim, dtype=np.float32),
@@ -714,8 +675,7 @@ def test_sampling_multigrids_non_vectorfield(pset_mode, mode, npart):
     class TestParticle(ptype[mode]):
         sample_var = Variable('sample_var', initial=0.)
 
-    pset = pset_type[pset_mode]['pset'].from_line(fieldset, pclass=TestParticle, start=[0.3, 0.3], finish=[0.7, 0.7],
-                                                  size=npart)
+    pset = ParticleSet.from_line(fieldset, pclass=TestParticle, start=[0.3, 0.3], finish=[0.7, 0.7], size=npart)
 
     def test_sample(particle, fieldset, time):
         particle.sample_var += fieldset.B[time, fieldset.sample_depth, particle.lat, particle.lon]
@@ -724,33 +684,22 @@ def test_sample(particle, fieldset, time):
     pset.execute(kernels, runtime=10, dt=1)
     assert np.allclose(pset.sample_var, 10.0)
     if mode == 'jit':
-        if pset_mode == 'soa':
-            assert len(pset.xi.shape) == 2
-            assert pset.xi.shape[0] == len(pset.lon)
-            assert pset.xi.shape[1] == fieldset.gridset.size
-            assert np.all(pset.xi >= 0)
-            assert np.all(pset.xi[:, fieldset.B.igrid] < xdim * 4)
-            assert np.all(pset.xi[:, 0] < xdim)
-            assert pset.yi.shape[0] == len(pset.lon)
-            assert pset.yi.shape[1] == fieldset.gridset.size
-            assert np.all(pset.yi >= 0)
-            assert np.all(pset.yi[:, fieldset.B.igrid] < ydim * 3)
-            assert np.all(pset.yi[:, 0] < ydim)
-        elif pset_mode == 'aos':
-            assert np.alltrue([[pxi > 0 for pxi in p.xi] for p in pset])
-            assert np.alltrue([len(p.xi) == fieldset.gridset.size for p in pset])
-            assert np.alltrue([p.xi[fieldset.B.igrid] < xdim * 4 for p in pset])
-            assert np.alltrue([p.xi[0] < xdim for p in pset])
-            assert np.alltrue([[pyi > 0 for pyi in p.yi] for p in pset])
-            assert np.alltrue([len(p.yi) == fieldset.gridset.size for p in pset])
-            assert np.alltrue([p.yi[fieldset.B.igrid] < ydim * 3 for p in pset])
-            assert np.alltrue([p.yi[0] < ydim for p in pset])
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
+        assert len(pset.xi.shape) == 2
+        assert pset.xi.shape[0] == len(pset.lon)
+        assert pset.xi.shape[1] == fieldset.gridset.size
+        assert np.all(pset.xi >= 0)
+        assert np.all(pset.xi[:, fieldset.B.igrid] < xdim * 4)
+        assert np.all(pset.xi[:, 0] < xdim)
+        assert pset.yi.shape[0] == len(pset.lon)
+        assert pset.yi.shape[1] == fieldset.gridset.size
+        assert np.all(pset.yi >= 0)
+        assert np.all(pset.yi[:, fieldset.B.igrid] < ydim * 3)
+        assert np.all(pset.yi[:, 0] < ydim)
+
+
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
 @pytest.mark.parametrize('ugridfactor', [1, 10])
-def test_sampling_multiple_grid_sizes(pset_mode, mode, ugridfactor):
+def test_sampling_multiple_grid_sizes(mode, ugridfactor):
     xdim, ydim = 10, 20
     U = Field('U', np.zeros((ydim*ugridfactor, xdim*ugridfactor), dtype=np.float32),
               lon=np.linspace(0., 1., xdim*ugridfactor, dtype=np.float32),
@@ -759,7 +708,7 @@ def test_sampling_multiple_grid_sizes(pset_mode, mode, ugridfactor):
               lon=np.linspace(0., 1., xdim, dtype=np.float32),
               lat=np.linspace(0., 1., ydim, dtype=np.float32))
     fieldset = FieldSet(U, V)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0.8], lat=[0.9])
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0.8], lat=[0.9])
 
     if ugridfactor > 1:
         assert fieldset.U.grid is not fieldset.V.grid
@@ -780,7 +729,7 @@ def test_multiple_grid_addlater_error():
               lat=np.linspace(0., 1., ydim, dtype=np.float32))
     fieldset = FieldSet(U, V)
 
-    pset = pset_type['soa']['pset'](fieldset, pclass=pclass('jit'), lon=[0.8], lat=[0.9])  # noqa ; to trigger fieldset.check_complete
+    pset = ParticleSet(fieldset, pclass=pclass('jit'), lon=[0.8], lat=[0.9])  # noqa ; to trigger fieldset.check_complete
 
     P = Field('P', np.zeros((ydim*10, xdim*10), dtype=np.float32),
               lon=np.linspace(0., 1., xdim*10, dtype=np.float32),
@@ -794,9 +743,8 @@ def test_multiple_grid_addlater_error():
     assert fail
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
-def test_nestedfields(pset_mode, mode, k_sample_p):
+def test_nestedfields(mode, k_sample_p):
     xdim = 10
     ydim = 20
 
@@ -835,24 +783,23 @@ def Recover(particle, fieldset, time):
             particle.p = 999
             particle.state = StatusCode.Evaluate
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0], lat=[.3])
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0], lat=[.3])
     pset.execute(AdvectionRK4+pset.Kernel(k_sample_p), runtime=2, dt=1)
     assert np.isclose(pset.lat[0], .5)
     assert np.isclose(pset.p[0], .1)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0], lat=[1.1])
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0], lat=[1.1])
     pset.execute(AdvectionRK4+pset.Kernel(k_sample_p), runtime=2, dt=1)
     assert np.isclose(pset.lat[0], 1.5)
     assert np.isclose(pset.p[0], .2)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=[0], lat=[2.3])
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=[0], lat=[2.3])
     pset.execute(pset.Kernel(AdvectionRK4) + k_sample_p + Recover, runtime=1, dt=1)
     assert np.isclose(pset.lat[0], 0)
     assert np.isclose(pset.p[0], 999)
     assert np.allclose(fieldset.UV[0][0, 0, 0, 0], [.1, .2])
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['jit', 'scipy'])
-def fieldset_sampling_updating_order(pset_mode, mode, tmpdir):
+def fieldset_sampling_updating_order(mode, tmpdir):
     def calc_p(t, y, x):
         return 10 * t + x + 0.2 * y
 
@@ -877,7 +824,7 @@ def calc_p(t, y, x):
     fieldset = FieldSet.from_data(data, dimensions, mesh="flat")
 
     xv, yv = np.meshgrid(np.arange(0, 1, 0.5), np.arange(0, 1, 0.5))
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten())
+    pset = ParticleSet(fieldset, pclass=pclass(mode), lon=xv.flatten(), lat=yv.flatten())
 
     def SampleP(particle, fieldset, time):
         particle.p = fieldset.P[time, particle.depth, particle.lat, particle.lon]
diff --git a/tests/test_grids.py b/tests/test_grids.py
index 5691d2848..7beae88e4 100644
--- a/tests/test_grids.py
+++ b/tests/test_grids.py
@@ -6,19 +6,14 @@
 import pytest
 import xarray as xr
 
-from parcels import (  # noqa
+from parcels import (
     AdvectionRK4,
     AdvectionRK4_3D,
     CurvilinearZGrid,
     Field,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     RectilinearSGrid,
     RectilinearZGrid,
     ScipyParticle,
@@ -27,15 +22,11 @@
     Variable,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_multi_structured_grids(pset_mode, mode):
+def test_multi_structured_grids(mode):
 
     def temp_func(lon, lat):
         return 20 + lat/1000. + 2 * np.sin(lon*2*np.pi/5000.)
@@ -97,21 +88,21 @@ class MyParticle(ptype[mode]):
         temp0 = Variable('temp0', dtype=np.float32, initial=20.)
         temp1 = Variable('temp1', dtype=np.float32, initial=20.)
 
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, MyParticle, lon=[3001], lat=[5001], repeatdt=1)
+    pset = ParticleSet.from_list(field_set, MyParticle, lon=[3001], lat=[5001], repeatdt=1)
 
     pset.execute(AdvectionRK4 + pset.Kernel(sampleTemp), runtime=3, dt=1)
 
     # check if particle xi and yi are different for the two grids
     # assert np.all([pset.xi[i, 0] != pset.xi[i, 1] for i in range(3)])
     # assert np.all([pset.yi[i, 0] != pset.yi[i, 1] for i in range(3)])
-    assert np.alltrue([pset[i].xi[0] != pset[i].xi[1] for i in range(3)])
-    assert np.alltrue([pset[i].yi[0] != pset[i].yi[1] for i in range(3)])
+    assert np.all([pset[i].xi[0] != pset[i].xi[1] for i in range(3)])
+    assert np.all([pset[i].yi[0] != pset[i].yi[1] for i in range(3)])
 
     # advect without updating temperature to test particle deletion
     pset.remove_indices(np.array([1]))
     pset.execute(AdvectionRK4, runtime=1, dt=1)
 
-    assert np.alltrue([np.isclose(p.temp0, p.temp1, atol=1e-3) for p in pset])
+    assert np.all([np.isclose(p.temp0, p.temp1, atol=1e-3) for p in pset])
 
 
 @pytest.mark.xfail(reason="Grid cannot be computed using a time vector which is neither float nor int", strict=True)
@@ -151,9 +142,8 @@ def test_avoid_repeated_grids():
     assert field_set.V.grid is not field_set.U.grid
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_multigrids_pointer(pset_mode, mode):
+def test_multigrids_pointer(mode):
     lon_g0 = np.linspace(0, 1e4, 21, dtype=np.float32)
     lat_g0 = np.linspace(0, 1000, 2, dtype=np.float32)
     depth_g0 = np.zeros((5, lat_g0.size, lon_g0.size), dtype=np.float32)
@@ -184,16 +174,15 @@ def bath_func(lon):
     assert u_field.grid == v_field.grid
     assert u_field.grid == w_field.grid  # w_field.grid is now supposed to be grid_1
 
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, ptype[mode], lon=[0], lat=[0], depth=[1])
+    pset = ParticleSet.from_list(field_set, ptype[mode], lon=[0], lat=[0], depth=[1])
 
     for i in range(10):
         pset.execute(AdvectionRK4_3D, runtime=1000, dt=500)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('z4d', ['True', 'False'])
-def test_rectilinear_s_grid_sampling(pset_mode, mode, z4d):
+def test_rectilinear_s_grid_sampling(mode, z4d):
     lon_g0 = np.linspace(-3e4, 3e4, 61, dtype=np.float32)
     lat_g0 = np.linspace(0, 1000, 2, dtype=np.float32)
     time_g0 = np.linspace(0, 1000, 2, dtype=np.float64)
@@ -241,16 +230,15 @@ class MyParticle(ptype[mode]):
     lon = 400
     lat = 0
     ratio = .3
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, MyParticle,
-                                                  lon=[lon], lat=[lat], depth=[bath_func(lon)*ratio])
+    pset = ParticleSet.from_list(field_set, MyParticle,
+                                 lon=[lon], lat=[lat], depth=[bath_func(lon)*ratio])
 
     pset.execute(pset.Kernel(sampleTemp), runtime=1)
     assert np.allclose(pset.temp[0], ratio, atol=1e-4)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_rectilinear_s_grids_advect1(pset_mode, mode):
+def test_rectilinear_s_grids_advect1(mode):
     # Constant water transport towards the east. check that the particle stays at the same relative depth (z/bath)
     lon_g0 = np.linspace(0, 1e4, 21, dtype=np.float32)
     lat_g0 = np.linspace(0, 1000, 2, dtype=np.float32)
@@ -286,15 +274,14 @@ def bath_func(lon):
     lat = np.zeros(11)
     ratio = [min(i/10., .99) for i in range(11)]
     depth = bath_func(lon)*ratio
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, ptype[mode], lon=lon, lat=lat, depth=depth)
+    pset = ParticleSet.from_list(field_set, ptype[mode], lon=lon, lat=lat, depth=depth)
 
     pset.execute(AdvectionRK4_3D, runtime=10000, dt=500)
     assert np.allclose(pset.depth/bath_func(pset.lon), ratio)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_rectilinear_s_grids_advect2(pset_mode, mode):
+def test_rectilinear_s_grids_advect2(mode):
     # Move particle towards the east, check relative depth evolution
     lon_g0 = np.linspace(0, 1e4, 21, dtype=np.float32)
     lat_g0 = np.linspace(0, 1000, 2, dtype=np.float32)
@@ -330,7 +317,7 @@ def moveEast(particle, fieldset, time):
         particle.relDepth = fieldset.relDepth[time, particle.depth, particle.lat, particle.lon]
 
     depth = .9
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, MyParticle, lon=[0], lat=[0], depth=[depth])
+    pset = ParticleSet.from_list(field_set, MyParticle, lon=[0], lat=[0], depth=[depth])
 
     kernel = pset.Kernel(moveEast)
     for _ in range(10):
@@ -338,9 +325,8 @@ def moveEast(particle, fieldset, time):
         assert np.allclose(pset.relDepth[0], depth/bath_func(pset.lon[0]))
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_curvilinear_grids(pset_mode, mode):
+def test_curvilinear_grids(mode):
 
     x = np.linspace(0, 1e3, 7, dtype=np.float32)
     y = np.linspace(0, 1e3, 5, dtype=np.float32)
@@ -369,14 +355,13 @@ def sampleSpeed(particle, fieldset, time):
     class MyParticle(ptype[mode]):
         speed = Variable('speed', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, MyParticle, lon=[400, -200], lat=[600, 600])
+    pset = ParticleSet.from_list(field_set, MyParticle, lon=[400, -200], lat=[600, 600])
     pset.execute(pset.Kernel(sampleSpeed), runtime=1)
     assert np.allclose(pset.speed[0], 1000)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_nemo_grid(pset_mode, mode):
+def test_nemo_grid(mode):
     data_path = path.join(path.dirname(__file__), 'test_data/')
 
     filenames = {'U': {'lon': data_path + 'mask_nemo_cross_180lon.nc',
@@ -390,7 +375,7 @@ def test_nemo_grid(pset_mode, mode):
     field_set = FieldSet.from_nemo(filenames, variables, dimensions)
 
     # test ParticleSet.from_field on curvilinear grids
-    pset_type[pset_mode]['pset'].from_field(field_set, ptype[mode], start_field=field_set.U, size=5)
+    ParticleSet.from_field(field_set, ptype[mode], start_field=field_set.U, size=5)
 
     def sampleVel(particle, fieldset, time):
         (particle.zonal, particle.meridional) = fieldset.UV[time, particle.depth, particle.lat, particle.lon]
@@ -401,7 +386,7 @@ class MyParticle(ptype[mode]):
 
     lonp = 175.5
     latp = 81.5
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, MyParticle, lon=[lonp], lat=[latp])
+    pset = ParticleSet.from_list(field_set, MyParticle, lon=[lonp], lat=[latp])
     pset.execute(pset.Kernel(sampleVel), runtime=1)
     u = field_set.U.units.to_source(pset.zonal[0], lonp, latp, 0)
     v = field_set.V.units.to_source(pset.meridional[0], lonp, latp, 0)
@@ -409,9 +394,8 @@ class MyParticle(ptype[mode]):
     assert abs(v) < 1e-4
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_advect_nemo(pset_mode, mode):
+def test_advect_nemo(mode):
     data_path = path.join(path.dirname(__file__), 'test_data/')
 
     filenames = {'U': {'lon': data_path + 'mask_nemo_cross_180lon.nc',
@@ -426,15 +410,14 @@ def test_advect_nemo(pset_mode, mode):
 
     lonp = 175.5
     latp = 81.5
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, ptype[mode], lon=[lonp], lat=[latp])
+    pset = ParticleSet.from_list(field_set, ptype[mode], lon=[lonp], lat=[latp])
     pset.execute(AdvectionRK4, runtime=delta(days=2), dt=delta(hours=6))
     assert abs(pset.lat[0] - latp) < 1e-3
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('time', [True, False])
-def test_cgrid_uniform_2dvel(pset_mode, mode, time):
+def test_cgrid_uniform_2dvel(mode, time):
     lon = np.array([[0, 2], [.4, 1.5]])
     lat = np.array([[0, -.5], [.8, .5]])
     U = np.array([[-99, -99], [4.4721359549995793e-01, 1.3416407864998738e+00]])
@@ -458,17 +441,16 @@ class MyParticle(ptype[mode]):
         zonal = Variable('zonal', dtype=np.float32, initial=0.)
         meridional = Variable('meridional', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, MyParticle, lon=.7, lat=.3)
+    pset = ParticleSet.from_list(fieldset, MyParticle, lon=.7, lat=.3)
     pset.execute(pset.Kernel(sampleVel), runtime=1)
     assert (pset[0].zonal - 1) < 1e-6
     assert (pset[0].meridional - 1) < 1e-6
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('vert_mode', ['zlev', 'slev1', 'slev2'])
 @pytest.mark.parametrize('time', [True, False])
-def test_cgrid_uniform_3dvel(pset_mode, mode, vert_mode, time):
+def test_cgrid_uniform_3dvel(mode, vert_mode, time):
 
     lon = np.array([[0, 2], [.4, 1.5]])
     lat = np.array([[0, -.5], [.8, .5]])
@@ -520,18 +502,17 @@ class MyParticle(ptype[mode]):
         meridional = Variable('meridional', dtype=np.float32, initial=0.)
         vertical = Variable('vertical', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, MyParticle, lon=.7, lat=.3, depth=.2)
+    pset = ParticleSet.from_list(fieldset, MyParticle, lon=.7, lat=.3, depth=.2)
     pset.execute(pset.Kernel(sampleVel), runtime=1)
     assert abs(pset[0].zonal - 1) < 1e-6
     assert abs(pset[0].meridional - 1) < 1e-6
     assert abs(pset[0].vertical - 1) < 1e-6
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('vert_mode', ['zlev', 'slev1'])
 @pytest.mark.parametrize('time', [True, False])
-def test_cgrid_uniform_3dvel_spherical(pset_mode, mode, vert_mode, time):
+def test_cgrid_uniform_3dvel_spherical(mode, vert_mode, time):
     data_path = path.join(path.dirname(__file__), 'test_data/')
     dim_file = xr.open_dataset(data_path + 'mask_nemo_cross_180lon.nc')
     u_file = xr.open_dataset(data_path + 'Uu_eastward_nemo_cross_180lon.nc')
@@ -580,24 +561,19 @@ class MyParticle(ptype[mode]):
 
     lonp = 179.8
     latp = 81.35
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, MyParticle, lon=lonp, lat=latp, depth=.2)
+    pset = ParticleSet.from_list(fieldset, MyParticle, lon=lonp, lat=latp, depth=.2)
     pset.execute(pset.Kernel(sampleVel), runtime=1)
-    if pset_mode == 'soa':
-        pset.zonal[0] = fieldset.U.units.to_source(pset.zonal[0], lonp, latp, 0)
-        pset.meridional[0] = fieldset.V.units.to_source(pset.meridional[0], lonp, latp, 0)
-    elif pset_mode == 'aos':
-        pset[0].zonal = fieldset.U.units.to_source(pset[0].zonal, lonp, latp, 0)
-        pset[0].meridional = fieldset.V.units.to_source(pset[0].meridional, lonp, latp, 0)
+    pset.zonal[0] = fieldset.U.units.to_source(pset.zonal[0], lonp, latp, 0)
+    pset.meridional[0] = fieldset.V.units.to_source(pset.meridional[0], lonp, latp, 0)
     assert abs(pset[0].zonal - 1) < 1e-3
     assert abs(pset[0].meridional) < 1e-3
     assert abs(pset[0].vertical - 1) < 1e-3
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('vert_discretisation', ['zlevel', 'slevel', 'slevel2'])
 @pytest.mark.parametrize('deferred_load', [True, False])
-def test_popgrid(pset_mode, mode, vert_discretisation, deferred_load):
+def test_popgrid(mode, vert_discretisation, deferred_load):
     mesh = path.join(path.join(path.dirname(__file__), 'test_data'), 'POPtestdata_time.nc')
     if vert_discretisation == 'zlevel':
         w_dep = 'w_dep'
@@ -632,7 +608,7 @@ class MyParticle(ptype[mode]):
         tracer = Variable('tracer', dtype=np.float32, initial=0.)
         out_of_bounds = Variable('out_of_bounds', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'].from_list(field_set, MyParticle, lon=[3, 5, 1], lat=[3, 5, 1], depth=[3, 7, 11])
+    pset = ParticleSet.from_list(field_set, MyParticle, lon=[3, 5, 1], lat=[3, 5, 1], depth=[3, 7, 11])
     pset.execute(pset.Kernel(sampleVel) + OutBoundsError, runtime=1)
     if vert_discretisation == 'slevel2':
         assert np.isclose(pset.vert[0], 0.)
@@ -651,11 +627,10 @@ class MyParticle(ptype[mode]):
         assert np.allclose(pset.tracer, 1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('gridindexingtype', ['mitgcm', 'nemo'])
 @pytest.mark.parametrize('coordtype', ['rectilinear', 'curvilinear'])
-def test_cgrid_indexing(pset_mode, mode, gridindexingtype, coordtype):
+def test_cgrid_indexing(mode, gridindexingtype, coordtype):
     xdim, ydim = 151, 201
     a = b = 20000  # domain size
     lon = np.linspace(-a / 2, a / 2, xdim, dtype=np.float32)
@@ -728,18 +703,17 @@ class MyParticle(ptype[mode]):
         radius = Variable('radius', dtype=np.float32, initial=0.)
         radius_start = Variable('radius_start', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=MyParticle, lon=0, lat=4e3, time=0)
+    pset = ParticleSet(fieldset, pclass=MyParticle, lon=0, lat=4e3, time=0)
 
     pset.execute(pset.Kernel(UpdateR) + AdvectionRK4,
                  runtime=delta(hours=14), dt=delta(minutes=5))
     assert np.allclose(pset.radius, pset.radius_start, atol=10)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('gridindexingtype', ['mitgcm', 'nemo'])
 @pytest.mark.parametrize('withtime', [False, True])
-def test_cgrid_indexing_3D(pset_mode, mode, gridindexingtype, withtime):
+def test_cgrid_indexing_3D(mode, gridindexingtype, withtime):
     xdim = zdim = 201
     ydim = 2
     a = c = 20000  # domain size
@@ -807,18 +781,17 @@ class MyParticle(ptype[mode]):
         radius = Variable('radius', dtype=np.float32, initial=0.)
         radius_start = Variable('radius_start', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=MyParticle, depth=4e3, lon=0, lat=0, time=0)
+    pset = ParticleSet(fieldset, pclass=MyParticle, depth=4e3, lon=0, lat=0, time=0)
 
     pset.execute(pset.Kernel(UpdateR) + AdvectionRK4_3D,
                  runtime=delta(hours=14), dt=delta(minutes=5))
     assert np.allclose(pset.radius, pset.radius_start, atol=10)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('gridindexingtype', ['pop', 'mom5'])
 @pytest.mark.parametrize('withtime', [False, True])
-def test_bgrid_indexing_3D(pset_mode, mode, gridindexingtype, withtime):
+def test_bgrid_indexing_3D(mode, gridindexingtype, withtime):
     xdim = zdim = 201
     ydim = 2
     a = c = 20000  # domain size
@@ -887,7 +860,7 @@ class MyParticle(ptype[mode]):
         radius = Variable('radius', dtype=np.float32, initial=0.)
         radius_start = Variable('radius_start', dtype=np.float32, initial=0.)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=MyParticle, depth=-9.995e3, lon=0, lat=0, time=0)
+    pset = ParticleSet(fieldset, pclass=MyParticle, depth=-9.995e3, lon=0, lat=0, time=0)
 
     pset.execute(pset.Kernel(UpdateR) + AdvectionRK4_3D,
                  runtime=delta(hours=14), dt=delta(minutes=5))
@@ -895,10 +868,9 @@ class MyParticle(ptype[mode]):
 
 
 @pytest.mark.parametrize('gridindexingtype', ['pop', 'mom5'])
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('extrapolation', [True, False])
-def test_bgrid_interpolation(gridindexingtype, pset_mode, mode, extrapolation):
+def test_bgrid_interpolation(gridindexingtype, mode, extrapolation):
     xi, yi = 3, 2
     if extrapolation:
         zi = 0 if gridindexingtype == 'mom5' else -1
@@ -981,8 +953,8 @@ class myParticle(ptype[mode]):
             if extrapolation:
                 deps = 0
 
-        pset = pset_type[pset_mode]['pset'].from_list(fieldset=fieldset, pclass=myParticle,
-                                                      lon=lons, lat=lats, depth=deps)
+        pset = ParticleSet.from_list(fieldset=fieldset, pclass=myParticle,
+                                     lon=lons, lat=lats, depth=deps)
         pset.execute(VelocityInterpolator, runtime=1)
 
         convfactor = 0.01 if gridindexingtype == "pop" else 1.
diff --git a/tests/test_interaction_kernel.py b/tests/test_interaction_kernel.py
index cb74a707d..b7c55045d 100644
--- a/tests/test_interaction_kernel.py
+++ b/tests/test_interaction_kernel.py
@@ -190,4 +190,4 @@ def test_pseudo_interaction(runtime, dt):
     # Check to see whether they have moved as predicted.
     assert np.all(pset.lon == pset2.lon)
     assert np.all(pset2.lat == pset2.lon)
-    assert np.all(pset2._collection.data["time"][0] == pset._collection.data["time"][0])
+    assert np.all(pset2.particledata.data["time"][0] == pset.particledata.data["time"][0])
diff --git a/tests/test_kernel_execution.py b/tests/test_kernel_execution.py
index c6c447264..9e11e9f96 100644
--- a/tests/test_kernel_execution.py
+++ b/tests/test_kernel_execution.py
@@ -4,26 +4,18 @@
 import numpy as np
 import pytest
 
-from parcels import (  # noqa
+from parcels import (
     AdvectionRK4,
     FieldOutOfBoundError,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     ScipyParticle,
     StatusCode,
     Variable,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def DoNothing(particle, fieldset, time):
@@ -45,10 +37,9 @@ def fieldset_fixture(xdim=20, ydim=20):
     return fieldset(xdim=xdim, ydim=ydim)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('kernel_type', ['update_lon', 'update_dlon'])
-def test_execution_order(pset_mode, mode, kernel_type):
+def test_execution_order(mode, kernel_type):
     fieldset = FieldSet.from_data({'U': [[0, 1], [2, 3]], 'V': np.ones((2, 2))}, {'lon': [0, 2], 'lat': [0, 2]}, mesh='flat')
 
     def MoveLon_Update_Lon(particle, fieldset, time):
@@ -69,7 +60,7 @@ class SampleParticle(ptype[mode]):
     lons = []
     ps = []
     for dir in [1, -1]:
-        pset = pset_type[pset_mode]['pset'](fieldset, pclass=SampleParticle, lon=0, lat=0)
+        pset = ParticleSet(fieldset, pclass=SampleParticle, lon=0, lat=0)
         pset.execute(kernels[::dir], endtime=1, dt=1)
         lons.append(pset.lon)
         ps.append(pset.p)
@@ -83,7 +74,6 @@ class SampleParticle(ptype[mode]):
         assert np.allclose(lons[0], 0.2)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('start, end, substeps, dt', [
     (0., 10., 1, 1.),
@@ -93,15 +83,14 @@ class SampleParticle(ptype[mode]):
     (20., 10., 4, -1.),
     (20., -10., 7, -2.),
 ])
-def test_execution_endtime(fieldset, pset_mode, mode, start, end, substeps, dt, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], time=start,
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.linspace(1, 0, npart))
+def test_execution_endtime(fieldset, mode, start, end, substeps, dt, npart=10):
+    pset = ParticleSet(fieldset, pclass=ptype[mode], time=start,
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.linspace(1, 0, npart))
     pset.execute(DoNothing, endtime=end, dt=dt)
     assert np.allclose(pset.time_nextloop, end)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('start, end, substeps, dt', [
     (0., 10., 1, 1.),
@@ -111,28 +100,27 @@ def test_execution_endtime(fieldset, pset_mode, mode, start, end, substeps, dt,
     (20., 10., 4, -1.),
     (20., -10., 7, -2.),
 ])
-def test_execution_runtime(fieldset, pset_mode, mode, start, end, substeps, dt, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], time=start,
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.linspace(1, 0, npart))
+def test_execution_runtime(fieldset, mode, start, end, substeps, dt, npart=10):
+    pset = ParticleSet(fieldset, pclass=ptype[mode], time=start,
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.linspace(1, 0, npart))
     t_step = abs(end - start) / substeps
     for _ in range(substeps):
         pset.execute(DoNothing, runtime=t_step, dt=dt)
     assert np.allclose(pset.time_nextloop, end)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy'])
-def test_execution_fail_python_exception(fieldset, pset_mode, mode, npart=10):
+def test_execution_fail_python_exception(fieldset, mode, npart=10):
     def PythonFail(particle, fieldset, time):
         if particle.time >= 10.:
             raise RuntimeError("Enough is enough!")
         else:
             pass
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.linspace(1, 0, npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.linspace(1, 0, npart))
     error_thrown = False
     try:
         pset.execute(PythonFail, endtime=20., dt=2.)
@@ -144,16 +132,15 @@ def PythonFail(particle, fieldset, time):
     assert np.allclose(pset.time[1:], 0.)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_execution_fail_out_of_bounds(fieldset, pset_mode, mode, npart=10):
+def test_execution_fail_out_of_bounds(fieldset, mode, npart=10):
     def MoveRight(particle, fieldset, time):
         tmp1, tmp2 = fieldset.UV[time, particle.depth, particle.lat, particle.lon + 0.1, particle]  # noqa
         particle_dlon += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.linspace(1, 0, npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.linspace(1, 0, npart))
     error_thrown = False
     try:
         pset.execute(MoveRight, endtime=10., dt=1.)
@@ -164,9 +151,8 @@ def MoveRight(particle, fieldset, time):
     assert (pset.lon - 1. > -1.e12).all()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_execution_recover_out_of_bounds(fieldset, pset_mode, mode, npart=2):
+def test_execution_recover_out_of_bounds(fieldset, mode, npart=2):
     def MoveRight(particle, fieldset, time):
         tmp1, tmp2 = fieldset.UV[time, particle.depth, particle.lat, particle.lon + 0.1, particle]  # noqa
         particle_dlon += 0.1  # noqa
@@ -178,7 +164,7 @@ def MoveLeft(particle, fieldset, time):
 
     lon = np.linspace(0.05, 0.95, npart)
     lat = np.linspace(1, 0, npart)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=lon, lat=lat)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=lon, lat=lat)
     pset.execute([MoveRight, MoveLeft], endtime=11., dt=1.)
     assert len(pset) == npart
     assert np.allclose(pset.lon, lon, rtol=1e-5)
@@ -194,14 +180,13 @@ def RecoverAllErrors(particle, fieldset, time):
         if particle.state > 4:
             particle.state = StatusCode.Delete
 
-    pset = ParticleSetSOA(fieldset, pclass=ptype[mode], lon=10, lat=0)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=10, lat=0)
     pset.execute([MoveRight, RecoverAllErrors], endtime=11., dt=1.)
     assert len(pset) == 0
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_execution_delete_out_of_bounds(fieldset, pset_mode, mode, npart=10):
+def test_execution_delete_out_of_bounds(fieldset, mode, npart=10):
     def MoveRight(particle, fieldset, time):
         tmp1, tmp2 = fieldset.UV[time, particle.depth, particle.lat, particle.lon + 0.1, particle]  # noqa
         particle_dlon += 0.1  # noqa
@@ -212,30 +197,28 @@ def DeleteMe(particle, fieldset, time):
 
     lon = np.linspace(0.05, 0.95, npart)
     lat = np.linspace(1, 0, npart)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=lon, lat=lat)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=lon, lat=lat)
     pset.execute([MoveRight, DeleteMe], endtime=10., dt=1.)
     assert len(pset) == 0
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_kernel_add_no_new_variables(fieldset, pset_mode, mode):
+def test_kernel_add_no_new_variables(fieldset, mode):
     def MoveEast(particle, fieldset, time):
         particle_dlon += 0.1  # noqa
 
     def MoveNorth(particle, fieldset, time):
         particle_dlat += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
     pset.execute(pset.Kernel(MoveEast) + pset.Kernel(MoveNorth),
                  endtime=2., dt=1.)
     assert np.allclose(pset.lon, 0.6, rtol=1e-5)
     assert np.allclose(pset.lat, 0.6, rtol=1e-5)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_multi_kernel_duplicate_varnames(fieldset, pset_mode, mode):
+def test_multi_kernel_duplicate_varnames(fieldset, mode):
     # Testing for merging of two Kernels with the same variable declared
     # Should throw a warning, but go ahead regardless
     def MoveEast(particle, fieldset, time):
@@ -246,14 +229,13 @@ def MoveWest(particle, fieldset, time):
         add_lon = -0.3
         particle_dlon += add_lon  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
     pset.execute([MoveEast, MoveWest], endtime=2., dt=1.)
     assert np.allclose(pset.lon, 0.3, rtol=1e-5)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_multi_kernel_reuse_varnames(fieldset, pset_mode, mode):
+def test_multi_kernel_reuse_varnames(fieldset, mode):
     # Testing for merging of two Kernels with the same variable declared
     # Should throw a warning, but go ahead regardless
     def MoveEast1(particle, fieldset, time):
@@ -263,14 +245,13 @@ def MoveEast1(particle, fieldset, time):
     def MoveEast2(particle, fieldset, time):
         particle_dlon += add_lon  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
     pset.execute(pset.Kernel(MoveEast1) + pset.Kernel(MoveEast2),
                  endtime=2., dt=1.)
     assert np.allclose(pset.lon, [0.9], rtol=1e-5)  # should be 0.5 + 0.2 + 0.2 = 0.9
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_combined_kernel_from_list(fieldset, pset_mode):
+def test_combined_kernel_from_list(fieldset):
     """
     Test pset.Kernel(List[function])
 
@@ -283,7 +264,7 @@ def MoveEast(particle, fieldset, time):
     def MoveNorth(particle, fieldset, time):
         particle_dlat += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=JITParticle, lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=JITParticle, lon=[0.5], lat=[0.5])
     kernels_single = pset.Kernel([AdvectionRK4])
     kernels_functions = pset.Kernel([AdvectionRK4, MoveEast, MoveNorth])
 
@@ -292,8 +273,7 @@ def MoveNorth(particle, fieldset, time):
     assert kernels_functions.funcname == "AdvectionRK4MoveEastMoveNorth"
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_combined_kernel_from_list_error_checking(fieldset, pset_mode):
+def test_combined_kernel_from_list_error_checking(fieldset):
     """
     Test pset.Kernel(List[function])
 
@@ -305,7 +285,7 @@ def MoveEast(particle, fieldset, time):
     def MoveNorth(particle, fieldset, time):
         particle_dlat += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=JITParticle, lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=JITParticle, lon=[0.5], lat=[0.5])
 
     # Test that list has to be non-empty
     with pytest.raises(ValueError):
@@ -321,9 +301,8 @@ def MoveNorth(particle, fieldset, time):
         assert kernels_mixed.funcname == "AdvectionRK4MoveEastMoveNorth"
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_update_kernel_in_script(fieldset, pset_mode, mode):
+def test_update_kernel_in_script(fieldset, mode):
     # Testing what happens when kernels are updated during runtime of a script
     # Should throw a warning, but go ahead regardless
     def MoveEast(particle, fieldset, time):
@@ -334,17 +313,16 @@ def MoveWest(particle, fieldset, time):
         add_lon = -0.3
         particle_dlon += add_lon  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.5], lat=[0.5])
     pset.execute(pset.Kernel(MoveEast), endtime=1., dt=1.)
     pset.execute(pset.Kernel(MoveWest), endtime=3., dt=1.)
     assert np.allclose(pset.lon, 0.3, rtol=1e-5)  # should be 0.5 + 0.1 - 0.3 = 0.3
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('delete_cfiles', [True, False])
 @pytest.mark.skipif(sys.platform.startswith("win"), reason="skipping windows test as windows compiler generates warning")
-def test_execution_keep_cfiles_and_nocompilation_warnings(pset_mode, fieldset, delete_cfiles):
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=JITParticle, lon=[0.], lat=[0.])
+def test_execution_keep_cfiles_and_nocompilation_warnings(fieldset, delete_cfiles):
+    pset = ParticleSet(fieldset, pclass=JITParticle, lon=[0.], lat=[0.])
     pset.execute(pset.Kernel(AdvectionRK4, delete_cfiles=delete_cfiles), endtime=1., dt=1.)
     cfile = pset.kernel.src_file
     logfile = pset.kernel.log_file
diff --git a/tests/test_kernel_language.py b/tests/test_kernel_language.py
index 42c3975d4..7cf15008c 100644
--- a/tests/test_kernel_language.py
+++ b/tests/test_kernel_language.py
@@ -4,16 +4,12 @@
 import numpy as np
 import pytest
 
-from parcels import (  # noqa
+from parcels import (
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
+    Kernel,
     ParcelsRandom,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     ScipyParticle,
     Variable,
 )
@@ -25,17 +21,14 @@
 )
 from parcels.application_kernels.TEOSseawaterdensity import PolyTEOS10_bsq
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
-def expr_kernel(name, pset, expr, pset_mode):
+def expr_kernel(name, pset, expr):
     pycode = (f"def {name}(particle, fieldset, time):\n"
               f"    particle.p = {expr}")
-    return pset_type[pset_mode]['kernel'](pset.fieldset, pset.collection.ptype, pyfunc=None,
-                                          funccode=pycode, funcname=name, funcvars=['particle'])
+    return Kernel(pset.fieldset, pset.particledata.ptype, pyfunc=None,
+                  funccode=pycode, funcname=name, funcvars=['particle'])
 
 
 def fieldset(xdim=20, ydim=20):
@@ -53,7 +46,6 @@ def fieldset_fixture(xdim=20, ydim=20):
     return fieldset(xdim=xdim, ydim=ydim)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('name, expr, result', [
     ('Add', '2 + 5', 7),
@@ -61,18 +53,17 @@ def fieldset_fixture(xdim=20, ydim=20):
     ('Mul', '3 * 5', 15),
     ('Div', '24 / 4', 6),
 ])
-def test_expression_int(pset_mode, mode, name, expr, result, npart=10):
+def test_expression_int(mode, name, expr, result, npart=10):
     """Test basic arithmetic expressions."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle,
-                                        lon=np.linspace(0., 1., npart),
-                                        lat=np.zeros(npart) + 0.5)
-    pset.execute(expr_kernel(f'Test{name}', pset, expr, pset_mode), endtime=1., dt=1.)
+    pset = ParticleSet(fieldset(), pclass=TestParticle,
+                       lon=np.linspace(0., 1., npart),
+                       lat=np.zeros(npart) + 0.5)
+    pset.execute(expr_kernel(f'Test{name}', pset, expr), endtime=1., dt=1.)
     assert np.alltrue([p.p == result for p in pset])
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('name, expr, result', [
     ('Add', '2. + 5.', 7),
@@ -81,18 +72,17 @@ class TestParticle(ptype[mode]):
     ('Div', '24. / 4.', 6),
     ('Pow', '2 ** 3', 8),
 ])
-def test_expression_float(pset_mode, mode, name, expr, result, npart=10):
+def test_expression_float(mode, name, expr, result, npart=10):
     """Test basic arithmetic expressions."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle,
-                                        lon=np.linspace(0., 1., npart),
-                                        lat=np.zeros(npart) + 0.5)
-    pset.execute(expr_kernel(f'Test{name}', pset, expr, pset_mode), endtime=1., dt=1.)
+    pset = ParticleSet(fieldset(), pclass=TestParticle,
+                       lon=np.linspace(0., 1., npart),
+                       lat=np.zeros(npart) + 0.5)
+    pset.execute(expr_kernel(f'Test{name}', pset, expr), endtime=1., dt=1.)
     assert np.alltrue([p.p == result for p in pset])
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('name, expr, result', [
     ('True', 'True', True),
@@ -107,27 +97,26 @@ class TestParticle(ptype[mode]):
     ('GreaterEq', '2 >= 4', False),
     ('CheckNaN', 'math.nan != math.nan', True),
 ])
-def test_expression_bool(pset_mode, mode, name, expr, result, npart=10):
+def test_expression_bool(mode, name, expr, result, npart=10):
     """Test basic arithmetic expressions."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle,
-                                        lon=np.linspace(0., 1., npart),
-                                        lat=np.zeros(npart) + 0.5)
-    pset.execute(expr_kernel(f'Test{name}', pset, expr, pset_mode), endtime=1., dt=1.)
+    pset = ParticleSet(fieldset(), pclass=TestParticle,
+                       lon=np.linspace(0., 1., npart),
+                       lat=np.zeros(npart) + 0.5)
+    pset.execute(expr_kernel(f'Test{name}', pset, expr), endtime=1., dt=1.)
     if mode == 'jit':
         assert np.all(result == (pset.p == 1))
     else:
         assert np.all(result == pset.p)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_while_if_break(pset_mode, mode):
+def test_while_if_break(mode):
     """Test while, if and break commands."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32, initial=0.)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle, lon=[0], lat=[0])
+    pset = ParticleSet(fieldset(), pclass=TestParticle, lon=[0], lat=[0])
 
     def kernel(particle, fieldset, time):
         while particle.p < 30:
@@ -140,14 +129,13 @@ def kernel(particle, fieldset, time):
     assert np.allclose(pset.p, 20., rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_nested_if(pset_mode, mode):
+def test_nested_if(mode):
     """Test nested if commands."""
     class TestParticle(ptype[mode]):
         p0 = Variable('p0', dtype=np.int32, initial=0)
         p1 = Variable('p1', dtype=np.int32, initial=1)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle, lon=0, lat=0)
+    pset = ParticleSet(fieldset(), pclass=TestParticle, lon=0, lat=0)
 
     def kernel(particle, fieldset, time):
         if particle.p1 >= particle.p0:
@@ -159,13 +147,12 @@ def kernel(particle, fieldset, time):
     assert np.allclose([pset.p0[0], pset.p1[0]], [0, 1])
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pass(pset_mode, mode):
+def test_pass(mode):
     """Test pass commands."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.int32, initial=0)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle, lon=0, lat=0)
+    pset = ParticleSet(fieldset(), pclass=TestParticle, lon=0, lat=0)
 
     def kernel(particle, fieldset, time):
         particle.p = -1
@@ -175,10 +162,9 @@ def kernel(particle, fieldset, time):
     assert np.allclose(pset[0].p, -1)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_dt_as_variable_in_kernel(pset_mode, mode):
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=ptype[mode], lon=0, lat=0)
+def test_dt_as_variable_in_kernel(mode):
+    pset = ParticleSet(fieldset(), pclass=ptype[mode], lon=0, lat=0)
 
     def kernel(particle, fieldset, time):
         dt = 1.  # noqa
@@ -186,11 +172,10 @@ def kernel(particle, fieldset, time):
     pset.execute(kernel, endtime=10, dt=1.)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_parcels_tmpvar_in_kernel(pset_mode):
+def test_parcels_tmpvar_in_kernel():
     """Tests for error thrown if variable with 'tmp' defined in custom kernel."""
     error_thrown = False
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=JITParticle, lon=0, lat=0)
+    pset = ParticleSet(fieldset(), pclass=JITParticle, lon=0, lat=0)
 
     def kernel_tmpvar(particle, fieldset, time):
         parcels_tmpvar0 = 0  # noqa
@@ -206,11 +191,10 @@ def kernel_pnum(particle, fieldset, time):
         assert error_thrown
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_abs(pset_mode):
+def test_abs():
     """Tests for error thrown if using abs in kernel."""
     error_thrown = False
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=JITParticle, lon=0, lat=0)
+    pset = ParticleSet(fieldset(), pclass=JITParticle, lon=0, lat=0)
 
     def kernel_abs(particle, fieldset, time):
         particle.lon = abs(3.1)  # noqa
@@ -222,13 +206,12 @@ def kernel_abs(particle, fieldset, time):
     assert error_thrown
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_if_withfield(fieldset, pset_mode, mode):
+def test_if_withfield(fieldset, mode):
     """Test combination of if and Field sampling commands."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32, initial=0.)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle, lon=[0], lat=[0])
+    pset = ParticleSet(fieldset, pclass=TestParticle, lon=[0], lat=[0])
 
     def kernel(particle, fieldset, time):
         u, v = fieldset.UV[time, 0, 0, 1.]
@@ -256,13 +239,12 @@ def kernel(particle, fieldset, time):
     assert np.allclose(pset.p, 7., rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy'])
-def test_print(fieldset, pset_mode, mode, capfd):
+def test_print(fieldset, mode, capfd):
     """Test print statements."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32, initial=0.)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle, lon=[0.5], lat=[0.5])
+    pset = ParticleSet(fieldset, pclass=TestParticle, lon=[0.5], lat=[0.5])
 
     def kernel(particle, fieldset, time):
         particle.p = 1e-3
@@ -283,10 +265,9 @@ def kernel2(particle, fieldset, time):
     assert abs(float(lst[0]) - 3) < tol
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_fieldset_access(fieldset, pset_mode, mode):
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=0, lat=0)
+def test_fieldset_access(fieldset, mode):
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=0, lat=0)
 
     def kernel(particle, fieldset, time):
         particle.lon = fieldset.U.grid.lon[2]
@@ -312,36 +293,34 @@ def random_series(npart, rngfunc, rngargs, mode):
     return series
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('rngfunc, rngargs', [
     ('random', []),
     ('uniform', [0., 20.]),
     ('randint', [0, 20]),
 ])
-def test_random_float(pset_mode, mode, rngfunc, rngargs, npart=10):
+def test_random_float(mode, rngfunc, rngargs, npart=10):
     """Test basic random number generation."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32 if rngfunc == 'randint' else np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle,
-                                        lon=np.linspace(0., 1., npart),
-                                        lat=np.zeros(npart) + 0.5)
+    pset = ParticleSet(fieldset(), pclass=TestParticle,
+                       lon=np.linspace(0., 1., npart),
+                       lat=np.zeros(npart) + 0.5)
     series = random_series(npart, rngfunc, rngargs, mode)
     rnglib = 'ParcelsRandom' if mode == 'jit' else 'random'
     kernel = expr_kernel(f"TestRandom_{rngfunc}", pset,
-                         f"{rnglib}.{rngfunc}({', '.join([str(a) for a in rngargs])})", pset_mode)
+                         f"{rnglib}.{rngfunc}({', '.join([str(a) for a in rngargs])})")
     pset.execute(kernel, endtime=1., dt=1.)
     assert np.allclose(pset.p, series, atol=1e-9)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('concat', [False, True])
-def test_random_kernel_concat(fieldset, pset_mode, mode, concat):
+def test_random_kernel_concat(fieldset, mode, concat):
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=np.float32)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle, lon=0, lat=0)
+    pset = ParticleSet(fieldset, pclass=TestParticle, lon=0, lat=0)
 
     def RandomKernel(particle, fieldset, time):
         particle.p += ParcelsRandom.uniform(0, 1)
@@ -354,13 +333,12 @@ def AddOne(particle, fieldset, time):
     assert pset.p > 1 if concat else pset.p < 1
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['jit', pytest.param('scipy', marks=pytest.mark.xfail(reason="c_kernels don't work in scipy mode"))])
 @pytest.mark.parametrize('c_inc', ['str', 'file'])
-def test_c_kernel(fieldset, pset_mode, mode, c_inc):
+def test_c_kernel(fieldset, mode, c_inc):
     coord_type = np.float32 if c_inc == 'str' else np.float64
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0.5], lat=[0],
-                                        lonlatdepth_dtype=coord_type)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0.5], lat=[0],
+                       lonlatdepth_dtype=coord_type)
 
     def func(U, lon, dt):
         u = U.data[0, 2, 1]
@@ -394,12 +372,11 @@ def pykernel(particle, fieldset, time):
     assert np.allclose(pset.lon[0], 0.81578948)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_dt_modif_by_kernel(pset_mode, mode):
+def test_dt_modif_by_kernel(mode):
     class TestParticle(ptype[mode]):
         age = Variable('age', dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=TestParticle, lon=[0.5], lat=[0])
+    pset = ParticleSet(fieldset(), pclass=TestParticle, lon=[0.5], lat=[0])
 
     def modif_dt(particle, fieldset, time):
         particle.age += particle.dt
@@ -410,12 +387,11 @@ def modif_dt(particle, fieldset, time):
     assert np.isclose(pset.time[0], endtime)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('dt', [1e-2, 1e-5, 1e-6])
-def test_small_dt(pset_mode, mode, dt, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset(), pclass=ptype[mode], lon=np.zeros(npart),
-                                        lat=np.zeros(npart), time=np.arange(0, npart)*dt*10)
+def test_small_dt(mode, dt, npart=10):
+    pset = ParticleSet(fieldset(), pclass=ptype[mode], lon=np.zeros(npart),
+                       lat=np.zeros(npart), time=np.arange(0, npart)*dt*10)
 
     def DoNothing(particle, fieldset, time):
         pass
@@ -431,9 +407,8 @@ def DoNothing(particle, fieldset, time):
         assert np.allclose([p.time for p in pset], dt*100)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_TEOSdensity_kernels(pset_mode, mode):
+def test_TEOSdensity_kernels(mode):
 
     def generate_fieldset(xdim=2, ydim=2, zdim=2, tdim=1):
         lon = np.linspace(0., 10., xdim, dtype=np.float32)
@@ -456,15 +431,14 @@ def generate_fieldset(xdim=2, ydim=2, zdim=2, tdim=1):
     class DensParticle(ptype[mode]):
         density = Variable('density', dtype=np.float32)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=DensParticle, lon=5, lat=5, depth=1000)
+    pset = ParticleSet(fieldset, pclass=DensParticle, lon=5, lat=5, depth=1000)
 
     pset.execute(PolyTEOS10_bsq, runtime=1)
     assert np.allclose(pset[0].density, 1022.85377)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_EOSseawaterproperties_kernels(pset_mode, mode):
+def test_EOSseawaterproperties_kernels(mode):
     fieldset = FieldSet.from_data(data={'U': 0, 'V': 0,
                                         'psu_salinity': 40,
                                         'temperature': 40,
@@ -475,28 +449,27 @@ def test_EOSseawaterproperties_kernels(pset_mode, mode):
     class PoTempParticle(ptype[mode]):
         potemp = Variable('potemp', dtype=np.float32)
         pressure = Variable('pressure', dtype=np.float32, initial=10000)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=PoTempParticle, lon=5, lat=5, depth=1000)
+    pset = ParticleSet(fieldset, pclass=PoTempParticle, lon=5, lat=5, depth=1000)
     pset.execute(PtempFromTemp, runtime=1)
     assert np.allclose(pset[0].potemp, 36.89073)
 
     class TempParticle(ptype[mode]):
         temp = Variable('temp', dtype=np.float32)
         pressure = Variable('pressure', dtype=np.float32, initial=10000)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TempParticle, lon=5, lat=5, depth=1000)
+    pset = ParticleSet(fieldset, pclass=TempParticle, lon=5, lat=5, depth=1000)
     pset.execute(TempFromPtemp, runtime=1)
     assert np.allclose(pset[0].temp, 40)
 
     class TPressureParticle(ptype[mode]):
         pressure = Variable('pressure', dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TempParticle, lon=5, lat=30, depth=7321.45)
+    pset = ParticleSet(fieldset, pclass=TempParticle, lon=5, lat=30, depth=7321.45)
     pset.execute(PressureFromLatDepth, runtime=1)
     assert np.allclose(pset[0].pressure, 7500, atol=1e-2)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('pressure', [0, 10])
-def test_UNESCOdensity_kernel(pset_mode, mode, pressure):
+def test_UNESCOdensity_kernel(mode, pressure):
 
     def generate_fieldset(p, xdim=2, ydim=2, zdim=2, tdim=1):
         lon = np.linspace(0., 10., xdim, dtype=np.float32)
@@ -521,7 +494,7 @@ def generate_fieldset(p, xdim=2, ydim=2, zdim=2, tdim=1):
     class DensParticle(ptype[mode]):
         density = Variable('density', dtype=np.float32)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=DensParticle, lon=5, lat=5, depth=1000)
+    pset = ParticleSet(fieldset, pclass=DensParticle, lon=5, lat=5, depth=1000)
 
     pset.execute(UNESCODensity, runtime=1)
 
diff --git a/tests/test_mpirun.py b/tests/test_mpirun.py
index 6bb7c5d76..2e5ddd579 100644
--- a/tests/test_mpirun.py
+++ b/tests/test_mpirun.py
@@ -9,18 +9,17 @@
 
 @pytest.mark.skipif(sys.platform.startswith("darwin"), reason="skipping macOS test as problem with file in pytest")
 @pytest.mark.skipif(sys.platform.startswith("win"), reason="skipping windows as mpi4py not available for windows")
-@pytest.mark.parametrize('pset_mode', ['soa'])
 @pytest.mark.parametrize('repeatdt, maxage', [(20*86400, 600*86400), (10*86400, 10*86400)])
 @pytest.mark.parametrize('nump', [4, 8])
-def test_mpi_run(pset_mode, tmpdir, repeatdt, maxage, nump):
+def test_mpi_run(tmpdir, repeatdt, maxage, nump):
     stommel_file = path.join(path.dirname(__file__), '..', 'docs', 'examples', 'example_stommel.py')
     outputMPI = tmpdir.join('StommelMPI')
     outputMPI_partition_function = tmpdir.join('StommelMPI_partition_function')
     outputNoMPI = tmpdir.join('StommelNoMPI.zarr')
 
-    system('mpirun -np 2 python %s -p %d -o %s -r %d -a %d -psm %s -wf False -cpf True' % (stommel_file, nump, outputMPI_partition_function, repeatdt, maxage, pset_mode))
-    system('mpirun -np 2 python %s -p %d -o %s -r %d -a %d -psm %s -wf False' % (stommel_file, nump, outputMPI, repeatdt, maxage, pset_mode))
-    system('python %s -p %d -o %s -r %d -a %d -psm %s -wf False' % (stommel_file, nump, outputNoMPI, repeatdt, maxage, pset_mode))
+    system('mpirun -np 2 python %s -p %d -o %s -r %d -a %d -wf False -cpf True' % (stommel_file, nump, outputMPI_partition_function, repeatdt, maxage))
+    system('mpirun -np 2 python %s -p %d -o %s -r %d -a %d -wf False' % (stommel_file, nump, outputMPI, repeatdt, maxage))
+    system('python %s -p %d -o %s -r %d -a %d -wf False' % (stommel_file, nump, outputNoMPI, repeatdt, maxage))
 
     ds2 = xr.open_zarr(outputNoMPI)
 
diff --git a/tests/test_particle_collections.py b/tests/test_particle_collections.py
deleted file mode 100644
index 473c78d74..000000000
--- a/tests/test_particle_collections.py
+++ /dev/null
@@ -1,77 +0,0 @@
-import numpy as np
-import pytest
-
-from parcels import (  # noqa
-    FieldSet,
-    JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
-    ScipyParticle,
-)
-
-pset_modes = ['soa', 'aos']
-ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
-
-
-def fieldset(xdim=40, ydim=100):
-    U = np.zeros((ydim, xdim), dtype=np.float32)
-    V = np.zeros((ydim, xdim), dtype=np.float32)
-    lon = np.linspace(0, 1, xdim, dtype=np.float32)
-    lat = np.linspace(-60, 60, ydim, dtype=np.float32)
-    depth = np.zeros(1, dtype=np.float32)
-    data = {'U': np.array(U, dtype=np.float32), 'V': np.array(V, dtype=np.float32)}
-    dimensions = {'lat': lat, 'lon': lon, 'depth': depth}
-    return FieldSet.from_data(data, dimensions)
-
-
-@pytest.fixture(name="fieldset")
-def fieldset_fixture(xdim=40, ydim=100):
-    return fieldset(xdim=xdim, ydim=ydim)
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_iteration_forward(fieldset, pset_mode, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=np.linspace(0, 1, npart), lat=np.zeros(npart), pclass=JITParticle)
-    assert np.all(np.isclose(np.array([p.id for p in pset]), range(npart)+pset[0].id))
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_iteration_backward(fieldset, pset_mode, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=np.linspace(0, 1, npart), lat=np.zeros(npart), pclass=JITParticle)
-    assert np.all(np.isclose(np.array([p.id for p in reversed(pset)]), pset[0].id+np.arange(npart-1, -1, -1)))
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_get(fieldset, pset_mode, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=np.linspace(0, 1, npart), lat=np.zeros(npart), pclass=JITParticle)
-    assert np.all(np.isclose([pset.collection.get(i).lon for i in range(npart)], np.linspace(0, 1, npart)))
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_get_single_by_index(fieldset, pset_mode, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=np.linspace(0, 1, npart), lat=np.zeros(npart), pclass=JITParticle)
-    assert np.all(np.isclose([pset.collection.get_single_by_index(i).lon for i in range(npart)], np.linspace(0, 1, npart)))
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_get_single_by_ID(fieldset, pset_mode, npart=10):
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=np.linspace(0, 1, npart), lat=np.zeros(npart), pclass=JITParticle)
-    ids = None
-    if pset_mode == 'soa':
-        ids = pset.collection._data['id']
-    elif pset_mode == 'aos':
-        ids = np.array([pset.collection._data[i].id for i in range(len(pset))], dtype=np.int64)
-    assert np.all(np.isclose([pset.collection.get_single_by_ID(np.int64(i)).lon for i in ids], np.linspace(0, 1, npart)))
-
-
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_getattr(fieldset, pset_mode, npart=10):
-    lats = np.random.random(npart)
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=np.linspace(0, 1, npart), lat=lats, pclass=JITParticle)
-    assert np.allclose(pset.lat, lats)
diff --git a/tests/test_particle_file.py b/tests/test_particle_file.py
index 49e2e0408..bcecf0a65 100644
--- a/tests/test_particle_file.py
+++ b/tests/test_particle_file.py
@@ -6,25 +6,11 @@
 import xarray as xr
 from zarr.storage import MemoryStore
 
-from parcels import (  # noqa
-    FieldSet,
-    JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
-    ScipyParticle,
-    Variable,
-)
+from parcels import FieldSet, JITParticle, ParticleSet, ScipyParticle, Variable
 from parcels.particlefile import _set_calendar
 from parcels.tools.converters import _get_cftime_calendars, _get_cftime_datetimes
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def fieldset(xdim=40, ydim=100):
@@ -43,14 +29,13 @@ def fieldset_ficture(xdim=40, ydim=100):
     return fieldset(xdim=xdim, ydim=ydim)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pfile_array_write_zarr_memorystore(fieldset, pset_mode, mode, npart=10):
+def test_pfile_array_write_zarr_memorystore(fieldset, mode, npart=10):
     """Checkt that writing to a Zarr MemoryStore works."""
     zarr_store = MemoryStore()
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=0.5*np.ones(npart), time=0)
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=0.5*np.ones(npart), time=0)
     pfile = pset.ParticleFile(zarr_store)
     pfile.write(pset, 0)
 
@@ -59,13 +44,12 @@ def test_pfile_array_write_zarr_memorystore(fieldset, pset_mode, mode, npart=10)
     ds.close()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pfile_array_remove_particles(fieldset, pset_mode, mode, tmpdir, npart=10):
+def test_pfile_array_remove_particles(fieldset, mode, tmpdir, npart=10):
     filepath = tmpdir.join("pfile_array_remove_particles.zarr")
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=0.5*np.ones(npart), time=0)
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=0.5*np.ones(npart), time=0)
     pfile = pset.ParticleFile(filepath)
     pfile.write(pset, 0)
     pset.remove_indices(3)
@@ -79,13 +63,12 @@ def test_pfile_array_remove_particles(fieldset, pset_mode, mode, tmpdir, npart=1
     ds.close()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pfile_set_towrite_False(fieldset, pset_mode, mode, tmpdir, npart=10):
+def test_pfile_set_towrite_False(fieldset, mode, tmpdir, npart=10):
     filepath = tmpdir.join("pfile_set_towrite_False.zarr")
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=0.5*np.ones(npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=0.5*np.ones(npart))
     pset.set_variable_write_status('depth', False)
     pset.set_variable_write_status('lat', False)
     pfile = pset.ParticleFile(filepath, outputdt=1)
@@ -106,15 +89,14 @@ def Update_lon(particle, fieldset, time):
     pset.set_variable_write_status('lat', True)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('chunks_obs', [1, None])
-def test_pfile_array_remove_all_particles(fieldset, pset_mode, mode, chunks_obs, tmpdir, npart=10):
+def test_pfile_array_remove_all_particles(fieldset, mode, chunks_obs, tmpdir, npart=10):
 
     filepath = tmpdir.join("pfile_array_remove_particles.zarr")
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=0.5*np.ones(npart), time=0)
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=0.5*np.ones(npart), time=0)
     chunks = (npart, chunks_obs) if chunks_obs else None
     pfile = pset.ParticleFile(filepath, chunks=chunks)
     pfile.write(pset, 0)
@@ -133,15 +115,14 @@ def test_pfile_array_remove_all_particles(fieldset, pset_mode, mode, chunks_obs,
     ds.close()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_variable_write_double(fieldset, pset_mode, mode, tmpdir):
+def test_variable_write_double(fieldset, mode, tmpdir):
     filepath = tmpdir.join("pfile_variable_write_double.zarr")
 
     def Update_lon(particle, fieldset, time):
         particle_dlon += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0], lat=[0], lonlatdepth_dtype=np.float64)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0], lat=[0], lonlatdepth_dtype=np.float64)
     ofile = pset.ParticleFile(name=filepath, outputdt=0.00001)
     pset.execute(pset.Kernel(Update_lon), endtime=0.001, dt=0.00001, output_file=ofile)
 
@@ -151,21 +132,20 @@ def Update_lon(particle, fieldset, time):
     ds.close()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_write_dtypes_pfile(fieldset, pset_mode, mode, tmpdir):
+def test_write_dtypes_pfile(fieldset, mode, tmpdir):
     filepath = tmpdir.join("pfile_dtypes.zarr")
 
     dtypes = ['float32', 'float64', 'int32', 'uint32', 'int64', 'uint64']
     if mode == 'scipy':
-        dtypes.extend(['bool_', 'int8', 'uint8', 'int16', 'uint16'])  # Not implemented in AoS JIT
+        dtypes.extend(['bool_', 'int8', 'uint8', 'int16', 'uint16'])
 
     class MyParticle(ptype[mode]):
         for d in dtypes:
             # need an exec() here because we need to dynamically set the variable name
             exec(f'v_{d} = Variable("v_{d}", dtype=np.{d}, initial=0.)')
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=MyParticle, lon=0, lat=0, time=0)
+    pset = ParticleSet(fieldset, pclass=MyParticle, lon=0, lat=0, time=0)
     pfile = pset.ParticleFile(name=filepath, outputdt=1)
     pfile.write(pset, 0)
 
@@ -174,10 +154,9 @@ class MyParticle(ptype[mode]):
         assert ds[f'v_{d}'].dtype == d
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('npart', [1, 2, 5])
-def test_variable_written_once(fieldset, pset_mode, mode, tmpdir, npart):
+def test_variable_written_once(fieldset, mode, tmpdir, npart):
     filepath = tmpdir.join("pfile_once_written_variables.zarr")
 
     def Update_v(particle, fieldset, time):
@@ -190,7 +169,7 @@ class MyParticle(ptype[mode]):
     lon = np.linspace(0, 1, npart)
     lat = np.linspace(1, 0, npart)
     time = np.arange(0, npart/10., 0.1, dtype=np.float64)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=MyParticle, lon=lon, lat=lat, time=time, v_once=time)
+    pset = ParticleSet(fieldset, pclass=MyParticle, lon=lon, lat=lat, time=time, v_once=time)
     ofile = pset.ParticleFile(name=filepath, outputdt=0.1)
     pset.execute(pset.Kernel(Update_v), endtime=1, dt=0.1, output_file=ofile)
 
@@ -202,12 +181,11 @@ class MyParticle(ptype[mode]):
 
 
 @pytest.mark.parametrize('type', ['repeatdt', 'timearr'])
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('repeatdt', range(1, 3))
 @pytest.mark.parametrize('dt', [-1, 1])
 @pytest.mark.parametrize('maxvar', [2, 4, 10])
-def test_pset_repeated_release_delayed_adding_deleting(type, fieldset, pset_mode, mode, repeatdt, tmpdir, dt, maxvar, runtime=10):
+def test_pset_repeated_release_delayed_adding_deleting(type, fieldset, mode, repeatdt, tmpdir, dt, maxvar, runtime=10):
     fieldset.maxvar = maxvar
     pset = None
 
@@ -216,9 +194,9 @@ class MyParticle(ptype[mode]):
         v_once = Variable('v_once', dtype=np.float64, initial=0., to_write='once')
 
     if type == 'repeatdt':
-        pset = pset_type[pset_mode]['pset'](fieldset, lon=[0], lat=[0], pclass=MyParticle, repeatdt=repeatdt)
+        pset = ParticleSet(fieldset, lon=[0], lat=[0], pclass=MyParticle, repeatdt=repeatdt)
     elif type == 'timearr':
-        pset = pset_type[pset_mode]['pset'](fieldset, lon=np.zeros(runtime), lat=np.zeros(runtime), pclass=MyParticle, time=list(range(runtime)))
+        pset = ParticleSet(fieldset, lon=np.zeros(runtime), lat=np.zeros(runtime), pclass=MyParticle, time=list(range(runtime)))
     outfilepath = tmpdir.join("pfile_repeated_release.zarr")
     pfile = pset.ParticleFile(outfilepath, outputdt=abs(dt), chunks=(1, 1))
 
@@ -244,16 +222,15 @@ def IncrLon(particle, fieldset, time):
     ds.close()
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_write_timebackward(fieldset, pset_mode, mode, tmpdir):
+def test_write_timebackward(fieldset, mode, tmpdir):
     outfilepath = tmpdir.join("pfile_write_timebackward.zarr")
 
     def Update_lon(particle, fieldset, time):
         particle_dlon -= 0.1 * particle.dt  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lat=np.linspace(0, 1, 3), lon=[0, 0, 0], time=[1, 2, 3])
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lat=np.linspace(0, 1, 3), lon=[0, 0, 0], time=[1, 2, 3])
     pfile = pset.ParticleFile(name=outfilepath, outputdt=1.)
     pset.execute(pset.Kernel(Update_lon), runtime=4, dt=-1.,
                  output_file=pfile)
@@ -271,9 +248,8 @@ def test_set_calendar():
     assert _set_calendar('np_datetime64') == 'standard'
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_reset_dt(fieldset, pset_mode, mode, tmpdir):
+def test_reset_dt(fieldset, mode, tmpdir):
     # Assert that p.dt gets reset when a write_time is not a multiple of dt
     # for p.dt=0.02 to reach outputdt=0.05 and endtime=0.1, the steps should be [0.2, 0.2, 0.1, 0.2, 0.2, 0.1], resulting in 6 kernel executions
     filepath = tmpdir.join("pfile_reset_dt.zarr")
@@ -281,7 +257,7 @@ def test_reset_dt(fieldset, pset_mode, mode, tmpdir):
     def Update_lon(particle, fieldset, time):
         particle_dlon += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=[0], lat=[0], lonlatdepth_dtype=np.float64)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=[0], lat=[0], lonlatdepth_dtype=np.float64)
     ofile = pset.ParticleFile(name=filepath, outputdt=0.05)
     pset.execute(pset.Kernel(Update_lon), endtime=0.12, dt=0.02, output_file=ofile)
 
diff --git a/tests/test_particle_sets.py b/tests/test_particle_sets.py
index 7c1b22c0f..804972a53 100644
--- a/tests/test_particle_sets.py
+++ b/tests/test_particle_sets.py
@@ -1,26 +1,18 @@
 import numpy as np
 import pytest
 
-from parcels import (  # noqa
+from parcels import (
     CurvilinearZGrid,
     Field,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     ScipyParticle,
     StatusCode,
     Variable,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def fieldset(xdim=40, ydim=100):
@@ -39,32 +31,29 @@ def fieldset_fixture(xdim=40, ydim=100):
     return fieldset(xdim=xdim, ydim=ydim)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_create_lon_lat(fieldset, pset_mode, mode, npart=100):
+def test_pset_create_lon_lat(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart, dtype=np.float32)
     lat = np.linspace(1, 0, npart, dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=lon, lat=lat, pclass=ptype[mode])
+    pset = ParticleSet(fieldset, lon=lon, lat=lat, pclass=ptype[mode])
     assert np.allclose([p.lon for p in pset], lon, rtol=1e-12)
     assert np.allclose([p.lat for p in pset], lat, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('lonlatdepth_dtype', [np.float64, np.float32])
-def test_pset_create_line(fieldset, pset_mode, mode, lonlatdepth_dtype, npart=100):
+def test_pset_create_line(fieldset, mode, lonlatdepth_dtype, npart=100):
     lon = np.linspace(0, 1, npart, dtype=lonlatdepth_dtype)
     lat = np.linspace(1, 0, npart, dtype=lonlatdepth_dtype)
-    pset = pset_type[pset_mode]['pset'].from_line(fieldset, size=npart, start=(0, 1), finish=(1, 0),
-                                                  pclass=ptype[mode], lonlatdepth_dtype=lonlatdepth_dtype)
+    pset = ParticleSet.from_line(fieldset, size=npart, start=(0, 1), finish=(1, 0),
+                                 pclass=ptype[mode], lonlatdepth_dtype=lonlatdepth_dtype)
     assert np.allclose([p.lon for p in pset], lon, rtol=1e-12)
     assert np.allclose([p.lat for p in pset], lat, rtol=1e-12)
     assert isinstance(pset[0].lat, lonlatdepth_dtype)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_create_list_with_customvariable(fieldset, pset_mode, mode, npart=100):
+def test_pset_create_list_with_customvariable(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart, dtype=np.float32)
     lat = np.linspace(1, 0, npart, dtype=np.float32)
 
@@ -72,16 +61,15 @@ class MyParticle(ptype[mode]):
         v = Variable('v')
 
     v_vals = np.arange(npart)
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, lon=lon, lat=lat, v=v_vals, pclass=MyParticle)
+    pset = ParticleSet.from_list(fieldset, lon=lon, lat=lat, v=v_vals, pclass=MyParticle)
     assert np.allclose([p.lon for p in pset], lon, rtol=1e-12)
     assert np.allclose([p.lat for p in pset], lat, rtol=1e-12)
     assert np.allclose([p.v for p in pset], v_vals, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('restart', [True, False])
-def test_pset_create_fromparticlefile(fieldset, pset_mode, mode, restart, tmpdir):
+def test_pset_create_fromparticlefile(fieldset, mode, restart, tmpdir):
     filename = tmpdir.join("pset_fromparticlefile.zarr")
     lon = np.linspace(0, 1, 10, dtype=np.float32)
     lat = np.linspace(1, 0, 10, dtype=np.float32)
@@ -91,7 +79,7 @@ class TestParticle(ptype[mode]):
         p2 = Variable('p2', np.float32, initial=1, to_write=False)
         p3 = Variable('p3', np.float32, to_write='once')
 
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=lon, lat=lat, depth=[4]*len(lon), pclass=TestParticle, p3=np.arange(len(lon)))
+    pset = ParticleSet(fieldset, lon=lon, lat=lat, depth=[4]*len(lon), pclass=TestParticle, p3=np.arange(len(lon)))
     pfile = pset.ParticleFile(filename, outputdt=1)
 
     def Kernel(particle, fieldset, time):
@@ -101,8 +89,8 @@ def Kernel(particle, fieldset, time):
 
     pset.execute(Kernel, runtime=2, dt=1, output_file=pfile)
 
-    pset_new = pset_type[pset_mode]['pset'].from_particlefile(fieldset, pclass=TestParticle, filename=filename,
-                                                              restart=restart, repeatdt=1)
+    pset_new = ParticleSet.from_particlefile(fieldset, pclass=TestParticle, filename=filename,
+                                             restart=restart, repeatdt=1)
 
     for var in ['lon', 'lat', 'depth', 'time', 'p', 'p2', 'p3']:
         assert np.allclose([getattr(p, var) for p in pset], [getattr(p, var) for p in pset_new])
@@ -113,16 +101,15 @@ def Kernel(particle, fieldset, time):
     assert len(pset_new) == 3*len(pset)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy'])
 @pytest.mark.parametrize('lonlatdepth_dtype', [np.float64, np.float32])
-def test_pset_create_field(fieldset, pset_mode, mode, lonlatdepth_dtype, npart=100):
+def test_pset_create_field(fieldset, mode, lonlatdepth_dtype, npart=100):
     np.random.seed(123456)
     shape = (fieldset.U.lon.size, fieldset.U.lat.size)
     K = Field('K', lon=fieldset.U.lon, lat=fieldset.U.lat,
               data=np.ones(shape, dtype=np.float32), transpose=True)
-    pset = pset_type[pset_mode]['pset'].from_field(fieldset, size=npart, pclass=ptype[mode],
-                                                   start_field=K, lonlatdepth_dtype=lonlatdepth_dtype)
+    pset = ParticleSet.from_field(fieldset, size=npart, pclass=ptype[mode],
+                                  start_field=K, lonlatdepth_dtype=lonlatdepth_dtype)
     assert (np.array([p.lon for p in pset]) <= K.lon[-1]).all()
     assert (np.array([p.lon for p in pset]) >= K.lon[0]).all()
     assert (np.array([p.lat for p in pset]) <= K.lat[-1]).all()
@@ -130,8 +117,7 @@ def test_pset_create_field(fieldset, pset_mode, mode, lonlatdepth_dtype, npart=1
     assert isinstance(pset[0].lat, lonlatdepth_dtype)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_create_field_curvi(pset_mode, npart=100):
+def test_pset_create_field_curvi(npart=100):
     np.random.seed(123456)
     r_v = np.linspace(.25, 2, 20)
     theta_v = np.linspace(0, np.pi/2, 200)
@@ -149,7 +135,7 @@ def test_pset_create_field_curvi(pset_mode, npart=100):
     ufield = Field('U', u, grid=grid)
     vfield = Field('V', v, grid=grid)
     fieldset = FieldSet(ufield, vfield)
-    pset = pset_type[pset_mode]['pset'].from_field(fieldset, size=npart, pclass=ptype['scipy'], start_field=fieldset.V)
+    pset = ParticleSet.from_field(fieldset, size=npart, pclass=ptype['scipy'], start_field=fieldset.V)
 
     lons = np.array([p.lon+1 for p in pset])
     lats = np.array([p.lat+1 for p in pset])
@@ -163,27 +149,25 @@ def test_pset_create_field_curvi(pset_mode, npart=100):
     assert np.all(test)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_create_with_time(fieldset, pset_mode, mode, npart=100):
+def test_pset_create_with_time(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart)
     lat = np.linspace(1, 0, npart)
     time = 5.
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=lon, lat=lat, pclass=ptype[mode], time=time)
+    pset = ParticleSet(fieldset, lon=lon, lat=lat, pclass=ptype[mode], time=time)
     assert np.allclose([p.time for p in pset], time, rtol=1e-12)
-    pset = pset_type[pset_mode]['pset'].from_list(fieldset, lon=lon, lat=lat, pclass=ptype[mode],
-                                                  time=[time]*npart)
+    pset = ParticleSet.from_list(fieldset, lon=lon, lat=lat, pclass=ptype[mode],
+                                 time=[time]*npart)
     assert np.allclose([p.time for p in pset], time, rtol=1e-12)
-    pset = pset_type[pset_mode]['pset'].from_line(fieldset, size=npart, start=(0, 1), finish=(1, 0),
-                                                  pclass=ptype[mode], time=time)
+    pset = ParticleSet.from_line(fieldset, size=npart, start=(0, 1), finish=(1, 0),
+                                 pclass=ptype[mode], time=time)
     assert np.allclose([p.time for p in pset], time, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_not_multipldt_time(fieldset, pset_mode, mode):
+def test_pset_not_multipldt_time(fieldset, mode):
     times = [0, 1.1]
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=[0]*2, lat=[0]*2, pclass=ptype[mode], time=times)
+    pset = ParticleSet(fieldset, lon=[0]*2, lat=[0]*2, pclass=ptype[mode], time=times)
 
     def Addlon(particle, fieldset, time):
         particle_dlon += particle.dt  # noqa
@@ -192,12 +176,11 @@ def Addlon(particle, fieldset, time):
     assert np.allclose([p.lon_nextloop for p in pset], [2 - t for t in times])
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_repeated_release(fieldset, pset_mode, mode, npart=10):
+def test_pset_repeated_release(fieldset, mode, npart=10):
     time = np.arange(0, npart, 1)  # release 1 particle every second
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=np.zeros(npart), lat=np.zeros(npart),
-                                        pclass=ptype[mode], time=time)
+    pset = ParticleSet(fieldset, lon=np.zeros(npart), lat=np.zeros(npart),
+                       pclass=ptype[mode], time=time)
     assert np.allclose([p.time for p in pset], time)
 
     def IncrLon(particle, fieldset, time):
@@ -206,9 +189,8 @@ def IncrLon(particle, fieldset, time):
     assert np.allclose([p.lon for p in pset], np.arange(npart, 0, -1))
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
-def test_pset_repeatdt_check_dt(pset_mode, fieldset):
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=[0], lat=[0], pclass=ScipyParticle, repeatdt=5)
+def test_pset_repeatdt_check_dt(fieldset):
+    pset = ParticleSet(fieldset, lon=[0], lat=[0], pclass=ScipyParticle, repeatdt=5)
 
     def IncrLon(particle, fieldset, time):
         particle.lon = 1.
@@ -216,13 +198,12 @@ def IncrLon(particle, fieldset, time):
     assert np.allclose([p.lon for p in pset], 1)  # if p.dt is nan, it won't be executed so p.lon will be 0
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_repeatdt_custominit(fieldset, pset_mode, mode):
+def test_pset_repeatdt_custominit(fieldset, mode):
     class MyParticle(ptype[mode]):
         sample_var = Variable('sample_var')
 
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=0, lat=0, pclass=MyParticle, repeatdt=1, sample_var=5)
+    pset = ParticleSet(fieldset, lon=0, lat=0, pclass=MyParticle, repeatdt=1, sample_var=5)
 
     def DoNothing(particle, fieldset, time):
         pass
@@ -231,10 +212,9 @@ def DoNothing(particle, fieldset, time):
     assert np.allclose([p.sample_var for p in pset], 5.)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_stop_simulation(fieldset, pset_mode, mode):
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=0, lat=0, pclass=ptype[mode])
+def test_pset_stop_simulation(fieldset, mode):
+    pset = ParticleSet(fieldset, lon=0, lat=0, pclass=ptype[mode])
 
     def Delete(particle, fieldset, time):
         if time == 4:
@@ -244,83 +224,77 @@ def Delete(particle, fieldset, time):
     assert pset[0].time == 4
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_access(fieldset, pset_mode, mode, npart=100):
+def test_pset_access(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart, dtype=np.float32)
     lat = np.linspace(1, 0, npart, dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=lon, lat=lat, pclass=ptype[mode])
+    pset = ParticleSet(fieldset, lon=lon, lat=lat, pclass=ptype[mode])
     assert pset.size == 100
     assert np.allclose([pset[i].lon for i in range(pset.size)], lon, rtol=1e-12)
     assert np.allclose([pset[i].lat for i in range(pset.size)], lat, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_custom_ptype(fieldset, pset_mode, mode, npart=100):
+def test_pset_custom_ptype(fieldset, mode, npart=100):
     class TestParticle(ptype[mode]):
         p = Variable('p', np.float32, initial=0.33)
         n = Variable('n', np.int32, initial=2)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle,
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.linspace(1, 0, npart))
+    pset = ParticleSet(fieldset, pclass=TestParticle,
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.linspace(1, 0, npart))
     assert pset.size == npart
     assert np.allclose([p.p - 0.33 for p in pset], np.zeros(npart), atol=1e-5)
     assert np.allclose([p.n - 2 for p in pset], np.zeros(npart), rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_add_explicit(fieldset, pset_mode, mode, npart=100):
+def test_pset_add_explicit(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart)
     lat = np.linspace(1, 0, npart)
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=[], lat=[], pclass=ptype[mode], lonlatdepth_dtype=np.float64)
+    pset = ParticleSet(fieldset, lon=[], lat=[], pclass=ptype[mode], lonlatdepth_dtype=np.float64)
     for i in range(npart):
-        particle = pset_type[pset_mode]['pset'](pclass=ptype[mode], lon=lon[i], lat=lat[i],
-                                                fieldset=fieldset, lonlatdepth_dtype=np.float64)
+        particle = ParticleSet(pclass=ptype[mode], lon=lon[i], lat=lat[i],
+                               fieldset=fieldset, lonlatdepth_dtype=np.float64)
         pset.add(particle)
     assert pset.size == npart
     assert np.allclose([p.lon for p in pset], lon, rtol=1e-12)
     assert np.allclose([p.lat for p in pset], lat, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_add_shorthand(fieldset, pset_mode, mode, npart=100):
+def test_pset_add_shorthand(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart, dtype=np.float32)
     lat = np.linspace(1, 0, npart, dtype=np.float32)
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=[], lat=[], pclass=ptype[mode])
+    pset = ParticleSet(fieldset, lon=[], lat=[], pclass=ptype[mode])
     for i in range(npart):
-        pset += pset_type[pset_mode]['pset'](pclass=ptype[mode], lon=lon[i], lat=lat[i], fieldset=fieldset)
+        pset += ParticleSet(pclass=ptype[mode], lon=lon[i], lat=lat[i], fieldset=fieldset)
     assert pset.size == npart
     assert np.allclose([p.lon for p in pset], lon, rtol=1e-12)
     assert np.allclose([p.lat for p in pset], lat, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_add_execute(fieldset, pset_mode, mode, npart=10):
+def test_pset_add_execute(fieldset, mode, npart=10):
     def AddLat(particle, fieldset, time):
         particle_dlat += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=[], lat=[], pclass=ptype[mode])
+    pset = ParticleSet(fieldset, lon=[], lat=[], pclass=ptype[mode])
     for i in range(npart):
-        pset += pset_type[pset_mode]['pset'](pclass=ptype[mode], lon=0.1, lat=0.1, fieldset=fieldset)
+        pset += ParticleSet(pclass=ptype[mode], lon=0.1, lat=0.1, fieldset=fieldset)
     for _ in range(4):
         pset.execute(pset.Kernel(AddLat), runtime=1., dt=1.0)
     assert np.allclose(np.array([p.lat for p in pset]), 0.4, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_merge_inplace(fieldset, pset_mode, mode, npart=100):
-    pset1 = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                         lon=np.linspace(0, 1, npart),
-                                         lat=np.linspace(1, 0, npart))
-    pset2 = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                         lon=np.linspace(0, 1, npart),
-                                         lat=np.linspace(0, 1, npart))
+def test_pset_merge_inplace(fieldset, mode, npart=100):
+    pset1 = ParticleSet(fieldset, pclass=ptype[mode],
+                        lon=np.linspace(0, 1, npart),
+                        lat=np.linspace(1, 0, npart))
+    pset2 = ParticleSet(fieldset, pclass=ptype[mode],
+                        lon=np.linspace(0, 1, npart),
+                        lat=np.linspace(0, 1, npart))
     assert pset1.size == npart
     assert pset2.size == npart
     pset1.add(pset2)
@@ -328,27 +302,25 @@ def test_pset_merge_inplace(fieldset, pset_mode, mode, npart=100):
 
 
 @pytest.mark.xfail(reason="ParticleSet duplication has not been implemented yet")
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_merge_duplicate(fieldset, pset_mode, mode, npart=100):
-    pset1 = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                         lon=np.linspace(0, 1, npart),
-                                         lat=np.linspace(1, 0, npart))
-    pset2 = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                         lon=np.linspace(0, 1, npart),
-                                         lat=np.linspace(0, 1, npart))
+def test_pset_merge_duplicate(fieldset, mode, npart=100):
+    pset1 = ParticleSet(fieldset, pclass=ptype[mode],
+                        lon=np.linspace(0, 1, npart),
+                        lat=np.linspace(1, 0, npart))
+    pset2 = ParticleSet(fieldset, pclass=ptype[mode],
+                        lon=np.linspace(0, 1, npart),
+                        lat=np.linspace(0, 1, npart))
     pset3 = pset1 + pset2
     assert pset1.size == npart
     assert pset2.size == npart
     assert pset3.size == 2*npart
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_remove_index(fieldset, pset_mode, mode, npart=100):
+def test_pset_remove_index(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart)
     lat = np.linspace(1, 0, npart)
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=lon, lat=lat, pclass=ptype[mode], lonlatdepth_dtype=np.float64)
+    pset = ParticleSet(fieldset, lon=lon, lat=lat, pclass=ptype[mode], lonlatdepth_dtype=np.float64)
     for ilon, ilat in zip(lon[::-1], lat[::-1]):
         assert pset[-1].lon == ilon
         assert pset[-1].lat == ilat
@@ -357,12 +329,11 @@ def test_pset_remove_index(fieldset, pset_mode, mode, npart=100):
 
 
 @pytest.mark.xfail(reason="Particle removal has not been implemented yet")
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_remove_particle(fieldset, pset_mode, mode, npart=100):
+def test_pset_remove_particle(fieldset, mode, npart=100):
     lon = np.linspace(0, 1, npart)
     lat = np.linspace(1, 0, npart)
-    pset = pset_type[pset_mode]['pset'](fieldset, lon=lon, lat=lat, pclass=ptype[mode])
+    pset = ParticleSet(fieldset, lon=lon, lat=lat, pclass=ptype[mode])
     for ilon, ilat in zip(lon[::-1], lat[::-1]):
         assert pset.lon[-1] == ilon
         assert pset.lat[-1] == ilat
@@ -370,44 +341,41 @@ def test_pset_remove_particle(fieldset, pset_mode, mode, npart=100):
     assert pset.size == 0
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_remove_kernel(fieldset, pset_mode, mode, npart=100):
+def test_pset_remove_kernel(fieldset, mode, npart=100):
     def DeleteKernel(particle, fieldset, time):
         if particle.lon >= .4:
             particle.delete()
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.linspace(1, 0, npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.linspace(1, 0, npart))
     pset.execute(pset.Kernel(DeleteKernel), endtime=1., dt=1.0)
     assert pset.size == 40
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_multi_execute(fieldset, pset_mode, mode, npart=10, n=5):
+def test_pset_multi_execute(fieldset, mode, npart=10, n=5):
     def AddLat(particle, fieldset, time):
         particle_dlat += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.zeros(npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.zeros(npart))
     k_add = pset.Kernel(AddLat)
     for _ in range(n+1):
         pset.execute(k_add, runtime=1., dt=1.0)
     assert np.allclose([p.lat - n*0.1 for p in pset], np.zeros(npart), rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_pset_multi_execute_delete(fieldset, pset_mode, mode, npart=10, n=5):
+def test_pset_multi_execute_delete(fieldset, mode, npart=10, n=5):
     def AddLat(particle, fieldset, time):
         particle_dlat += 0.1  # noqa
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode],
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.zeros(npart))
+    pset = ParticleSet(fieldset, pclass=ptype[mode],
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.zeros(npart))
     k_add = pset.Kernel(AddLat)
     for _ in range(n+1):
         pset.execute(k_add, runtime=1., dt=1.0)
@@ -415,12 +383,11 @@ def AddLat(particle, fieldset, time):
     assert np.allclose(pset.lat, n*0.1, atol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('area_scale', [True, False])
-def test_density(fieldset, pset_mode, mode, area_scale):
+def test_density(fieldset, mode, area_scale):
     lons, lats = np.meshgrid(np.linspace(0.05, 0.95, 10), np.linspace(-30, 30, 20))
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=ptype[mode], lon=lons, lat=lats)
+    pset = ParticleSet(fieldset, pclass=ptype[mode], lon=lons, lat=lats)
     arr = pset.density(area_scale=area_scale)
     if area_scale:
         assert np.allclose(arr, 1 / fieldset.U.cell_areas(), rtol=1e-3)  # check that density equals 1/area
@@ -432,9 +399,8 @@ def test_density(fieldset, pset_mode, mode, area_scale):
             assert np.allclose(fieldset.U.lat[inds[0][i]], pset[i].lat, atol=fieldset.U.lat[1]-fieldset.U.lat[0])
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('staggered_grid', ['Agrid', 'Cgrid'])
-def test_from_field_exact_val(pset_mode, staggered_grid):
+def test_from_field_exact_val(staggered_grid):
     xdim = 4
     ydim = 3
 
@@ -477,7 +443,7 @@ class SampleParticle(ptype['scipy']):
     def SampleMask(particle, fieldset, time):
         particle.mask = fieldset.mask[particle]
 
-    pset = pset_type[pset_mode]['pset'].from_field(fieldset, size=400, pclass=SampleParticle, start_field=FMask, time=0)
+    pset = ParticleSet.from_field(fieldset, size=400, pclass=SampleParticle, start_field=FMask, time=0)
     pset.execute(SampleMask, dt=1, runtime=1)
     assert np.allclose([p.mask for p in pset], 1)
     assert (np.array([p.lon for p in pset]) <= 1).all()
diff --git a/tests/test_particles.py b/tests/test_particles.py
index a5ed82b2e..615e13293 100644
--- a/tests/test_particles.py
+++ b/tests/test_particles.py
@@ -3,24 +3,16 @@
 import numpy as np
 import pytest
 
-from parcels import (  # noqa
+from parcels import (
     AdvectionRK4,
     FieldSet,
     JITParticle,
-    KernelAOS,
-    KernelSOA,
-    ParticleFileAOS,
-    ParticleFileSOA,
-    ParticleSetAOS,
-    ParticleSetSOA,
+    ParticleSet,
     ScipyParticle,
     Variable,
 )
 
-pset_modes = ['soa']
 ptype = {'scipy': ScipyParticle, 'jit': JITParticle}
-pset_type = {'soa': {'pset': ParticleSetSOA, 'pfile': ParticleFileSOA, 'kernel': KernelSOA},
-             'aos': {'pset': ParticleSetAOS, 'pfile': ParticleFileAOS, 'kernel': KernelAOS}}
 
 
 def fieldset(xdim=100, ydim=100):
@@ -36,27 +28,25 @@ def fieldset_fixture(xdim=100, ydim=100):
     return fieldset(xdim=xdim, ydim=ydim)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_print(fieldset, pset_mode, mode):
+def test_print(fieldset, mode):
     class TestParticle(ptype[mode]):
         p = Variable('p', to_write=True)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle, lon=[0, 1], lat=[0, 1])
+    pset = ParticleSet(fieldset, pclass=TestParticle, lon=[0, 1], lat=[0, 1])
     print(pset)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_variable_init(fieldset, pset_mode, mode, npart=10):
+def test_variable_init(fieldset, mode, npart=10):
     """Test that checks correct initialisation of custom variables."""
     class TestParticle(ptype[mode]):
         p_float = Variable('p_float', dtype=np.float32, initial=10.)
         p_double = Variable('p_double', dtype=np.float64, initial=11.)
         p_int = Variable('p_int', dtype=np.int32, initial=12.)
 
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle,
-                                        lon=np.linspace(0, 1, npart),
-                                        lat=np.linspace(1, 0, npart))
+    pset = ParticleSet(fieldset, pclass=TestParticle,
+                       lon=np.linspace(0, 1, npart),
+                       lat=np.linspace(1, 0, npart))
 
     def addOne(particle, fieldset, time):
         particle.p_float += 1.
@@ -68,40 +58,37 @@ def addOne(particle, fieldset, time):
     assert np.allclose([p.p_int for p in pset], 13, rtol=1e-12)
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['jit'])
 @pytest.mark.parametrize('type', ['np.int8', 'mp.float', 'np.int16'])
-def test_variable_unsupported_dtypes(fieldset, pset_mode, mode, type):
+def test_variable_unsupported_dtypes(fieldset, mode, type):
     """Test that checks errors thrown for unsupported dtypes in JIT mode."""
     class TestParticle(ptype[mode]):
         p = Variable('p', dtype=type, initial=10.)
     error_thrown = False
     try:
-        pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle, lon=[0], lat=[0])
+        ParticleSet(fieldset, pclass=TestParticle, lon=[0], lat=[0])
     except (RuntimeError, TypeError):
         error_thrown = True
     assert error_thrown
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
-def test_variable_special_names(fieldset, pset_mode, mode):
+def test_variable_special_names(fieldset, mode):
     """Test that checks errors thrown for special names."""
     for vars in ['z', 'lon']:
         class TestParticle(ptype[mode]):
             tmp = Variable(vars, dtype=np.float32, initial=10.)
         error_thrown = False
         try:
-            pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle, lon=[0], lat=[0])
+            ParticleSet(fieldset, pclass=TestParticle, lon=[0], lat=[0])
         except AttributeError:
             error_thrown = True
         assert error_thrown
 
 
-@pytest.mark.parametrize('pset_mode', pset_modes)
 @pytest.mark.parametrize('mode', ['scipy', 'jit'])
 @pytest.mark.parametrize('coord_type', [np.float32, np.float64])
-def test_variable_init_relative(fieldset, pset_mode, mode, coord_type, npart=10):
+def test_variable_init_relative(fieldset, mode, coord_type, npart=10):
     """Test that checks relative initialisation of custom variables."""
     lonlat_type = np.float64 if coord_type == 'double' else np.float32
 
@@ -116,7 +103,7 @@ class TestParticle(ptype[mode]):
 
     lon = np.linspace(0, 1, npart, dtype=lonlat_type)
     lat = np.linspace(1, 0, npart, dtype=lonlat_type)
-    pset = pset_type[pset_mode]['pset'](fieldset, pclass=TestParticle, lon=lon, lat=lat, lonlatdepth_dtype=coord_type)
+    pset = ParticleSet(fieldset, pclass=TestParticle, lon=lon, lat=lat, lonlatdepth_dtype=coord_type)
     # Adjust base variable to test for aliasing effects
     for p in pset:
         p.p_base += 3.