Repository: OceanParcels/Parcels (language: python)

Issue:
Improve `repr`s for key Parcels classes
So far, we haven't really worked on the output of `print()` on objects like `Field`, `FieldSet` etc. While there is a [`ParticleSet.__repr__`](https://github.com/OceanParcels/Parcels/blob/5fff42c2ea4cc12d03df8c936dd391e251658c8f/parcels/particleset.py#L339-L340), even that is pretty clunky and doesn't take into account notebook functionality.

It would be nice to have better `print()` implementation for at least the following classes
- [x] `Field`
- [x] `FieldSet`
- [x] `ParticleSet`
- [x] `Particle`
- [x] `ParticleFile`
- ~~`Kernel`(?)~~ EDIT Vecko: Out of scope
Improve `repr`s for key Parcels classes
So far, we haven't really worked on the output of `print()` on objects like `Field`, `FieldSet` etc. While there is a [`ParticleSet.__repr__`](https://github.com/OceanParcels/Parcels/blob/5fff42c2ea4cc12d03df8c936dd391e251658c8f/parcels/particleset.py#L339-L340), even that is pretty clunky and doesn't take into account notebook functionality.

It would be nice to have better `print()` implementation for at least the following classes
- [x] `Field`
- [x] `FieldSet`
- [x] `ParticleSet`
- [x] `Particle`
- [x] `ParticleFile`
- ~~`Kernel`(?)~~ EDIT Vecko: Out of scope


Discussion / hints:
Hi,
I’d like to contribute to this issue. Could you please specify how you would like the print() implementation to be improved for the Field, FieldSet, and ParticleSet classes? 
Thanks for offering to contribute to this Issue, @rehamansoor! I don't have a very clear idea what to print in mind yet, but was inspired by the html-version `__repr__` in xarray (see e.g. https://github.com/pydata/xarray/issues/1627). So a table-like/collapsable view that shows the attributes of these objects that are relevant to users

Since a `FieldSet` is a collection of `Fields`, I suggest we start with a `Field.__repr__`? This should include information on the name, grid, dimension sizes, interpolation_method etc. 
@VeckoTheGecko, since you are working on redefining the API, perhaps you also have ideas what attributes to show in a notebook?
Hi @rehamansoor, great to hear that you’re interested in this issue.

Before the wall of text that is about to follow, please let me know if you have any questions about anything below, or if you'd like any clarification. I'm not sure what your Python level is so - if you feel like this is a bit much for where you're at - let me know (there are issues in the codebase that are better suited to new Python developers 😁). I imagine these won't all be fixed in one PR, or by the same person.

<details><summary>Useful reference</summary>
<p>

- [Python 3 docs - repr](https://docs.python.org/3/library/functions.html#repr)
- [SO - Purpose of `__repr__()`?](https://stackoverflow.com/questions/1984162/purpose-of-repr-method)
- [SO - Should Python `__repr__()` be an expression?](https://stackoverflow.com/a/452310/15545258)
</p>
</details> 
    
<details><summary>TLDR: I think we shouldn't do HTML outputs for now. Let's focus on text reprs</summary>
<p>

HTML repr outputs would be amazing in theory, but after doing some diving into the implementation in xarray (see entrypoint `ds._repr_html_()` for the curious) their implementation is complex requiring custom CSS (vendored in the package itself in a `static` folder), HTML generation (including escaping characters to produce valid HTML), and they even use custom decorators to drive the reprs.
    
```py
import xarray as xr
import numpy as np
import pandas as pd

ds = xr.Dataset(
    data_vars=dict(
        temperature=(["loc", "time"], np.random.randn(2, 4)),
        precipitation=(["loc", "time"], 10 * np.random.rand(2, 4)),
    ),
    coords=dict(
        lat=("loc", [42.25, 42.21]),
        time=pd.date_range("2014-09-06", periods=4),
        reference_time=pd.Timestamp("2014-09-05"),
    ),
    attrs=dict(description="Weather related data."),
)

print(ds._repr_html_())
```

I think there is quite some room for improvement in our text reprs (we'll need them anyway for scripts) and down the line see if there is a need for HTML ones as well (/investigate how we can better customise iPython notebooks output). At the moment the apparent complexity/maintenance burden makes me hesitate. Note customising iPython notebook reprs is [easily done with the `_repr_html_()` method](https://ipython.org/ipython-doc/3/config/integrating.html).

</p>
</details> 



Starting with the smallest objects to the largest, this is the information (`self.value`s) that would be good to have in the repr for each:

- [ ] `Field`
    - `self.` information to include
        - `grid`
        - `mesh`
        - `allow_time_extrapolation`
        - `time_periodic`
        - `gridindexingtype`
        - `to_write`
    - notes: pretty formatting with indentation
- [ ] `VectorField`
    - `self.` information to include
        - `name`
        - `U`
        - `V`
        - `W`
    - notes: pretty formatting with indentation
- [ ] `FieldSet`
    - information to include
        - Fields (call `.get_fields()`). To be formatted as a list going down

- [ ] `ParticleFile`
    - `self.` information to include
        - name
        - outputdt
        - chunks
        - create_new_zarrfile
        - (intentionally left out `particleset`)
    - notes: have a simple oneline repr (e.g., `ParticleFile(name='my_name', outputdt='...`)
- [ ] `ParticleSet`
    - `self.` information to include
        - fieldset
        - pclass
        - repeatdt
    - Other information to include:
        - number of particles: do `len(self)`
        - particle information (only first x and last y particles):
            - Currently the `__repr__` print out all particles, which isn’t really useful in the case of thousands of particles (which occurs frequently). Current implementation is `return "\n".join([str(p) for p in self])` which can be adapted accordingly. Choose sensible defaults for `x` and `y`
    - notes: pretty formatting with indentation
- [ ] `Kernel`
    - Hmmmm, not sure for the timebeing. No changes needed
- [ ] `Particle` and `Variable`
    - @erikvansebille I saw some reprs of the format `f"PType<{self.name}>::{self.variables}"`. Does Parcels dynamically evaluate these in the C-code? Are these worth avoiding changes to?


---

I would recommend working on the simple reprs first. Also at this stage we are not too concerned about user customising the repr output (like the `set_options()` class in xarray [options.py](https://github.com/pydata/xarray/blob/93e410bf049b25788aaf49dd92a2bf612e2618f4/xarray/core/options.py) allowing to set `max_width`), but having a sensible output of 80 should be good. Please rely on formatting helper functions to reduce code duplication, you can put them in `tools/_formatting.py`.

This isn't the be-all and end-all for the reprs. We'll be reworking Parcels internals over the coming months cleaning things up. But having a starting point to go from would be super helpfull.

If there are numpy arrays in the repr, please use the context manager `with np.printoptions(threshold=5, suppress=True, linewidth=120):` or some other sensible defaults ([see docs](https://numpy.org/doc/stable/reference/generated/numpy.set_printoptions.html)) so that the output is managable.

Thank you for the detailed breakdown, and I apologize for the delay in replying—I got a bit busy with school. I really appreciate the opportunity to contribute. While I have experience in Python through my college courses, I think these tasks might be a bit complex for me at the moment. Could you suggest an issue that might be a better fit for my current skill level? I’m eager to help and continue improving my abilities.

Thanks again, and I look forward to contributing!
Hi @rehamansoor . All good! We do have a few issues that would be better suited:

In #1620  with have the subtask ` Remove erroneous calls to os.path in cleanup_remove_files() and clean up function`. This is a purely Pythonic task and, once you understand that section of code, should be quite straightforward. We also have #1701 which is just documenting adding metadata to the particlefile output (for example an entry `"contact_email": "email@example.com"`) . There is also #1511 which is more specific to Parcels itself
> * @erikvansebille I saw some reprs of the format `f"PType<{self.name}>::{self.variables}"`. Does Parcels dynamically evaluate these in the C-code? Are these worth avoiding changes to?

bump
Hi,
I’d like to contribute to this issue. Could you please specify how you would like the print() implementation to be improved for the Field, FieldSet, and ParticleSet classes? 
Thanks for offering to contribute to this Issue, @rehamansoor! I don't have a very clear idea what to print in mind yet, but was inspired by the html-version `__repr__` in xarray (see e.g. https://github.com/pydata/xarray/issues/1627). So a table-like/collapsable view that shows the attributes of these objects that are relevant to users

Since a `FieldSet` is a collection of `Fields`, I suggest we start with a `Field.__repr__`? This should include information on the name, grid, dimension sizes, interpolation_method etc. 
@VeckoTheGecko, since you are working on redefining the API, perhaps you also have ideas what attributes to show in a notebook?
Hi @rehamansoor, great to hear that you’re interested in this issue.

Before the wall of text that is about to follow, please let me know if you have any questions about anything below, or if you'd like any clarification. I'm not sure what your Python level is so - if you feel like this is a bit much for where you're at - let me know (there are issues in the codebase that are better suited to new Python developers 😁). I imagine these won't all be fixed in one PR, or by the same person.

<details><summary>Useful reference</summary>
<p>

- [Python 3 docs - repr](https://docs.python.org/3/library/functions.html#repr)
- [SO - Purpose of `__repr__()`?](https://stackoverflow.com/questions/1984162/purpose-of-repr-method)
- [SO - Should Python `__repr__()` be an expression?](https://stackoverflow.com/a/452310/15545258)
</p>
</details> 
    
<details><summary>TLDR: I think we shouldn't do HTML outputs for now. Let's focus on text reprs</summary>
<p>

HTML repr outputs would be amazing in theory, but after doing some diving into the implementation in xarray (see entrypoint `ds._repr_html_()` for the curious) their implementation is complex requiring custom CSS (vendored in the package itself in a `static` folder), HTML generation (including escaping characters to produce valid HTML), and they even use custom decorators to drive the reprs.
    
```py
import xarray as xr
import numpy as np
import pandas as pd

ds = xr.Dataset(
    data_vars=dict(
        temperature=(["loc", "time"], np.random.randn(2, 4)),
        precipitation=(["loc", "time"], 10 * np.random.rand(2, 4)),
    ),
    coords=dict(
        lat=("loc", [42.25, 42.21]),
        time=pd.date_range("2014-09-06", periods=4),
        reference_time=pd.Timestamp("2014-09-05"),
    ),
    attrs=dict(description="Weather related data."),
)

print(ds._repr_html_())
```

I think there is quite some room for improvement in our text reprs (we'll need them anyway for scripts) and down the line see if there is a need for HTML ones as well (/investigate how we can better customise iPython notebooks output). At the moment the apparent complexity/maintenance burden makes me hesitate. Note customising iPython notebook reprs is [easily done with the `_repr_html_()` method](https://ipython.org/ipython-doc/3/config/integrating.html).

</p>
</details> 



Starting with the smallest objects to the largest, this is the information (`self.value`s) that would be good to have in the repr for each:

- [ ] `Field`
    - `self.` information to include
        - `grid`
        - `mesh`
        - `allow_time_extrapolation`
        - `time_periodic`
        - `gridindexingtype`
        - `to_write`
    - notes: pretty formatting with indentation
- [ ] `VectorField`
    - `self.` information to include
        - `name`
        - `U`
        - `V`
        - `W`
    - notes: pretty formatting with indentation
- [ ] `FieldSet`
    - information to include
        - Fields (call `.get_fields()`). To be formatted as a list going down

- [ ] `ParticleFile`
    - `self.` information to include
        - name
        - outputdt
        - chunks
        - create_new_zarrfile
        - (intentionally left out `particleset`)
    - notes: have a simple oneline repr (e.g., `ParticleFile(name='my_name', outputdt='...`)
- [ ] `ParticleSet`
    - `self.` information to include
        - fieldset
        - pclass
        - repeatdt
    - Other information to include:
        - number of particles: do `len(self)`
        - particle information (only first x and last y particles):
            - Currently the `__repr__` print out all particles, which isn’t really useful in the case of thousands of particles (which occurs frequently). Current implementation is `return "\n".join([str(p) for p in self])` which can be adapted accordingly. Choose sensible defaults for `x` and `y`
    - notes: pretty formatting with indentation
- [ ] `Kernel`
    - Hmmmm, not sure for the timebeing. No changes needed
- [ ] `Particle` and `Variable`
    - @erikvansebille I saw some reprs of the format `f"PType<{self.name}>::{self.variables}"`. Does Parcels dynamically evaluate these in the C-code? Are these worth avoiding changes to?


---

I would recommend working on the simple reprs first. Also at this stage we are not too concerned about user customising the repr output (like the `set_options()` class in xarray [options.py](https://github.com/pydata/xarray/blob/93e410bf049b25788aaf49dd92a2bf612e2618f4/xarray/core/options.py) allowing to set `max_width`), but having a sensible output of 80 should be good. Please rely on formatting helper functions to reduce code duplication, you can put them in `tools/_formatting.py`.

This isn't the be-all and end-all for the reprs. We'll be reworking Parcels internals over the coming months cleaning things up. But having a starting point to go from would be super helpfull.

If there are numpy arrays in the repr, please use the context manager `with np.printoptions(threshold=5, suppress=True, linewidth=120):` or some other sensible defaults ([see docs](https://numpy.org/doc/stable/reference/generated/numpy.set_printoptions.html)) so that the output is managable.

Thank you for the detailed breakdown, and I apologize for the delay in replying—I got a bit busy with school. I really appreciate the opportunity to contribute. While I have experience in Python through my college courses, I think these tasks might be a bit complex for me at the moment. Could you suggest an issue that might be a better fit for my current skill level? I’m eager to help and continue improving my abilities.

Thanks again, and I look forward to contributing!
Hi @rehamansoor . All good! We do have a few issues that would be better suited:

In #1620  with have the subtask ` Remove erroneous calls to os.path in cleanup_remove_files() and clean up function`. This is a purely Pythonic task and, once you understand that section of code, should be quite straightforward. We also have #1701 which is just documenting adding metadata to the particlefile output (for example an entry `"contact_email": "email@example.com"`) . There is also #1511 which is more specific to Parcels itself
> * @erikvansebille I saw some reprs of the format `f"PType<{self.name}>::{self.variables}"`. Does Parcels dynamically evaluate these in the C-code? Are these worth avoiding changes to?

bump

Fix patch:
diff --git a/docs/examples/parcels_tutorial.ipynb b/docs/examples/parcels_tutorial.ipynb
index 5936644aa..102a87419 100644
--- a/docs/examples/parcels_tutorial.ipynb
+++ b/docs/examples/parcels_tutorial.ipynb
@@ -72,11 +72,41 @@
    "cell_type": "code",
    "execution_count": 2,
    "metadata": {},
-   "outputs": [],
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<FieldSet>\n",
+      "    fields:\n",
+      "        <Field>\n",
+      "            name            : 'U'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 0.00,  86400.00,  172800.00, ...,  432000.00,  518400.00,  604800.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <Field>\n",
+      "            name            : 'V'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 0.00,  86400.00,  172800.00, ...,  432000.00,  518400.00,  604800.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <VectorField>\n",
+      "            name: 'UV'\n",
+      "            U: <parcels.field.Field object at 0x1580e0980>\n",
+      "            V: <parcels.field.Field object at 0x1585d8920>\n",
+      "            W: None\n"
+     ]
+    }
+   ],
    "source": [
     "example_dataset_folder = parcels.download_example_dataset(\"MovingEddies_data\")\n",
     "\n",
-    "fieldset = parcels.FieldSet.from_parcels(f\"{example_dataset_folder}/moving_eddies\")"
+    "fieldset = parcels.FieldSet.from_parcels(f\"{example_dataset_folder}/moving_eddies\")\n",
+    "\n",
+    "print(fieldset)"
    ]
   },
   {
@@ -152,8 +182,35 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "P[0](lon=330000.000000, lat=100000.000000, depth=0.000000, time=not_yet_set)\n",
-      "P[1](lon=330000.000000, lat=280000.000000, depth=0.000000, time=not_yet_set)\n"
+      "<ParticleSet>\n",
+      "    fieldset   : <FieldSet>\n",
+      "    fields:\n",
+      "        <Field>\n",
+      "            name            : 'U'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 0.00,  86400.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <Field>\n",
+      "            name            : 'V'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 0.00,  86400.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <VectorField>\n",
+      "            name: 'UV'\n",
+      "            U: <parcels.field.Field object at 0x1580e0980>\n",
+      "            V: <parcels.field.Field object at 0x1585d8920>\n",
+      "            W: None\n",
+      "    pclass     : <class 'parcels.particle.JITParticle'>\n",
+      "    repeatdt   : None\n",
+      "    # particles: 2\n",
+      "    particles  : [\n",
+      "        P[0](lon=330000.000000, lat=100000.000000, depth=0.000000, time=not_yet_set),\n",
+      "        P[1](lon=330000.000000, lat=280000.000000, depth=0.000000, time=not_yet_set)\n",
+      "    ]\n"
      ]
     }
    ],
@@ -208,7 +265,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "The final step is to run (or 'execute') the `ParticelSet`. We run the particles using the `AdvectionRK4` kernel, which is a 4th order Runge-Kutte implementation that comes with Parcels. We run the particles for 6 days (using the `timedelta` function from `datetime`), at an RK4 timestep of 5 minutes. We store the trajectory information at an interval of 1 hour in a file called `EddyParticles.zarr`. Because `time` was `not_yet_set`, the particles will be advected from the first date available in the `fieldset`, which is the default behaviour.\n"
+    "We can now create `ParticleFile` objects to store the output of the particles. We will store the output at an interval of 1 hour in a `zarr` file called `EddyParticles.zar`. See the [Working with Parcels output tutorial](https://docs.oceanparcels.org/en/latest/examples/tutorial_output.html) for more information on the `zarr` format."
    ]
   },
   {
@@ -220,8 +277,7 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "INFO: Output files are stored in EddyParticles.zarr.\n",
-      "100%|██████████| 518400.0/518400.0 [00:03<00:00, 172443.78it/s]\n"
+      "ParticleFile(name='EddyParticles.zarr', particleset=<parcels.particleset.ParticleSet object at 0x1584ee7e0>, outputdt=3600.0, chunks=None, create_new_zarrfile=True)\n"
      ]
     }
    ],
@@ -230,6 +286,32 @@
     "    name=\"EddyParticles.zarr\",  # the file name\n",
     "    outputdt=timedelta(hours=1),  # the time step of the outputs\n",
     ")\n",
+    "\n",
+    "print(output_file)"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The final step is to run (or 'execute') the `ParticelSet`. We run the particles using the `AdvectionRK4` kernel, which is a 4th order Runge-Kutte implementation that comes with Parcels. We run the particles for 6 days (using the `timedelta` function from `datetime`), at an RK4 timestep of 5 minutes. Because `time` was `not_yet_set`, the particles will be advected from the first date available in the `fieldset`, which is the default behaviour.\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "100%|██████████| 518400.0/518400.0 [00:05<00:00, 95421.55it/s] \n"
+     ]
+    }
+   ],
+   "source": [
     "pset.execute(\n",
     "    parcels.AdvectionRK4,  # the kernel (which defines how particles move)\n",
     "    runtime=timedelta(days=6),  # the total length of the run\n",
@@ -248,15 +330,42 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 8,
+   "execution_count": 9,
    "metadata": {},
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "P[0](lon=226905.562500, lat=82515.218750, depth=0.000000, time=518100.000000)\n",
-      "P[1](lon=260835.125000, lat=320403.343750, depth=0.000000, time=518100.000000)\n"
+      "<ParticleSet>\n",
+      "    fieldset   : <FieldSet>\n",
+      "    fields:\n",
+      "        <Field>\n",
+      "            name            : 'U'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 432000.00,  518400.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <Field>\n",
+      "            name            : 'V'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 432000.00,  518400.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <VectorField>\n",
+      "            name: 'UV'\n",
+      "            U: <parcels.field.Field object at 0x1580e0980>\n",
+      "            V: <parcels.field.Field object at 0x1585d8920>\n",
+      "            W: None\n",
+      "    pclass     : <class 'parcels.particle.JITParticle'>\n",
+      "    repeatdt   : None\n",
+      "    # particles: 2\n",
+      "    particles  : [\n",
+      "        P[0](lon=226905.562500, lat=82515.218750, depth=0.000000, time=518100.000000),\n",
+      "        P[1](lon=260835.125000, lat=320403.343750, depth=0.000000, time=518100.000000)\n",
+      "    ]\n"
      ]
     },
     {
@@ -300,7 +409,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 9,
+   "execution_count": 10,
    "metadata": {},
    "outputs": [
     {
@@ -333,7 +442,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 10,
+   "execution_count": 11,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -371,7 +480,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 11,
+   "execution_count": 12,
    "metadata": {},
    "outputs": [
     {
@@ -13159,7 +13268,7 @@
        "<IPython.core.display.HTML object>"
       ]
      },
-     "execution_count": 11,
+     "execution_count": 12,
      "metadata": {},
      "output_type": "execute_result"
     }
@@ -13186,7 +13295,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 12,
+   "execution_count": 13,
    "metadata": {},
    "outputs": [
     {
@@ -13221,15 +13330,42 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 13,
+   "execution_count": 14,
    "metadata": {},
    "outputs": [
     {
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "P[0](lon=329983.281250, lat=100495.609375, depth=0.000000, time=300.000000)\n",
-      "P[1](lon=330289.968750, lat=280418.906250, depth=0.000000, time=300.000000)\n"
+      "<ParticleSet>\n",
+      "    fieldset   : <FieldSet>\n",
+      "    fields:\n",
+      "        <Field>\n",
+      "            name            : 'U'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 0.00,  86400.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <Field>\n",
+      "            name            : 'V'\n",
+      "            grid            : RectilinearZGrid(lon=array([ 0.00,  2010.05,  4020.10, ...,  395979.91,  397989.94,  400000.00], dtype=float32), lat=array([ 0.00,  2005.73,  4011.46, ...,  695988.56,  697994.25,  700000.00], dtype=float32), time=array([ 0.00,  86400.00]), time_origin=0.0, mesh='flat')\n",
+      "            extrapolate time: False\n",
+      "            time_periodic   : False\n",
+      "            gridindexingtype: 'nemo'\n",
+      "            to_write        : False\n",
+      "        <VectorField>\n",
+      "            name: 'UV'\n",
+      "            U: <parcels.field.Field object at 0x1580e0980>\n",
+      "            V: <parcels.field.Field object at 0x1585d8920>\n",
+      "            W: None\n",
+      "    pclass     : <class 'parcels.particle.JITParticle'>\n",
+      "    repeatdt   : None\n",
+      "    # particles: 2\n",
+      "    particles  : [\n",
+      "        P[0](lon=329983.281250, lat=100495.609375, depth=0.000000, time=300.000000),\n",
+      "        P[1](lon=330289.968750, lat=280418.906250, depth=0.000000, time=300.000000)\n",
+      "    ]\n"
      ]
     },
     {
@@ -13281,7 +13417,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 14,
+   "execution_count": 15,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13308,7 +13444,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 15,
+   "execution_count": 16,
    "metadata": {},
    "outputs": [
     {
@@ -13346,7 +13482,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 16,
+   "execution_count": 17,
    "metadata": {},
    "outputs": [
     {
@@ -13405,7 +13541,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 17,
+   "execution_count": 18,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13427,7 +13563,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 18,
+   "execution_count": 19,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13448,7 +13584,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 19,
+   "execution_count": 20,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13465,7 +13601,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 20,
+   "execution_count": 21,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13488,7 +13624,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 21,
+   "execution_count": 22,
    "metadata": {},
    "outputs": [
     {
@@ -13522,7 +13658,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 22,
+   "execution_count": 23,
    "metadata": {},
    "outputs": [
     {
@@ -13568,7 +13704,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 23,
+   "execution_count": 24,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13590,7 +13726,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 24,
+   "execution_count": 25,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13620,7 +13756,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 25,
+   "execution_count": 26,
    "metadata": {},
    "outputs": [
     {
@@ -13663,7 +13799,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 26,
+   "execution_count": 27,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13715,7 +13851,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 28,
+   "execution_count": 29,
    "metadata": {},
    "outputs": [
     {
@@ -13775,7 +13911,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 29,
+   "execution_count": 30,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13802,7 +13938,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 30,
+   "execution_count": 31,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13841,7 +13977,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 31,
+   "execution_count": 32,
    "metadata": {},
    "outputs": [],
    "source": [
@@ -13871,7 +14007,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 32,
+   "execution_count": 33,
    "metadata": {},
    "outputs": [
     {
@@ -13904,7 +14040,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 33,
+   "execution_count": 34,
    "metadata": {},
    "outputs": [
     {
diff --git a/parcels/field.py b/parcels/field.py
index c7f5d2eac..a8a79dfc6 100644
--- a/parcels/field.py
+++ b/parcels/field.py
@@ -20,7 +20,7 @@
     assert_valid_gridindexingtype,
     assert_valid_interp_method,
 )
-from parcels.tools._helpers import deprecated_made_private, timedelta_to_float
+from parcels.tools._helpers import default_repr, deprecated_made_private, field_repr, timedelta_to_float
 from parcels.tools.converters import (
     Geographic,
     GeographicPolar,
@@ -149,6 +149,8 @@ class Field:
     * `Nested Fields <../examples/tutorial_NestedFields.ipynb>`__
     """
 
+    allow_time_extrapolation: bool
+    time_periodic: TimePeriodic
     _cast_data_dtype: type[np.float32] | type[np.float64]
 
     def __init__(
@@ -321,6 +323,9 @@ def __init__(
         if len(kwargs) > 0:
             raise SyntaxError(f'Field received an unexpected keyword argument "{list(kwargs.keys())[0]}"')
 
+    def __repr__(self) -> str:
+        return field_repr(self)
+
     @property
     @deprecated_made_private  # TODO: Remove 6 months after v3.1.0
     def dataFiles(self):
@@ -1915,6 +1920,14 @@ def __init__(self, name: str, U: Field, V: Field, W: Field | None = None):
                 assert W.interp_method == "cgrid_velocity", "Interpolation methods of U and W are not the same."
                 assert self._check_grid_dimensions(U.grid, W.grid), "Dimensions of U and W are not the same."
 
+    def __repr__(self):
+        w_repr = default_repr(self.W) if self.W is not None else repr(self.W)
+        return f"""<{type(self).__name__}>
+    name: {self.name!r}
+    U: {default_repr(self.U)}
+    V: {default_repr(self.V)}
+    W: {w_repr}"""
+
     @staticmethod
     def _check_grid_dimensions(grid1, grid2):
         return (
diff --git a/parcels/fieldset.py b/parcels/fieldset.py
index aad0635fa..9a7fd39da 100644
--- a/parcels/fieldset.py
+++ b/parcels/fieldset.py
@@ -14,7 +14,7 @@
 from parcels.grid import Grid
 from parcels.gridset import GridSet
 from parcels.particlefile import ParticleFile
-from parcels.tools._helpers import deprecated_made_private
+from parcels.tools._helpers import deprecated_made_private, fieldset_repr
 from parcels.tools.converters import TimeConverter, convert_xarray_time_units
 from parcels.tools.loggers import logger
 from parcels.tools.statuscodes import TimeExtrapolationError
@@ -56,6 +56,9 @@ def __init__(self, U: Field | NestedField | None, V: Field | NestedField | None,
         self.compute_on_defer = None
         self._add_UVfield()
 
+    def __repr__(self):
+        return fieldset_repr(self)
+
     @property
     def particlefile(self):
         return self._particlefile
diff --git a/parcels/particle.py b/parcels/particle.py
index c7f1681e0..87dce269f 100644
--- a/parcels/particle.py
+++ b/parcels/particle.py
@@ -53,7 +53,7 @@ def __set__(self, instance, value):
             setattr(instance, f"_{self.name}", value)
 
     def __repr__(self):
-        return f"PVar<{self.name}|{self.dtype}>"
+        return f"Variable(name={self._name}, dtype={self.dtype}, initial={self.initial}, to_write={self.to_write})"
 
     def is64bit(self):
         """Check whether variable is 64-bit."""
diff --git a/parcels/particlefile.py b/parcels/particlefile.py
index c8482b9e6..4ab406b4a 100644
--- a/parcels/particlefile.py
+++ b/parcels/particlefile.py
@@ -10,7 +10,7 @@
 
 import parcels
 from parcels._compat import MPI
-from parcels.tools._helpers import deprecated, deprecated_made_private, timedelta_to_float
+from parcels.tools._helpers import default_repr, deprecated, deprecated_made_private, timedelta_to_float
 from parcels.tools.warnings import FileWarning
 
 __all__ = ["ParticleFile"]
@@ -116,6 +116,16 @@ def __init__(self, name, particleset, outputdt=np.inf, chunks=None, create_new_z
                 fname = name if extension in [".zarr"] else f"{name}.zarr"
         self._fname = fname
 
+    def __repr__(self) -> str:
+        return (
+            f"{type(self).__name__}("
+            f"name={self.fname!r}, "
+            f"particleset={default_repr(self.particleset)}, "
+            f"outputdt={self.outputdt!r}, "
+            f"chunks={self.chunks!r}, "
+            f"create_new_zarrfile={self.create_new_zarrfile!r})"
+        )
+
     @property
     def create_new_zarrfile(self):
         return self._create_new_zarrfile
diff --git a/parcels/particleset.py b/parcels/particleset.py
index 1fde32ca6..ffe7eb652 100644
--- a/parcels/particleset.py
+++ b/parcels/particleset.py
@@ -27,7 +27,7 @@
 from parcels.particle import JITParticle, Variable
 from parcels.particledata import ParticleData, ParticleDataIterator
 from parcels.particlefile import ParticleFile
-from parcels.tools._helpers import deprecated, deprecated_made_private, timedelta_to_float
+from parcels.tools._helpers import deprecated, deprecated_made_private, particleset_repr, timedelta_to_float
 from parcels.tools.converters import _get_cftime_calendars, convert_to_flat_array
 from parcels.tools.global_statics import get_package_dir
 from parcels.tools.loggers import logger
@@ -112,6 +112,7 @@ def __init__(
         self.fieldset = fieldset
         self.fieldset._check_complete()
         self.time_origin = fieldset.time_origin
+        self._pclass = pclass
 
         # ==== 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 ==== #
@@ -386,8 +387,12 @@ def size(self):
         # ==== to change at some point - len and size are different things ==== #
         return len(self.particledata)
 
+    @property
+    def pclass(self):
+        return self._pclass
+
     def __repr__(self):
-        return "\n".join([str(p) for p in self])
+        return particleset_repr(self)
 
     def __len__(self):
         return len(self.particledata)
diff --git a/parcels/tools/_helpers.py b/parcels/tools/_helpers.py
index 24c8ba9c4..9078690cc 100644
--- a/parcels/tools/_helpers.py
+++ b/parcels/tools/_helpers.py
@@ -1,12 +1,19 @@
 """Internal helpers for Parcels."""
 
+from __future__ import annotations
+
 import functools
+import textwrap
 import warnings
 from collections.abc import Callable
 from datetime import timedelta
+from typing import TYPE_CHECKING, Any
 
 import numpy as np
 
+if TYPE_CHECKING:
+    from parcels import Field, FieldSet, ParticleSet
+
 PACKAGE = "Parcels"
 
 
@@ -61,6 +68,77 @@ def patch_docstring(obj: Callable, extra: str) -> None:
     obj.__doc__ = f"{obj.__doc__ or ''}{extra}".strip()
 
 
+def field_repr(field: Field) -> str:
+    """Return a pretty repr for Field"""
+    out = f"""<{type(field).__name__}>
+    name            : {field.name!r}
+    grid            : {field.grid!r}
+    extrapolate time: {field.allow_time_extrapolation!r}
+    time_periodic   : {field.time_periodic!r}
+    gridindexingtype: {field.gridindexingtype!r}
+    to_write        : {field.to_write!r}
+"""
+    return textwrap.dedent(out).strip()
+
+
+def _format_list_items_multiline(items: list[str], level: int = 1) -> str:
+    """Given a list of strings, formats them across multiple lines.
+
+    Uses indentation levels of 4 spaces provided by ``level``.
+
+    Example
+    -------
+    >>> output = _format_list_items_multiline(["item1", "item2", "item3"], 4)
+    >>> f"my_items: {output}"
+    my_items: [
+        item1,
+        item2,
+        item3,
+    ]
+    """
+    if len(items) == 0:
+        return "[]"
+
+    assert level >= 1, "Indentation level >=1 supported"
+    indentation_str = level * 4 * " "
+    indentation_str_end = (level - 1) * 4 * " "
+
+    items_str = ",\n".join([textwrap.indent(i, indentation_str) for i in items])
+    return f"[\n{items_str}\n{indentation_str_end}]"
+
+
+def particleset_repr(pset: ParticleSet) -> str:
+    """Return a pretty repr for ParticleSet"""
+    if len(pset) < 10:
+        particles = [repr(p) for p in pset]
+    else:
+        particles = [repr(pset[i]) for i in range(7)] + ["..."]
+
+    out = f"""<{type(pset).__name__}>
+    fieldset   : {pset.fieldset}
+    pclass     : {pset.pclass}
+    repeatdt   : {pset.repeatdt}
+    # particles: {len(pset)}
+    particles  : {_format_list_items_multiline(particles, level=2)}
+"""
+    return textwrap.dedent(out).strip()
+
+
+def fieldset_repr(fieldset: FieldSet) -> str:
+    """Return a pretty repr for FieldSet"""
+    fields_repr = "\n".join([repr(f) for f in fieldset.get_fields()])
+
+    out = f"""<{type(fieldset).__name__}>
+    fields:
+{textwrap.indent(fields_repr, 8 * " ")}
+"""
+    return textwrap.dedent(out).strip()
+
+
+def default_repr(obj: Any):
+    return object.__repr__(obj)
+
+
 def timedelta_to_float(dt: float | timedelta | np.timedelta64) -> float:
     """Convert a timedelta to a float in seconds."""
     if isinstance(dt, timedelta):


Test patch:
diff --git a/tests/test_reprs.py b/tests/test_reprs.py
index d072ded9f..bce4bce4b 100644
--- a/tests/test_reprs.py
+++ b/tests/test_reprs.py
@@ -1,12 +1,16 @@
+import re
+from datetime import timedelta
 from typing import Any
 
 import numpy as np
 
-from parcels import Grid, TimeConverter
+import parcels
+from parcels import Grid, ParticleFile, TimeConverter, Variable
 from parcels.grid import RectilinearGrid
+from tests.utils import create_fieldset_unit_mesh, create_simple_pset
 
 
-def validate_simple_repr(class_: type, kwargs: dict[str, Any]):
+def assert_simple_repr(class_: type, kwargs: dict[str, Any]):
     """Test that the repr of an object contains all the arguments. This only works for objects where the repr matches the calling signature."""
     obj = class_(**kwargs)
     obj_repr = repr(obj)
@@ -17,12 +21,48 @@ def validate_simple_repr(class_: type, kwargs: dict[str, Any]):
     assert class_.__name__ in obj_repr
 
 
+def valid_indentation(s: str) -> bool:
+    """Make sure that all lines in string is indented with a multiple of 4 spaces."""
+    if s.startswith(" "):
+        return False
+
+    lines = s.split("\n")
+    for line in lines:
+        line = re.sub("^( {4})+", "", line)
+        if line.startswith(" "):
+            return False
+    return True
+
+
+def test_check_indentation():
+    valid = """
+test
+    test
+test
+    test
+        test
+    test"""
+    assert valid_indentation(valid)
+    invalid = """
+test
+    test
+ invalid!
+"""
+    assert not valid_indentation(invalid)
+
+
 def test_grid_repr():
     """Test arguments are in the repr of a Grid object"""
     kwargs = dict(
         lon=np.array([1, 2, 3]), lat=np.array([4, 5, 6]), time=None, time_origin=TimeConverter(), mesh="spherical"
     )
-    validate_simple_repr(Grid, kwargs)
+    assert_simple_repr(Grid, kwargs)
+
+
+def test_variable_repr():
+    """Test arguments are in the repr of the Variable object."""
+    kwargs = dict(name="test", dtype=np.float32, initial=0, to_write=False)
+    assert_simple_repr(Variable, kwargs)
 
 
 def test_rectilineargrid_repr():
@@ -34,4 +74,41 @@ def test_rectilineargrid_repr():
     kwargs = dict(
         lon=np.array([1, 2, 3]), lat=np.array([4, 5, 6]), time=None, time_origin=TimeConverter(), mesh="spherical"
     )
-    validate_simple_repr(RectilinearGrid, kwargs)
+    assert_simple_repr(RectilinearGrid, kwargs)
+
+
+def test_particlefile_repr():
+    pset = create_simple_pset()
+    kwargs = dict(
+        name="file.zarr", particleset=pset, outputdt=timedelta(hours=1), chunks=None, create_new_zarrfile=False
+    )
+    assert_simple_repr(ParticleFile, kwargs)
+
+
+def test_field_repr():
+    field = create_fieldset_unit_mesh().U
+    assert valid_indentation(repr(field))
+
+
+def test_vectorfield_repr():
+    field = create_fieldset_unit_mesh().UV
+    assert isinstance(field, parcels.VectorField)
+    assert valid_indentation(repr(field))
+
+
+def test_fieldset_repr():
+    fieldset = create_fieldset_unit_mesh()
+    assert valid_indentation(repr(fieldset))
+
+
+def test_particleset_repr():
+    pset = create_simple_pset()
+    valid_indentation(repr(pset))
+
+    pset = create_simple_pset(n=15)
+    valid_indentation(repr(pset))
+
+
+def capture(s):
+    with open("file.txt", "a") as f:
+        f.write(s)
diff --git a/tests/tools/test_helpers.py b/tests/tools/test_helpers.py
index 3e6f5a992..1403b679f 100644
--- a/tests/tools/test_helpers.py
+++ b/tests/tools/test_helpers.py
@@ -3,9 +3,20 @@
 import numpy as np
 import pytest
 
+import parcels.tools._helpers as helpers
 from parcels.tools._helpers import deprecated, deprecated_made_private, timedelta_to_float
 
 
+def test_format_list_items_multiline():
+    expected = """[
+    item1,
+    item2,
+    item3
+]"""
+    assert helpers._format_list_items_multiline(["item1", "item2", "item3"], 1) == expected
+    assert helpers._format_list_items_multiline([], 1) == "[]"
+
+
 def test_deprecated():
     class SomeClass:
         @deprecated()
diff --git a/tests/utils.py b/tests/utils.py
index a5153f6be..14ef8b7e5 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -4,6 +4,7 @@
 
 import numpy as np
 
+import parcels
 from parcels import FieldSet
 
 PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -47,6 +48,18 @@ def create_fieldset_zeros_conversion(mesh="spherical", xdim=200, ydim=100, mesh_
     return FieldSet.from_data(data, dimensions, mesh=mesh)
 
 
+def create_simple_pset(n=1):
+    zeros = np.zeros(n)
+    return parcels.ParticleSet(
+        fieldset=create_fieldset_unit_mesh(),
+        pclass=parcels.ScipyParticle,
+        lon=zeros,
+        lat=zeros,
+        depth=zeros,
+        time=zeros,
+    )
+
+
 def create_spherical_positions(n_particles, max_depth=100000):
     yrange = 2 * np.random.rand(n_particles)
     lat = 180 * (np.arccos(1 - yrange) - 0.5 * np.pi) / np.pi