Repository: GoLP-IST/nata (language: python)

Issue:
Update ParticleDatasets to follow new API
Update the `ParticleDataset` to the new API currently implemented with `GridDatasets`. 

<!-- Edit the body of your new issue then click the ✓ "Create Issue" button in the top right of the editor. The first line will be the issue title. Assignees and Labels follow after a blank line. Leave an empty line before beginning the body of the issue. -->


Discussion / hints:

Fix patch:
diff --git a/nata/__init__.py b/nata/__init__.py
index 4067ef9..f91efab 100644
--- a/nata/__init__.py
+++ b/nata/__init__.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
-from nata.backends import *
-from nata.comfort import load
-from nata.comfort import activate_logging
+# from nata.backends import *
+# from nata.comfort import load
+# from nata.comfort import activate_logging
 from nata.containers import *
 from nata.plugins import *
diff --git a/nata/containers/__init__.py b/nata/containers/__init__.py
index ee1b017..d7158a3 100644
--- a/nata/containers/__init__.py
+++ b/nata/containers/__init__.py
@@ -4,8 +4,14 @@
 from .grid import GridDataset
 from .grid import GridArray
 
-from .ParticleDataset import ParticleQuantity
-from .ParticleDataset import ParticleDataset
+from .particle import Quantity
+from .particle import QuantityArray
+from .particle import Particle
+from .particle import ParticleArray
+from .particle import ParticleDataset
+
+# from .ParticleDataset import ParticleQuantity
+# from .ParticleDataset import ParticleDataset
 from .DatasetCollection import DatasetCollection
 
 from .grid import stack
diff --git a/nata/containers/axis.py b/nata/containers/axis.py
index 17d9d52..4aaf5d9 100644
--- a/nata/containers/axis.py
+++ b/nata/containers/axis.py
@@ -1,63 +1,37 @@
 # -*- coding: utf-8 -*-
 from textwrap import dedent
 from typing import Any
-from typing import Callable
 from typing import Dict
-from typing import Optional
 from typing import Tuple
 from typing import Type
 from typing import Union
 
 import dask.array as da
 import numpy as np
+from numpy.typing import ArrayLike
 
-from .formatting import Table
+from .core import HasAnnotations
+from .core import HasNumpyInterface
 
 
-class Axis(np.lib.mixins.NDArrayOperatorsMixin):
-    _handled_array_function = {}
-    _handled_array_ufunc = {}
-
+class Axis(HasAnnotations, HasNumpyInterface):
     def __init__(
         self,
         data: da.Array,
-        *,
-        name: str = "unnamed",
-        label: str = "unlabeled",
-        unit: str = "",
+        name: str,
+        label: str,
+        unit: str,
     ) -> None:
-        self._data = data if isinstance(data, da.Array) else da.asanyarray(data)
-
         if not name.isidentifier():
             raise ValueError("Argument 'name' has to be a valid identifier")
 
+        self._data = data
         self._name = name
         self._label = label
         self._unit = unit
 
-    def __len__(self) -> int:
-        if self._data.ndim == 0:
-            return 0
-        else:
-            return len(self._data)
-
     def __repr__(self) -> str:
-        return f"Axis(name='{self.name}', label='{self.label}', unit='{self.unit}')"
-
-    def _repr_html_(self) -> str:
-        html = Table(
-            f"{type(self).__name__}",
-            {
-                "name": self.name,
-                "label": self.label,
-                "unit": self.unit or "''",
-                "ndim": self.ndim,
-                "shape": self.shape,
-                "dtype": self.dtype,
-            },
-            fold_closed=False,
-        ).render_as_html()
-        return html
+        return f"Axis<name='{self.name}', label='{self.label}', unit='{self.unit}'>"
 
     def _repr_markdown_(self) -> str:
         md = f"""
@@ -72,139 +46,16 @@ def _repr_markdown_(self) -> str:
         """
         return dedent(md)
 
-    @property
-    def name(self) -> str:
-        return self._name
-
-    @name.setter
-    def name(self, new_name: str) -> None:
-        if not new_name.isidentifier():
-            raise ValueError("New name has to be a valid identifier")
-
-        self._name = new_name
-
-    @property
-    def label(self) -> str:
-        return self._label
-
-    @label.setter
-    def label(self, new_label: str) -> None:
-        self._label = new_label
-
-    @property
-    def unit(self) -> str:
-        return self._unit
-
-    @unit.setter
-    def unit(self, new_unit: str) -> None:
-        self._unit = new_unit
-
-    @property
-    def shape(self) -> Tuple[int, ...]:
-        return self._data.shape
-
-    @property
-    def ndim(self) -> int:
-        return self._data.ndim
-
-    @property
-    def dtype(self) -> np.dtype:
-        return self._data.dtype
-
-    def to_dask(self) -> da.Array:
-        return self._data
-
-    def to_numpy(self) -> np.ndarray:
-        return self._data.compute()
-
-    def __array__(self, dtype: Optional[np.dtype] = None):
-        data = self._data.compute()
-        return data.astype(dtype) if dtype else data
-
-    @classmethod
-    def implements(
-        cls,
-        numpy_function: Callable,
-        function_type: str = "array_function",
-    ):
-        def decorator(func):
-            if function_type == "array_function":
-                cls._handled_array_function[numpy_function] = func
-            elif function_type == "array_ufunc":
-                cls._handled_array_ufunc[numpy_function] = func
-            else:
-                raise ValueError("Invalid 'function_type' provided")
-
-            return func
-
-        return decorator
-
-    def __array_ufunc__(
-        self,
-        ufunc: np.ufunc,
-        method: str,
-        *inputs: Tuple[Any, ...],
-        **kwargs: Dict[str, Any],
-    ) -> "Axis":
-        if ufunc in self._handled_array_ufunc:
-            return self._handled_array_ufunc[ufunc](method, *inputs, **kwargs)
-
-        # Takes inputs and replaces instances of 'self' by '_data'
-        inputs = tuple(self._data if obj is self else obj for obj in inputs)
-
-        if "out" in kwargs:
-            kwargs["out"] = tuple(
-                self._data if obj is self else obj for obj in kwargs["out"]
-            )
-
-        data = self._data.__array_ufunc__(ufunc, method, *inputs, **kwargs)
-
-        if data is NotImplemented:
-            raise NotImplementedError(
-                f"{ufunc=} for {method=}, {inputs=}, and {kwargs=} not implemented"
-            )
-        elif data is None:
-            # in-place
-            self._data = kwargs["out"][0]
-            return self
-        else:
-            return self.__class__(
-                data,
-                name=self.name,
-                label=self.label,
-                unit=self.unit,
-            )
-
-    def __array_function__(
-        self,
-        function: Callable,
-        types: Tuple[Type[Any], ...],
-        args: Tuple[Any, ...],
-        kwargs: Dict[str, Any],
-    ):
-        if function in self._handled_array_function:
-            return self._handled_array_function[function](*args, **kwargs)
-
-        repacked_types = tuple(
-            type_ if not issubclass(type_, Axis) else da.Array for type_ in types
-        )
-        repacked_args = tuple(
-            arg if not isinstance(arg, Axis) else arg.to_dask() for arg in args
-        )
-
-        data = self._data.__array_function__(
-            function,
-            repacked_types,
-            repacked_args,
-            kwargs,
+    def __getitem__(self, key: Any) -> "Axis":
+        return Axis(
+            self._data[key],
+            name=self._name,
+            label=self._label,
+            unit=self._unit,
         )
 
-        # TODO: Are name, label, and unit required to be passed here or should we have
-        #       default option
-        return self.__class__(data)
-
-    def __getitem__(self, key: Any) -> "Axis":
-        return Axis(self._data[key], name=self.name, label=self.label, unit=self.unit)
+    def __hash__(self) -> int:
+        return hash((self._name, self._label, self._unit))
 
     @staticmethod
     def _log_axis(
@@ -222,6 +73,18 @@ def _lin_axis(
         """Generates linearly spaced array."""
         return da.linspace(min_, max_, points)
 
+    @classmethod
+    def from_array(
+        cls,
+        data: ArrayLike,
+        *,
+        name: str = "unnamed",
+        label: str = "unlabeled",
+        unit: str = "",
+    ) -> "Axis":
+        data = data if isinstance(data, da.Array) else da.asanyarray(data)
+        return cls(data, name, label, unit)
+
     @classmethod
     def from_limits(
         cls,
@@ -248,8 +111,36 @@ def from_limits(
 
 
 @Axis.implements(np.concatenate)
-def concatenate(arrays, *args, **kwargs):
-    arrays = tuple(
-        array if not isinstance(array, Axis) else array.to_dask() for array in arrays
-    )
-    return Axis(np.concatenate(arrays, *args, **kwargs))
+def Axis_concatenate(
+    types: Tuple[Type],
+    args: Tuple[Any, ...],
+    kwargs: Dict[str, Any],
+) -> Axis:
+    # repack args -> turn Axis into dask
+    arrays, *rest = args
+    arrays = tuple(arr.to_dask() if isinstance(arr, Axis) else arr for arr in arrays)
+    args = (arrays,) + tuple(rest)
+    data = da.concatenate(*args, **kwargs)
+    return Axis.from_array(data)
+
+
+class HasAxes:
+    _axes: Tuple[Axis, ...]
+
+    @property
+    def axes(self) -> Tuple[Axis, ...]:
+        return self._axes
+
+
+class HasTimeAxis:
+    _time: Axis
+
+    def __init__(self, time: Axis) -> None:
+        if not isinstance(time, Axis):
+            raise TypeError(f"'time' has to be of type '{type(Axis)}'")
+
+        self._time = time
+
+    @property
+    def time(self) -> Axis:
+        return self._time
diff --git a/nata/containers/core.py b/nata/containers/core.py
new file mode 100644
index 0000000..38c0be8
--- /dev/null
+++ b/nata/containers/core.py
@@ -0,0 +1,499 @@
+# -*- coding: utf-8 -*-
+from functools import partial
+from pathlib import Path
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import Optional
+from typing import Protocol
+from typing import Set
+from typing import Tuple
+from typing import Type
+from typing import Union
+
+import dask.array as da
+import numpy as np
+from numpy.lib.mixins import NDArrayOperatorsMixin
+
+
+class BackendType(Protocol):
+    name: str
+
+    @staticmethod
+    def is_valid_backend(location: Path) -> bool:
+        ...  # pragma: no cover
+
+
+class HasBackends:
+    """Class containing the conceptional logic of backends in nata.
+
+    This class provides an abstraction layer on implementing the backend logic in nata.
+    To use this logic, any class can use this class as a parent class to incoporate
+    backends.
+    """
+
+    _backend_protocol: BackendType
+    _backends: Set[BackendType]
+
+    def __init_subclass__(
+        cls,
+        *,
+        backend_protocol: Optional[BackendType] = None,
+        **kwargs: Dict[str, Any],
+    ) -> None:
+        super().__init_subclass__(**kwargs)
+
+        if backend_protocol is None:
+            raise AttributeError("requires backend protocol")
+
+        cls._backend_protocol = backend_protocol
+        cls._backends = set()
+
+    @classmethod
+    def add_backend(cls, backend: BackendType) -> None:
+        """Add backend to class.
+
+        Class method which takes a backend and add it to the set of available backends
+        for the class.
+
+        Arguments:
+            backend: Backend which will be added to the class.
+
+        Raises:
+            TypeError: Raised when provided backend is invalid
+        """
+        if cls.is_valid_backend(backend):
+            cls._backends.add(backend)
+        else:
+            raise TypeError("invalid backend provided")
+
+    @classmethod
+    def remove_backend(cls, backend: Union[BackendType, str]) -> None:
+        """Remove backend from a class.
+
+        Class method which removes a backend from a set of class-specific backends.
+
+        Arguments:
+            backend: Backend or name of the backend which will be removed.
+
+        Raises:
+            ValueError: Raised when provided backend or name do not match registered
+                        backends.
+        """
+        if isinstance(backend, str):
+            for b in cls._backends:
+                if b.name == backend:
+                    cls._backends.remove(b)
+                    break
+            else:
+                raise ValueError(f"backend '{backend}' not registered")
+        else:
+            try:
+                cls._backends.remove(backend)
+            except KeyError:
+                raise ValueError(f"backend '{backend}' not registered")
+
+    @classmethod
+    def is_valid_backend(cls, backend: BackendType) -> bool:
+        """Check if a backend is a valid backend.
+
+        Any class which uses a backend systems can only accept a specific backend. This
+        backend is determined by the backend protocol `cls._backend_protocol` of a
+        class.
+
+        Arguments:
+            backend: Backend which will be checked if it fulfills the backend protocol
+                     of the class.
+        """
+        return isinstance(backend, cls._backend_protocol)
+
+    @classmethod
+    def get_backends(cls) -> Dict[str, BackendType]:
+        """Return a dictionary of registred backends."""
+        backends_dict = {}
+        for backend in cls._backends:
+            backends_dict[backend.name] = backend
+        return backends_dict
+
+    @classmethod
+    def get_valid_backend(cls, path: Path) -> Optional[BackendType]:
+        """Returns a valid backend for a given file location."""
+        for backend in cls._backends:
+            if backend.is_valid_backend(path):
+                return backend
+        else:
+            return None
+
+
+class HasNumpyInterface(NDArrayOperatorsMixin):
+    _handled_array_ufunc: Dict[np.ufunc, Callable]
+    _handled_array_function: Dict[Callable, Callable]
+
+    _data: da.Array
+
+    def __init__(self, data: da.Array) -> None:
+        if not isinstance(data, da.Array):
+            raise TypeError(f"'data' has to be of type '{type(da.Array)}'")
+
+        self._data = data
+
+    def __init_subclass__(cls, **kwargs: Dict[str, Any]) -> None:
+        super().__init_subclass__(**kwargs)
+
+        cls._handled_array_ufunc = {}
+        cls._handled_array_function = {}
+
+    @classmethod
+    def from_array(cls, data: da.Array) -> "HasNumpyInterface":
+        return cls(data)
+
+    @classmethod
+    def get_handled_array_ufunc(cls) -> Dict[np.ufunc, Callable]:
+        return cls._handled_array_ufunc
+
+    @classmethod
+    def add_handled_array_ufunc(
+        cls, function: np.ufunc, implementation: Callable
+    ) -> None:
+        if not isinstance(function, np.ufunc):
+            raise TypeError("provided function is not of type ufunc")
+
+        cls._handled_array_ufunc[function] = implementation
+
+    @classmethod
+    def remove_handled_array_ufunc(cls, function: np.ufunc) -> None:
+        if function not in cls._handled_array_ufunc:
+            raise ValueError(f"ufunc '{function}' is not registered")
+
+        del cls._handled_array_ufunc[function]
+
+    @classmethod
+    def get_handled_array_function(cls) -> Dict[Callable, Callable]:
+        return cls._handled_array_function
+
+    @classmethod
+    def add_handled_array_function(
+        cls, function: Callable, implementation: Callable
+    ) -> None:
+        cls._handled_array_function[function] = implementation
+
+    @classmethod
+    def remove_handeld_array_function(cls, function: Callable) -> None:
+        if function not in cls._handled_array_function:
+            raise ValueError(f"function '{function}' is not registered")
+
+        del cls._handled_array_function[function]
+
+    @classmethod
+    def implements(cls, function: Union[np.ufunc, Callable]):
+        def decorator(func):
+            if isinstance(function, np.ufunc):
+                cls._handled_array_ufunc[function] = func
+            else:
+                cls._handled_array_function[function] = func
+
+            return func
+
+        return decorator
+
+    def to_dask(self) -> da.Array:
+        return self._data
+
+    def to_numpy(self) -> np.ndarray:
+        return self._data.compute()
+
+    @property
+    def dtype(self) -> np.dtype:
+        return self._data.dtype
+
+    @property
+    def ndim(self) -> int:
+        return self._data.ndim
+
+    @property
+    def shape(self) -> Tuple[int, ...]:
+        return self._data.shape
+
+    def __len__(self) -> int:
+        return len(self._data)
+
+    def __array__(self, dtype: Optional[np.dtype] = None) -> np.ndarray:
+        arr = self.to_numpy()
+
+        # '__array__' requires to return ndarray and 'to_numpy' can produce other types
+        if not isinstance(arr, np.ndarray):
+            arr = np.asanyarray(arr)
+
+        return arr.astype(dtype) if dtype else arr
+
+    def __array_ufunc__(
+        self,
+        ufunc: np.ufunc,
+        method: str,
+        *inputs: Tuple[Any, ...],
+        **kwargs: Dict[str, Any],
+    ) -> "HasNumpyInterface":
+        if ufunc in self._handled_array_ufunc:
+            return self._handled_array_ufunc[ufunc](method, *inputs, **kwargs)
+
+        # repack inputs to mimic passing as dask array
+        repacked_inputs = ()
+        for input_ in inputs:
+            if input_ is self:
+                repacked_inputs += (self._data,)
+            elif isinstance(input_, HasNumpyInterface):
+                repacked_inputs += (input_.to_dask(),)
+            else:
+                repacked_inputs += (input_,)
+
+        # required additional repacking if in-place
+        if "out" in kwargs:
+            output = tuple(self._data if arg is self else arg for arg in kwargs["out"])
+            kwargs["out"] = output
+
+        data = self._data.__array_ufunc__(ufunc, method, *repacked_inputs, **kwargs)
+
+        # Reraise 'NotImplemented' if does dask does not implement it and let numpy take
+        # over in the dispatching process:
+        #
+        # https://numpy.org/doc/stable/reference/arrays.classes.html#numpy.class.__array_ufunc__
+        if data is NotImplemented:
+            raise NotImplemented  # noqa: F901
+
+        # Handle in-place operations scenario
+        elif data is None:
+            self._data = kwargs["out"][0]
+            return self
+        else:
+            return self.from_array(data)
+
+    def __array_function__(
+        self,
+        func: Callable,
+        types: Tuple[Type],
+        args: Tuple[Any, ...],
+        kwargs: Dict[str, Any],
+    ) -> "HasNumpyInterface":
+        if func in self._handled_array_function:
+            return self._handled_array_function[func](types, args, kwargs)
+
+        # repack arguments
+        types = tuple(type(self._data) if t is type(self) else t for t in types)
+        args = tuple(self._data if arg is self else arg for arg in args)
+        data = self._data.__array_function__(func, types, args, kwargs)
+
+        return self.from_array(data)
+
+
+class HasPluginSystem:
+    _plugin_as_property: Dict[str, Callable]
+    _plugin_as_method: Dict[str, Callable]
+
+    def __init_subclass__(cls, **kwargs: Dict[str, Any]) -> None:
+        super().__init_subclass__(**kwargs)
+
+        cls._plugin_as_property = {}
+        cls._plugin_as_method = {}
+
+        cls._something = None
+
+    def __getattribute__(self, name: str) -> Any:
+        if name == "_plugin_as_property":
+            return super().__getattribute__(name)
+
+        if name == "_plugin_as_method":
+            return super().__getattribute__(name)
+
+        if name in self._plugin_as_property:
+            return self._plugin_as_property[name](self)
+
+        if name in self._plugin_as_method:
+            func = partial(self._plugin_as_method[name], self)
+            func.__doc__ = self._plugin_as_method[name].__doc__
+            return func
+
+        return super().__getattribute__(name)
+
+    @classmethod
+    def get_property_plugin(cls) -> Dict[str, Callable]:
+        return cls._plugin_as_property
+
+    @classmethod
+    def add_property_plugin(cls, plugin_name: str, plugin: Callable) -> None:
+        if not isinstance(plugin_name, str):
+            raise TypeError("'plugin_name' has to be a 'str'")
+
+        if not plugin_name.isidentifier():
+            raise ValueError(f"'{plugin_name}' has to be a valid identifier")
+
+        cls._plugin_as_property[plugin_name] = plugin
+
+    @classmethod
+    def remove_property_plugin(cls, plugin_name: str) -> None:
+        if plugin_name not in cls._plugin_as_property:
+            raise ValueError(f"plugin '{plugin_name}' is not registered")
+
+        del cls._plugin_as_property[plugin_name]
+
+    @classmethod
+    def get_method_plugin(cls) -> Dict[str, Callable]:
+        return cls._plugin_as_method
+
+    @classmethod
+    def add_method_plugin(cls, plugin_name: str, plugin: Callable) -> None:
+        if not isinstance(plugin_name, str):
+            raise TypeError("'plugin_name' has to be a 'str'")
+
+        if not plugin_name.isidentifier():
+            raise ValueError(f"'{plugin_name}' has to be a valid identifier")
+
+        cls._plugin_as_method[plugin_name] = plugin
+
+    @classmethod
+    def remove_method_plugin(cls, plugin_name: str) -> None:
+        if plugin_name not in cls._plugin_as_method:
+            raise ValueError(f"plugin '{plugin_name}' is not registered")
+
+        del cls._plugin_as_method[plugin_name]
+
+
+class HasAnnotations:
+    _name: str
+    _label: str
+    _unit: str
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @name.setter
+    def name(self, new: str) -> None:
+        new = new if isinstance(new, str) else str(new, encoding="utf-8")
+        if not new.isidentifier():
+            raise ValueError("'name' has to be an identifier")
+        self._name = new
+
+    @property
+    def label(self) -> str:
+        return self._label
+
+    @label.setter
+    def label(self, new: str) -> None:
+        self._label = new
+
+    @property
+    def unit(self) -> str:
+        return self._unit
+
+    @unit.setter
+    def unit(self, new: str) -> None:
+        self._unit = new
+
+
+class HasName:
+    _name: str
+    _label: str
+
+    def __init__(self, name: str, label: str) -> None:
+        if not isinstance(name, str):
+            raise TypeError("'name' has to be of type 'str'")
+
+        if not isinstance(label, str):
+            raise TypeError("'label' has to be of type 'str'")
+
+        if not name.isidentifier():
+            raise ValueError("'name' has to be valid identifier")
+
+        self._name = name
+        self._label = label
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @name.setter
+    def name(self, new_name: str) -> None:
+        if not isinstance(new_name, str):
+            raise TypeError("'new_name' has to be of type 'str'")
+
+        if not new_name.isidentifier():
+            raise ValueError("'name' has to be valid identifier")
+
+        self._name = new_name
+
+    @property
+    def label(self) -> str:
+        return self._label
+
+    @label.setter
+    def label(self, new_label: str) -> None:
+        if not isinstance(new_label, str):
+            raise TypeError("'new_label' has to be of type 'str'")
+
+        self._label = new_label
+
+
+class HasUnit:
+    _unit: str
+
+    def __init__(self, unit: str) -> None:
+        if not isinstance(unit, str):
+            raise TypeError("'unit' has to be of type 'str'")
+
+        self._unit = unit
+
+    @property
+    def unit(self) -> str:
+        return self._unit
+
+    @unit.setter
+    def unit(self, new: str) -> None:
+        self._unit = new
+
+
+# TODO: make quantitites indexable and allow to change name, label, and units
+class HasQuantities:
+    _quantities: Tuple[Tuple[str, str, str], ...]
+
+    def __init__(self, quantities: Tuple[Tuple[str, str, str], ...]) -> None:
+        if (
+            not isinstance(quantities, tuple)
+            or not all(isinstance(q, tuple) for q in quantities)
+            or not all(isinstance(v, str) for q in quantities for v in q)
+            or not all(len(q) == 3 for q in quantities)
+        ):
+            raise TypeError(
+                "'quantities' has to be a 'Tuple[Tuple[str, str, str], ...]'"
+            )
+
+        self._quantities = quantities
+
+    @property
+    def quantities(self) -> Tuple[Tuple[str, str, str], ...]:
+        return self._quantities
+
+    @property
+    def quantity_names(self) -> Tuple[str, ...]:
+        return tuple(q[0] for q in self._quantities)
+
+    @property
+    def quantity_labels(self) -> Tuple[str, ...]:
+        return tuple(q[1] for q in self._quantities)
+
+    @property
+    def quantity_units(self) -> Tuple[str, ...]:
+        return tuple(q[2] for q in self._quantities)
+
+
+class HasParticleCount:
+    _count: int
+
+    def __init__(self, count: int) -> None:
+        if not isinstance(count, int):
+            raise TypeError("'count' has to be 'int'")
+
+        self._count = count
+
+    @property
+    def count(self) -> int:
+        return self._count
diff --git a/nata/containers/exceptions.py b/nata/containers/exceptions.py
new file mode 100644
index 0000000..cbc4b83
--- /dev/null
+++ b/nata/containers/exceptions.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+class NoValidBackend(Exception):
+    pass
diff --git a/nata/containers/grid.py b/nata/containers/grid.py
index 749cb0d..1dea103 100644
--- a/nata/containers/grid.py
+++ b/nata/containers/grid.py
@@ -1,20 +1,14 @@
 # -*- coding: utf-8 -*-
 from collections import defaultdict
-from functools import partial
 from pathlib import Path
 from textwrap import dedent
-from types import FunctionType
 from typing import Any
-from typing import Callable
 from typing import Dict
-from typing import Iterable
 from typing import List
 from typing import Optional
 from typing import Protocol
 from typing import Sequence
-from typing import Set
 from typing import Tuple
-from typing import Type
 from typing import Union
 from typing import runtime_checkable
 from warnings import warn
@@ -29,15 +23,18 @@
 from nata.utils.types import FileLocation
 
 from .axis import Axis
-from .utils import get_doc_heading
+from .axis import HasAxes
+from .axis import HasTimeAxis
+from .core import HasAnnotations
+from .core import HasBackends
+from .core import HasNumpyInterface
+from .core import HasPluginSystem
+from .exceptions import NoValidBackend
+from .utils import is_unique
 
 __all__ = ["GridArray", "GridDataset", "stack"]
 
 
-def is_unique(iterable: Iterable) -> bool:
-    return len(set(iterable)) == 1
-
-
 def stack(grid_arrs: Sequence["GridArray"]) -> "GridDataset":
     if not len(grid_arrs):
         raise ValueError("can not iterate over 0-length sequence of GridArrays")
@@ -52,7 +49,7 @@ def stack(grid_arrs: Sequence["GridArray"]) -> "GridDataset":
     unit = base.unit
 
     data = da.stack([grid.to_dask() for grid in grid_arrs])
-    time = Axis(
+    time = Axis.from_array(
         da.stack([grid.time.to_dask() for grid in grid_arrs]),
         name=base.time.name,
         label=base.time.label,
@@ -62,17 +59,15 @@ def stack(grid_arrs: Sequence["GridArray"]) -> "GridDataset":
     axes = []
     for i, ax in enumerate(base.axes):
         axes_data = da.stack([grid.axes[i].to_dask() for grid in grid_arrs])
-        axes.append(Axis(axes_data, name=ax.name, label=ax.label, unit=ax.unit))
+        axes.append(
+            Axis.from_array(axes_data, name=ax.name, label=ax.label, unit=ax.unit)
+        )
 
     return GridDataset(data, (time,) + tuple(axes), name=name, label=label, unit=unit)
 
 
-class NoValidBackend(Exception):
-    pass
-
-
 @runtime_checkable
-class GridBackendType(Protocol):
+class GridBackend(Protocol):
     name: str
     location: Path
 
@@ -103,355 +98,20 @@ def is_valid_backend(location: FileLocation) -> bool:
 
 
 @runtime_checkable
-class GridDataReader(GridBackendType, Protocol):
+class GridDataReader(GridBackend, Protocol):
     def get_data(self, indexing: Optional[BasicIndexing] = None) -> np.ndarray:
         ...
 
 
-class _HasGridBackend:
-    _backends: Set[GridBackendType] = set()
-
-    @classmethod
-    def add_backend(cls, backend: GridBackendType) -> None:
-        """Classmethod to add Backend to GridDatasets"""
-        if cls.is_valid_backend(backend):
-            cls._backends.add(backend)
-        else:
-            raise ValueError("Invalid backend provided")
-
-    @classmethod
-    def remove_backend(cls, backend: GridBackendType) -> None:
-        """Remove a backend which is stored in ``GridDatasets``."""
-        cls._backends.remove(backend)
-
-    @staticmethod
-    def is_valid_backend(backend: GridBackendType) -> bool:
-        """Check if a backend is a valid backend for ``GridDatasets``."""
-        return isinstance(backend, GridBackendType)
-
-    @classmethod
-    def get_backends(cls) -> Dict[str, GridBackendType]:
-        """Dictionary of registered backends for :class:`.GridDataset`"""
-        backends_dict = {}
-        for backend in cls._backends:
-            backends_dict[backend.name] = backend
-        return backends_dict
-
-    @classmethod
-    def get_valid_backend(cls, path: Path) -> Optional[GridBackendType]:
-        for backend in cls._backends:
-            if backend.is_valid_backend(path):
-                return backend
-        else:
-            return None
-
-    @staticmethod
-    def _unpack_backend(
-        backend: GridBackendType, path: Path, time_axis: str
-    ) -> Tuple[da.Array, Tuple[Axis, ...], Axis, str, str, str]:
-        grid = backend(path)
-        data = da.from_array(grid)
-
-        name = grid.dataset_name
-        label = grid.dataset_label
-        unit = grid.dataset_unit
-
-        if time_axis == "time":
-            time = Axis(grid.time_step, name="time", label="time", unit=grid.time_unit)
-        else:
-            time = Axis(grid.iteration, name="iteration", label="iteration")
-
-        axes = ()
-        for min_, max_, ax_pts, ax_name, ax_label, ax_unit in zip(
-            grid.axes_min,
-            grid.axes_max,
-            grid.shape,
-            grid.axes_names,
-            grid.axes_labels,
-            grid.axes_units,
-        ):
-            axes += (
-                Axis.from_limits(
-                    min_, max_, ax_pts, name=ax_name, label=ax_label, unit=ax_unit
-                ),
-            )
-
-        return data, axes, time, name, label, unit
-
-
-class _HasNumpyInterface(np.lib.mixins.NDArrayOperatorsMixin):
-    _handled_array_ufunc: Dict[np.ufunc, Callable]
-    _handled_array_function: Dict[FunctionType, Callable]
-
-    _data: da.Array
-
-    @classmethod
-    def implements(cls, numpy_function: Union[np.ufunc, FunctionType]):
-        def decorator(func):
-            if isinstance(numpy_function, np.ufunc):
-                cls._handled_array_ufunc[numpy_function] = func
-            else:
-                cls._handled_array_function[numpy_function] = func
-
-            return func
-
-        return decorator
-
-    @classmethod
-    def get_handled_ufuncs(cls) -> Set[np.ufunc]:
-        return set(func for func in cls._handled_array_ufunc)
-
-    @classmethod
-    def remove_handled_ufuncs(cls, function: np.ufunc) -> None:
-        del cls._handled_array_ufunc[function]
-
-    @classmethod
-    def get_handled_array_function(cls) -> Set[np.ufunc]:
-        return set(func for func in cls._handled_array_function)
-
-    @classmethod
-    def remove_handled_array_function(cls, function: np.ufunc) -> None:
-        del cls._handled_array_function[function]
-
-    @classmethod
-    def from_array(cls, data, *args, **kwargs) -> "_HasNumpyInterface":
-        raise NotImplementedError
-
-    def __array__(self, dtype: Optional[np.dtype] = None) -> np.ndarray:
-        # ensures it is a ndarray -> tries to avoid copying, hence `np.asanyarray`
-        arr = self.to_numpy()
-        if not isinstance(arr, np.ndarray):
-            arr = np.asanyarray(self._data.compute())
-
-        if dtype:
-            return arr.astype(dtype)
-        else:
-            return arr
-
-    def __array_ufunc__(
-        self,
-        ufunc: np.ufunc,
-        method: str,
-        *inputs: Tuple[Any, ...],
-        **kwargs: Dict[str, Any],
-    ) -> "_HasNumpyInterface":
-        if ufunc in self._handled_array_ufunc:
-            return self._handled_array_ufunc[ufunc](method, inputs, kwargs)
-
-        # repack inputs to mimic passing as dask array
-        inputs = tuple(self._data if input_ is self else input_ for input_ in inputs)
-
-        # required additional repacking if in-place
-        if "out" in kwargs:
-            output = tuple(self._data if arg is self else arg for arg in kwargs["out"])
-            kwargs["out"] = output
-
-        data = self._data.__array_ufunc__(ufunc, method, *inputs, **kwargs)
-
-        # __array_ufunc__ return not implemented by dask
-        if data is NotImplemented:
-            raise NotImplementedError(f"ufunc '{ufunc}' not implemented!")
-
-        # in-place scenario
-        elif data is None:
-            self._data = kwargs["out"][0]
-            return self
-        else:
-            return self.from_array(data)
-
-    def __array_function__(
-        self,
-        func: FunctionType,
-        types: Tuple[Type],
-        args: Tuple[Any, ...],
-        kwargs: Dict[str, Any],
-    ) -> "_HasNumpyInterface":
-        if func in self._handled_array_function:
-            return self._handled_array_function[func](types, args, kwargs)
-
-        # repack arguments
-        types = tuple(type(self._data) if t is type(self) else t for t in types)
-        args = tuple(self._data if arg is self else arg for arg in args)
-        data = self._data.__array_function__(func, types, args, kwargs)
-
-        return self.from_array(data)
-
-    def __len__(self) -> int:
-        return len(self._data)
-
-    @property
-    def dtype(self) -> np.dtype:
-        return self._data.dtype
-
-    @property
-    def ndim(self) -> int:
-        return self._data.ndim
-
-    @property
-    def shape(self) -> Tuple[int, ...]:
-        return self._data.shape
-
-    def to_dask(self) -> da.Array:
-        return self._data
-
-    def to_numpy(self) -> np.ndarray:
-        return self._data.compute()
-
-
-class _HasPluginSystem:
-    _plugin_as_property: Dict[str, Callable]
-    _plugin_as_method: Dict[str, Callable]
-
-    @classmethod
-    def register_plugin(
-        cls,
-        plugin_name: str,
-        plugin: Callable,
-        plugin_type: str = "property",
-    ) -> None:
-        if (
-            plugin_name in cls._plugin_as_property
-            or plugin_name in cls._plugin_as_method
-        ):
-            raise ValueError(f"plugin '{plugin_name}' already registerd")
-
-        if plugin_type not in ("property", "method"):
-            raise ValueError("'plugin_type' can only be 'property' or 'method'")
-
-        if not plugin_name.isidentifier():
-            raise ValueError("plugin name has to be an identifier")
-
-        if plugin_type == "property":
-            cls._plugin_as_property[plugin_name] = plugin
-        else:
-            cls._plugin_as_method[plugin_name] = plugin
-
-    @classmethod
-    def remove_plugin(cls, plugin_name: str) -> None:
-        if (
-            plugin_name not in cls._plugin_as_property
-            and plugin_name not in cls._plugin_as_method
-        ):
-            raise ValueError(f"plugin '{plugin_name}' not registerd")
-
-        if plugin_name in cls._plugin_as_property:
-            del cls._plugin_as_property[plugin_name]
-        else:
-            del cls._plugin_as_method[plugin_name]
-
-    @classmethod
-    def get_plugins(cls) -> Dict[str, str]:
-        plugins = {**cls._plugin_as_method, **cls._plugin_as_property}
-        return {name: get_doc_heading(plugin) for name, plugin in plugins.items()}
-
-    def __getattribute__(self, name: str) -> Any:
-        if name == "_plugin_as_property":
-            return super().__getattribute__(name)
-
-        if name == "_plugin_as_method":
-            return super().__getattribute__(name)
-
-        if name in self._plugin_as_property:
-            return self._plugin_as_property[name](self)
-
-        if name in self._plugin_as_method:
-            func = partial(self._plugin_as_method[name], self)
-            func.__doc__ = self._plugin_as_method[name].__doc__
-            return func
-
-        return super().__getattribute__(name)
-
-
-class _HasAnnotations:
-    _name: str
-    _label: str
-    _unit: str
-
-    _axes: Tuple[Axis, ...]
-    _time: Axis
-
-    def __repr__(self) -> str:
-        repr_ = (
-            f"{type(self).__name__}<"
-            f"shape={self.shape}, "
-            f"dtype={self.dtype}, "
-            f"time={self.time.to_numpy()}, "
-            f"axes=({', '.join(f'Axis({ax.name})' for ax in self.axes)})"
-            ">"
-        )
-        return repr_
-
-    def _repr_markdown_(self) -> str:
-        md = f"""
-        | **{type(self).__name__}** | |
-        | ---: | :--- |
-        | **name**  | {self.name} |
-        | **label** | {self.label} |
-        | **unit**  | {self.unit or "''"} |
-        | **shape** | {self.shape} |
-        | **dtype** | {self.dtype} |
-        | **time**  | {self.time.to_numpy()} |
-        | **axes**  | {', '.join(f"Axis({ax.name})" for ax in self.axes)} |
-
-        """
-        return dedent(md)
-
-    @property
-    def name(self) -> str:
-        return self._name
-
-    @name.setter
-    def name(self, new: str) -> None:
-        new = new if isinstance(new, str) else str(new, encoding="utf-8")
-        if not new.isidentifier():
-            raise ValueError("name has to be an identifier")
-        self._name = new
-
-    @property
-    def label(self) -> str:
-        return self._label
-
-    @label.setter
-    def label(self, new: str) -> None:
-        self._label = new
-
-    @property
-    def unit(self) -> str:
-        return self._unit
-
-    @unit.setter
-    def unit(self, new: str) -> None:
-        self._unit = new
-
-    @property
-    def axes(self) -> Tuple[Axis, ...]:
-        return self._axes
-
-    @property
-    def time(self) -> Axis:
-        return self._time
-
-    def __hash__(self) -> int:
-        # general naming
-        key = (self.name, self.label, self.unit, self.shape)
-
-        # time specific
-        key += (self.time.name, self.time.label, self.time.unit)
-
-        # axes specific
-        for ax in self.axes:
-            key += (ax.name, ax.label, ax.unit)
-
-        return hash(key)
-
-
-class GridArray(_HasAnnotations, _HasGridBackend, _HasNumpyInterface, _HasPluginSystem):
-    _plugin_as_property: Dict[str, Callable] = {}
-    _plugin_as_method: Dict[str, Callable] = {}
-
-    _handled_array_ufunc: Dict[np.ufunc, Callable] = {}
-    _handled_array_function: Dict[FunctionType, Callable] = {}
-
+class GridArray(
+    HasBackends,
+    HasAnnotations,
+    HasNumpyInterface,
+    HasPluginSystem,
+    HasAxes,
+    HasTimeAxis,
+    backend_protocol=GridBackend,
+):
     def __init__(
         self,
         data: da.Array,
@@ -501,7 +161,7 @@ def __getitem__(self, key: Any) -> "GridArray":
                 axes.append(ax[ind])
 
         for pos in indices_of_new_axis:
-            axes.insert(pos, Axis([0]))
+            axes.insert(pos, Axis.from_array([0]))
 
         data = self._data[index]
         axes = tuple(axes)
@@ -512,6 +172,45 @@ def __getitem__(self, key: Any) -> "GridArray":
 
         return GridArray(data, axes, time, name, label, unit)
 
+    def __hash__(self) -> int:
+        # general naming
+        key = (self.name, self.label, self.unit, self.shape)
+
+        # time specific
+        key += (self.time.name, self.time.label, self.time.unit)
+
+        # axes specific
+        for ax in self.axes:
+            key += (ax.name, ax.label, ax.unit)
+
+        return hash(key)
+
+    def __repr__(self) -> str:
+        repr_ = (
+            f"{type(self).__name__}<"
+            f"shape={self.shape}, "
+            f"dtype={self.dtype}, "
+            f"time={self.time.to_numpy()}, "
+            f"axes=({', '.join(f'Axis({ax.name})' for ax in self.axes)})"
+            ">"
+        )
+        return repr_
+
+    def _repr_markdown_(self) -> str:
+        md = f"""
+        | **{type(self).__name__}** | |
+        | ---: | :--- |
+        | **name**  | {self.name} |
+        | **label** | {self.label} |
+        | **unit**  | {self.unit or "''"} |
+        | **shape** | {self.shape} |
+        | **dtype** | {self.dtype} |
+        | **time**  | {self.time.to_numpy()} |
+        | **axes**  | {', '.join(f"Axis({ax.name})" for ax in self.axes)} |
+
+        """
+        return dedent(md)
+
     @classmethod
     def from_array(
         cls,
@@ -529,26 +228,61 @@ def from_array(
         if axes is None:
             axes = ()
             for i, l in enumerate(data.shape):
-                axes += (Axis(da.arange(l), name=f"axis{i}"),)
+                axes += (Axis.from_array(da.arange(l), name=f"axis{i}"),)
         else:
             # ensure that every element in axes is an axis
             if any(not isinstance(ax, Axis) for ax in axes):
                 tmp = []
                 for i, ax in enumerate(axes):
                     if not isinstance(ax, Axis):
-                        ax = Axis(da.asanyarray(ax), name=f"axis{i}")
+                        ax = Axis.from_array(da.asanyarray(ax), name=f"axis{i}")
                     tmp.append(ax)
 
                 axes = tuple(tmp)
 
         if time is None:
-            time = Axis(0.0, name="time", label="time")
+            time = Axis.from_array(0.0, name="time", label="time")
         else:
             if not isinstance(time, Axis):
-                time = Axis(time, name="time", label="time")
+                time = Axis.from_array(time, name="time", label="time")
 
         return cls(data, axes, time, name, label, unit)
 
+    @staticmethod
+    def _unpack_backend(
+        backend: GridBackend, path: Path, time_axis: str
+    ) -> Tuple[da.Array, Tuple[Axis, ...], Axis, str, str, str]:
+        grid = backend(path)
+        data = da.from_array(grid)
+
+        name = grid.dataset_name
+        label = grid.dataset_label
+        unit = grid.dataset_unit
+
+        if time_axis == "time":
+            time = Axis.from_array(
+                grid.time_step, name="time", label="time", unit=grid.time_unit
+            )
+        else:
+            time = Axis.from_array(grid.iteration, name="iteration", label="iteration")
+
+        axes = ()
+        for min_, max_, ax_pts, ax_name, ax_label, ax_unit in zip(
+            grid.axes_min,
+            grid.axes_max,
+            grid.shape,
+            grid.axes_names,
+            grid.axes_labels,
+            grid.axes_units,
+        ):
+            axes += (
+                Axis.from_limits(
+                    min_, max_, ax_pts, name=ax_name, label=ax_label, unit=ax_unit
+                ),
+            )
+
+        return data, axes, time, name, label, unit
+
     @classmethod
     def from_path(cls, path: Union[str, Path], time_axis: str = "time") -> "GridArray":
         if not isinstance(path, Path):
@@ -570,15 +304,12 @@ def from_path(cls, path: Union[str, Path], time_axis: str = "time") -> "GridArra
 
 
 class GridDataset(
-    _HasAnnotations, _HasGridBackend, _HasNumpyInterface, _HasPluginSystem
+    HasAnnotations,
+    HasNumpyInterface,
+    HasPluginSystem,
+    HasAxes,
+    HasTimeAxis,
 ):
-
-    _plugin_as_property: Dict[str, Callable] = {}
-    _plugin_as_method: Dict[str, Callable] = {}
-
-    _handled_array_ufunc: Dict[np.ufunc, Callable] = {}
-    _handled_array_function: Dict[FunctionType, Callable] = {}
-
     def __init__(
         self,
         data: da.Array,
@@ -619,6 +350,45 @@ def __init__(
         self._label = label
         self._unit = unit
 
+    def __hash__(self) -> int:
+        # general naming
+        key = (self.name, self.label, self.unit, self.shape)
+
+        # time specific
+        key += (self.time.name, self.time.label, self.time.unit)
+
+        # axes specific
+        for ax in self.axes:
+            key += (ax.name, ax.label, ax.unit)
+
+        return hash(key)
+
+    def __repr__(self) -> str:
+        repr_ = (
+            f"{type(self).__name__}<"
+            f"shape={self.shape}, "
+            f"dtype={self.dtype}, "
+            f"time={self.time.to_numpy()}, "
+            f"axes=({', '.join(f'Axis({ax.name})' for ax in self.axes)})"
+            ">"
+        )
+        return repr_
+
+    def _repr_markdown_(self) -> str:
+        md = f"""
+        | **{type(self).__name__}** | |
+        | ---: | :--- |
+        | **name**  | {self.name} |
+        | **label** | {self.label} |
+        | **unit**  | {self.unit or "''"} |
+        | **shape** | {self.shape} |
+        | **dtype** | {self.dtype} |
+        | **time**  | {self.time.to_numpy()} |
+        | **axes**  | {', '.join(f"Axis({ax.name})" for ax in self.axes)} |
+
+        """
+        return dedent(md)
+
     def __getitem__(self, key: Any) -> Union["GridDataset", GridArray]:
         # unpack axes -> remember the first axis is responsible for time slicing
         time_axis, *rest_axes = self.axes
@@ -647,7 +417,7 @@ def __getitem__(self, key: Any) -> Union["GridDataset", GridArray]:
                 axes.append(ax[time_slicing, ind])
 
         for pos in indices_of_new_axis:
-            axes.insert(pos + 1, Axis(da.zeros((len(time_axis), 1))))
+            axes.insert(pos + 1, Axis.from_array(da.zeros((len(time_axis), 1))))
 
         data = self._data[index]
         name = self.name
@@ -679,11 +449,15 @@ def from_array(
             for i, l in enumerate(data.shape):
                 if i == 0:
                     time_steps = l
-                    time = Axis(da.arange(time_steps), name="time", label="time")
+                    time = Axis.from_array(
+                        da.arange(time_steps), name="time", label="time"
+                    )
                     axes += (time,)
                 else:
                     axis_shape = (time_steps, 1)
-                    axis = Axis(da.tile(da.arange(l), axis_shape), name=f"axis{i-1}")
+                    axis = Axis.from_array(
+                        da.tile(da.arange(l), axis_shape), name=f"axis{i-1}"
+                    )
                     axes += (axis,)
 
         else:
@@ -696,7 +470,7 @@ def from_array(
                     label = "time" if i == 0 else "unlabeled"
 
                     if not isinstance(ax, Axis):
-                        ax = Axis(da.asanyarray(ax), name=name, label=label)
+                        ax = Axis.from_array(da.asanyarray(ax), name=name, label=label)
 
                     tmp.append(ax)
 
diff --git a/nata/containers/particle.py b/nata/containers/particle.py
new file mode 100644
index 0000000..ee7f0f4
--- /dev/null
+++ b/nata/containers/particle.py
@@ -0,0 +1,889 @@
+# -*- coding: utf-8 -*-
+from collections import defaultdict
+from pathlib import Path
+from textwrap import dedent
+from typing import Any
+from typing import Dict
+from typing import Iterable
+from typing import List
+from typing import Optional
+from typing import Protocol
+from typing import Sequence
+from typing import Tuple
+from typing import Union
+from typing import runtime_checkable
+from warnings import warn
+
+import dask.array as da
+import ndindex as ndx
+import numpy as np
+from numpy.typing import ArrayLike
+
+from nata.utils.io import FileList
+from nata.utils.types import BasicIndexing
+from nata.utils.types import FileLocation
+
+from .axis import Axis
+from .axis import HasTimeAxis
+from .core import HasBackends
+from .core import HasName
+from .core import HasNumpyInterface
+from .core import HasParticleCount
+from .core import HasPluginSystem
+from .core import HasQuantities
+from .core import HasUnit
+from .exceptions import NoValidBackend
+from .utils import is_unique
+from .utils import unstructured_to_structured
+
+
+def expand_arr_1d(arr: da.Array, required_shape: Tuple[int]) -> da.Array:
+    missing = (required_shape[0] - arr.shape[0],)
+    values = da.block([arr, da.zeros(missing, dtype=arr.dtype)])
+    mask = da.block([da.zeros(arr.shape, dtype=bool), da.ones(missing, dtype=bool)])
+    return da.ma.masked_array(values, mask=mask)
+
+
+def expand_arr(arr: da.Array, required_shape: Tuple[int]) -> da.Array:
+    if arr.ndim == 1:
+        return expand_arr_1d(arr, required_shape)
+    else:
+        raise NotImplementedError(f"not implemented for 'ndim = {arr.ndim}'")
+
+
+def expand_and_stack(data: Iterable) -> da.Array:
+    arrays = [d if isinstance(d, da.Array) else da.asanyarray(d) for d in data]
+    max_shape = max(arr.shape for arr in arrays)
+    arrays = [expand_arr(arr, max_shape) for arr in arrays]
+    return da.stack(arrays)
+
+
+def stack(part_arrs: Sequence["ParticleArray"]) -> "ParticleDataset":
+    if not len(part_arrs):
+        raise ValueError("can not iterate over 0-length sequence of GridArrays")
+
+    if not is_unique(hash(prt) for prt in part_arrs):
+        raise ValueError("provided GridArrays are not equivalent to each other")
+
+    base = part_arrs[0]
+
+    data = expand_and_stack([grid.to_dask() for grid in part_arrs])
+    time = Axis.from_array(
+        da.stack([grid.time.to_dask() for grid in part_arrs]),
+        name=base.time.name,
+        label=base.time.label,
+        unit=base.time.unit,
+    )
+
+    return ParticleDataset(data, base.quantities, time, base.name, base.label)
+
+
+@runtime_checkable
+class ParticleBackend(Protocol):
+    name: str
+    location: Path
+
+    def __init__(self, location: FileLocation) -> None:
+        ...
+
+    @staticmethod
+    def is_valid_backend(location: FileLocation) -> bool:
+        ...
+
+    dataset_name: str
+    dataset_label: str
+
+    num_particles: int
+
+    quantity_names: Sequence[str]
+    quantity_labels: Sequence[str]
+    quantity_units: Sequence[str]
+
+    iteration: int
+    time_step: float
+    time_unit: str
+
+    shape: Tuple[int, ...]
+    dtype: np.dtype
+    ndim: int
+
+
+@runtime_checkable
+class ParticleDataReader(ParticleBackend, Protocol):
+    def get_data(
+        self,
+        indexing: Optional[BasicIndexing] = None,
+        fields: Optional[Union[str, Sequence[str]]] = None,
+    ) -> np.ndarray:
+        ...
+
+
+class Quantity(
+    HasNumpyInterface,
+    HasName,
+    HasUnit,
+    HasPluginSystem,
+    HasTimeAxis,
+    HasParticleCount,
+):
+    def __init__(
+        self,
+        data: da.Array,
+        time: Axis,
+        name: str,
+        label: str,
+        unit: str,
+    ) -> None:
+        if data.ndim != 0:
+            raise ValueError("only 0d data is supported")
+
+        if data.dtype.fields:
+            raise ValueError("only unstructured data types supported")
+
+        self._name = name
+        self._label = label
+        self._unit = unit
+
+        self._count = 1
+        self._time = time
+
+        self._data = data
+
+    def __getitem__(self, key: Any) -> None:
+        raise IndexError("Quantity is not indexable")
+
+    def __hash__(self) -> int:
+        # general naming
+        key = (self._name, self._label, self._unit, self._data.shape)
+
+        # time specific
+        key += (self._time.name, self._time.label, self._time.unit)
+
+        # number of particles
+        key += (self._count,)
+
+        return hash(key)
+
+    def __repr__(self) -> str:
+        repr_ = (
+            f"{type(self).__name__}<"
+            f"name={self.name}, "
+            f"dtype={self.dtype}, "
+            f"time={self.time.to_numpy()}"
+            ">"
+        )
+        return repr_
+
+    def _repr_markdown_(self) -> str:
+        md = f"""
+        | **{type(self).__name__}** | |
+        | ---: | :--- |
+        | **name**  | {self.name} |
+        | **label** | {self.label} |
+        | **unit**  | {self.unit or "''"} |
+        | **count** | {self.count} |
+        | **dtype** | {self.dtype} |
+        | **time**  | {self.time.to_numpy()} |
+
+        """
+        return dedent(md)
+
+    @classmethod
+    def from_array(
+        cls,
+        data: ArrayLike,
+        *,
+        name: str = "unnamed",
+        label: str = "unlabeled",
+        unit: str = "",
+        time: Optional[Union[Axis, int, float]] = None,
+    ) -> "Quantity":
+        data = data if isinstance(data, da.Array) else da.asanyarray(data)
+
+        if time is None:
+            time = Axis.from_array(0.0, name="time", label="time")
+        else:
+            if not isinstance(time, Axis):
+                time = Axis.from_array(time, name="time", label="time")
+
+        return cls(data, time, name, label, unit)
+
+
+class QuantityArray(Quantity):
+    def __init__(
+        self,
+        data: da.Array,
+        time: Axis,
+        name: str,
+        label: str,
+        unit: str,
+    ) -> None:
+        if not data.ndim >= 1:
+            raise ValueError("only 1d and higher dimensional data is supported")
+
+        if data.dtype.fields:
+            raise ValueError("only unstructured data types supported")
+
+        self._name = name
+        self._label = label
+        self._unit = unit
+
+        self._count = data.shape[-1]
+        self._time = time
+
+        self._data = data
+
+    def __getitem__(self, key: Any) -> Union["Quantity", "QuantityArray"]:
+        index = ndx.ndindex(key).expand(self.shape).raw
+
+        if isinstance(index[0], int):
+            return Quantity.from_array(
+                self._data[index],
+                name=self.name,
+                label=self.label,
+                unit=self.unit,
+                time=self.time,
+            )
+
+        else:
+            return QuantityArray.from_array(
+                self._data[index],
+                name=self.name,
+                label=self.label,
+                unit=self.unit,
+                time=self.time,
+            )
+
+
+class Particle(
+    HasNumpyInterface,
+    HasQuantities,
+    HasPluginSystem,
+    HasName,
+    HasTimeAxis,
+    HasParticleCount,
+):
+    def __init__(
+        self,
+        data: da.Array,
+        quantities: Tuple[Tuple[str, str, str], ...],
+        time: Axis,
+        name: str,
+        label: str,
+    ) -> None:
+        if data.ndim != 0:
+            raise ValueError("only 0d data is supported")
+
+        if not data.dtype.fields:
+            raise ValueError("only structured data types supported")
+
+        if (len(data.dtype.fields) != len(quantities)) or any(
+            q[0] != f for q, f in zip(quantities, data.dtype.fields)
+        ):
+            raise ValueError("mismatch betweend fields and quantities")
+
+        self._data = data
+        self._quantities = quantities
+
+        self._name = name
+        self._label = label
+
+        self._time = time
+        self._count = 1
+
+    @staticmethod
+    def _expand_key(key: Any, shape: Tuple[int, int]) -> Tuple[Union[str, List[str]]]:
+        # convert to tuple
+        key = np.index_exp[key]
+
+        if len(key) > 1:
+            raise IndexError("too many indices provided")
+
+        return key
+
+    @staticmethod
+    def _determine_reduction(index: Tuple[Union[str, Sequence[str]]]) -> bool:
+        return isinstance(index[0], str)
+
+    def _decay_to_Quantity(self, index: Tuple[str]) -> "Quantity":
+        quantity_index = self.quantity_names.index(index[0])
+        name, label, unit = self.quantities[quantity_index]
+
+        return Quantity.from_array(
+            self._data[index[0]],
+            name=name,
+            label=label,
+            unit=unit,
+            time=self.time,
+        )
+
+    def _decay_to_Particle(self, index: Tuple[Sequence[str]]) -> "Particle":
+        # if sequence not empty -> iterate over picking up quantities
+        if index[0]:
+            new_quantities = ()
+            for quant in index[0]:
+                quant_index = self.quantity_names.index(quant)
+                new_quantities += (self.quantities[quant_index],)
+            data = self._data[index[0]]
+        else:
+            new_quantities = self.quantities
+            data = self._data
+
+        return Particle.from_array(
+            data,
+            name=self.name,
+            label=self.label,
+            quantities=new_quantities,
+            time=self.time,
+        )
+
+    def __getitem__(self, key: Any) -> Union["Quantity", "ParticleArray"]:
+        key = self._expand_key(key, self.shape)
+        quant_reduction = self._determine_reduction(key)
+
+        if quant_reduction:
+            return self._decay_to_Quantity(key)
+        else:
+            return self._decay_to_Particle(key)
+
+    def __hash__(self) -> int:
+        # general naming
+        key = (self.name, self.label)
+
+        # data properties
+        key += (self.dtype,)
+
+        # time specific
+        key += (self.time.name, self.time.label, self.time.unit)
+
+        # quantities
+        for q in self._quantities:
+            key += q
+
+        return hash(key)
+
+    def __repr__(self) -> str:
+        repr_ = (
+            f"{type(self).__name__}<"
+            f"quantities={self.quantity_names}, "
+            f"dtype={self.dtype}, "
+            f"time={self.time.to_numpy()}"
+            ">"
+        )
+        return repr_
+
+    def _repr_markdown_(self) -> str:
+        md = f"""
+        | **{type(self).__name__}** | |
+        | ---: | :--- |
+        | **name**  | {self.name} |
+        | **label** | {self.label} |
+        | **unit**  | {self.unit or "''"} |
+        | **count** | {self.count} |
+        | **dtype** | {self.dtype} |
+        | **time**  | {self.time.to_numpy()} |
+
+        """
+        return dedent(md)
+
+    @classmethod
+    def from_array(
+        cls,
+        data: ArrayLike,
+        *,
+        name: str = "unnamed",
+        label: str = "unlabeled",
+        quantities: Optional[Tuple[Tuple[str, str, str], ...]] = None,
+        time: Optional[Union[Axis, int, float]] = None,
+    ) -> "Particle":
+        if time is None:
+            time = Axis.from_array(0.0, name="time", label="time")
+        else:
+            if not isinstance(time, Axis):
+                time = Axis.from_array(time, name="time", label="time")
+
+        if not isinstance(data, da.Array):
+            data = da.asanyarray(data)
+
+        if quantities is None:
+            if data.dtype.fields:
+                quantities = tuple((f, f"{f} label", "") for f in data.dtype.fields)
+            else:
+                quantities = (("quant1", "quant1 label", ""),)
+                new_dtype = np.dtype([("quant1", data.dtype)])
+                data = unstructured_to_structured(data[..., np.newaxis], new_dtype)
+        else:
+            if not data.dtype.fields:
+                new_dtype = np.dtype([(q[0], data.dtype) for q in quantities])
+                data = unstructured_to_structured(data, new_dtype)
+
+        return cls(data, quantities, time, name, label)
+
+
+class ParticleArray(
+    Particle,
+    HasBackends,
+    backend_protocol=ParticleBackend,
+):
+    def __init__(
+        self,
+        data: da.Array,
+        quantities: Tuple[Tuple[str, str, str], ...],
+        time: Axis,
+        name: str,
+        label: str,
+    ) -> None:
+        if not data.ndim >= 1:
+            raise ValueError("0d data is not supported")
+
+        if not data.dtype.fields:
+            raise ValueError("only structured data types supported")
+
+        if (len(data.dtype.fields) != len(quantities)) or any(
+            q[0] != f for q, f in zip(quantities, data.dtype.fields)
+        ):
+            raise ValueError("mismatch betweend fields and quantities")
+
+        self._data = data
+        self._quantities = quantities
+
+        self._name = name
+        self._label = label
+
+        self._time = time
+        self._count = data.shape[-1]
+
+    @staticmethod
+    def _expand_key(
+        key: Any,
+        shape: Tuple[int],
+    ) -> Tuple[Union[int, slice], Union[str, List[str]]]:
+        # convert to tuple
+        key = np.index_exp[key]
+
+        if len(key) > 2:
+            raise IndexError("too many indices provided")
+
+        if len(key) == 2:
+            expanded_key = ndx.ndindex(key[:1]).expand(shape).raw + (key[1],)
+        else:
+            expanded_key = ndx.ndindex(key).expand(shape).raw + ([],)
+
+        return expanded_key
+
+    @staticmethod
+    def _determine_reduction(
+        index: Tuple[Union[int, slice], Union[str, Sequence[str]]],
+    ) -> Tuple[bool, bool, bool]:
+        return isinstance(index[0], int), isinstance(index[1], str)
+
+    def _decay_to_Quantity(self, index: Tuple[int, str]) -> "Quantity":
+        quantity_index = self.quantity_names.index(index[1])
+        name, label, unit = self.quantities[quantity_index]
+
+        return Quantity.from_array(
+            self._data[index[0]][index[1]],
+            name=name,
+            label=label,
+            unit=unit,
+            time=self.time,
+        )
+
+    def _decay_to_QuantityArray(self, index: Tuple[slice, str]) -> "QuantityArray":
+        quantity_index = self.quantity_names.index(index[1])
+        name, label, unit = self.quantities[quantity_index]
+
+        return QuantityArray.from_array(
+            self._data[index[0]][index[1]],
+            name=name,
+            label=label,
+            unit=unit,
+            time=self.time,
+        )
+
+    def _decay_to_Particle(self, index: Tuple[int, Sequence[str]]) -> "Particle":
+        # if sequence not empty -> iterate over picking up quantities
+        if index[1]:
+            new_quantities = ()
+            for quant in index[1]:
+                quant_index = self.quantity_names.index(quant)
+                new_quantities += (self.quantities[quant_index],)
+            data = self._data[index[0]][index[1]]
+        else:
+            new_quantities = self.quantities
+            data = self._data[index[0]]
+
+        return Particle.from_array(
+            data,
+            name=self.name,
+            label=self.label,
+            quantities=new_quantities,
+            time=self.time,
+        )
+
+    def _decay_to_ParticleArray(self, index: Tuple[slice, str]) -> "ParticleArray":
+        # if sequence not empty -> iterate over picking up quantities
+        if index[1]:
+            new_quantities = ()
+            for quant in index[1]:
+                quant_index = self.quantity_names.index(quant)
+                new_quantities += (self.quantities[quant_index],)
+            data = self._data[index[0]][index[1]]
+        else:
+            new_quantities = self.quantities
+            data = self._data[index[0]]
+
+        return ParticleArray.from_array(
+            data,
+            name=self.name,
+            label=self.label,
+            quantities=new_quantities,
+            time=self.time,
+        )
+
+    def __getitem__(
+        self, key: Any
+    ) -> Union["Quantity", "QuantityArray", "ParticleArray"]:
+        key = self._expand_key(key, self.shape)
+        prt_reduction, quant_reduction = self._determine_reduction(key)
+
+        if prt_reduction and quant_reduction:
+            return self._decay_to_Quantity(key)
+        elif quant_reduction:
+            return self._decay_to_QuantityArray(key)
+        elif prt_reduction:
+            return self._decay_to_Particle(key)
+        else:
+            return self._decay_to_ParticleArray(key)
+
+    @staticmethod
+    def _unpack_backend(
+        backend: ParticleBackend, path: Path, time_axis: str
+    ) -> Tuple[da.Array, Tuple[Tuple[str, str, str], ...], Axis, str, str]:
+        prts = backend(path)
+        data = da.from_array(prts)
+
+        name = prts.dataset_name
+        label = prts.dataset_label
+
+        if time_axis == "time":
+            time = Axis.from_array(
+                prts.time_step,
+                name="time",
+                label="time",
+                unit=prts.time_unit,
+            )
+        else:
+            time = Axis.from_array(
+                prts.iteration,
+                name="iteration",
+                label="iteration",
+            )
+
+        quantities = tuple(
+            zip(prts.quantity_names, prts.quantity_labels, prts.quantity_units)
+        )
+
+        return data, quantities, time, name, label
+
+    @classmethod
+    def from_path(
+        cls, path: Union[str, Path], time_axis: str = "time"
+    ) -> "ParticleArray":
+        if not isinstance(path, Path):
+            path = Path(path)
+
+        # check validity of passed arguments
+        if not path.is_file():
+            raise ValueError("only a single file is supported")
+
+        if time_axis not in ("time", "iteration"):
+            raise ValueError("only 'time' and 'iteration' are supported for time axis")
+
+        # get valid backend
+        backend = cls.get_valid_backend(path)
+        if not backend:
+            raise NoValidBackend(f"no valid backend for '{path}' found")
+
+        backend_data = cls._unpack_backend(backend, path, time_axis)
+        return cls(*backend_data)
+
+
+class ParticleDataset(
+    HasNumpyInterface,
+    HasQuantities,
+    HasPluginSystem,
+    HasName,
+    HasTimeAxis,
+    HasParticleCount,
+):
+    def __init__(
+        self,
+        data: da.Array,
+        quantities: Tuple[Tuple[str, str, str], ...],
+        time: Axis,
+        name: str,
+        label: str,
+    ) -> None:
+        if data.ndim != 2:
+            raise ValueError("only 2d data is supported")
+
+        if not data.dtype.fields:
+            raise ValueError("only structured data types supported")
+
+        if (len(data.dtype.fields) != len(quantities)) or any(
+            q[0] != f for q, f in zip(quantities, data.dtype.fields)
+        ):
+            raise ValueError("mismatch betweend fields and quantities")
+
+        if time.ndim != 1:
+            raise ValueError("only 1d time axis are supported")
+
+        HasNumpyInterface.__init__(self, data)
+        HasQuantities.__init__(self, quantities)
+        HasName.__init__(self, name, label)
+        HasTimeAxis.__init__(self, time)
+        HasParticleCount.__init__(self, data.shape[-1])
+
+    def __hash__(self) -> int:
+        # general naming
+        key = (self.name, self.label)
+
+        # data props
+        key += (self.shape,)
+
+        # time
+        key += (self.time.name, self.time.label, self.time.unit)
+
+        return hash(key)
+
+    def __repr__(self) -> str:
+        repr_ = (
+            f"{type(self).__name__}"
+            "<"
+            f"name={self.name}, "
+            f"dtype={self.dtype}, "
+            f"quantities={self.quantity_names}, "
+            f"time={repr(self.time)}"
+            ">"
+        )
+        return repr_
+
+    def _repr_markdown_(self) -> str:
+        md = f"""
+        | **{type(self).__name__}** | |
+        | ---: | :--- |
+        | **name**       | {self.name} |
+        | **label**      | {self.label} |
+        | **count**      | {self.count} |
+        | **shape**      | {self.shape} |
+        | **dtype**      | {self.dtype} |
+        | **quantities** | {self.quantity_names} |
+        | **time**       | {self.time} |
+
+        """
+        return dedent(md)
+
+    @staticmethod
+    def _expand_key(
+        key: Any,
+        shape: Tuple[int, int],
+    ) -> Tuple[Union[int, slice], Union[int, slice], Union[str, List[str]]]:
+        # convert to tuple
+        key = np.index_exp[key]
+
+        if len(key) > 3:
+            raise IndexError("too many indices provided")
+
+        if len(key) == 3:
+            expanded_key = ndx.ndindex(key[:2]).expand(shape).raw + (key[2],)
+        else:
+            expanded_key = ndx.ndindex(key).expand(shape).raw + ([],)
+
+        return expanded_key
+
+    @staticmethod
+    def _determine_reduction(
+        index: Tuple[Union[int, slice], Union[int, slice], Union[str, Sequence[str]]],
+    ) -> Tuple[bool, bool, bool]:
+        return (
+            isinstance(index[0], int),
+            isinstance(index[1], int),
+            isinstance(index[2], str),
+        )
+
+    def _decay_to_Quantity(self, index: Tuple[int, int, str]) -> "Quantity":
+        quantity_index = self.quantity_names.index(index[2])
+        name, label, unit = self.quantities[quantity_index]
+
+        return Quantity.from_array(
+            self._data[index[0], index[1]][index[2]],
+            name=name,
+            label=label,
+            unit=unit,
+            time=self.time[index[0]],
+        )
+
+    def _decay_to_QuantityArray(self, index: Tuple[int, slice, str]) -> "QuantityArray":
+        quantity_index = self.quantity_names.index(index[2])
+        name, label, unit = self.quantities[quantity_index]
+
+        return QuantityArray.from_array(
+            self._data[index[0], index[1]][index[2]],
+            name=name,
+            label=label,
+            unit=unit,
+            time=self.time[index[0]],
+        )
+
+    def _decay_to_Particle(self, index: Tuple[int, int, Sequence[str]]) -> "Particle":
+        # if sequence not empty -> iterate over picking up quantities
+        if index[2]:
+            new_quantities = ()
+            for quant in index[2]:
+                quant_index = self.quantity_names.index(quant)
+                new_quantities += (self.quantities[quant_index],)
+            data = self._data[index[0], index[1]][index[2]]
+        else:
+            new_quantities = self.quantities
+            data = self._data[index[0], index[1]]
+
+        return Particle.from_array(
+            data,
+            name=self.name,
+            label=self.label,
+            quantities=new_quantities,
+            time=self.time[index[0]],
+        )
+
+    def _decay_to_ParticleArray(
+        self,
+        index: Tuple[int, slice, Sequence[str]],
+    ) -> "ParticleArray":
+        # if sequence not empty -> iterate over picking up quantities
+        if index[2]:
+            new_quantities = ()
+            for quant in index[2]:
+                quant_index = self.quantity_names.index(quant)
+                new_quantities += (self.quantities[quant_index],)
+            data = self._data[index[0], index[1]][index[2]]
+        else:
+            new_quantities = self.quantities
+            data = self._data[index[0], index[1]]
+
+        return ParticleArray.from_array(
+            data,
+            name=self.name,
+            label=self.label,
+            quantities=new_quantities,
+            time=self.time[index[0]],
+        )
+
+    def _decay_to_ParticleDataset(
+        self,
+        index: Tuple[slice, Union[int, slice], Sequence[str]],
+    ) -> "ParticleDataset":
+        # if sequence not empty -> iterate over picking up quantities
+        if index[2]:
+            new_quantities = ()
+            for quant in index[2]:
+                quant_index = self.quantity_names.index(quant)
+                new_quantities += (self.quantities[quant_index],)
+            data = self._data[index[0], index[1]][index[2]]
+        else:
+            new_quantities = self.quantities
+            data = self._data[index[0], index[1]]
+
+        return ParticleDataset.from_array(
+            data,
+            name=self.name,
+            label=self.label,
+            quantities=new_quantities,
+            time=self.time[index[0]],
+        )
+
+    def __getitem__(
+        self, key: Any
+    ) -> Union[
+        "ParticleDataset",
+        "ParticleArray",
+        "Particle",
+        "Quantity",
+        "QuantityArray",
+    ]:
+        key = self._expand_key(key, self.shape)
+        time_reduction, prt_reduction, quant_reduction = self._determine_reduction(key)
+
+        if quant_reduction and time_reduction and prt_reduction:
+            return self._decay_to_Quantity(key)
+        elif quant_reduction and time_reduction:
+            return self._decay_to_QuantityArray(key)
+        elif time_reduction and prt_reduction:
+            return self._decay_to_Particle(key)
+        elif time_reduction:
+            return self._decay_to_ParticleArray(key)
+        else:
+            return self._decay_to_ParticleDataset(key)
+
+    @classmethod
+    def from_array(
+        cls,
+        data: da.Array,
+        *,
+        name: str = "unnamed",
+        label: str = "unlabeled",
+        quantities: Optional[Tuple[Tuple[str, str, str], ...]] = None,
+        time: Optional[Union[Axis, int, float]] = None,
+    ) -> "ParticleDataset":
+        if not isinstance(data, da.Array):
+            data = expand_and_stack(data)
+
+        if data.ndim not in (2, 3):
+            raise ValueError("data array has to be at least 2d")
+
+        if not data.dtype.fields:
+            if data.ndim == 2:
+                data = data[..., np.newaxis]
+
+            dtype_list = [(f"quant{i+1}", data.dtype) for i in range(data.shape[-1])]
+            new_dtype = np.dtype(dtype_list)
+
+            data = unstructured_to_structured(data, new_dtype)
+
+        if time is None:
+            time = Axis.from_array(da.arange(len(data)), name="time", label="time")
+        else:
+            if not isinstance(time, Axis):
+                time = Axis.from_array(time, name="time", label="time")
+
+        if quantities is None:
+            quantities = tuple((f, f"{f} label", "") for f in data.dtype.fields)
+
+        return cls(data, quantities, time, name, label)
+
+    @classmethod
+    def from_path(
+        cls, path: Union[str, Path], time_axis: str = "time"
+    ) -> "ParticleDataset":
+        files = FileList(path, recursive=False)
+
+        prt_arrs: Dict[int, List[Particle]] = defaultdict(list)
+
+        for f in files.paths:
+            try:
+                prt_arr = ParticleArray.from_path(f, time_axis=time_axis)
+            except NoValidBackend:
+                continue
+
+            prt_arrs[hash(prt_arr)].append(prt_arr)
+
+        parts = list(prt_arrs.values())
+
+        if not len(parts):
+            raise ValueError(f"no valid particles found for '{path}'")
+
+        # warn if multiple grids were found
+        if len(parts) > 1:
+            warn(f"found multiple particles datasets and picking '{parts[0].name}'")
+
+        return stack(parts[0])
diff --git a/nata/containers/utils.py b/nata/containers/utils.py
index 8cdb8dc..768c92f 100644
--- a/nata/containers/utils.py
+++ b/nata/containers/utils.py
@@ -1,12 +1,21 @@
 # -*- coding: utf-8 -*-
-from typing import Callable
+from typing import Iterable
 from warnings import warn
 
+import dask.array as da
+import numpy as np
+from dask import delayed
+from numpy.lib import recfunctions
+
 # TODO: remove the use of types
 from nata.types import BackendType
 from nata.types import DatasetType
 
 
+def is_unique(iterable: Iterable) -> bool:
+    return len(set(iterable)) == 1
+
+
 def register_backend(container: DatasetType):
     """Decorater for registering a backend for a Datset"""
     # if not isinstance(container, DatasetType):
@@ -15,6 +24,8 @@ def register_backend(container: DatasetType):
     def add_backend_to_container(backend: BackendType):
         if container.is_valid_backend(backend):
             container.add_backend(backend)
+            # TODO: make sure it works well with quantities
+            #       -> labels are passed appropiate
             backend.__getitem__ = backend.get_data
         else:
             warn(
@@ -26,14 +37,7 @@ def add_backend_to_container(backend: BackendType):
     return add_backend_to_container
 
 
-def get_doc_heading(func: Callable) -> str:
-    docs = func.__doc__
-
-    if not docs:
-        return ""
-
-    for line in docs.split("\n"):
-        if line:
-            return line.strip()
-
-    return ""
+def unstructured_to_structured(data: da.Array, new_dtype: np.dtype) -> da.Array:
+    new_shape = data.shape[:-1]
+    new_data = delayed(recfunctions.unstructured_to_structured)(data, dtype=new_dtype)
+    return da.from_delayed(new_data, new_shape, dtype=new_dtype)
diff --git a/nata/plugins/register.py b/nata/plugins/register.py
index 7ede4ab..cf2db72 100644
--- a/nata/plugins/register.py
+++ b/nata/plugins/register.py
@@ -46,11 +46,13 @@ def decorator(func):
         def wrapper(self, *args, **kwargs):
             return func(self, *args, **kwargs)
 
-        container.register_plugin(
-            function_name if function_name else func.__name__,
-            wrapper,
-            plugin_type=plugin_type,
-        )
+        plugin_name = function_name if function_name else func.__name__
+        if plugin_type == "method":
+            container.add_method_plugin(plugin_name, wrapper)
+        elif plugin_type == "property":
+            container.add_property_plugin(plugin_name, wrapper)
+        else:
+            raise ValueError("'plugin_type' has to be either 'property' or 'method'")
         return func
 
     if function:


Test patch:
diff --git a/tests/backends/osiris/test_GridFile.py b/tests/backends/osiris/test_GridFile.py
index 339cf78..e8627d1 100644
--- a/tests/backends/osiris/test_GridFile.py
+++ b/tests/backends/osiris/test_GridFile.py
@@ -1,258 +1,268 @@
 # -*- coding: utf-8 -*-
-import h5py as h5
-import numpy as np
-import pytest
+# import h5py as h5
+# import numpy as np
+# import pytest
 
-from nata.backends.osiris.hdf5 import Osiris_Dev_Hdf5_GridFile
-from nata.backends.osiris.hdf5 import Osiris_Hdf5_GridFile
-from nata.backends.osiris.zdf import Osiris_zdf_GridFile
-from nata.containers import GridDataset
-from nata.types import GridBackendType
+# from nata.backends.osiris.hdf5 import Osiris_Dev_Hdf5_GridFile
+# from nata.backends.osiris.hdf5 import Osiris_Hdf5_GridFile
+# from nata.backends.osiris.zdf import Osiris_zdf_GridFile
+# from nata.containers import GridDataset
+# from nata.types import GridBackendType
 
 
-def test_Osiris_Hdf5_GridFile_isinstance_GridBackendType():
-    assert isinstance(Osiris_Hdf5_GridFile, GridBackendType)
+# def test_Osiris_Hdf5_GridFile_isinstance_GridBackendType():
+#     assert isinstance(Osiris_Hdf5_GridFile, GridBackendType)
 
 
-def test_Osiris_Dev_Hdf5_GridFile_isinstance_GridBackendType():
-    assert isinstance(Osiris_Dev_Hdf5_GridFile, GridBackendType)
+# def test_Osiris_Dev_Hdf5_GridFile_isinstance_GridBackendType():
+#     assert isinstance(Osiris_Dev_Hdf5_GridFile, GridBackendType)
 
 
-def test_Osiris_zdf_GridFile_isinstance_GridBackendType():
-    assert isinstance(Osiris_zdf_GridFile, GridBackendType)
+# def test_Osiris_zdf_GridFile_isinstance_GridBackendType():
+#     assert isinstance(Osiris_zdf_GridFile, GridBackendType)
 
 
-def test_GridDatasets_backends_are_registered():
-    backends = GridDataset.get_backends()
+# def test_GridDatasets_backends_are_registered():
+#     backends = GridDataset.get_backends()
 
-    assert backends[Osiris_Hdf5_GridFile.name] is Osiris_Hdf5_GridFile
-    assert backends[Osiris_Dev_Hdf5_GridFile.name] is Osiris_Dev_Hdf5_GridFile
-    assert backends[Osiris_zdf_GridFile.name] is Osiris_zdf_GridFile
+#     assert backends[Osiris_Hdf5_GridFile.name] is Osiris_Hdf5_GridFile
+#     assert backends[Osiris_Dev_Hdf5_GridFile.name] is Osiris_Dev_Hdf5_GridFile
+#     assert backends[Osiris_zdf_GridFile.name] is Osiris_zdf_GridFile
 
 
-@pytest.fixture(name="os_hdf5_grid_444_file", scope="session")
-def _generate_valid_Osiris_Hdf5_GridFile(tmp_path_factory):
-    """Fixture for valid HDF5 file for Osiris_Hdf5_GridFile backend"""
-    tmp_path = tmp_path_factory.mktemp("os_hdf5_grid_444_fixture")
-    file_ = tmp_path / "os_hdf5_grid_444_file.h5"
-    dtype = np.dtype("f4")
+# @pytest.fixture(name="os_hdf5_grid_444_file", scope="session")
+# def _generate_valid_Osiris_Hdf5_GridFile(tmp_path_factory):
+#     """Fixture for valid HDF5 file for Osiris_Hdf5_GridFile backend"""
+#     tmp_path = tmp_path_factory.mktemp("os_hdf5_grid_444_fixture")
+#     file_ = tmp_path / "os_hdf5_grid_444_file.h5"
+#     dtype = np.dtype("f4")
 
-    with h5.File(file_, mode="w") as fp:
-        # root attrs
-        fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
-        fp.attrs["TYPE"] = np.array([b"grid"], dtype="|S4")
-        fp.attrs["ITER"] = np.array([12345], dtype="i4")
-        fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
-        fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
+#     with h5.File(file_, mode="w") as fp:
+#         # root attrs
+#         fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
+#         fp.attrs["TYPE"] = np.array([b"grid"], dtype="|S4")
+#         fp.attrs["ITER"] = np.array([12345], dtype="i4")
+#         fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
+#         fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
 
-        # dataset
-        # * osiris stores data like a fortran array
-        data = np.transpose(np.arange(24, dtype=dtype).reshape((2, 3, 4)))
+#         # dataset
+#         # * osiris stores data like a fortran array
+#         data = np.transpose(np.arange(24, dtype=dtype).reshape((2, 3, 4)))
 
-        ds = fp.create_dataset("test ds", data=data)
-        ds.attrs["LONG_NAME"] = np.array([b"\\Latex test dataset label"], dtype="|S256")
-        ds.attrs["UNITS"] = np.array([b"test dataset unit"], dtype="|S256")
+#         ds = fp.create_dataset("test ds", data=data)
+#         ds.attrs["LONG_NAME"] = np.array(
+#             [b"\\Latex test dataset label"], dtype="|S256"
+#         )
+#         ds.attrs["UNITS"] = np.array([b"test dataset unit"], dtype="|S256")
 
-        # axes
-        axis1_data = np.array([-1, 2], dtype=dtype)
-        axis2_data = np.array([-2, 3], dtype=dtype)
-        axis3_data = np.array([-3, 4], dtype=dtype)
+#         # axes
+#         axis1_data = np.array([-1, 2], dtype=dtype)
+#         axis2_data = np.array([-2, 3], dtype=dtype)
+#         axis3_data = np.array([-3, 4], dtype=dtype)
 
-        axis_grp = fp.create_group("AXIS")
-        axis1 = axis_grp.create_dataset("AXIS1", data=axis1_data)
-        axis1.attrs["NAME"] = np.array([b"axis1 name"], dtype="|S256")
-        axis1.attrs["LONG_NAME"] = np.array([b"axis_1"], dtype="|S256")
-        axis1.attrs["UNITS"] = np.array([b"axis1 unit"], dtype="|S256")
+#         axis_grp = fp.create_group("AXIS")
+#         axis1 = axis_grp.create_dataset("AXIS1", data=axis1_data)
+#         axis1.attrs["NAME"] = np.array([b"axis1 name"], dtype="|S256")
+#         axis1.attrs["LONG_NAME"] = np.array([b"axis_1"], dtype="|S256")
+#         axis1.attrs["UNITS"] = np.array([b"axis1 unit"], dtype="|S256")
 
-        axis2 = axis_grp.create_dataset("AXIS2", data=axis2_data)
-        axis2.attrs["NAME"] = np.array([b"axis2 name"], dtype="|S256")
-        axis2.attrs["LONG_NAME"] = np.array([b"axis_2"], dtype="|S256")
-        axis2.attrs["UNITS"] = np.array([b"axis2 unit"], dtype="|S256")
+#         axis2 = axis_grp.create_dataset("AXIS2", data=axis2_data)
+#         axis2.attrs["NAME"] = np.array([b"axis2 name"], dtype="|S256")
+#         axis2.attrs["LONG_NAME"] = np.array([b"axis_2"], dtype="|S256")
+#         axis2.attrs["UNITS"] = np.array([b"axis2 unit"], dtype="|S256")
 
-        axis3 = axis_grp.create_dataset("AXIS3", data=axis3_data)
-        axis3.attrs["NAME"] = np.array([b"axis3 name"], dtype="|S256")
-        axis3.attrs["LONG_NAME"] = np.array([b"axis_3"], dtype="|S256")
-        axis3.attrs["UNITS"] = np.array([b"axis3 unit"], dtype="|S256")
+#         axis3 = axis_grp.create_dataset("AXIS3", data=axis3_data)
+#         axis3.attrs["NAME"] = np.array([b"axis3 name"], dtype="|S256")
+#         axis3.attrs["LONG_NAME"] = np.array([b"axis_3"], dtype="|S256")
+#         axis3.attrs["UNITS"] = np.array([b"axis3 unit"], dtype="|S256")
 
-    return file_
+#     return file_
 
 
-def test_Osiris_Hdf5_GridFile_check_is_valid_backend(os_hdf5_grid_444_file):
-    """Check 'Osiris_Hdf5_GridFile' is a valid backend exclusively"""
-    assert Osiris_Hdf5_GridFile.is_valid_backend(os_hdf5_grid_444_file) is True
+# def test_Osiris_Hdf5_GridFile_check_is_valid_backend(os_hdf5_grid_444_file):
+#     """Check 'Osiris_Hdf5_GridFile' is a valid backend exclusively"""
+#     assert Osiris_Hdf5_GridFile.is_valid_backend(os_hdf5_grid_444_file) is True
 
-    # backend are registered automatically for GridDatasets
-    for (name, backend) in GridDataset.get_backends().items():
-        if name == Osiris_Hdf5_GridFile.name:
-            continue
+#     # backend are registered automatically for GridDatasets
+#     for (name, backend) in GridDataset.get_backends().items():
+#         if name == Osiris_Hdf5_GridFile.name:
+#             continue
 
-        assert backend.is_valid_backend(os_hdf5_grid_444_file) is False
+#         assert backend.is_valid_backend(os_hdf5_grid_444_file) is False
 
 
-def test_Osiris_Hdf5_GridFile_dataset_props(os_hdf5_grid_444_file):
-    """Check 'Osiris_Hdf5_GridFile' dataset properties"""
-    backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
-    # TODO: check if dataset name is valid identifier
-    assert backend.dataset_name == "test ds"
-    assert backend.dataset_label == "\\Latex test dataset label"
-    assert backend.dataset_unit == "test dataset unit"
+# def test_Osiris_Hdf5_GridFile_dataset_props(os_hdf5_grid_444_file):
+#     """Check 'Osiris_Hdf5_GridFile' dataset properties"""
+#     backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
+#     # TODO: check if dataset name is valid identifier
+#     assert backend.dataset_name == "test ds"
+#     assert backend.dataset_label == "\\Latex test dataset label"
+#     assert backend.dataset_unit == "test dataset unit"
 
-    assert backend.shape == (2, 3, 4)
-    assert backend.dtype == np.dtype("f4")
-    assert backend.ndim == 3
+#     assert backend.shape == (2, 3, 4)
+#     assert backend.dtype == np.dtype("f4")
+#     assert backend.ndim == 3
 
 
-def test_Osiris_Hdf5_GridFile_grid_axes_props(os_hdf5_grid_444_file):
-    """Check 'Osiris_Hdf5_GridFile' extraction of axes properties"""
-    backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
-    # TODO: check if axis names are valid identifier
-    expected_names = ("axis1 name", "axis2 name", "axis3 name")
-    expected_labels = ("axis_1", "axis_2", "axis_3")
-    expected_units = ("axis1 unit", "axis2 unit", "axis3 unit")
+# def test_Osiris_Hdf5_GridFile_grid_axes_props(os_hdf5_grid_444_file):
+#     """Check 'Osiris_Hdf5_GridFile' extraction of axes properties"""
+#     backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
+#     # TODO: check if axis names are valid identifier
+#     expected_names = ("axis1 name", "axis2 name", "axis3 name")
+#     expected_labels = ("axis_1", "axis_2", "axis_3")
+#     expected_units = ("axis1 unit", "axis2 unit", "axis3 unit")
 
-    np.testing.assert_array_equal(backend.axes_names, expected_names)
-    np.testing.assert_array_equal(backend.axes_labels, expected_labels)
-    np.testing.assert_array_equal(backend.axes_units, expected_units)
+#     np.testing.assert_array_equal(backend.axes_names, expected_names)
+#     np.testing.assert_array_equal(backend.axes_labels, expected_labels)
+#     np.testing.assert_array_equal(backend.axes_units, expected_units)
 
-    np.testing.assert_array_equal(backend.axes_min, [-1, -2, -3])
-    np.testing.assert_array_equal(backend.axes_max, [2, 3, 4])
+#     np.testing.assert_array_equal(backend.axes_min, [-1, -2, -3])
+#     np.testing.assert_array_equal(backend.axes_max, [2, 3, 4])
+
+
+# def test_Osiris_Hdf5_GridFile_iteration_props(os_hdf5_grid_444_file):
+#     """Check 'Osiris_Hdf5_GridFile' extraction of iteration"""
+#     backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
+#     assert backend.iteration == 12345
+
+
+# def test_Osiris_Hdf5_GridFile_time_props(os_hdf5_grid_444_file):
+#     """Check 'Osiris_Hdf5_GridFile' extraction of time props"""
+#     backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
+#     np.testing.assert_allclose(backend.time_step, -321.9)
+#     assert backend.time_unit == "time unit"
+
+
+# def test_Osiris_Hdf5_GridFile_reading_data(os_hdf5_grid_444_file):
+#     """Check 'Osiris_Hdf5_GridFile' reading array correctly"""
+#     backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
+#     full_array = np.arange(24, dtype="f4").reshape((2, 3, 4))
+
+#     # full array
+#     np.testing.assert_array_equal(backend.get_data(), full_array)
+
+#     # subarray
+#     index = np.s_[1, :2, ::2]
+#     np.testing.assert_array_equal(
+#         backend.get_data(indexing=index), full_array[index]
+#     )
 
 
-def test_Osiris_Hdf5_GridFile_iteration_props(os_hdf5_grid_444_file):
-    """Check 'Osiris_Hdf5_GridFile' extraction of iteration"""
-    backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
-    assert backend.iteration == 12345
-
+# @pytest.fixture(name="os_hdf5_grid_dev_file", scope="session")
+# def _generate_valid_Osiris_Dev_Hdf5_GridFile(tmp_path_factory):
+#     """Fixture for valid HDF5 file for Osiris_Hdf5_GridFile backend"""
+#     tmp_path = tmp_path_factory.mktemp("os_hdf5_grid_dev_fixture")
+#     file_ = tmp_path / "os_hdf5_grid_dev_file.h5"
+#     dtype = np.dtype("f4")
 
-def test_Osiris_Hdf5_GridFile_time_props(os_hdf5_grid_444_file):
-    """Check 'Osiris_Hdf5_GridFile' extraction of time props"""
-    backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
-    np.testing.assert_allclose(backend.time_step, -321.9)
-    assert backend.time_unit == "time unit"
-
-
-def test_Osiris_Hdf5_GridFile_reading_data(os_hdf5_grid_444_file):
-    """Check 'Osiris_Hdf5_GridFile' reading array correctly"""
-    backend = Osiris_Hdf5_GridFile(os_hdf5_grid_444_file)
-    full_array = np.arange(24, dtype="f4").reshape((2, 3, 4))
+#     with h5.File(file_, mode="w") as fp:
+#         # root attrs
+#         fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
+#         fp.attrs["TYPE"] = np.array([b"grid"], dtype="|S4")
+#         fp.attrs["LABEL"] = np.array(
+#             [b"\\Latex test dataset label"], dtype="|S256"
+#         )
+#         fp.attrs["ITER"] = np.array([12345], dtype="i4")
+#         fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
+#         fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
 
-    # full array
-    np.testing.assert_array_equal(backend.get_data(), full_array)
+#         # dataset
+#         # * osiris stores data like a fortran array
+#         data = np.transpose(np.arange(24, dtype=dtype).reshape((2, 3, 4)))
 
-    # subarray
-    index = np.s_[1, :2, ::2]
-    np.testing.assert_array_equal(backend.get_data(indexing=index), full_array[index])
+#         fp.create_dataset("test ds", data=data)
+#         fp.attrs["UNITS"] = np.array([b"test dataset unit"], dtype="|S256")
 
+#         # axes
+#         axis1_data = np.array([-1, 2], dtype=dtype)
+#         axis2_data = np.array([-2, 3], dtype=dtype)
+#         axis3_data = np.array([-3, 4], dtype=dtype)
 
-@pytest.fixture(name="os_hdf5_grid_dev_file", scope="session")
-def _generate_valid_Osiris_Dev_Hdf5_GridFile(tmp_path_factory):
-    """Fixture for valid HDF5 file for Osiris_Hdf5_GridFile backend"""
-    tmp_path = tmp_path_factory.mktemp("os_hdf5_grid_dev_fixture")
-    file_ = tmp_path / "os_hdf5_grid_dev_file.h5"
-    dtype = np.dtype("f4")
+#         axis_grp = fp.create_group("AXIS")
+#         axis1 = axis_grp.create_dataset("AXIS1", data=axis1_data)
+#         axis1.attrs["NAME"] = np.array([b"axis1 name"], dtype="|S256")
+#         axis1.attrs["LONG_NAME"] = np.array([b"axis_1"], dtype="|S256")
+#         axis1.attrs["UNITS"] = np.array([b"axis1 unit"], dtype="|S256")
 
-    with h5.File(file_, mode="w") as fp:
-        # root attrs
-        fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
-        fp.attrs["TYPE"] = np.array([b"grid"], dtype="|S4")
-        fp.attrs["LABEL"] = np.array([b"\\Latex test dataset label"], dtype="|S256")
-        fp.attrs["ITER"] = np.array([12345], dtype="i4")
-        fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
-        fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
+#         axis2 = axis_grp.create_dataset("AXIS2", data=axis2_data)
+#         axis2.attrs["NAME"] = np.array([b"axis2 name"], dtype="|S256")
+#         axis2.attrs["LONG_NAME"] = np.array([b"axis_2"], dtype="|S256")
+#         axis2.attrs["UNITS"] = np.array([b"axis2 unit"], dtype="|S256")
 
-        # dataset
-        # * osiris stores data like a fortran array
-        data = np.transpose(np.arange(24, dtype=dtype).reshape((2, 3, 4)))
+#         axis3 = axis_grp.create_dataset("AXIS3", data=axis3_data)
+#         axis3.attrs["NAME"] = np.array([b"axis3 name"], dtype="|S256")
+#         axis3.attrs["LONG_NAME"] = np.array([b"axis_3"], dtype="|S256")
+#         axis3.attrs["UNITS"] = np.array([b"axis3 unit"], dtype="|S256")
+
+#     return file_
 
-        fp.create_dataset("test ds", data=data)
-        fp.attrs["UNITS"] = np.array([b"test dataset unit"], dtype="|S256")
 
-        # axes
-        axis1_data = np.array([-1, 2], dtype=dtype)
-        axis2_data = np.array([-2, 3], dtype=dtype)
-        axis3_data = np.array([-3, 4], dtype=dtype)
+# def test_Osiris_Dev_Hdf5_GridFile_check_is_valid_backend(os_hdf5_grid_dev_file):
+#     """Check 'Osiris_Hdf5_GridFile' is a valid backend exclusively"""
+#     assert (
+#         Osiris_Dev_Hdf5_GridFile.is_valid_backend(os_hdf5_grid_dev_file) is True
+#     )
 
-        axis_grp = fp.create_group("AXIS")
-        axis1 = axis_grp.create_dataset("AXIS1", data=axis1_data)
-        axis1.attrs["NAME"] = np.array([b"axis1 name"], dtype="|S256")
-        axis1.attrs["LONG_NAME"] = np.array([b"axis_1"], dtype="|S256")
-        axis1.attrs["UNITS"] = np.array([b"axis1 unit"], dtype="|S256")
+#     # backend are registered automatically for GridDatasets
+#     for (name, backend) in GridDataset.get_backends().items():
+#         if name == Osiris_Dev_Hdf5_GridFile.name:
+#             continue
 
-        axis2 = axis_grp.create_dataset("AXIS2", data=axis2_data)
-        axis2.attrs["NAME"] = np.array([b"axis2 name"], dtype="|S256")
-        axis2.attrs["LONG_NAME"] = np.array([b"axis_2"], dtype="|S256")
-        axis2.attrs["UNITS"] = np.array([b"axis2 unit"], dtype="|S256")
+#         assert backend.is_valid_backend(os_hdf5_grid_dev_file) is False
 
-        axis3 = axis_grp.create_dataset("AXIS3", data=axis3_data)
-        axis3.attrs["NAME"] = np.array([b"axis3 name"], dtype="|S256")
-        axis3.attrs["LONG_NAME"] = np.array([b"axis_3"], dtype="|S256")
-        axis3.attrs["UNITS"] = np.array([b"axis3 unit"], dtype="|S256")
 
-    return file_
+# def test_Osiris_Dev_Hdf5_GridFile_dataset_props(os_hdf5_grid_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_GridFile' dataset properties"""
+#     backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
+#     # TODO: check if dataset name is valid identifier
+#     assert backend.dataset_name == "test ds"
+#     assert backend.dataset_label == "\\Latex test dataset label"
+#     assert backend.dataset_unit == "test dataset unit"
+
+#     assert backend.shape == (2, 3, 4)
+#     assert backend.dtype == np.dtype("f4")
+#     assert backend.ndim == 3
 
 
-def test_Osiris_Dev_Hdf5_GridFile_check_is_valid_backend(os_hdf5_grid_dev_file):
-    """Check 'Osiris_Hdf5_GridFile' is a valid backend exclusively"""
-    assert Osiris_Dev_Hdf5_GridFile.is_valid_backend(os_hdf5_grid_dev_file) is True
+# def test_Osiris_Dev_Hdf5_GridFile_grid_axes_props(os_hdf5_grid_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_GridFile' extraction of axes properties"""
+#     backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
+#     # TODO: check if axis names are valid identifier
+#     expected_names = ("axis1 name", "axis2 name", "axis3 name")
+#     expected_labels = ("axis_1", "axis_2", "axis_3")
+#     expected_units = ("axis1 unit", "axis2 unit", "axis3 unit")
 
-    # backend are registered automatically for GridDatasets
-    for (name, backend) in GridDataset.get_backends().items():
-        if name == Osiris_Dev_Hdf5_GridFile.name:
-            continue
+#     np.testing.assert_array_equal(backend.axes_names, expected_names)
+#     np.testing.assert_array_equal(backend.axes_labels, expected_labels)
+#     np.testing.assert_array_equal(backend.axes_units, expected_units)
+
+#     np.testing.assert_array_equal(backend.axes_min, [-1, -2, -3])
+#     np.testing.assert_array_equal(backend.axes_max, [2, 3, 4])
+
+
+# def test_Osiris_Dev_Hdf5_GridFile_iteration_props(os_hdf5_grid_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_GridFile' extraction of iteration"""
+#     backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
+#     assert backend.iteration == 12345
+
+
+# def test_Osiris_Dev_Hdf5_GridFile_time_props(os_hdf5_grid_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_GridFile' extraction of time props"""
+#     backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
+#     np.testing.assert_allclose(backend.time_step, -321.9)
+#     assert backend.time_unit == "time unit"
+
+
+# def test_Osiris_Dev_Hdf5_GridFile_reading_data(os_hdf5_grid_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_GridFile' reading array correctly"""
+#     backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
+#     full_array = np.arange(24, dtype="f4").reshape((2, 3, 4))
+
+#     # full array
+#     np.testing.assert_array_equal(backend.get_data(), full_array)
 
-        assert backend.is_valid_backend(os_hdf5_grid_dev_file) is False
-
-
-def test_Osiris_Dev_Hdf5_GridFile_dataset_props(os_hdf5_grid_dev_file):
-    """Check 'Osiris_Dev_Hdf5_GridFile' dataset properties"""
-    backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
-    # TODO: check if dataset name is valid identifier
-    assert backend.dataset_name == "test ds"
-    assert backend.dataset_label == "\\Latex test dataset label"
-    assert backend.dataset_unit == "test dataset unit"
-
-    assert backend.shape == (2, 3, 4)
-    assert backend.dtype == np.dtype("f4")
-    assert backend.ndim == 3
-
-
-def test_Osiris_Dev_Hdf5_GridFile_grid_axes_props(os_hdf5_grid_dev_file):
-    """Check 'Osiris_Dev_Hdf5_GridFile' extraction of axes properties"""
-    backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
-    # TODO: check if axis names are valid identifier
-    expected_names = ("axis1 name", "axis2 name", "axis3 name")
-    expected_labels = ("axis_1", "axis_2", "axis_3")
-    expected_units = ("axis1 unit", "axis2 unit", "axis3 unit")
-
-    np.testing.assert_array_equal(backend.axes_names, expected_names)
-    np.testing.assert_array_equal(backend.axes_labels, expected_labels)
-    np.testing.assert_array_equal(backend.axes_units, expected_units)
-
-    np.testing.assert_array_equal(backend.axes_min, [-1, -2, -3])
-    np.testing.assert_array_equal(backend.axes_max, [2, 3, 4])
-
-
-def test_Osiris_Dev_Hdf5_GridFile_iteration_props(os_hdf5_grid_dev_file):
-    """Check 'Osiris_Dev_Hdf5_GridFile' extraction of iteration"""
-    backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
-    assert backend.iteration == 12345
-
-
-def test_Osiris_Dev_Hdf5_GridFile_time_props(os_hdf5_grid_dev_file):
-    """Check 'Osiris_Dev_Hdf5_GridFile' extraction of time props"""
-    backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
-    np.testing.assert_allclose(backend.time_step, -321.9)
-    assert backend.time_unit == "time unit"
-
-
-def test_Osiris_Dev_Hdf5_GridFile_reading_data(os_hdf5_grid_dev_file):
-    """Check 'Osiris_Dev_Hdf5_GridFile' reading array correctly"""
-    backend = Osiris_Dev_Hdf5_GridFile(os_hdf5_grid_dev_file)
-    full_array = np.arange(24, dtype="f4").reshape((2, 3, 4))
-
-    # full array
-    np.testing.assert_array_equal(backend.get_data(), full_array)
-
-    # subarray
-    index = np.s_[1, :2, ::2]
-    np.testing.assert_array_equal(backend.get_data(indexing=index), full_array[index])
+#     # subarray
+#     index = np.s_[1, :2, ::2]
+#     np.testing.assert_array_equal(
+#         backend.get_data(indexing=index), full_array[index]
+#     )
diff --git a/tests/backends/osiris/test_ParticleFile.py b/tests/backends/osiris/test_ParticleFile.py
index 30fac83..0b164b3 100644
--- a/tests/backends/osiris/test_ParticleFile.py
+++ b/tests/backends/osiris/test_ParticleFile.py
@@ -1,277 +1,292 @@
 # -*- coding: utf-8 -*-
-import h5py as h5
-import numpy as np
-import pytest
+# import h5py as h5
+# import numpy as np
+# import pytest
 
-from nata.backends.osiris.hdf5 import Osiris_Dev_Hdf5_ParticleFile
-from nata.backends.osiris.hdf5 import Osiris_Hdf5_ParticleFile
-from nata.backends.osiris.zdf import Osiris_zdf_ParticleFile
-from nata.containers import ParticleDataset
-from nata.types import ParticleBackendType
+# from nata.backends.osiris.hdf5 import Osiris_Dev_Hdf5_ParticleFile
+# from nata.backends.osiris.hdf5 import Osiris_Hdf5_ParticleFile
+# from nata.backends.osiris.zdf import Osiris_zdf_ParticleFile
+# from nata.containers import ParticleDataset
+# from nata.types import ParticleBackendType
 
 
-def test_Osiris_Hdf5_ParticleFile_ParticleBackendType():
-    assert isinstance(Osiris_Hdf5_ParticleFile, ParticleBackendType)
+# def test_Osiris_Hdf5_ParticleFile_ParticleBackendType():
+#     assert isinstance(Osiris_Hdf5_ParticleFile, ParticleBackendType)
 
 
-def test_Osiris_Dev_Hdf5_ParticleFile_ParticleBackendType():
-    assert isinstance(Osiris_Dev_Hdf5_ParticleFile, ParticleBackendType)
+# def test_Osiris_Dev_Hdf5_ParticleFile_ParticleBackendType():
+#     assert isinstance(Osiris_Dev_Hdf5_ParticleFile, ParticleBackendType)
 
 
-def test_Osiris_zdf_ParticleFile_ParticleBackendType():
-    assert isinstance(Osiris_zdf_ParticleFile, ParticleBackendType)
+# def test_Osiris_zdf_ParticleFile_ParticleBackendType():
+#     assert isinstance(Osiris_zdf_ParticleFile, ParticleBackendType)
 
 
-def test_ParticleDatasets_backends_are_registered():
-    backends = ParticleDataset.get_backends()
+# def test_ParticleDatasets_backends_are_registered():
+#     backends = ParticleDataset.get_backends()
 
-    assert backends[Osiris_Hdf5_ParticleFile.name] is Osiris_Hdf5_ParticleFile
-    assert backends[Osiris_Dev_Hdf5_ParticleFile.name] is Osiris_Dev_Hdf5_ParticleFile
-    assert backends[Osiris_zdf_ParticleFile.name] is Osiris_zdf_ParticleFile
+#     assert backends[Osiris_Hdf5_ParticleFile.name] is Osiris_Hdf5_ParticleFile
+#     assert (
+#         backends[Osiris_Dev_Hdf5_ParticleFile.name]
+#         is Osiris_Dev_Hdf5_ParticleFile
+#     )
+#     assert backends[Osiris_zdf_ParticleFile.name] is Osiris_zdf_ParticleFile
 
 
-@pytest.fixture(name="os_hdf5_particle_444_file", scope="session")
-def _generate_valid_Osiris_Hdf5_ParticleFile(tmp_path_factory):
-    tmp_path = tmp_path_factory.mktemp("os_hdf5_particle_444_fixture")
-    file_ = tmp_path / "os_hdf5_particle_444_file.h5"
-    dtype = np.dtype("f4")
+# @pytest.fixture(name="os_hdf5_particle_444_file", scope="session")
+# def _generate_valid_Osiris_Hdf5_ParticleFile(tmp_path_factory):
+#     tmp_path = tmp_path_factory.mktemp("os_hdf5_particle_444_fixture")
+#     file_ = tmp_path / "os_hdf5_particle_444_file.h5"
+#     dtype = np.dtype("f4")
 
-    with h5.File(file_, mode="w") as fp:
-        # root attrs
-        fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
-        fp.attrs["TYPE"] = np.array([b"particles"], dtype="|S9")
-        fp.attrs["ITER"] = np.array([12345], dtype="i4")
-        fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
-        fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
-
-        # charge
-        data_q = np.arange(13, dtype=dtype)
-        q = fp.create_dataset("q", data=data_q)
-        q.attrs["LONG_NAME"] = np.array([b"q label"], dtype="|S256")
-        q.attrs["UNITS"] = np.array([b"q unit"], dtype="|S256")
-
-        # quant 1
-        data_quant1 = np.arange(13, dtype=dtype) - 10
-        quant1 = fp.create_dataset("quant1", data=data_quant1)
-        quant1.attrs["LONG_NAME"] = np.array([b"quant1 label"], dtype="|S256")
-        quant1.attrs["UNITS"] = np.array([b"quant1 unit"], dtype="|S256")
-
-        # quant 2
-        data_quant2 = np.arange(13, dtype=dtype) + 10
-        quant2 = fp.create_dataset("quant2", data=data_quant2)
-        quant2.attrs["LONG_NAME"] = np.array([b"quant2 label"], dtype="|S256")
-        quant2.attrs["UNITS"] = np.array([b"quant2 unit"], dtype="|S256")
-
-        # tags
-        tags = np.arange(13 * 2, dtype="i4").reshape((13, 2))
-        fp.create_dataset("tag", data=tags)
-
-    return file_
-
-
-def test_Osiris_Hdf5_ParticleFile_check_is_valid_backend(
-    os_hdf5_particle_444_file,
-):
-    """Check 'Osiris_Hdf5_ParticleFile' is a valid backend exclusively"""
-    assert Osiris_Hdf5_ParticleFile.is_valid_backend(os_hdf5_particle_444_file) is True
-
-    # backend are registered automatically for ParticleDatasets
-    for (name, backend) in ParticleDataset.get_backends().items():
-        if name == Osiris_Hdf5_ParticleFile.name:
-            continue
-
-        assert backend.is_valid_backend(os_hdf5_particle_444_file) is False
-
-
-def test_Osiris_Hdf5_ParticleFile_dataset_props(os_hdf5_particle_444_file):
-    """Check 'Osiris_Hdf5_ParticleFile' dataset properties"""
-    backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
-    assert backend.dataset_name == "test ds"
-    assert backend.num_particles == 13
-    assert backend.dtype == np.dtype([(s, "f4") for s in ("q", "quant1", "quant2")])
-
-
-def test_Osiris_Hdf5_ParticleFile_quantity_props(os_hdf5_particle_444_file):
-    """Check 'Osiris_Hdf5_ParticleFile' quantity properties"""
-    backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
-    # TODO: check if quantity names are valid identifier
-    expected_names = ("q", "quant1", "quant2")
-    expected_labels = ("q label", "quant1 label", "quant2 label")
-    expected_units = ("q unit", "quant1 unit", "quant2 unit")
-
-    np.testing.assert_array_equal(backend.quantity_names, expected_names)
-    np.testing.assert_array_equal(backend.quantity_labels, expected_labels)
-    np.testing.assert_array_equal(backend.quantity_units, expected_units)
-
-
-def test_Osiris_Hdf5_ParticleFile_iteration_props(os_hdf5_particle_444_file):
-    """Check 'Osiris_Hdf5_ParticleFile' extraction of iteration"""
-    backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
-    assert backend.iteration == 12345
-
-
-def test_Osiris_Hdf5_ParticleFile_time_props(os_hdf5_particle_444_file):
-    """Check 'Osiris_Hdf5_ParticleFile' extraction of time props"""
-    backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
-    np.testing.assert_allclose(backend.time_step, -321.9)
-    assert backend.time_unit == "time unit"
-
-
-def test_Osiris_Hdf5_ParticleFile_reading_data(os_hdf5_particle_444_file):
-    """Check 'Osiris_Hdf5_ParticleFile' reading array correctly"""
-    backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
-    dtype = np.dtype([(quant, "f4") for quant in ("q", "quant1", "quant2")])
-
-    full_array = np.zeros(13, dtype=dtype)
-    full_array["q"] = np.arange(13, dtype="f4")
-    full_array["quant1"] = np.arange(13, dtype="f4") - 10
-    full_array["quant2"] = np.arange(13, dtype="f4") + 10
-
-    # full data
-    np.testing.assert_array_equal(backend.get_data(), full_array)
-
-    # --- subdata ---
-    # select every 2nd particle
-    index = np.s_[::2]
-    np.testing.assert_array_equal(backend.get_data(indexing=index), full_array[index])
-    # select two quantities
-    np.testing.assert_array_equal(
-        backend.get_data(fields=["quant1", "quant2"]),
-        full_array[["quant1", "quant2"]],
-    )
-    # select one quantity
-    np.testing.assert_array_equal(
-        backend.get_data(fields="quant1"),
-        full_array["quant1"],
-    )
-    # select one quantity and every 3rd particle
-    np.testing.assert_array_equal(
-        backend.get_data(indexing=index, fields="quant1"),
-        full_array["quant1"][index],
-    )
-
-
-@pytest.fixture(name="os_hdf5_particle_dev_file", scope="session")
-def _generate_valid_Osiris_Dev_Hdf5_ParticleFile(tmp_path_factory):
-    tmp_path = tmp_path_factory.mktemp("os_hdf5_particle_dev_fixture")
-    file_ = tmp_path / "os_hdf5_particle_dev_file.h5"
-    dtype = np.dtype("f4")
-
-    with h5.File(file_, mode="w") as fp:
-        # root attrs
-        fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
-        fp.attrs["TYPE"] = np.array([b"particles"], dtype="|S9")
-        fp.attrs["QUANTS"] = np.array([b"q", b"quant1", b"quant2"], dtype="|S256")
-        fp.attrs["LABELS"] = np.array(
-            [b"q label", b"quant1 label", b"quant2 label"], dtype="|S256"
-        )
-        fp.attrs["UNITS"] = np.array(
-            [b"q unit", b"quant1 unit", b"quant2 unit"], dtype="|S256"
-        )
-        fp.attrs["ITER"] = np.array([12345], dtype="i4")
-        fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
-        fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
-
-        # charge
-        data_q = np.arange(13, dtype=dtype)
-        fp.create_dataset("q", data=data_q)
-
-        # # quant 1
-        data_quant1 = np.arange(13, dtype=dtype) - 10
-        fp.create_dataset("quant1", data=data_quant1)
-
-        # # quant 2
-        data_quant2 = np.arange(13, dtype=dtype) + 10
-        fp.create_dataset("quant2", data=data_quant2)
-
-        # tags
-        tags = np.arange(13 * 2, dtype="i4").reshape((13, 2))
-        fp.create_dataset("tag", data=tags)
-
-    return file_
-
-
-def test_Osiris_Dev_Hdf5_ParticleFile_check_is_valid_backend(
-    os_hdf5_particle_dev_file,
-):
-    """Check 'Osiris_Dev_Hdf5_ParticleFile' is a valid backend exclusively"""
-    assert (
-        Osiris_Dev_Hdf5_ParticleFile.is_valid_backend(os_hdf5_particle_dev_file) is True
-    )
-
-    # backend are registered automatically for ParticleDatasets
-    for (name, backend) in ParticleDataset.get_backends().items():
-        if name == Osiris_Dev_Hdf5_ParticleFile.name:
-            continue
-
-        assert backend.is_valid_backend(os_hdf5_particle_dev_file) is False
-
-
-def test_Osiris_Dev_Hdf5_ParticleFile_dataset_props(os_hdf5_particle_dev_file):
-    """Check 'Osiris_Dev_Hdf5_ParticleFile' dataset properties"""
-    backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
-    assert backend.dataset_name == "test ds"
-    assert backend.num_particles == 13
-    assert backend.dtype == np.dtype([(s, "f4") for s in ("q", "quant1", "quant2")])
-
-
-def test_Osiris_Dev_Hdf5_ParticleFile_quantity_props(os_hdf5_particle_dev_file):
-    """Check 'Osiris_Dev_Hdf5_ParticleFile' quantity properties"""
-    backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
-    # TODO: check if quantity names are valid identifier
-    expected_names = ("q", "quant1", "quant2")
-    expected_labels = ("q label", "quant1 label", "quant2 label")
-    expected_units = ("q unit", "quant1 unit", "quant2 unit")
-
-    np.testing.assert_array_equal(backend.quantity_names, expected_names)
-    np.testing.assert_array_equal(backend.quantity_labels, expected_labels)
-    np.testing.assert_array_equal(backend.quantity_units, expected_units)
-
-
-def test_Osiris_Dev_Hdf5_ParticleFile_iteration_props(
-    os_hdf5_particle_dev_file,
-):
-    """Check 'Osiris_Dev_Hdf5_ParticleFile' extraction of iteration"""
-    backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
-    assert backend.iteration == 12345
-
-
-def test_Osiris_Dev_Hdf5_ParticleFile_time_props(os_hdf5_particle_dev_file):
-    """Check 'Osiris_Dev_Hdf5_ParticleFile' extraction of time props"""
-    backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
-    np.testing.assert_allclose(backend.time_step, -321.9)
-    assert backend.time_unit == "time unit"
-
-
-def test_Osiris_Dev_Hdf5_ParticleFile_reading_data(os_hdf5_particle_dev_file):
-    """Check 'Osiris_Hdf5_ParticleFile' reading array correctly"""
-    backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
-    dtype = np.dtype([(quant, "f4") for quant in ("q", "quant1", "quant2")])
-
-    full_array = np.zeros(13, dtype=dtype)
-    full_array["q"] = np.arange(13, dtype="f4")
-    full_array["quant1"] = np.arange(13, dtype="f4") - 10
-    full_array["quant2"] = np.arange(13, dtype="f4") + 10
-
-    # full data
-    np.testing.assert_array_equal(backend.get_data(), full_array)
-
-    # --- subdata ---
-    # select every 2nd particle
-    index = np.s_[::2]
-    np.testing.assert_array_equal(backend.get_data(indexing=index), full_array[index])
-    # select two quantities
-    np.testing.assert_array_equal(
-        backend.get_data(fields=["quant1", "quant2"]),
-        full_array[["quant1", "quant2"]],
-    )
-    # select one quantity
-    np.testing.assert_array_equal(
-        backend.get_data(fields="quant1"),
-        full_array["quant1"],
-    )
-    # select one quantity and every 3rd particle
-    np.testing.assert_array_equal(
-        backend.get_data(indexing=index, fields="quant1"),
-        full_array["quant1"][index],
-    )
+#     with h5.File(file_, mode="w") as fp:
+#         # root attrs
+#         fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
+#         fp.attrs["TYPE"] = np.array([b"particles"], dtype="|S9")
+#         fp.attrs["ITER"] = np.array([12345], dtype="i4")
+#         fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
+#         fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
+
+#         # charge
+#         data_q = np.arange(13, dtype=dtype)
+#         q = fp.create_dataset("q", data=data_q)
+#         q.attrs["LONG_NAME"] = np.array([b"q label"], dtype="|S256")
+#         q.attrs["UNITS"] = np.array([b"q unit"], dtype="|S256")
+
+#         # quant 1
+#         data_quant1 = np.arange(13, dtype=dtype) - 10
+#         quant1 = fp.create_dataset("quant1", data=data_quant1)
+#         quant1.attrs["LONG_NAME"] = np.array([b"quant1 label"], dtype="|S256")
+#         quant1.attrs["UNITS"] = np.array([b"quant1 unit"], dtype="|S256")
+
+#         # quant 2
+#         data_quant2 = np.arange(13, dtype=dtype) + 10
+#         quant2 = fp.create_dataset("quant2", data=data_quant2)
+#         quant2.attrs["LONG_NAME"] = np.array([b"quant2 label"], dtype="|S256")
+#         quant2.attrs["UNITS"] = np.array([b"quant2 unit"], dtype="|S256")
+
+#         # tags
+#         tags = np.arange(13 * 2, dtype="i4").reshape((13, 2))
+#         fp.create_dataset("tag", data=tags)
+
+#     return file_
+
+
+# def test_Osiris_Hdf5_ParticleFile_check_is_valid_backend(
+#     os_hdf5_particle_444_file,
+# ):
+#     """Check 'Osiris_Hdf5_ParticleFile' is a valid backend exclusively"""
+#     assert (
+#         Osiris_Hdf5_ParticleFile.is_valid_backend(os_hdf5_particle_444_file)
+#         is True
+#     )
+
+#     # backend are registered automatically for ParticleDatasets
+#     for (name, backend) in ParticleDataset.get_backends().items():
+#         if name == Osiris_Hdf5_ParticleFile.name:
+#             continue
+
+#         assert backend.is_valid_backend(os_hdf5_particle_444_file) is False
+
+
+# def test_Osiris_Hdf5_ParticleFile_dataset_props(os_hdf5_particle_444_file):
+#     """Check 'Osiris_Hdf5_ParticleFile' dataset properties"""
+#     backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
+#     assert backend.dataset_name == "test ds"
+#     assert backend.num_particles == 13
+#     assert backend.dtype == np.dtype(
+#         [(s, "f4") for s in ("q", "quant1", "quant2")]
+#     )
+
+
+# def test_Osiris_Hdf5_ParticleFile_quantity_props(os_hdf5_particle_444_file):
+#     """Check 'Osiris_Hdf5_ParticleFile' quantity properties"""
+#     backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
+#     # TODO: check if quantity names are valid identifier
+#     expected_names = ("q", "quant1", "quant2")
+#     expected_labels = ("q label", "quant1 label", "quant2 label")
+#     expected_units = ("q unit", "quant1 unit", "quant2 unit")
+
+#     np.testing.assert_array_equal(backend.quantity_names, expected_names)
+#     np.testing.assert_array_equal(backend.quantity_labels, expected_labels)
+#     np.testing.assert_array_equal(backend.quantity_units, expected_units)
+
+
+# def test_Osiris_Hdf5_ParticleFile_iteration_props(os_hdf5_particle_444_file):
+#     """Check 'Osiris_Hdf5_ParticleFile' extraction of iteration"""
+#     backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
+#     assert backend.iteration == 12345
+
+
+# def test_Osiris_Hdf5_ParticleFile_time_props(os_hdf5_particle_444_file):
+#     """Check 'Osiris_Hdf5_ParticleFile' extraction of time props"""
+#     backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
+#     np.testing.assert_allclose(backend.time_step, -321.9)
+#     assert backend.time_unit == "time unit"
+
+
+# def test_Osiris_Hdf5_ParticleFile_reading_data(os_hdf5_particle_444_file):
+#     """Check 'Osiris_Hdf5_ParticleFile' reading array correctly"""
+#     backend = Osiris_Hdf5_ParticleFile(os_hdf5_particle_444_file)
+#     dtype = np.dtype([(quant, "f4") for quant in ("q", "quant1", "quant2")])
+
+#     full_array = np.zeros(13, dtype=dtype)
+#     full_array["q"] = np.arange(13, dtype="f4")
+#     full_array["quant1"] = np.arange(13, dtype="f4") - 10
+#     full_array["quant2"] = np.arange(13, dtype="f4") + 10
+
+#     # full data
+#     np.testing.assert_array_equal(backend.get_data(), full_array)
+
+#     # --- subdata ---
+#     # select every 2nd particle
+#     index = np.s_[::2]
+#     np.testing.assert_array_equal(
+#         backend.get_data(indexing=index), full_array[index]
+#     )
+#     # select two quantities
+#     np.testing.assert_array_equal(
+#         backend.get_data(fields=["quant1", "quant2"]),
+#         full_array[["quant1", "quant2"]],
+#     )
+#     # select one quantity
+#     np.testing.assert_array_equal(
+#         backend.get_data(fields="quant1"), full_array["quant1"],
+#     )
+#     # select one quantity and every 3rd particle
+#     np.testing.assert_array_equal(
+#         backend.get_data(indexing=index, fields="quant1"),
+#         full_array["quant1"][index],
+#     )
+
+
+# @pytest.fixture(name="os_hdf5_particle_dev_file", scope="session")
+# def _generate_valid_Osiris_Dev_Hdf5_ParticleFile(tmp_path_factory):
+#     tmp_path = tmp_path_factory.mktemp("os_hdf5_particle_dev_fixture")
+#     file_ = tmp_path / "os_hdf5_particle_dev_file.h5"
+#     dtype = np.dtype("f4")
+
+#     with h5.File(file_, mode="w") as fp:
+#         # root attrs
+#         fp.attrs["NAME"] = np.array([b"test ds"], dtype="|S256")
+#         fp.attrs["TYPE"] = np.array([b"particles"], dtype="|S9")
+#         fp.attrs["QUANTS"] = np.array(
+#             [b"q", b"quant1", b"quant2"], dtype="|S256"
+#         )
+#         fp.attrs["LABELS"] = np.array(
+#             [b"q label", b"quant1 label", b"quant2 label"], dtype="|S256"
+#         )
+#         fp.attrs["UNITS"] = np.array(
+#             [b"q unit", b"quant1 unit", b"quant2 unit"], dtype="|S256"
+#         )
+#         fp.attrs["ITER"] = np.array([12345], dtype="i4")
+#         fp.attrs["TIME"] = np.array([-321.9], dtype="f4")
+#         fp.attrs["TIME UNITS"] = np.array([b"time unit"], dtype="|S256")
+
+#         # charge
+#         data_q = np.arange(13, dtype=dtype)
+#         fp.create_dataset("q", data=data_q)
+
+#         # # quant 1
+#         data_quant1 = np.arange(13, dtype=dtype) - 10
+#         fp.create_dataset("quant1", data=data_quant1)
+
+#         # # quant 2
+#         data_quant2 = np.arange(13, dtype=dtype) + 10
+#         fp.create_dataset("quant2", data=data_quant2)
+
+#         # tags
+#         tags = np.arange(13 * 2, dtype="i4").reshape((13, 2))
+#         fp.create_dataset("tag", data=tags)
+
+#     return file_
+
+
+# def test_Osiris_Dev_Hdf5_ParticleFile_check_is_valid_backend(
+#     os_hdf5_particle_dev_file,
+# ):
+#     """Check 'Osiris_Dev_Hdf5_ParticleFile' is a valid backend exclusively"""
+#     assert (
+#         Osiris_Dev_Hdf5_ParticleFile.is_valid_backend(os_hdf5_particle_dev_file)
+#         is True
+#     )
+
+#     # backend are registered automatically for ParticleDatasets
+#     for (name, backend) in ParticleDataset.get_backends().items():
+#         if name == Osiris_Dev_Hdf5_ParticleFile.name:
+#             continue
+
+#         assert backend.is_valid_backend(os_hdf5_particle_dev_file) is False
+
+
+# def test_Osiris_Dev_Hdf5_ParticleFile_dataset_props(os_hdf5_particle_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_ParticleFile' dataset properties"""
+#     backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
+#     assert backend.dataset_name == "test ds"
+#     assert backend.num_particles == 13
+#     assert backend.dtype == np.dtype(
+#         [(s, "f4") for s in ("q", "quant1", "quant2")]
+#     )
+
+
+# def test_Osiris_Dev_Hdf5_ParticleFile_quantity_props(os_hdf5_particle_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_ParticleFile' quantity properties"""
+#     backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
+#     # TODO: check if quantity names are valid identifier
+#     expected_names = ("q", "quant1", "quant2")
+#     expected_labels = ("q label", "quant1 label", "quant2 label")
+#     expected_units = ("q unit", "quant1 unit", "quant2 unit")
+
+#     np.testing.assert_array_equal(backend.quantity_names, expected_names)
+#     np.testing.assert_array_equal(backend.quantity_labels, expected_labels)
+#     np.testing.assert_array_equal(backend.quantity_units, expected_units)
+
+
+# def test_Osiris_Dev_Hdf5_ParticleFile_iteration_props(
+#     os_hdf5_particle_dev_file,
+# ):
+#     """Check 'Osiris_Dev_Hdf5_ParticleFile' extraction of iteration"""
+#     backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
+#     assert backend.iteration == 12345
+
+
+# def test_Osiris_Dev_Hdf5_ParticleFile_time_props(os_hdf5_particle_dev_file):
+#     """Check 'Osiris_Dev_Hdf5_ParticleFile' extraction of time props"""
+#     backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
+#     np.testing.assert_allclose(backend.time_step, -321.9)
+#     assert backend.time_unit == "time unit"
+
+
+# def test_Osiris_Dev_Hdf5_ParticleFile_reading_data(os_hdf5_particle_dev_file):
+#     """Check 'Osiris_Hdf5_ParticleFile' reading array correctly"""
+#     backend = Osiris_Dev_Hdf5_ParticleFile(os_hdf5_particle_dev_file)
+#     dtype = np.dtype([(quant, "f4") for quant in ("q", "quant1", "quant2")])
+
+#     full_array = np.zeros(13, dtype=dtype)
+#     full_array["q"] = np.arange(13, dtype="f4")
+#     full_array["quant1"] = np.arange(13, dtype="f4") - 10
+#     full_array["quant2"] = np.arange(13, dtype="f4") + 10
+
+#     # full data
+#     np.testing.assert_array_equal(backend.get_data(), full_array)
+
+#     # --- subdata ---
+#     # select every 2nd particle
+#     index = np.s_[::2]
+#     np.testing.assert_array_equal(
+#         backend.get_data(indexing=index), full_array[index]
+#     )
+#     # select two quantities
+#     np.testing.assert_array_equal(
+#         backend.get_data(fields=["quant1", "quant2"]),
+#         full_array[["quant1", "quant2"]],
+#     )
+#     # select one quantity
+#     np.testing.assert_array_equal(
+#         backend.get_data(fields="quant1"), full_array["quant1"],
+#     )
+#     # select one quantity and every 3rd particle
+#     np.testing.assert_array_equal(
+#         backend.get_data(indexing=index, fields="quant1"),
+#         full_array["quant1"][index],
+#     )
diff --git a/tests/containers/GridDatset_test.py b/tests/containers/GridDatset_test.py
deleted file mode 100644
index 1ff0db5..0000000
--- a/tests/containers/GridDatset_test.py
+++ /dev/null
@@ -1,160 +0,0 @@
-# -*- coding: utf-8 -*-
-# from pathlib import Path
-# from typing import Optional
-# from typing import Union
-
-# import numpy as np
-# import pytest
-# from numpy.typing import ArrayLike
-
-# from nata.containers import GridDataset
-# from nata.types import BasicIndexing
-# from nata.types import FileLocation
-# from nata.utils.container_tools import register_backend
-
-
-# @pytest.fixture(name="grid_files")
-# def custom_grid_dataset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
-#     # make checking for 'is_valid_backend' always succeed
-#     monkeypatch.setattr(GridDataset, "is_valid_backend", lambda _: True)
-
-#     # make 'basedir'
-#     basedir = tmp_path / "grid_files"
-#     basedir.mkdir(parents=True, exist_ok=True)
-
-#     # create dummy files
-#     dummy_data = np.arange(32).reshape((4, 8))
-#     np.savetxt(basedir / "grid.0", dummy_data, delimiter=",")
-#     np.savetxt(basedir / "grid.1", dummy_data, delimiter=",")
-#     np.savetxt(basedir / "grid.2", dummy_data, delimiter=",")
-
-#     @register_backend(GridDataset)
-#     class Dummy_GridFile:
-#         name: str = "dummy_backend"
-
-#         def __init__(self, location: FileLocation) -> None:
-#             self.location = Path(location)
-#             self.name = "dummy_backend"
-#             self.data = np.loadtxt(location, delimiter=",")
-#             self.dataset_name = "dummy_grid"
-#             self.dataset_label = "dummy grid label"
-#             self.dataset_unit = "dummy unit"
-#             self.ndim = dummy_data.ndim
-#             self.shape = dummy_data.shape
-#             self.dtype = dummy_data.dtype
-#             self.axes_min = (0, 1)
-#             self.axes_max = (1, 2)
-#             self.axes_names = ("dummy_axis0", "dummy_axis1")
-#             self.axes_labels = ("dummy label axis0", "dummy label axis1")
-#             self.axes_units = ("dummy unit axis0", "dummy unit axis1")
-#             self.iteration = 0
-#             self.time_step = 1.0
-#             self.time_unit = "dummy time unit"
-
-#         @staticmethod
-#         def is_valid_backend(location: Union[str, Path]) -> bool:
-#             location = Path(location)
-#             if location.stem == "grid" and location.suffix in (".0", ".1", ".2"):
-#                 return True
-#             else:
-#                 return False
-
-#         def get_data(self, indexing: Optional[BasicIndexing] = None) -> ArrayLike:
-#             if indexing:
-#                 return self.data[indexing]
-#             else:
-#                 return self.data
-
-#     yield basedir
-#     GridDataset.remove_backend(Dummy_GridFile)
-
-
-# def test_GridDataset_from_array():
-#     grid = GridDataset.from_array([[1, 2, 3], [3, 4, 5]])
-
-#     # general information
-#     assert grid.name == "unnamed"
-#     assert grid.label == "unlabeled"
-#     assert grid.unit == ""
-
-#     # array and grid information
-#     assert grid.ndim == 2
-#     assert grid.shape == (2, 3)
-#     assert grid.dtype == int
-
-#     # axis info
-#     assert grid.axes[0].name == "axis0"
-#     assert grid.axes[1].name == "axis1"
-
-
-# def test_GridDataset_from_array_naming():
-#     grid = GridDataset.from_array(
-#         [[1, 2, 3], [3, 4, 5]],
-#         name="my_new_name",
-#         label="My New Label",
-#         unit="some unit",
-#     )
-
-#     assert grid.name == "my_new_name"
-#     assert grid.label == "My New Label"
-#     assert grid.unit == "some unit"
-
-
-# def test_GridDataset_change_name():
-#     grid = GridDataset.from_array([[1, 2, 3], [3, 4, 5]], name="old_name")
-
-#     assert grid.name == "old_name"
-#     grid.name = "new_name"
-#     assert grid.name == "new_name"
-
-
-# def test_GridDataset_raise_invalid_name():
-#     with pytest.raises(ValueError, match="has to be an identifier"):
-#         GridDataset.from_array([1, 2], name="some invalid name")
-
-#     grid = GridDataset.from_array([1, 2])
-#     with pytest.raises(ValueError, match="has to be an identifier"):
-#         grid.name = "some invalid name"
-
-
-# def test_GridDataset_from_array_with_indexable_axes():
-#     grid = GridDataset.from_array(
-#         [[1, 2, 3], [3, 4, 5]],
-#         indexable_axes=[[0.0, 0.1, 0.2], [10, 20, 30]],
-#     )
-
-#     assert grid.ndim == 2
-#     assert grid.shape == (2, 3)
-
-#     assert grid.axes[0].name == "axis0"
-#     assert grid.axes[1].name == "axis1"
-
-
-# def test_GridDataset_from_array_with_non_indexable_axes():
-#     grid = GridDataset.from_array(
-#         [[1, 2, 3], [3, 4, 5]],
-#         hidden_axes=[0, 1, 2, 3, 4, 5],
-#     )
-
-#     assert grid.ndim == 2
-#     assert grid.shape == (2, 3)
-
-#     assert grid.axes[0].name == "axis0"
-#     assert grid.axes[1].name == "axis1"
-#     assert grid.axes.hidden[0].name == "axis2"
-
-
-# def test_GridDataset_append():
-#     grid = GridDataset.from_array([1, 2, 3])
-
-#     grid.append(GridDataset.from_array([2, 3, 4]))
-#     grid.append(GridDataset.from_array([3, 4, 5]))
-
-#     assert grid.shape == (3, 3)
-#     np.testing.assert_array_equal(grid, [[1, 2, 3], [2, 3, 4], [3, 4, 5]])
-
-
-# def test_GridDataset_from_path(grid_files):
-#     grid = GridDataset.from_path(grid_files / "grid.*")
-#     expected_data = [np.arange(32).reshape((4, 8)) for _ in range(3)]
-#     np.testing.assert_array_equal(grid, expected_data)
diff --git a/tests/containers/axis_test.py b/tests/containers/axis_test.py
index f3bd67e..2275782 100644
--- a/tests/containers/axis_test.py
+++ b/tests/containers/axis_test.py
@@ -8,7 +8,7 @@
 
 def test_Axis_init_default():
     """Create from an array-like object with pre-defined names, labels, and units"""
-    axis = Axis([1, 2, 3])
+    axis = Axis.from_array([1, 2, 3])
     assert axis.name == "unnamed"
     assert axis.label == "unlabeled"
     assert axis.unit == ""
@@ -16,32 +16,14 @@ def test_Axis_init_default():
 
 def test_Axis_repr():
     """Ensures correct repr formatting"""
-    axis = Axis(())
-    expected = f"Axis(name='{axis.name}', label='{axis.label}', unit='{axis.unit}')"
+    axis = Axis.from_array(())
+    expected = f"Axis<name='{axis.name}', label='{axis.label}', unit='{axis.unit}'>"
     assert repr(axis) == expected
 
 
-@pytest.mark.skip
-def test_Axis_repr_html():
-    """Ensures correct repr_html formatting"""
-    axis = Axis(())
-    expected = (
-        "<span>Axis</span>"
-        "<span style='color: var(--jp-info-color0);'>"
-        "("
-        f"name='{axis.name}', "
-        f"label='{axis.label}', "
-        f"unit='{axis.unit}'"
-        ")"
-        "</span>"
-    )
-
-    assert axis._repr_html_() == expected
-
-
 def test_Axis_change_name():
     """Makes sure name property of Axis can be changed"""
-    axis = Axis((), name="some_name")
+    axis = Axis.from_array((), name="some_name")
     assert axis.name == "some_name"
 
     axis.name = "some_new_name"
@@ -50,16 +32,12 @@ def test_Axis_change_name():
 
 def test_Axis_raises_not_identifier():
     with pytest.raises(ValueError, match="has to be a valid identifier"):
-        Axis((), name="invalid name with space")
-
-    with pytest.raises(ValueError, match="has to be a valid identifier"):
-        axis = Axis(())
-        axis.name = "invalid name with space"
+        Axis.from_array((), name="invalid name with space")
 
 
 def test_Axis_change_label():
     """Makes sure label property of Axis can be changed"""
-    axis = Axis((), label="some label")
+    axis = Axis.from_array((), label="some label")
     assert axis.label == "some label"
 
     axis.label = "some new label"
@@ -68,7 +46,7 @@ def test_Axis_change_label():
 
 def test_Axis_change_unit():
     """Makes sure unit property of Axis can be changed"""
-    axis = Axis((), unit="some unit")
+    axis = Axis.from_array((), unit="some unit")
     assert axis.unit == "some unit"
 
     axis.unit = "some new unit"
@@ -77,27 +55,27 @@ def test_Axis_change_unit():
 
 def test_Axis_to_dask():
     """Check that '.to_dask' returns a dask array"""
-    axis = Axis(())
+    axis = Axis.from_array(())
     assert isinstance(axis.to_dask(), da.Array)
 
 
 def test_Axis_to_numpy():
     """Check that '.to_numpy' returns a numpy array"""
-    axis = Axis(())
+    axis = Axis.from_array(())
     assert isinstance(axis.to_numpy(), np.ndarray)
 
 
 def test_Axis_len():
     """Check length represents appendable dimension."""
-    axis = Axis([1, 2, 3])
+    axis = Axis.from_array([1, 2, 3])
     assert len(axis) == 3
 
-    axis = Axis([[1, 2, 3]])
+    axis = Axis.from_array([[1, 2, 3]])
     assert len(axis) == 1
 
 
 def test_Axis_array_props():
-    axis = Axis([1, 2, 3])
+    axis = Axis.from_array([1, 2, 3])
 
     assert axis.shape == (3,)
     assert axis.ndim == 1
@@ -105,20 +83,20 @@ def test_Axis_array_props():
 
 
 def test_Axis_array_method():
-    axis = Axis([0, 1, 2])
+    axis = Axis.from_array([0, 1, 2])
     np.testing.assert_almost_equal(np.array(axis), [0, 1, 2])
 
 
 def test_Axis_getitem():
-    axis = Axis(np.arange(12).reshape((4, 3)))
+    axis = Axis.from_array(np.arange(12).reshape((4, 3)))
     sub_axis = axis[3]
     np.testing.assert_array_equal(sub_axis, [9, 10, 11])
 
-    axis = Axis(np.arange(12).reshape((4, 3)))
+    axis = Axis.from_array(np.arange(12).reshape((4, 3)))
     sub_axis = axis[1:3]
     np.testing.assert_array_equal(sub_axis, [[3, 4, 5], [6, 7, 8]])
 
-    axis = Axis(np.arange(12).reshape((4, 3)))
+    axis = Axis.from_array(np.arange(12).reshape((4, 3)))
     sub_axis = axis[1:3]
     np.testing.assert_array_equal(sub_axis, [[3, 4, 5], [6, 7, 8]])
 
@@ -171,14 +149,19 @@ def test_Axis_from_limits(case):
 
 
 def test_Axis_ufunc():
-    axis = Axis([0, 1, 2])
+    axis = Axis.from_array([0, 1, 2])
     axis += 1
     np.testing.assert_array_equal(axis, [1, 2, 3])
 
-    axis = Axis([0, 1, 2])
+    axis = Axis.from_array([0, 1, 2])
     np.testing.assert_array_equal(axis + 1, [1, 2, 3])
 
 
 def test_Axis_array_function():
-    concanated_axes = np.concatenate((Axis([0, 1]), Axis([2, 3])))
+    # passing as a tuple of args
+    concanated_axes = np.concatenate((Axis.from_array([0, 1]), Axis.from_array([2, 3])))
+    np.testing.assert_array_equal(concanated_axes, [0, 1, 2, 3])
+
+    # passing as a list of args
+    concanated_axes = np.concatenate([Axis.from_array([0, 1]), Axis.from_array([2, 3])])
     np.testing.assert_array_equal(concanated_axes, [0, 1, 2, 3])
diff --git a/tests/containers/core_test.py b/tests/containers/core_test.py
new file mode 100644
index 0000000..b98b4f4
--- /dev/null
+++ b/tests/containers/core_test.py
@@ -0,0 +1,420 @@
+# -*- coding: utf-8 -*-
+from pathlib import Path
+from typing import Protocol
+from typing import Tuple
+from typing import runtime_checkable
+
+import dask.array as da
+import numpy as np
+import pytest
+
+from nata.containers.core import BackendType
+from nata.containers.core import HasAnnotations
+from nata.containers.core import HasBackends
+from nata.containers.core import HasNumpyInterface
+from nata.containers.core import HasPluginSystem
+
+
+@pytest.fixture(name="ExtendedProtocol")
+def _ExtendedProtocol() -> BackendType:
+    @runtime_checkable
+    class ExtendedProtocol(Protocol):
+        name: str
+        some_other_prop: Tuple[int, ...]
+
+        @staticmethod
+        def is_valid_backend(location: Path) -> bool:
+            ...
+
+        def some_other_method(self, foo: int) -> float:
+            ...
+
+    return ExtendedProtocol
+
+
+@pytest.fixture(name="UseBackend")
+def _ClassWithBackend(ExtendedProtocol: BackendType) -> HasBackends:
+    class UseBackend(HasBackends, backend_protocol=ExtendedProtocol):
+        pass
+
+    return UseBackend
+
+
+def test_custom_class_no_backends(UseBackend: HasBackends):
+    """Ensures custom defined test class does not have registered backends."""
+    assert len(UseBackend.get_backends()) == 0
+
+
+def test_is_valid_backend_with_valid_protocol(
+    UseBackend: HasBackends, ExtendedProtocol: BackendType
+):
+    """Make sure custom classes pass valid backend test"""
+
+    class ClassSatisfiesProtocol:
+        name = "name of class"
+        some_other_prop = (1, 2, 3)
+
+        @staticmethod
+        def is_valid_backend(location: Path) -> bool:
+            raise NotImplementedError("should never be reached")
+
+        def some_other_method(self, foo: int) -> float:
+            raise NotImplementedError("should never be reached")
+
+    # requires init because
+    # `TypeError: Protocols with non-method members don't support issubclass()`
+    assert isinstance(ClassSatisfiesProtocol(), ExtendedProtocol)
+    assert UseBackend.is_valid_backend(ClassSatisfiesProtocol) is True
+
+
+def test_is_valid_backend_with_invalid_protocol(
+    UseBackend: HasBackends, ExtendedProtocol: BackendType
+):
+    """Make sure custom classes pass valid backend test"""
+
+    class ClassDoesNotSatisfyProtocol:
+        name = "name of class"
+
+        @staticmethod
+        def is_valid_backend(location: Path) -> bool:
+            raise NotImplementedError("should never be reached")
+
+    # requires init because
+    # `TypeError: Protocols with non-method members don't support issubclass()`
+    assert not isinstance(ClassDoesNotSatisfyProtocol(), ExtendedProtocol)
+    assert UseBackend.is_valid_backend(ClassDoesNotSatisfyProtocol) is False
+
+
+def test_raise_when_no_protocol_specified():
+    """Raise when no protocol is specified and backend is checked"""
+
+    with pytest.raises(AttributeError, match=r"requires backend protocol"):
+
+        class UseBackend(HasBackends):
+            pass
+
+
+def test_adding_removing_new_backend(UseBackend: HasBackends):
+    """Check adding and removing of custom backends"""
+
+    class NewBackend:
+        # random asigned values for attrs
+        name = "name of backend"
+        some_other_prop = (10, 10)
+
+        @staticmethod
+        def is_valid_backend(location: Path) -> bool:
+            raise NotImplementedError("should never be reached")
+
+        def some_other_method(self, foo: int) -> float:
+            raise NotImplementedError("should never be reached")
+
+    assert len(UseBackend.get_backends()) == 0
+
+    # add backend
+    UseBackend.add_backend(NewBackend)
+    assert UseBackend.get_backends()[NewBackend.name] is NewBackend
+
+    # remove by value
+    UseBackend.remove_backend(NewBackend)
+    assert len(UseBackend.get_backends()) == 0
+
+    # add backend
+    UseBackend.add_backend(NewBackend)
+    assert UseBackend.get_backends()[NewBackend.name] is NewBackend
+
+    # remove by name
+    UseBackend.remove_backend(NewBackend.name)
+    assert len(UseBackend.get_backends()) == 0
+
+
+def test_removing_raises(UseBackend: HasBackends):
+    with pytest.raises(ValueError, match=r"backend '.*' not registered"):
+        # remove by value
+        UseBackend.remove_backend(object)
+
+    with pytest.raises(ValueError, match=r"backend '.*' not registered"):
+        # remove by name
+        UseBackend.remove_backend("invalide backend name")
+
+
+def test_adding_invalid_backend_raises(UseBackend: HasBackends):
+    with pytest.raises(TypeError, match=r"invalid backend provided"):
+        UseBackend.add_backend(object)
+
+
+def test_get_valid_backend(UseBackend: HasBackends):
+    """Check if correct backend is returned"""
+
+    class DummyBackend1:
+        # random asigned values for attrs
+        name = "name of backend"
+        some_other_prop = (10, 10)
+
+        @staticmethod
+        def is_valid_backend(location: Path) -> bool:
+            return location == Path("/DummyBackend1")
+
+        def some_other_method(self, foo: int) -> float:
+            raise NotImplementedError("should never be reached")
+
+    class DummyBackend2:
+        # random asigned values for attrs
+        name = "name of backend"
+        some_other_prop = (10, 10)
+
+        @staticmethod
+        def is_valid_backend(location: Path) -> bool:
+            return location == Path("/DummyBackend2")
+
+        def some_other_method(self, foo: int) -> float:
+            raise NotImplementedError("should never be reached")
+
+    # adding backends
+    UseBackend.add_backend(DummyBackend1)
+    UseBackend.add_backend(DummyBackend2)
+
+    assert UseBackend.get_valid_backend(Path("/DummyBackend1")) is DummyBackend1
+    assert UseBackend.get_valid_backend(Path("/DummyBackend2")) is DummyBackend2
+    assert UseBackend.get_valid_backend(Path("/does/not/have/valid/backend")) is None
+
+
+def test_HasNumpyInterface_handled_array_ufunc():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    some_ufunc = np.add
+
+    def implementation_some_ufunc():
+        pass
+
+    assert len(ExtendedClass.get_handled_array_ufunc()) == 0
+    ExtendedClass.add_handled_array_ufunc(some_ufunc, implementation_some_ufunc)
+    assert some_ufunc in ExtendedClass.get_handled_array_ufunc()
+    assert (
+        ExtendedClass.get_handled_array_ufunc()[some_ufunc] is implementation_some_ufunc
+    )
+    ExtendedClass.remove_handled_array_ufunc(some_ufunc)
+    assert len(ExtendedClass.get_handled_array_ufunc()) == 0
+
+
+def test_HasNumpyInterface_raise_when_not_ufunc():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    with pytest.raises(TypeError, match="function is not of type ufunc"):
+
+        def not_ufunc():
+            pass
+
+        ExtendedClass.add_handled_array_ufunc(not_ufunc, lambda _: None)
+
+
+def test_HasNumpyInterface_raise_when_remove_invalid_for_ufunc():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    with pytest.raises(ValueError, match=r"ufunc '.*' is not registered"):
+        ExtendedClass.remove_handled_array_ufunc(lambda _: None)
+
+
+def test_HasNumpyInterface_handled_array_function():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    some_array_function = np.fft.fft
+
+    def implementation_some_function():
+        pass
+
+    assert len(ExtendedClass.get_handled_array_function()) == 0
+    ExtendedClass.add_handled_array_function(
+        some_array_function, implementation_some_function
+    )
+    assert some_array_function in ExtendedClass.get_handled_array_function()
+    assert (
+        ExtendedClass.get_handled_array_function()[some_array_function]
+        is implementation_some_function
+    )
+    ExtendedClass.remove_handeld_array_function(some_array_function)
+    assert len(ExtendedClass.get_handled_array_function()) == 0
+
+
+def test_HasNumpyInterface_raise_when_remove_invalid_for_array_function():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    with pytest.raises(ValueError, match=r"function '.*' is not registered"):
+        ExtendedClass.remove_handeld_array_function(lambda _: None)
+
+
+def test_HasNumpyInterface_implements_ufunc():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    @ExtendedClass.implements(np.add)
+    def custom_add() -> None:
+        pass
+
+    assert ExtendedClass.get_handled_array_ufunc()[np.add] is custom_add
+
+
+def test_HasNumpyInterface_implements_array_func():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    @ExtendedClass.implements(np.fft.fft)
+    def custom_fft() -> None:
+        pass
+
+    assert ExtendedClass.get_handled_array_function()[np.fft.fft] is custom_fft
+
+
+def test_HasNumpyInterface_to_dask():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    obj = ExtendedClass(da.arange(10))
+    assert isinstance(obj.to_dask(), da.Array)
+    np.testing.assert_array_equal(obj.to_dask(), np.arange(10))
+
+
+def test_HasNumpyInterface_to_numpy():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    obj = ExtendedClass(da.arange(10))
+    assert isinstance(obj.to_numpy(), np.ndarray)
+    np.testing.assert_array_equal(obj.to_numpy(), np.arange(10))
+
+
+def test_HasNumpyInterface_array_props():
+    class ExtendedClass(HasNumpyInterface):
+        pass
+
+    obj = ExtendedClass(da.arange(10, dtype=int))
+    assert obj.dtype == int
+    assert obj.ndim == 1
+    assert obj.shape == (10,)
+    assert len(obj) == 10
+
+
+@pytest.mark.skip
+def test_HasNumpyInterface_array_ufunc_dispatch():
+    # TODO: add tests
+    pass
+
+
+@pytest.mark.skip
+def test_HasNumpyInterface_array_function_dispatch():
+    # TODO: add tests
+    pass
+
+
+def test_HasPluginSystem_property_plugin():
+    class ExtendedClass(HasPluginSystem):
+        pass
+
+    def plug_function(obj):
+        return obj
+
+    assert len(ExtendedClass.get_property_plugin()) == 0
+    ExtendedClass.add_property_plugin("my_plugin", plug_function)
+
+    obj = ExtendedClass()
+    assert obj.my_plugin is obj
+    assert ExtendedClass.get_property_plugin()["my_plugin"] is plug_function
+    ExtendedClass.remove_property_plugin("my_plugin")
+    assert len(ExtendedClass.get_property_plugin()) == 0
+
+
+def test_HasPluginSystem_property_pluging_raise_invalid_plugin_name():
+    class ExtendedClass(HasPluginSystem):
+        pass
+
+    with pytest.raises(TypeError, match="'plugin_name' has to be a 'str'"):
+        ExtendedClass.add_property_plugin(1, lambda _: None)
+
+    with pytest.raises(
+        ValueError, match="'not identifier' has to be a valid identifier"
+    ):
+        ExtendedClass.add_property_plugin("not identifier", lambda _: None)
+
+
+def test_HasPluginSystem_property_pluging_raise_not_registered():
+    class ExtendedClass(HasPluginSystem):
+        pass
+
+    with pytest.raises(ValueError, match="plugin 'not_registered' is not registered"):
+        ExtendedClass.remove_property_plugin("not_registered")
+
+
+def test_HasPluginSystem_method_plugin():
+    class ExtendedClass(HasPluginSystem):
+        pass
+
+    def plug_function(obj):
+        return obj
+
+    assert len(ExtendedClass.get_method_plugin()) == 0
+    ExtendedClass.add_method_plugin("my_plugin", plug_function)
+
+    obj = ExtendedClass()
+    assert obj.my_plugin() is obj
+    assert ExtendedClass.get_method_plugin()["my_plugin"] is plug_function
+    ExtendedClass.remove_method_plugin("my_plugin")
+    assert len(ExtendedClass.get_method_plugin()) == 0
+
+
+def test_HasPluginSystem_method_pluging_raise_invalid_plugin_name():
+    class ExtendedClass(HasPluginSystem):
+        pass
+
+    with pytest.raises(TypeError, match="'plugin_name' has to be a 'str'"):
+        ExtendedClass.add_method_plugin(1, lambda _: None)
+
+    with pytest.raises(
+        ValueError, match="'not identifier' has to be a valid identifier"
+    ):
+        ExtendedClass.add_method_plugin("not identifier", lambda _: None)
+
+
+def test_HasPluginSystem_method_pluging_raise_not_registered():
+    class ExtendedClass(HasPluginSystem):
+        pass
+
+    with pytest.raises(ValueError, match="plugin 'not_registered' is not registered"):
+        ExtendedClass.remove_method_plugin("not_registered")
+
+
+def test_HasAnnotations():
+    class ExtendedClass(HasAnnotations):
+        def __init__(self) -> None:
+            self._name = "some_name"
+            self._label = "some label"
+            self._unit = "some unit"
+
+    obj = ExtendedClass()
+
+    assert obj.name == "some_name"
+    assert obj.label == "some label"
+    assert obj.unit == "some unit"
+
+    obj.name = "some_new_name"
+    assert obj.name == "some_new_name"
+
+    obj.label = "some new label"
+    assert obj.label == "some new label"
+
+    obj.unit = "some new unit"
+    assert obj.unit == "some new unit"
+
+
+def test_HasAnnotations_raise_invalid_name():
+    class ExtendedClass(HasAnnotations):
+        pass
+
+    obj = ExtendedClass()
+
+    with pytest.raises(ValueError, match="'name' has to be an identifier"):
+        obj.name = "not an identifier"
diff --git a/tests/containers/grid_test.py b/tests/containers/grid_test.py
index 7a79bee..72ba491 100644
--- a/tests/containers/grid_test.py
+++ b/tests/containers/grid_test.py
@@ -16,7 +16,7 @@
 from nata.containers import GridArray
 from nata.containers import GridDataset
 from nata.containers.axis import Axis
-from nata.containers.grid import GridBackendType
+from nata.containers.grid import GridBackend
 from nata.utils.container_tools import register_backend
 
 # TODO: restructure the test to represent individual classes rather then
@@ -54,7 +54,7 @@ def is_valid_backend(location: Union[str, Path]) -> bool:
         dtype = np.dtype("i")
         ndim = int()
 
-    assert isinstance(DummyBackend, GridBackendType)
+    assert isinstance(DummyBackend, GridBackend)
     yield DummyBackend
 
     if DummyBackend.name in GridArray.get_backends():
@@ -198,17 +198,6 @@ def test_GridArray_from_array_raise_invalid_axes():
         GridArray.from_array([0, 1], axes=[[0, 1, 2, 3]])
 
 
-def test_GridArray_change_name_by_prop():
-    grid_arr = GridArray.from_array([])
-    assert grid_arr.name == "unnamed"
-
-    grid_arr.name = "new_name"
-    assert grid_arr.name == "new_name"
-
-    with pytest.raises(ValueError, match="name has to be an identifier"):
-        grid_arr.name = "invalid name"
-
-
 def test_GridArray_change_label_by_prop():
     grid_arr = GridArray.from_array([])
     assert grid_arr.label == "unlabeled"
@@ -225,12 +214,12 @@ def test_GridArray_change_unit_by_prop():
     assert grid_arr.unit == "new unit"
 
 
-def test_GridArray_is_valid_backend(valid_grid_backend: GridBackendType):
+def test_GridArray_is_valid_backend(valid_grid_backend: GridBackend):
     assert GridArray.is_valid_backend(valid_grid_backend)
     assert not GridArray.is_valid_backend(object)
 
 
-def test_GridArray_add_remove_backend(valid_grid_backend: GridBackendType):
+def test_GridArray_add_remove_backend(valid_grid_backend: GridBackend):
     assert valid_grid_backend.name not in GridArray.get_backends()
     GridArray.add_backend(valid_grid_backend)
 
@@ -240,7 +229,7 @@ def test_GridArray_add_remove_backend(valid_grid_backend: GridBackendType):
     assert valid_grid_backend.name not in GridArray.get_backends()
 
 
-def test_GridArray_get_valid_backend(valid_grid_backend: GridBackendType):
+def test_GridArray_get_valid_backend(valid_grid_backend: GridBackend):
     valid_grid_backend.is_valid_backend = staticmethod(lambda p: p == Path())
 
     # register backend
@@ -250,41 +239,6 @@ def test_GridArray_get_valid_backend(valid_grid_backend: GridBackendType):
     assert GridArray.get_valid_backend(Path("some/invalid/path")) is None
 
 
-def test_GridArray_register_plugin():
-    GridArray.register_plugin("my_custom_plugin", lambda _: None)
-
-    assert GridArray.get_plugins()["my_custom_plugin"] == ""
-
-
-def test_GridArray_calling_plugin_as_method():
-    def plugin_function(obj: GridArray, should_return_obj: bool):
-        """my custom docs"""
-        return obj if should_return_obj else None
-
-    GridArray.register_plugin("return_self_as_method", plugin_function, "method")
-    grid = GridArray.from_array([])
-
-    assert grid.return_self_as_method(True) is grid
-    assert grid.return_self_as_method(False) is None
-
-    # check plugin perserves docs
-    assert grid.return_self_as_method.__doc__ == plugin_function.__doc__
-
-
-def test_GridArray_calling_plugin_as_property():
-    GridArray.register_plugin("return_self_as_property", lambda s: s, "property")
-    grid = GridArray.from_array([])
-    assert grid.return_self_as_property is grid
-
-
-def test_GridArray_remove_plugin():
-    assert "dummy_plugin" not in GridArray.get_plugins()
-    GridArray.register_plugin("dummy_plugin", lambda s: s, "property")
-    assert "dummy_plugin" in GridArray.get_plugins()
-    GridArray.remove_plugin("dummy_plugin")
-    assert "dummy_plugin" not in GridArray.get_plugins()
-
-
 def test_GridArray_repr():
     grid = GridArray.from_array(np.arange(12, dtype=np.int32).reshape((4, 3)))
     expected_repr = (
@@ -329,34 +283,6 @@ def test_GridArray_array():
     np.testing.assert_array_equal(grid, [1, 2])
 
 
-def test_GridArray_implements_ufunc():
-    assert np.add not in GridArray.get_handled_ufuncs()
-
-    @GridArray.implements(np.add)
-    def my_function(*args, **kwargs):
-        return "my_function implementation"
-
-    assert np.add in GridArray.get_handled_ufuncs()
-    assert (GridArray.from_array([]) + 1) == "my_function implementation"
-    GridArray.remove_handled_ufuncs(np.add)
-
-    assert np.add not in GridArray.get_handled_ufuncs()
-
-
-def test_GridArray_implements_array_function():
-    assert np.fft.fft not in GridArray.get_handled_array_function()
-
-    @GridArray.implements(np.fft.fft)
-    def my_function(*args, **kwargs):
-        return "my_function implementation"
-
-    assert np.fft.fft in GridArray.get_handled_array_function()
-    assert np.fft.fft(GridArray.from_array([])) == "my_function implementation"
-    GridArray.remove_handled_array_function(np.fft.fft)
-
-    assert np.fft.fft not in GridArray.get_handled_array_function()
-
-
 def test_GridArray_ufunc_proxy():
     grid = GridArray.from_array([1, 2])
 
@@ -415,12 +341,15 @@ def test_GridArray_from_path_check_time_axis(grid_files):
 _testCases_getitem["(4,), [1:3]"] = {
     "arr": [1, 2, 3, 4],
     "indexing": np.s_[1:3],
-    "expected_axes": (Axis([1, 2], name="axis0"),),
+    "expected_axes": (Axis.from_array([1, 2], name="axis0"),),
 }
 _testCases_getitem["(4,), [np.newaxis]"] = {
     "arr": [1, 2, 3, 4],
     "indexing": np.s_[np.newaxis],
-    "expected_axes": (Axis([0]), Axis(np.arange(4), name="axis0")),
+    "expected_axes": (
+        Axis.from_array([0]),
+        Axis.from_array(np.arange(4), name="axis0"),
+    ),
 }
 _testCases_getitem["(3, 4, 5), [1, 2, 3]"] = {
     "arr": np.arange(3 * 4 * 5).reshape((3, 4, 5)),
@@ -430,11 +359,11 @@ def test_GridArray_from_path_check_time_axis(grid_files):
     "arr": np.arange(3 * 4 * 5 * 6 * 7).reshape((3, 4, 5, 6, 7)),
     "indexing": np.s_[1:3, np.newaxis, ..., 4],
     "expected_axes": (
-        Axis([1, 2], name="axis0"),
-        Axis([0]),
-        Axis(np.arange(4), name="axis1"),
-        Axis(np.arange(5), name="axis2"),
-        Axis(np.arange(6), name="axis3"),
+        Axis.from_array([1, 2], name="axis0"),
+        Axis.from_array([0]),
+        Axis.from_array(np.arange(4), name="axis1"),
+        Axis.from_array(np.arange(5), name="axis2"),
+        Axis.from_array(np.arange(6), name="axis3"),
     ),
 }
 
@@ -543,33 +472,6 @@ def test_GridDataset_from_array_raise_invalid_axes():
         GridDataset.from_array([[0, 1]], axes=[[0], [[0, 1, 2, 3]]])
 
 
-def test_GridDataset_change_name_by_prop():
-    grid_ds = GridDataset.from_array([])
-    assert grid_ds.name == "unnamed"
-
-    grid_ds.name = "new_name"
-    assert grid_ds.name == "new_name"
-
-    with pytest.raises(ValueError, match="name has to be an identifier"):
-        grid_ds.name = "invalid name"
-
-
-def test_GridDataset_change_label_by_prop():
-    grid_ds = GridDataset.from_array([])
-    assert grid_ds.label == "unlabeled"
-
-    grid_ds.label = "new label"
-    assert grid_ds.label == "new label"
-
-
-def test_GridDataset_change_unit_by_prop():
-    grid_ds = GridDataset.from_array([])
-    assert grid_ds.unit == ""
-
-    grid_ds.unit = "new unit"
-    assert grid_ds.unit == "new unit"
-
-
 @pytest.mark.parametrize(
     "left, right, operation",
     [
@@ -589,44 +491,44 @@ def test_GridDataset_change_unit_by_prop():
         (GridArray.from_array([], time=0), GridArray.from_array([], time=1), eq),
         # time name
         (
-            GridArray.from_array([], time=Axis(0)),
-            GridArray.from_array([], time=Axis(0, name="some")),
+            GridArray.from_array([], time=Axis.from_array(0)),
+            GridArray.from_array([], time=Axis.from_array(0, name="some")),
             ne,
         ),
         # time label
         (
-            GridArray.from_array([], time=Axis(0)),
-            GridArray.from_array([], time=Axis(0, label="some")),
+            GridArray.from_array([], time=Axis.from_array(0)),
+            GridArray.from_array([], time=Axis.from_array(0, label="some")),
             ne,
         ),
         # time unit
         (
-            GridArray.from_array([], time=Axis(0)),
-            GridArray.from_array([], time=Axis(0, unit="some")),
+            GridArray.from_array([], time=Axis.from_array(0)),
+            GridArray.from_array([], time=Axis.from_array(0, unit="some")),
             ne,
         ),
         # axes[..] value
         (
-            GridArray.from_array([1], axes=[Axis([1])]),
-            GridArray.from_array([1], axes=[Axis([2])]),
+            GridArray.from_array([1], axes=[Axis.from_array([1])]),
+            GridArray.from_array([1], axes=[Axis.from_array([2])]),
             eq,
         ),
         # axes[..].name value
         (
-            GridArray.from_array([1], axes=[Axis([1])]),
-            GridArray.from_array([1], axes=[Axis([1], name="some")]),
+            GridArray.from_array([1], axes=[Axis.from_array([1])]),
+            GridArray.from_array([1], axes=[Axis.from_array([1], name="some")]),
             ne,
         ),
         # axes[..].label value
         (
-            GridArray.from_array([1], axes=[Axis([1])]),
-            GridArray.from_array([1], axes=[Axis([1], label="some")]),
+            GridArray.from_array([1], axes=[Axis.from_array([1])]),
+            GridArray.from_array([1], axes=[Axis.from_array([1], label="some")]),
             ne,
         ),
         # axes[..].unit value
         (
-            GridArray.from_array([1], axes=[Axis([1])]),
-            GridArray.from_array([1], axes=[Axis([1], unit="some")]),
+            GridArray.from_array([1], axes=[Axis.from_array([1])]),
+            GridArray.from_array([1], axes=[Axis.from_array([1], unit="some")]),
             ne,
         ),
     ],
@@ -712,10 +614,10 @@ def test_GridDataset_from_path(grid_files: Path):
     "indexing": np.s_[..., np.newaxis],
     "instance_after_indexing": GridDataset,
     "expected_axes": (
-        Axis(np.arange(3), name="time", label="time"),
-        Axis(np.tile(np.arange(4), (3, 1)), name="axis0"),
-        Axis(np.tile(np.arange(5), (3, 1)), name="axis1"),
-        Axis(np.zeros((3, 1))),
+        Axis.from_array(np.arange(3), name="time", label="time"),
+        Axis.from_array(np.tile(np.arange(4), (3, 1)), name="axis0"),
+        Axis.from_array(np.tile(np.arange(5), (3, 1)), name="axis1"),
+        Axis.from_array(np.zeros((3, 1))),
     ),
 }
 
diff --git a/tests/containers/particle_test.py b/tests/containers/particle_test.py
new file mode 100644
index 0000000..5411c55
--- /dev/null
+++ b/tests/containers/particle_test.py
@@ -0,0 +1,987 @@
+# -*- coding: utf-8 -*-
+from operator import eq
+from operator import ne
+from pathlib import Path
+from textwrap import dedent
+from typing import Any
+from typing import Callable
+from typing import Optional
+from typing import Sequence
+from typing import Tuple
+from typing import Union
+
+import dask.array as da
+import numpy as np
+import pytest
+from numpy.lib.recfunctions import unstructured_to_structured
+from numpy.typing import ArrayLike
+
+from nata.containers.axis import Axis
+from nata.containers.particle import Particle
+from nata.containers.particle import ParticleArray
+from nata.containers.particle import ParticleDataReader
+from nata.containers.particle import ParticleDataset
+from nata.containers.particle import Quantity
+from nata.containers.particle import QuantityArray
+from nata.containers.particle import expand_and_stack
+from nata.containers.particle import expand_arr
+from nata.containers.utils import register_backend
+
+
+@pytest.fixture(name="path_to_particle_files")
+def _path_to_particle_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
+    # make checking for 'is_valid_backend' always succeed.
+    # ensures that 'any' backend can be added, even if properties defined in '__init__'
+    monkeypatch.setattr(ParticleArray, "is_valid_backend", lambda _: True)
+
+    # make 'basedir'
+    basedir = tmp_path / "particle_files"
+    basedir.mkdir(parents=True, exist_ok=True)
+
+    # create dummy files
+    dummy_data = unstructured_to_structured(np.arange(32, dtype=float).reshape((8, 4)))
+    np.savetxt(basedir / "prt.0", dummy_data, delimiter=",")
+
+    dummy_data = unstructured_to_structured(np.arange(64, dtype=float).reshape((16, 4)))
+    np.savetxt(basedir / "prt.1", dummy_data, delimiter=",")
+
+    dummy_data = unstructured_to_structured(np.empty((0, 4), dtype=float))
+    np.savetxt(basedir / "prt.2", dummy_data, delimiter=",")
+
+    @register_backend(ParticleArray)
+    class Dummy_ParticleFile:
+        name = "dummy_backend"
+
+        def __init__(self, location: Union[str, Path]) -> None:
+            self.location = Path(location)
+            # capture UserWarnings when opening empty file
+            with pytest.warns(None):
+                self.data = np.loadtxt(location, dtype=dummy_data.dtype, delimiter=",")
+            self.dataset_name = "dummy_prt"
+            self.dataset_label = "dummy prt label"
+
+            self.num_particles = self.data.shape[-1]
+
+            self.quantity_names = tuple(f"f{i}" for i in range(4))
+            self.quantity_labels = tuple(f"f{i} label" for i in range(4))
+            self.quantity_units = tuple(f"f{i} unit" for i in range(4))
+
+            self.ndim = self.data.ndim
+            self.shape = self.data.shape
+            self.dtype = self.data.dtype
+
+            self.iteration = int(self.location.suffix[1:])
+            self.time_step = float(self.location.suffix[1:])
+            self.time_unit = "dummy time unit"
+
+        @staticmethod
+        def is_valid_backend(location: Union[str, Path]) -> bool:
+            location = Path(location)
+            if location.stem == "prt" and location.suffix in (".0", ".1", ".2"):
+                return True
+            else:
+                return False
+
+        def get_data(
+            self,
+            indexing: Optional[Any] = None,
+            fields: Optional[Union[str, Sequence[str]]] = None,
+        ) -> ArrayLike:
+            return self.data[indexing]
+
+    assert isinstance(Dummy_ParticleFile(basedir / "prt.0"), ParticleDataReader)
+    assert isinstance(Dummy_ParticleFile(basedir / "prt.1"), ParticleDataReader)
+    assert isinstance(Dummy_ParticleFile(basedir / "prt.2"), ParticleDataReader)
+
+    yield basedir
+    # remove backend from ParticleArray -> avoids collision with other backends
+    ParticleArray.remove_backend(Dummy_ParticleFile)
+
+
+def test_Quantity():
+    quant = Quantity.from_array(123)
+
+    assert quant.name == "unnamed"
+    assert quant.label == "unlabeled"
+    assert quant.unit == ""
+
+    assert quant.count == 1
+
+    assert quant.time.name == "time"
+    assert quant.time.label == "time"
+    assert quant.time.unit == ""
+    assert quant.time.to_numpy() == 0.0
+
+    assert quant.shape == ()
+    assert quant.ndim == 0
+    assert quant.dtype == int
+
+    np.testing.assert_array_equal(quant, 123)
+
+
+def test_QuantityArray():
+    quantArr = QuantityArray.from_array([1, 2, 3])
+
+    assert quantArr.name == "unnamed"
+    assert quantArr.label == "unlabeled"
+    assert quantArr.unit == ""
+
+    assert quantArr.count == 3
+
+    assert quantArr.time.name == "time"
+    assert quantArr.time.label == "time"
+    assert quantArr.time.unit == ""
+    assert quantArr.time.to_numpy() == 0.0
+
+    assert quantArr.shape == (3,)
+    assert quantArr.ndim == 1
+    assert quantArr.dtype == int
+
+    np.testing.assert_array_equal(quantArr, [1, 2, 3])
+
+
+def test_Quantity_raise_non_0d():
+    with pytest.raises(ValueError, match="only 0d data is supported"):
+        Quantity.from_array([123])
+
+
+def test_Quantity_raise_when_fields_represent():
+    with pytest.raises(ValueError, match="only unstructured data types supported"):
+        Quantity.from_array(np.array(0, dtype=[("f1", int)]))
+
+
+@pytest.mark.parametrize(
+    "left, right, operation",
+    [
+        # general
+        (Quantity.from_array(1), Quantity.from_array(1), eq),
+        # content
+        (Quantity.from_array(1), Quantity.from_array(2), eq),
+        # name
+        (Quantity.from_array(1), Quantity.from_array(1, name="some"), ne),
+        # label
+        (Quantity.from_array(1), Quantity.from_array(1, label="some"), ne),
+        # unit
+        (Quantity.from_array(1), Quantity.from_array(1, unit="some"), ne),
+        # time value
+        (Quantity.from_array(1, time=0), Quantity.from_array(1, time=1), eq),
+        # time name
+        (
+            Quantity.from_array(1, time=Axis.from_array(0)),
+            Quantity.from_array(1, time=Axis.from_array(0, name="some")),
+            ne,
+        ),
+        # time label
+        (
+            Quantity.from_array(1, time=Axis.from_array(0)),
+            Quantity.from_array(1, time=Axis.from_array(0, label="some")),
+            ne,
+        ),
+        # time unit
+        (
+            Quantity.from_array(1, time=Axis.from_array(0)),
+            Quantity.from_array(1, time=Axis.from_array(0, unit="some")),
+            ne,
+        ),
+    ],
+    ids=(
+        "general",
+        "content",
+        "name",
+        "label",
+        "unit",
+        "time",
+        "time.name",
+        "time.label",
+        "time.unit",
+    ),
+)
+def test_Quantity_hash(left: Quantity, right: Quantity, operation: Callable):
+    assert operation(hash(left), hash(right))
+
+
+def test_Quantity_repr():
+    quant = Quantity.from_array(np.array(0, dtype=np.int64))
+    assert repr(quant) == "Quantity<name=unnamed, dtype=int64, time=0.0>"
+
+
+def test_Quantity_repr_html():
+    grid = Quantity.from_array(np.array(0, dtype=np.int64))
+    expected_markdown = """
+    | **Quantity** | |
+    | ---: | :--- |
+    | **name**  | unnamed |
+    | **label** | unlabeled |
+    | **unit**  | '' |
+    | **count** | 1 |
+    | **dtype** | int64 |
+    | **time**  | 0.0 |
+
+    """
+
+    assert grid._repr_markdown_() == dedent(expected_markdown)
+
+
+def test_Particle():
+    prt = Particle.from_array(0.0)
+    expected_dtype = np.dtype([("quant1", float)])
+
+    assert prt.name == "unnamed"
+    assert prt.label == "unlabeled"
+
+    assert prt.time.name == "time"
+    assert prt.time.label == "time"
+    assert prt.time.unit == ""
+    assert prt.time.to_numpy() == 0.0
+
+    assert prt.count == 1
+    assert prt.quantities == (("quant1", "quant1 label", ""),)
+    assert prt.quantity_names == ("quant1",)
+    assert prt.quantity_labels == ("quant1 label",)
+    assert prt.quantity_units == ("",)
+
+    assert prt.ndim == 0
+    assert prt.shape == ()
+    assert prt.dtype == expected_dtype
+
+    np.testing.assert_array_equal(prt, np.array(0.0, expected_dtype))
+
+
+@pytest.mark.parametrize(
+    "arr, kwargs, quantities, expected_arr",
+    [
+        (
+            1.0,
+            {},
+            (("quant1", "quant1 label", ""),),
+            np.array(1.0, dtype=np.dtype([("quant1", float)])),
+        ),
+        (
+            np.array(1.0, dtype=np.dtype([("x", float)])),
+            {},
+            (("x", "x label", ""),),
+            np.array(1.0, dtype=np.dtype([("x", float)])),
+        ),
+        (
+            np.array([1, 2, 3, 4]),
+            {
+                "quantities": tuple(
+                    (f"q{i}", f"q_{i} label", f"q{i} unit") for i in range(4)
+                ),
+            },
+            tuple((f"q{i}", f"q_{i} label", f"q{i} unit") for i in range(4)),
+            np.array((1, 2, 3, 4), dtype=np.dtype([(f"q{i}", int) for i in range(4)])),
+        ),
+    ],
+    ids=["number -> 1 quantity", "structured array", "array -> n quantities"],
+)
+def test_Particle_from_array_various_inputs(arr, kwargs, quantities, expected_arr):
+    prt = Particle.from_array(arr, **kwargs)
+
+    assert prt.count == 1
+    assert prt.ndim == 0
+    assert prt.shape == ()
+
+    assert prt.quantities == quantities
+
+    np.testing.assert_array_equal(prt, expected_arr)
+
+
+def test_ParticleArray():
+    prt = ParticleArray.from_array([0.0, 1.0, 2.0])
+    expected_dtype = np.dtype([("quant1", float)])
+
+    assert prt.name == "unnamed"
+    assert prt.label == "unlabeled"
+
+    assert prt.time.name == "time"
+    assert prt.time.label == "time"
+    assert prt.time.unit == ""
+    assert prt.time.to_numpy() == 0.0
+
+    assert prt.count == 3
+    assert prt.quantities == (("quant1", "quant1 label", ""),)
+    assert prt.quantity_names == ("quant1",)
+    assert prt.quantity_labels == ("quant1 label",)
+    assert prt.quantity_units == ("",)
+
+    assert prt.ndim == 1
+    assert prt.shape == (3,)
+    assert prt.dtype == expected_dtype
+
+    np.testing.assert_array_equal(prt, np.array([0.0, 1.0, 2.0], expected_dtype))
+
+
+def test_ParticleArray_from_path(path_to_particle_files: Path):
+    particleArray_file = path_to_particle_files / "prt.0"
+
+    prt_arr = ParticleArray.from_path(particleArray_file)
+    expected_arr = unstructured_to_structured(
+        np.arange(32, dtype=float).reshape((8, 4))
+    )
+
+    # information stored in fixture for dummy particle backend
+    assert prt_arr.name == "dummy_prt"
+    assert prt_arr.label == "dummy prt label"
+
+    assert prt_arr.time.name == "time"
+    assert prt_arr.time.label == "time"
+    assert prt_arr.time.unit == "dummy time unit"
+    assert prt_arr.time.to_numpy() == 0.0
+
+    assert prt_arr.count == 8
+    assert prt_arr.quantities == tuple(
+        (f"f{i}", f"f{i} label", f"f{i} unit") for i in range(4)
+    )
+    assert prt_arr.quantity_names == tuple(f"f{i}" for i in range(4))
+    assert prt_arr.quantity_labels == tuple(f"f{i} label" for i in range(4))
+    assert prt_arr.quantity_units == tuple(f"f{i} unit" for i in range(4))
+
+    assert prt_arr.ndim == 1
+    assert prt_arr.shape == (8,)
+    assert prt_arr.dtype == expected_arr.dtype
+
+    np.testing.assert_array_equal(prt_arr, expected_arr)
+
+
+def test_ParticleDataset():
+    # passed array has to fulfill following shape requirements
+    # array.shape == (t_steps, n_particles, n_quantities)
+    #
+    #           `t_step`: number of time steps, REQUIRED
+    #      `n_particles`: number of particles, REQUIRED
+    #     `n_quantities`: number of quantities, OPTIONAL
+
+    prt_ds = ParticleDataset.from_array([[1, 2, 3], [3, 4, 5]])
+    expected_dtype = np.dtype([("quant1", int)])
+    expected_arr = unstructured_to_structured(
+        np.array([[1, 2, 3], [3, 4, 5]])[..., np.newaxis], dtype=expected_dtype
+    )
+
+    assert prt_ds.name == "unnamed"
+    assert prt_ds.label == "unlabeled"
+
+    assert prt_ds.time.name == "time"
+    assert prt_ds.time.label == "time"
+    assert prt_ds.time.unit == ""
+    np.testing.assert_array_equal(prt_ds.time, [0, 1])
+
+    assert prt_ds.count == 3
+    assert prt_ds.quantities == (("quant1", "quant1 label", ""),)
+    assert prt_ds.quantity_names == ("quant1",)
+    assert prt_ds.quantity_labels == ("quant1 label",)
+    assert prt_ds.quantity_units == ("",)
+
+    assert prt_ds.ndim == 2
+    assert prt_ds.shape == (2, 3)
+    assert prt_ds.dtype == expected_dtype
+
+    np.testing.assert_array_equal(prt_ds, expected_arr)
+
+
+@pytest.mark.parametrize(
+    "left, right, operation",
+    [
+        # general
+        (ParticleDataset.from_array([[1]]), ParticleDataset.from_array([[1]]), eq),
+        # content
+        (ParticleDataset.from_array([[1]]), ParticleDataset.from_array([[2]]), eq),
+        # name
+        (
+            ParticleDataset.from_array([[1]]),
+            ParticleDataset.from_array([[1]], name="some"),
+            ne,
+        ),
+        # label
+        (
+            ParticleDataset.from_array([[1]]),
+            ParticleDataset.from_array([[1]], label="some"),
+            ne,
+        ),
+        # time value
+        (
+            ParticleDataset.from_array([[1]], time=[0]),
+            ParticleDataset.from_array([[1]], time=[1]),
+            eq,
+        ),
+        # time name
+        (
+            ParticleDataset.from_array([[1]], time=Axis.from_array([0])),
+            ParticleDataset.from_array([[1]], time=Axis.from_array([0], name="some")),
+            ne,
+        ),
+        # time label
+        (
+            ParticleDataset.from_array([[1]], time=Axis.from_array([0])),
+            ParticleDataset.from_array([[1]], time=Axis.from_array([0], label="some")),
+            ne,
+        ),
+        # time unit
+        (
+            ParticleDataset.from_array([[1]], time=Axis.from_array([0])),
+            ParticleDataset.from_array([[1]], time=Axis.from_array([0], unit="some")),
+            ne,
+        ),
+    ],
+    ids=(
+        "general",
+        "content",
+        "name",
+        "label",
+        "time",
+        "time.name",
+        "time.label",
+        "time.unit",
+    ),
+)
+def test_ParticleDataset_hash(
+    left: ParticleDataset,
+    right: ParticleDataset,
+    operation: Callable,
+):
+    assert operation(hash(left), hash(right))
+
+
+def test_ParticleDataset_repr():
+    prt_ds = ParticleDataset.from_array(np.arange(32, dtype=np.int64).reshape((4, 8)))
+    assert repr(prt_ds) == (
+        "ParticleDataset<"
+        "name=unnamed, "
+        "dtype=[('quant1', '<i8')], "
+        "quantities=('quant1',), "
+        "time=Axis<name='time', label='time', unit=''>"
+        ">"
+    )
+
+
+def test_ParticleDataset_repr_html():
+    grid = ParticleDataset.from_array(np.arange(32, dtype=np.int64).reshape((4, 8)))
+    expected_markdown = """
+    | **ParticleDataset** | |
+    | ---: | :--- |
+    | **name**       | unnamed |
+    | **label**      | unlabeled |
+    | **count**      | 8 |
+    | **shape**      | (4, 8) |
+    | **dtype**      | [('quant1', '<i8')] |
+    | **quantities** | ('quant1',) |
+    | **time**       | Axis<name='time', label='time', unit=''> |
+
+    """
+
+    assert grid._repr_markdown_() == dedent(expected_markdown)
+
+
+def test_GridDataset_from_path(path_to_particle_files: Path):
+    prt_ds = ParticleDataset.from_path(path_to_particle_files / "*")
+
+    assert prt_ds.name == "dummy_prt"
+    assert prt_ds.label == "dummy prt label"
+
+    assert prt_ds.count == 16
+
+    assert prt_ds.quantity_names == tuple(f"f{i}" for i in range(4))
+    assert prt_ds.quantity_labels == tuple(f"f{i} label" for i in range(4))
+    assert prt_ds.quantity_units == tuple(f"f{i} unit" for i in range(4))
+
+    assert prt_ds.ndim == 2
+    assert prt_ds.shape == (3, 16)
+
+
+def test_GridDataset_variable_prt_number():
+    prt_ds = ParticleDataset.from_array([[1, 2, 3], [3, 5]])
+    assert prt_ds.shape == (2, 3)
+
+
+def test_expand_to_array():
+    arr = expand_and_stack([[1, 2, 3, 4], [5, 6], []])
+    assert isinstance(arr, da.Array)
+    assert arr.shape == (3, 4)
+    assert np.sum(np.arange(1, 7)) == np.sum(arr).compute()
+
+
+@pytest.mark.parametrize(
+    "arr, required_shape, has_mask",
+    [
+        (da.arange(12), (32,), True),
+        (da.arange(12), (12,), False),
+    ],
+    ids=[
+        "(12,) -> (32,)",
+        "(12,) -> (12,)",
+    ],
+)
+def test_expand_arr(arr: da.Array, required_shape: Tuple[int, ...], has_mask: bool):
+    output = expand_arr(arr, required_shape)
+
+    assert isinstance(output, da.Array)
+    assert output.shape == required_shape
+    assert isinstance(output.compute(), np.ma.MaskedArray)
+    assert output.compute().mask.any() == has_mask
+    # masked arrays have to keep same values -> testing by using aggregation
+    assert np.sum(output).compute() == np.sum(arr).compute()
+
+
+@pytest.mark.parametrize(
+    "init_arr, index, expected_type",
+    [
+        (
+            unstructured_to_structured(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            np.s_[1:5],
+            ParticleDataset,
+        ),
+        (
+            unstructured_to_structured(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            np.s_[1],
+            ParticleArray,
+        ),
+        (
+            unstructured_to_structured(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            np.s_[2, 1:3],
+            ParticleArray,
+        ),
+        (
+            unstructured_to_structured(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            np.s_[2, 3],
+            Particle,
+        ),
+    ],
+    ids=[
+        "t1:t2",
+        "t1",
+        "t1, i1:i2",
+        "t1, i1",
+    ],
+)
+def test_ParticleDataset_getitem_perserve_quants(
+    init_arr: np.ndarray, index: Any, expected_type: type
+):
+    prt_ds = ParticleDataset.from_array(
+        init_arr,
+        name="some_name",
+        label="some label",
+    )
+    selection = prt_ds[index]
+
+    assert selection.name == prt_ds.name
+    assert selection.label == prt_ds.label
+
+    assert selection.quantities == prt_ds.quantities
+    assert isinstance(selection, expected_type)
+
+    # get data from masked array
+    np.testing.assert_array_equal(selection.to_numpy().data, init_arr[index])
+
+
+_u2s = unstructured_to_structured
+
+
+@pytest.mark.parametrize(
+    [
+        "init_arr",
+        "index",
+        "expected_type",
+        "expected_name",
+        "expected_label",
+        "expected_unit",
+        "expected_quantity_names",
+        "expected_quantity_labels",
+        "expected_quantity_units",
+        "expected_arr",
+    ],
+    [
+        (
+            # init_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            # index
+            np.s_[1, 1:3, ["f1", "f3"]],
+            # expected_type
+            ParticleArray,
+            # expected_name
+            "some_name",
+            # expected_label
+            "some label",
+            # expected_unit
+            None,
+            # expected_quantity_names
+            ("f1", "f3"),
+            # expected_quantity_labels
+            ("f1 label", "f3 label"),
+            # expected_quantity_units
+            ("", ""),
+            # expected_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4)))[1, 1:3][["f1", "f3"]],
+        ),
+        (
+            # init_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            # index
+            np.s_[1, 1, ["f2", "f3"]],
+            # expected_type
+            Particle,
+            # expected_name
+            "some_name",
+            # expected_label
+            "some label",
+            # expected_unit
+            None,
+            # expected_quantity_names
+            ("f2", "f3"),
+            # expected_quantity_labels
+            ("f2 label", "f3 label"),
+            # expected_quantity_units
+            ("", ""),
+            # expected_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4)))[1, 1][["f2", "f3"]],
+        ),
+        (
+            # init_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            # index
+            np.s_[1, 1, "f2"],
+            # expected_type
+            Quantity,
+            # expected_name
+            "f2",
+            # expected_label
+            "f2 label",
+            # expected_unit
+            "",
+            # expected_quantity_names
+            (),
+            # expected_quantity_labels
+            (),
+            # expected_quantity_units
+            (),
+            # expected_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4)))[1, 1]["f2"],
+        ),
+        (
+            # init_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4))),
+            # index
+            np.s_[3, 2:5, "f0"],
+            # expected_type
+            QuantityArray,
+            # expected_name
+            "f0",
+            # expected_label
+            "f0 label",
+            # expected_unit
+            "",
+            # expected_quantity_names
+            (),
+            # expected_quantity_labels
+            (),
+            # expected_quantity_units
+            (),
+            # expected_arr
+            _u2s(np.arange(8 * 5 * 4).reshape((8, 5, 4)))[3, 2:5]["f0"],
+        ),
+    ],
+    ids=["t1, i1:i2, [q1, q2]", "t1, i1, [q1, q2]", "t1, i1, q1", "t1, i1:i2, q1"],
+)
+def test_ParticleDataset_getitem_reduce_quantities(
+    init_arr: np.ndarray,
+    index: Any,
+    expected_type: type,
+    expected_name: str,
+    expected_label: str,
+    expected_unit: Optional[str],
+    expected_quantity_names: Tuple[str, ...],
+    expected_quantity_labels: Tuple[str, ...],
+    expected_quantity_units: Tuple[str, ...],
+    expected_arr: np.ndarray,
+):
+    prt_ds = ParticleDataset.from_array(
+        init_arr,
+        name="some_name",
+        label="some label",
+    )
+    selection = prt_ds[index]
+
+    assert selection.name == expected_name
+    assert selection.label == expected_label
+
+    if expected_unit is not None:
+        assert selection.unit == expected_unit
+
+    assert isinstance(selection, expected_type)
+
+    if expected_quantity_names:
+        assert selection.quantity_names == expected_quantity_names
+
+    if expected_quantity_labels:
+        assert selection.quantity_labels == expected_quantity_labels
+
+    if expected_quantity_units:
+        assert selection.quantity_units == expected_quantity_units
+
+    np.testing.assert_array_equal(selection.to_numpy().data, expected_arr)
+
+
+@pytest.mark.parametrize(
+    [
+        "init_arr",
+        "index",
+        "expected_type",
+        "expected_name",
+        "expected_label",
+        "expected_unit",
+        "expected_quantity_names",
+        "expected_quantity_labels",
+        "expected_quantity_units",
+        "expected_arr",
+    ],
+    [
+        (
+            # init_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4))),
+            # index
+            np.s_[1:3],
+            # expected_type
+            ParticleArray,
+            # expected_name
+            "some_name",
+            # expected_label
+            "some label",
+            # expected_unit
+            None,
+            # expected_quantity_names
+            tuple(f"f{i}" for i in range(4)),
+            # expected_quantity_labels
+            tuple(f"f{i} label" for i in range(4)),
+            # expected_quantity_units
+            tuple("" for _ in range(4)),
+            # expected_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4)))[1:3],
+        ),
+        (
+            # init_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4))),
+            # index
+            np.s_[3],
+            # expected_type
+            Particle,
+            # expected_name
+            "some_name",
+            # expected_label
+            "some label",
+            # expected_unit
+            None,
+            # expected_quantity_names
+            tuple(f"f{i}" for i in range(4)),
+            # expected_quantity_labels
+            tuple(f"f{i} label" for i in range(4)),
+            # expected_quantity_units
+            tuple("" for _ in range(4)),
+            # expected_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4)))[3],
+        ),
+        (
+            # init_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4))),
+            # index
+            np.s_[0:2, "f1"],
+            # expected_type
+            QuantityArray,
+            # expected_name
+            "f1",
+            # expected_label
+            "f1 label",
+            # expected_unit
+            "",
+            # expected_quantity_names
+            None,
+            # expected_quantity_labels
+            None,
+            # expected_quantity_units
+            None,
+            # expected_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4)))[0:2]["f1"],
+        ),
+        (
+            # init_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4))),
+            # index
+            np.s_[3, "f3"],
+            # expected_type
+            Quantity,
+            # expected_name
+            "f3",
+            # expected_label
+            "f3 label",
+            # expected_unit
+            "",
+            # expected_quantity_names
+            None,
+            # expected_quantity_labels
+            None,
+            # expected_quantity_units
+            None,
+            # expected_arr
+            _u2s(np.arange(5 * 4).reshape((5, 4)))[3]["f3"],
+        ),
+    ],
+    ids=["i1:i2", "i1", "i1:i2, q1", "i1, q1"],
+)
+def test_ParticleArray_getitem(
+    init_arr: np.ndarray,
+    index: Any,
+    expected_type: type,
+    expected_name: str,
+    expected_label: str,
+    expected_unit: Optional[str],
+    expected_quantity_names: Tuple[str, ...],
+    expected_quantity_labels: Tuple[str, ...],
+    expected_quantity_units: Tuple[str, ...],
+    expected_arr: np.ndarray,
+):
+    prt_arr = ParticleArray.from_array(
+        init_arr,
+        name="some_name",
+        label="some label",
+    )
+    selection = prt_arr[index]
+
+    assert selection.name == expected_name
+    assert selection.label == expected_label
+
+    if expected_unit is not None:
+        assert selection.unit == expected_unit
+
+    assert isinstance(selection, expected_type)
+
+    if expected_quantity_names:
+        assert selection.quantity_names == expected_quantity_names
+
+    if expected_quantity_labels:
+        assert selection.quantity_labels == expected_quantity_labels
+
+    if expected_quantity_units:
+        assert selection.quantity_units == expected_quantity_units
+
+    np.testing.assert_array_equal(selection.to_numpy().data, expected_arr)
+
+
+@pytest.mark.parametrize(
+    [
+        "init_arr",
+        "index",
+        "expected_type",
+        "expected_name",
+        "expected_label",
+        "expected_unit",
+        "expected_quantity_names",
+        "expected_quantity_labels",
+        "expected_quantity_units",
+        "expected_arr",
+    ],
+    [
+        (
+            # init_arr
+            _u2s(np.arange(4)),
+            # index
+            np.s_["f3"],
+            # expected_type
+            Quantity,
+            # expected_name
+            "f3",
+            # expected_label
+            "f3 label",
+            # expected_unit
+            "",
+            # expected_quantity_names
+            None,
+            # expected_quantity_labels
+            None,
+            # expected_quantity_units
+            None,
+            # expected_arr
+            _u2s(np.arange(4))["f3"],
+        ),
+        (
+            # init_arr
+            _u2s(np.arange(4)),
+            # index
+            np.s_[["f1", "f3"]],
+            # expected_type
+            Particle,
+            # expected_name
+            "some_name",
+            # expected_label
+            "some label",
+            # expected_unit
+            None,
+            # expected_quantity_names
+            ("f1", "f3"),
+            # expected_quantity_labels
+            ("f1 label", "f3 label"),
+            # expected_quantity_units
+            ("", ""),
+            # expected_arr
+            _u2s(np.arange(4))[["f1", "f3"]],
+        ),
+    ],
+    ids=["q1", "['q1']"],
+)
+def test_Particle_getitem(
+    init_arr: np.ndarray,
+    index: Any,
+    expected_type: type,
+    expected_name: str,
+    expected_label: str,
+    expected_unit: Optional[str],
+    expected_quantity_names: Tuple[str, ...],
+    expected_quantity_labels: Tuple[str, ...],
+    expected_quantity_units: Tuple[str, ...],
+    expected_arr: np.ndarray,
+):
+    prt_arr = Particle.from_array(
+        init_arr,
+        name="some_name",
+        label="some label",
+    )
+    selection = prt_arr[index]
+
+    assert selection.name == expected_name
+    assert selection.label == expected_label
+
+    if expected_unit is not None:
+        assert selection.unit == expected_unit
+
+    assert isinstance(selection, expected_type)
+
+    if expected_quantity_names:
+        assert selection.quantity_names == expected_quantity_names
+
+    if expected_quantity_labels:
+        assert selection.quantity_labels == expected_quantity_labels
+
+    if expected_quantity_units:
+        assert selection.quantity_units == expected_quantity_units
+
+    np.testing.assert_array_equal(selection.to_numpy().data, expected_arr)
+
+
+@pytest.mark.parametrize(
+    "init_arr, index, expected_type",
+    [(np.arange(10), np.s_[1:3], QuantityArray), (np.arange(10), np.s_[4], Quantity)],
+    ids=["i1:i2", "i1"],
+)
+def test_QuantityArray_getitem(init_arr: np.ndarray, index: Any, expected_type: type):
+    quant_arr = QuantityArray.from_array(
+        init_arr,
+        name="some_name",
+        label="some label",
+        unit="some_unit",
+    )
+
+    selection = quant_arr[index]
+
+    assert isinstance(selection, expected_type)
+    assert selection.name == quant_arr.name
+    assert selection.label == quant_arr.label
+    assert selection.unit == quant_arr.unit
+
+    np.testing.assert_array_equal(selection, init_arr[index])
diff --git a/tests/containers/test_GridDataset.py b/tests/containers/test_GridDataset.py
deleted file mode 100644
index 363cad2..0000000
--- a/tests/containers/test_GridDataset.py
+++ /dev/null
@@ -1,919 +0,0 @@
-# -*- coding: utf-8 -*-
-# from pathlib import Path
-# from typing import Optional
-# from typing import Union
-
-# import numpy as np
-# import pytest
-
-# import dask.array as da
-# from nata import GridDataset
-# from nata.axes import Axis
-# from nata.containers.GridDataset import GridDatasetAxes
-# from nata.types import BasicIndexing
-# from nata.types import FileLocation
-# from nata.types import GridBackendType
-# from nata.types import GridDatasetType
-# from nata.utils.container_tools import register_backend
-
-
-# def test_GridDatasetAxes_init():
-#     assert GridDatasetAxes([]).mapping == {}
-
-#     axes = [Axis([0], name="a0"), Axis([0], name="a1")]
-#     assert GridDatasetAxes(axes).mapping == {i: a for i, a in enumerate(axes)}
-
-#     axis = Axis([0], name="some_name")
-#     assert isinstance(GridDatasetAxes([axis]).some_name, Axis)
-#     assert GridDatasetAxes([axis]).some_name is axis
-
-
-# _testCases_GridDatasetAxes_eq = {}
-# _testCases_GridDatasetAxes_eq["default"] = (
-#     GridDatasetAxes([]),
-#     GridDatasetAxes([]),
-#     True,
-# )
-# _testCases_GridDatasetAxes_eq["axes length"] = (
-#     GridDatasetAxes([]),
-#     GridDatasetAxes([Axis([0])]),
-#     False,
-# )
-# _testCases_GridDatasetAxes_eq["axes equivalence"] = (
-#     GridDatasetAxes([Axis([0], name="some")]),
-#     GridDatasetAxes([Axis([1], name="some")]),
-#     True,
-# )
-# _testCases_GridDatasetAxes_eq["time axis"] = (
-#     GridDatasetAxes([], time=Axis(0)),
-#     GridDatasetAxes([], time=None),
-#     False,
-# )
-
-# _testCases_GridDatasetAxes_eq["time axis equivalence"] = (
-#     GridDatasetAxes([], time=Axis(0)),
-#     GridDatasetAxes([], time=Axis(0, name="some")),
-#     False,
-# )
-
-# _testCases_GridDatasetAxes_eq["iteration axis"] = (
-#     GridDatasetAxes([], iteration=Axis(0)),
-#     GridDatasetAxes([], iteration=None),
-#     False,
-# )
-
-# _testCases_GridDatasetAxes_eq["iteration axis equivalence"] = (
-#     GridDatasetAxes([], iteration=Axis(0)),
-#     GridDatasetAxes([], iteration=Axis(0, name="some")),
-#     False,
-# )
-
-
-# @pytest.mark.parametrize(
-#     "left, right, equal",
-#     _testCases_GridDatasetAxes_eq.values(),
-#     ids=_testCases_GridDatasetAxes_eq.keys(),
-# )
-# def test_GridDatasetAxes_eq(left, right, equal):
-#     if equal:
-#         assert left == right
-#         assert not (left != right)
-#     else:
-#         assert left != right
-#         assert not (left == right)
-
-
-# def test_GridDatasetAxes_len():
-#     assert len(GridDatasetAxes([])) == 0
-#     assert len(GridDatasetAxes([Axis([0])])) == 1
-#     assert len(GridDatasetAxes([], time=Axis(0))) == 0
-
-
-# def test_GridDatasetAxes_contains():
-#     assert "time" in GridDatasetAxes(())
-#     assert "some_name" in GridDatasetAxes((Axis([0], name="some_name"),))
-#     assert "time" in GridDatasetAxes((), time=Axis(0, name="time"))
-#     assert "time" in GridDatasetAxes([Axis([0, 1], axis_dim=0, name="time")])
-#     assert "iteration" in GridDatasetAxes((), iteration=Axis(0, name="iteration"))
-
-
-# def test_GridDatasetAxes_raises_warning():
-#     with pytest.warns(UserWarning):
-#         GridDatasetAxes([Axis([0]), Axis([0])])
-
-
-# def test_GridDatasetAxes_index():
-#     example = GridDatasetAxes((Axis([0], name="a0"), Axis([0], name="a1")))
-#     assert example.index("a0") == 0
-#     assert example.index("a1") == 1
-#     assert example.index("some") is None
-#     assert GridDatasetAxes((), time=Axis(0, name="time")).index("time") is None
-
-
-# def test_GridDatasetAxes_raises_TypeError_invalid_axis_object():
-#     with pytest.raises(TypeError):
-#         GridDatasetAxes((object(),))
-
-
-# def test_GridDatasetAxes_raises_TypeError_invalid_time():
-#     with pytest.raises(TypeError):
-#         GridDatasetAxes([], time=1.0)
-
-
-# def test_GridDatasetAxes_raises_TypeError_invalid_iteration():
-#     with pytest.raises(TypeError):
-#         GridDatasetAxes([], iteration=1)
-
-
-# def test_GridDatasetAxes_raises_TypeError_invalid_0d_axis():
-#     with pytest.raises(ValueError):
-#         GridDatasetAxes([Axis(0)])
-
-
-# def test_GridDatasetAxes_raises_ValueError():
-#     with pytest.raises(ValueError):
-#         GridDatasetAxes(
-#             [
-#                 Axis(np.arange(10).reshape(5, 2), axis_dim=1),
-#                 Axis(np.arange(5), axis_dim=1),
-#             ]
-#         )
-
-
-# def test_GridDatasetAxes_iter():
-#     axes = (Axis([0], name="a0"), Axis([0], name="a1"))
-#     grid_axes = GridDatasetAxes(axes)
-
-#     for ax, expected in zip(grid_axes, axes):
-#         assert ax is expected
-
-
-# def test_GridDatasetAxes_getitem():
-#     axes = (Axis([0], name="a"), Axis([0], name="b"))
-#     grid_axes = GridDatasetAxes(axes)
-
-#     assert grid_axes[0] is axes[0]
-#     assert grid_axes[1] is axes[1]
-#     assert grid_axes["a"] is axes[0]
-#     assert grid_axes["b"] is axes[1]
-
-
-# def test_GridDataset_span():
-#     assert GridDatasetAxes(
-#         [
-#             Axis(range(10), name="a0", axis_dim=1),
-#             Axis(range(20), name="a1", axis_dim=1),
-#             Axis(range(30), name="a2", axis_dim=1),
-#         ]
-#     ).span == (10, 20, 30)
-
-
-# @pytest.fixture(name="TestGridBackend")
-# def _TestGridBackend():
-#     """Fixture for returning GridBackend"""
-
-#     class TestGridBackend:
-#         """Test backend for GridDatsets
-
-#         * Backend provides 3D random data.
-#         """
-
-#         name = "TetsGridBackend"
-#         location = None
-
-#         dataset_name = "test_dataset_name"
-#         dataset_label = "test dataset label"
-#         dataset_unit = "test dataset unit"
-
-#         axes_names = ("axes0", "axes1", "axes2")
-#         axes_labels = ("axes 0", "axes 1", "axes 3")
-#         axes_units = ("axes unit 0", "axes unit 1", "axes unit 2")
-#         axes_min = np.array([-1.0, -2.0, -3.0])
-#         axes_max = np.array([0.5, 1.5, 2.5])
-
-#         iteration = 42
-
-#         time_step = 12.3
-#         time_unit = "time unit"
-
-#         shape = (4, 5, 6)
-#         dtype = np.float
-
-#         ndim = 3
-
-#         def __init__(
-#             self, location: Optional[FileLocation] = None, raise_on_read: bool = True,
-#         ):
-#             self.location = location
-#             self._raise_on_read = raise_on_read
-
-#         @staticmethod
-#         def is_valid_backend(location: Union[Path, str]) -> bool:
-#             return Path(location) == Path("TestGridBackend_location")
-
-#         def get_data(self, indexing: Optional[BasicIndexing] = None) -> np.ndarray:
-#             if self._raise_on_read:
-#                 raise IOError("Should not read any file")
-
-#             data = np.arange(4 * 5 * 6).reshape(self.shape)
-#             return data[indexing] if indexing else data
-
-#     # ensure dummy backend is of valid type
-#     assert isinstance(TestGridBackend, GridBackendType)
-
-#     GridDataset.add_backend(TestGridBackend)
-#     yield TestGridBackend
-#     # teardown
-#     GridDataset.remove_backend(TestGridBackend)
-
-
-# def test_GridDataset_isinstance_GridDatasetType():
-#     """Ensures that a GridDataset fulfills `GridDatasetType` protocol"""
-#     assert isinstance(GridDataset, GridDatasetType)
-
-
-# def test_GridDataset_registration(TestGridBackend):
-#     """Check if fixture registers backend properly"""
-#     assert TestGridBackend.name in GridDataset.get_backends()
-
-
-# def test_GridDataset_default_init():
-#     """Checks default init of GridDataset. Ensures some properties are proper setup"""
-#     axis = Axis([0, 1, 2])
-#     ds = GridDataset(np.arange(3), GridDatasetAxes([axis]))
-
-#     assert ds.name == "unnamed"
-#     assert ds.label == "unnamed"
-#     assert ds.unit == ""
-#     assert "time" in ds.axes
-#     assert "iteration" in ds.axes
-#     assert ds.axes.time is None
-#     assert ds.axes.iteration is None
-#     assert ds.axes[0] is axis
-
-
-# def test_GridDataset_init_raises_ValueError_for_mismatched_dimensionality():
-#     with pytest.raises(ValueError):
-#         GridDataset(np.arange(3), GridDatasetAxes([]))
-
-
-# def test_GridDataset_init_raises_ValueError_for_mismatched_shapes():
-#     with pytest.raises(ValueError):
-#         GridDataset(np.arange(3), GridDatasetAxes([Axis(0)]))
-
-
-# def test_GridDatset_init_with_name():
-#     """Checks if name property changes if name was defined"""
-#     args = (np.arange(3), GridDatasetAxes([Axis(np.arange(3))]))
-#     ds = GridDataset(*args, name="some_name")
-#     assert ds.name == "some_name"
-
-
-# def test_GridDatset_init_with_label():
-#     """Checks if label property changes if label was defined"""
-#     args = (np.arange(3), GridDatasetAxes([Axis(np.arange(3))]))
-#     ds = GridDataset(*args, label="some label")
-#     assert ds.label == "some label"
-
-
-# def test_GridDatset_init_with_unit():
-#     """Checks if unit property changes if unit was defined"""
-#     args = (np.arange(3), GridDatasetAxes([Axis(np.arange(3))]))
-#     ds = GridDataset(*args, unit="some unit")
-#     assert ds.unit == "some unit"
-
-
-# def test_GridDatset_init_with_backend():
-#     """Checks if backend property changes if backend was defined"""
-#     args = (np.arange(3), GridDatasetAxes([Axis(np.arange(3))]))
-#     ds = GridDataset(*args, backend="some_backend")
-#     assert ds.backend == "some_backend"
-
-
-# def test_GridDatset_init_with_locations():
-#     """Checks if location property changes if location was defined"""
-#     args = (np.arange(3), GridDatasetAxes([Axis(np.arange(3))]))
-#     ds = GridDataset(*args, locations=(Path("some/location"),))
-#     assert isinstance(ds.locations, list)
-#     assert len(ds.locations) == 1
-#     assert ds.locations[0] == Path("some/location")
-
-
-# def test_GridDatset_init_with_grid_axes():
-#     """Checks if grid axes are properly set if passed to init"""
-#     # additional dimension is used for temporal (t_dim, *spatial_dim)
-#     ds = GridDataset(
-#         np.arange(3), GridDatasetAxes([Axis.from_limits(0, 1, 3, name="a1")]),
-#     )
-#     assert "a1" in ds.axes
-#     assert ds.axes.grid_axes is not None
-#     assert isinstance(ds.axes.grid_axes, list)
-#     assert len(ds.axes.grid_axes) == 1
-#     assert isinstance(ds.axes.grid_axes[0], Axis)
-#     assert ds.axes.grid_axes[0].shape == (3,)
-
-
-# def test_GridDatset_init_with_time():
-#     time = Axis([0, 1], name="time", axis_dim=0)
-#     other_axis = Axis(np.arange(6).reshape((2, 3)), axis_dim=1)
-
-#     ds = GridDataset(
-#         np.random.random_sample((2, 3)), GridDatasetAxes([time, other_axis]),
-#     )
-#     assert "time" in ds.axes
-#     assert ds.axes.time is time
-
-
-# def test_GridDatset_init_with_iteration():
-#     iteration = Axis([0, 1], name="iteration", axis_dim=0)
-#     other_axis = Axis(np.arange(6).reshape((2, 3)), axis_dim=1)
-
-#     ds = GridDataset(
-#         np.random.random_sample((2, 3)), GridDatasetAxes([iteration, other_axis]),
-#     )
-#     assert "iteration" in ds.axes
-#     assert ds.axes.iteration is iteration
-
-
-# def test_GridDataset_from_array_default():
-#     """Checks default behavior for init by class method `.from_array`"""
-#     array_content = np.random.random_sample((4, 3, 5))
-#     ds = GridDataset.from_array(array_content)
-
-#     assert ds.name == "unnamed"
-#     assert ds.label == "unlabeled"
-#     assert ds.unit == ""
-
-#     assert ds.backend is None
-#     assert ds.locations is None
-
-#     assert "time" in ds.axes
-#     assert ds.axes.time is None
-#     assert "iteration" in ds.axes
-#     assert ds.axes.iteration is None
-#     assert len(ds.axes) == 3
-#     for i, s in enumerate(array_content.shape):
-#         assert f"axis{i}" in ds.axes
-#         assert f"axis{i}" == ds.axes[i].name
-#         assert f"axis{i}" == ds.axes[i].label
-#         np.testing.assert_equal(ds.axes[i], np.arange(s))
-
-#     np.testing.assert_array_equal(ds, array_content)
-
-
-# def test_GridDataset_from_array_with_name():
-#     """Test if `.from_array` passes name through"""
-#     ds = GridDataset.from_array([], name="some_name")
-#     assert ds.name == "some_name"
-
-
-# def test_GridDataset_from_array_with_label():
-#     """Test if `.from_array` passes label through"""
-#     ds = GridDataset.from_array([], label="some label")
-#     assert ds.label == "some label"
-
-
-# def test_GridDataset_from_array_with_unit():
-#     """Test if `.from_array` passes unit through"""
-#     ds = GridDataset.from_array([], unit="some unit")
-#     assert ds.unit == "some unit"
-
-
-# _testCases_GridDataset_from_array_with_time = {}
-# _testCases_GridDataset_from_array_with_time["numpy"] = (
-#     np.arange(10),
-#     Axis(np.arange(10), name="time", label="time", unit="", axis_dim=0),
-# )
-# _testCases_GridDataset_from_array_with_time["dask"] = (
-#     da.arange(10),
-#     Axis(np.arange(10), name="time", label="time", unit="", axis_dim=0),
-# )
-# _testCases_GridDataset_from_array_with_time["iterator"] = (
-#     range(10),
-#     Axis(np.arange(10), name="time", label="time", unit="", axis_dim=0),
-# )
-# _testCases_GridDataset_from_array_with_time["Axis"] = (
-#     Axis(np.arange(10)),
-#     Axis(np.arange(10), name="time", label="time", unit="", axis_dim=0),
-# )
-
-
-# @pytest.mark.parametrize(
-#     "case",
-#     _testCases_GridDataset_from_array_with_time.values(),
-#     ids=_testCases_GridDataset_from_array_with_time.keys(),
-# )
-# def test_GridDataset_from_array_with_time(case):
-#     """Test if `.from_array` initializes time correctly"""
-#     input_, expected = case
-#     ds = GridDataset.from_array(np.arange(10), time=input_)
-#     assert "time" in ds.axes
-#     assert isinstance(ds.axes.time, Axis)
-#     assert ds.axes.time is ds.axes[0]
-#     assert ds.axes[0].is_equiv_to(expected)
-#     np.testing.assert_array_equal(ds.axes.time, expected)
-
-
-# _testCases_GridDataset_from_array_with_iteration = {}
-# _testCases_GridDataset_from_array_with_iteration["numpy"] = (
-#     np.arange(10),
-#     Axis(np.arange(10), name="iteration", label="iteration", unit="", axis_dim=0),
-# )
-# _testCases_GridDataset_from_array_with_iteration["dask"] = (
-#     da.arange(10),
-#     Axis(np.arange(10), name="iteration", label="iteration", unit="", axis_dim=0),
-# )
-# _testCases_GridDataset_from_array_with_iteration["iterator"] = (
-#     range(10),
-#     Axis(np.arange(10), name="iteration", label="iteration", unit="", axis_dim=0),
-# )
-# _testCases_GridDataset_from_array_with_iteration["Axis"] = (
-#     Axis(np.arange(10)),
-#     Axis(np.arange(10), name="iteration", label="iteration", unit="", axis_dim=0),
-# )
-
-
-# @pytest.mark.parametrize(
-#     "case",
-#     _testCases_GridDataset_from_array_with_iteration.values(),
-#     ids=_testCases_GridDataset_from_array_with_iteration.keys(),
-# )
-# def test_GridDataset_from_array_with_iteration(case):
-#     """Test if `.from_array` initializes iteration correctly"""
-#     input_, expected = case
-#     ds = GridDataset.from_array(np.arange(10), iteration=input_)
-#     assert "iteration" in ds.axes
-#     assert isinstance(ds.axes.iteration, Axis)
-#     assert ds.axes.iteration is ds.axes[0]
-#     assert ds.axes[0].is_equiv_to(expected)
-#     np.testing.assert_array_equal(ds.axes.iteration, expected)
-
-
-# _testCases_GridDataset_from_array_with_grid_axes = {}
-# _testCases_GridDataset_from_array_with_grid_axes["numpy"] = (
-#     [np.arange(3), np.arange(4)],
-#     [
-#         Axis(np.arange(3), name="axis0", label="axis 0", axis_dim=1),
-#         Axis(np.arange(4), name="axis1", label="axis 1", axis_dim=1),
-#     ],
-# )
-# _testCases_GridDataset_from_array_with_grid_axes["dask"] = (
-#     [da.arange(3), da.arange(4)],
-#     [
-#         Axis(np.arange(3), name="axis0", label="axis 0", axis_dim=1),
-#         Axis(np.arange(4), name="axis1", label="axis 1", axis_dim=1),
-#     ],
-# )
-# _testCases_GridDataset_from_array_with_grid_axes["iterator"] = (
-#     [range(3), range(4)],
-#     [
-#         Axis(np.arange(3), name="axis0", label="axis 0", axis_dim=1),
-#         Axis(np.arange(4), name="axis1", label="axis 1", axis_dim=1),
-#     ],
-# )
-# _testCases_GridDataset_from_array_with_grid_axes["Axis"] = (
-#     [
-#         Axis(
-#             np.arange(3),
-#             name="a0",
-#             label="some axis",
-#             unit="some_unit",
-#             axis_dim=1
-#         ),
-#         Axis(
-#             np.arange(4),
-#             name="a1",
-#             label="some other axis",
-#             unit="some other unit",
-#             axis_dim=1,
-#         ),
-#     ],
-#     [
-#         Axis(
-#             np.arange(3),
-#             name="a0",
-#             label="some axis",
-#             unit="some_unit",
-#             axis_dim=1),
-#         Axis(
-#             np.arange(4),
-#             name="a1",
-#             label="some other axis",
-#             unit="some other unit",
-#             axis_dim=1,
-#         ),
-#     ],
-# )
-
-
-# @pytest.mark.parametrize(
-#     "case",
-#     _testCases_GridDataset_from_array_with_grid_axes.values(),
-#     ids=_testCases_GridDataset_from_array_with_grid_axes.keys(),
-# )
-# def test_GridDataset_from_array_with_grid_axes(case):
-#     """Test if `.from_array` initializes iteration correctly"""
-#     input_, expected = case
-#     ds = GridDataset.from_array(np.arange(3 * 4).reshape((3, 4)), grid_axes=input_)
-
-#     for actual_axis, expected_axis in zip(ds.axes.grid_axes, expected):
-#         assert isinstance(actual_axis, Axis)
-#         assert expected_axis.is_equiv_to(actual_axis)
-#         np.testing.assert_array_equal(actual_axis, expected_axis)
-
-
-# @pytest.fixture(name="backend_on_disk")
-# def _create_backend_file(tmp_path):
-#     @register_backend(GridDataset)
-#     class DummyBackend:
-#         valid_paths = set()
-#         name = "DummyBackend"
-#         location: Optional[str] = None
-#         dataset_name = "dummy_name"
-#         dataset_label = "dummy label"
-#         dataset_unit = "dummy unit"
-#         iteration = 123
-#         time_step = -12314.0
-#         time_unit = "time unit"
-#         shape = (2, 5)
-#         ndim = 2
-#         axes_min = (-123.0, 321.0)
-#         axes_max = (346.0, 898.0)
-#         axes_names = ("dummy_axis_0", "dummy_axis_1")
-#         axes_labels = ("dummy axis 0", "dummy axis 1")
-#         axes_units = ("axis0 unit", "axis1 unit")
-#         dtype = np.int
-
-#         def __init__(self, location: str) -> None:
-#             self.location = location
-
-#         def get_data(self, indexing):
-#             raise NotImplementedError
-
-#         @classmethod
-#         def is_valid_backend(cls, path: Path) -> bool:
-#             return True if path in cls.valid_paths else False
-
-#     def create_file(filename: str):
-#         f = tmp_path / filename
-#         f.touch()
-#         DummyBackend.valid_paths |= {f}
-#         return f
-
-#     create_file.root_path = tmp_path
-#     create_file.backend_info = DummyBackend
-
-#     return create_file
-
-
-# def test_GridDataset_get_valid_backend(backend_on_disk):
-#     test_file = backend_on_disk("test")
-#     assert GridDataset.get_valid_backend(test_file)
-
-
-# def test_GridDataset_from_file_with_single_file_path(backend_on_disk):
-#     # create backend file
-#     backend_on_disk("test")
-
-#     ds = GridDataset.from_path(backend_on_disk.root_path / "test")
-
-#     # temporal info
-#     assert ds.temporal_steps == 1
-
-#     # check for grid properties
-#     assert len(ds) == 1
-#     assert ds.shape == backend_on_disk.backend_info.shape
-#     assert ds.grid_shape == backend_on_disk.backend_info.shape
-#     assert ds.ndim == backend_on_disk.backend_info.ndim
-#     assert ds.grid_ndim == backend_on_disk.backend_info.ndim
-#     assert ds.dtype == backend_on_disk.backend_info.dtype
-
-#     # check propagation of namings
-#     assert ds.name == backend_on_disk.backend_info.dataset_name
-#     assert ds.label == backend_on_disk.backend_info.dataset_label
-#     assert ds.unit == backend_on_disk.backend_info.dataset_unit
-
-#     # check axes init
-#     assert isinstance(ds.axes.iteration, Axis)
-#     assert isinstance(ds.axes.time, Axis)
-#     assert all(isinstance(ax, Axis) for ax in ds.axes.grid_axes)
-
-
-# def test_GridDataset_from_file_with_multiple_files(backend_on_disk):
-#     # create backend file
-#     for i in range(10):
-#         backend_on_disk(f"test{i}")
-
-#     ds = GridDataset.from_path(backend_on_disk.root_path / "*")
-
-#     # temporal info
-#     assert ds.temporal_steps == 10
-
-#     # check for grid properties
-#     assert len(ds) == 10
-#     assert ds.shape == (10,) + backend_on_disk.backend_info.shape
-#     assert ds.grid_shape == backend_on_disk.backend_info.shape
-#     assert ds.ndim == backend_on_disk.backend_info.ndim + 1
-#     assert ds.grid_ndim == backend_on_disk.backend_info.ndim
-#     assert ds.dtype == backend_on_disk.backend_info.dtype
-
-#     # check propagation of namings
-#     assert ds.name == backend_on_disk.backend_info.dataset_name
-#     assert ds.label == backend_on_disk.backend_info.dataset_label
-#     assert ds.unit == backend_on_disk.backend_info.dataset_unit
-
-#     # check axes init
-#     assert isinstance(ds.axes.iteration, Axis)
-#     assert isinstance(ds.axes.time, Axis)
-#     assert all(isinstance(ax, Axis) for ax in ds.axes.grid_axes)
-
-
-# def test_GridDataset___array__():
-#     ds = GridDataset.from_array(np.arange(10).reshape((1, 10)))
-#     assert ds.shape == (1, 10)
-#     assert ds.grid_shape == (1, 10)
-#     np.testing.assert_array_equal(ds, np.arange(10).reshape((1, 10)))
-
-#     ds = GridDataset.from_array(np.arange(10))
-#     assert ds.shape == (10,)
-#     assert ds.grid_shape == (10,)
-#     np.testing.assert_array_equal(ds, np.arange(10))
-
-
-# _ufunc_test_cases = [
-#     (np.add, (10,), {}),
-#     (np.subtract, (10,), {}),
-#     (np.multiply, (10,), {}),
-#     # (np.matmul, ),
-#     (np.divide, (10,), {}),
-#     (np.logaddexp, (10,), {}),
-#     (np.logaddexp, (10,), {}),
-#     (np.true_divide, (10,), {}),
-#     (np.floor_divide, (10,), {}),
-#     (np.negative, (), {}),
-#     (np.positive, (), {}),
-#     (np.power, (10,), {}),
-#     (np.float_power, (10,), {}),
-#     (np.remainder, (10,), {}),
-#     (np.mod, (10,), {}),
-#     (np.fmod, (10,), {}),
-#     # (np.divmod, (10,), {}),
-#     (np.absolute, (), {}),
-#     (np.fabs, (), {}),
-#     (np.rint, (), {}),
-#     (np.sign, (), {}),
-#     (np.heaviside, (10,), {}),
-#     (np.conj, (), {}),
-#     (np.conjugate, (), {}),
-#     (np.exp, (), {}),
-#     (np.exp2, (), {}),
-#     (np.log, (), {}),
-#     (np.log2, (), {}),
-#     (np.log10, (), {}),
-#     (np.expm1, (), {}),
-#     (np.log1p, (), {}),
-#     (np.sqrt, (), {}),
-#     (np.square, (), {}),
-#     (np.cbrt, (), {}),
-#     (np.reciprocal, (), {}),
-#     # (np.gcd, (10,), {}),
-#     # (np.lcm, (10,), {}),
-#     (np.sin, (), {}),
-#     (np.cos, (), {}),
-#     (np.tan, (), {}),
-#     (np.arcsin, (), {}),
-#     (np.arccos, (), {}),
-#     (np.arctan, (), {}),
-#     (np.arctan2, (10,), {}),
-#     (np.hypot, (10,), {}),
-#     (np.sinh, (), {}),
-#     (np.cosh, (), {}),
-#     (np.tanh, (), {}),
-#     (np.arcsinh, (), {}),
-#     (np.arccosh, (), {}),
-#     (np.arctanh, (), {}),
-#     (np.degrees, (), {}),
-#     (np.radians, (), {}),
-#     (np.deg2rad, (), {}),
-#     (np.rad2deg, (), {}),
-#     # (np.bitwise_and, (10,), {}),
-#     # (np.bitwise_or, (), {}),
-#     # (np.bitwise_xor, (10,), {}),
-#     # (np.invert, (), {}),
-#     # (np.left_shift, (10,), {}),
-#     # (np.right_shift, (10,), {}),
-#     (np.greater, (10,), {}),
-#     (np.greater_equal, (10,), {}),
-#     (np.less, (10,), {}),
-#     (np.less_equal, (10,), {}),
-#     (np.not_equal, (10,), {}),
-#     (np.equal, (10,), {}),
-#     (np.logical_and, (10,), {}),
-#     (np.logical_or, (10,), {}),
-#     (np.logical_xor, (10,), {}),
-#     (np.logical_not, (), {}),
-#     (np.maximum, (10,), {}),
-#     (np.minimum, (10,), {}),
-#     (np.fmax, (10,), {}),
-#     (np.fmin, (10,), {}),
-#     (np.isfinite, (), {}),
-#     (np.isinf, (), {}),
-#     (np.isnan, (), {}),
-#     # (np.isnat, (), {}),
-#     (np.signbit, (), {}),
-#     (np.copysign, (10,), {}),
-#     (np.nextafter, (10,), {}),
-#     (np.spacing, (), {}),
-#     # (np.modf, (), {}),
-#     (np.ldexp, (10,), {}),
-#     # (np.frexp, (), {}),
-#     (np.floor, (), {}),
-#     (np.ceil, (), {}),
-#     (np.trunc, (), {}),
-# ]
-
-
-# @pytest.mark.parametrize(
-#     "func, args, kwargs",
-#     _ufunc_test_cases,
-#     ids=[str(case[0].__name__) for case in _ufunc_test_cases],
-# )
-# @pytest.mark.filterwarnings("ignore:invalid value")
-# def test_GridDAtaset___array_ufunc__(func, args, kwargs):
-#     ds = GridDataset.from_array(np.linspace(0.1, 0.9))
-#     np.testing.assert_array_equal(
-#         func(ds, *args, **kwargs), func(np.linspace(0.1, 0.9), *args, **kwargs)
-#     )
-
-
-# _array_function_test_cases = [
-#     (np.fft.fft, (), {}),
-# ]
-
-
-# @pytest.mark.parametrize(
-#     "func, args, kwargs",
-#     _array_function_test_cases,
-#     ids=[str(case[0].__name__) for case in _array_function_test_cases],
-# )
-# @pytest.mark.filterwarnings("ignore:invalid value")
-# def test_GridDAtaset___array_function__(func, args, kwargs):
-#     ds = GridDataset.from_array(np.linspace(0.1, 0.9))
-#     np.testing.assert_array_equal(
-#         func(ds, *args, **kwargs), func(np.linspace(0.1, 0.9), *args, **kwargs)
-#     )
-
-
-# _testCases_GridDataset_getitem = {}
-# _testCases_GridDataset_getitem["(axis,) [int]"] = {
-#     "arr": np.arange(123),
-#     "indexing": np.s_[2],
-#     "expected_arr": np.array(2),
-# }
-# _testCases_GridDataset_getitem["(axis,) [:]"] = {
-#     "arr": np.arange(123),
-#     "indexing": np.s_[:],
-#     "expected_arr": np.arange(123),
-# }
-# _testCases_GridDataset_getitem["(axis,) [range]"] = {
-#     "arr": np.arange(123),
-#     "indexing": np.s_[8:-12],
-#     "expected_arr": np.arange(123)[8:-12],
-# }
-# _testCases_GridDataset_getitem["(axis,) [newaxis]"] = {
-#     "arr": np.arange(123),
-#     "indexing": np.s_[np.newaxis],
-#     "expected_arr": np.arange(123).reshape((1, 123)),
-#     "expected_kwargs": {
-#         "grid_axes": [Axis([0], name="newaxis0"), Axis(np.arange(123), name="axis0")]
-#     },
-# }
-# _testCases_GridDataset_getitem["(axis,) [..., newaxis]"] = {
-#     "arr": np.arange(123),
-#     "indexing": np.s_[..., np.newaxis],
-#     "expected_arr": np.arange(123).reshape((123, 1)),
-#     "expected_kwargs": {
-#         "grid_axes": [Axis(np.arange(123), name="axis0"), Axis([0], name="newaxis0")]
-#     },
-# }
-# _testCases_GridDataset_getitem["(time, axis) [int]"] = {
-#     "arr": np.arange(4 * 5).reshape((4, 5)),
-#     "kwargs": {
-#         "grid_axes": [
-#             Axis(np.arange(4), name="time", label="time", axis_dim=0),
-#             Axis(
-#                 np.arange(5).reshape((1, 5)).repeat(4, axis=0),
-#                 name="axis0",
-#                 label="axis0",
-#                 axis_dim=1,
-#             ),
-#         ]
-#     },
-#     "indexing": np.s_[2],
-#     "expected_arr": np.arange(4 * 5).reshape((4, 5))[2],
-#     "expected_kwargs": {
-#         "time": Axis(2, name="time", label="time", axis_dim=0),
-#         "grid_axes": [Axis(np.arange(5), name="axis0", label="axis0", axis_dim=1)],
-#     },
-# }
-# _testCases_GridDataset_getitem["(time, axis) [newaxis]"] = {
-#     "arr": np.arange(4 * 5).reshape((4, 5)),
-#     "kwargs": {
-#         "grid_axes": [
-#             Axis(np.arange(4), name="time", label="time", axis_dim=0),
-#             Axis(
-#                 np.arange(5).reshape((1, 5)).repeat(4, axis=0),
-#                 name="axis0",
-#                 label="axis0",
-#                 axis_dim=1,
-#             ),
-#         ]
-#     },
-#     "indexing": np.s_[np.newaxis],
-#     "expected_arr": np.arange(4 * 5).reshape((1, 4, 5)),
-#     "expected_kwargs": {
-#         "grid_axes": [
-#             Axis(
-#                 np.arange(1).reshape((1, 1)).repeat(4, axis=0),
-#                 name="newaxis0",
-#                 axis_dim=1,
-#             ),
-#             Axis(np.arange(4), name="time", label="time", axis_dim=0),
-#             Axis(
-#                 np.arange(5).reshape((1, 5)).repeat(4, axis=0),
-#                 name="axis0",
-#                 label="axis0",
-#                 axis_dim=1,
-#             ),
-#         ],
-#     },
-# }
-# _testCases_GridDataset_getitem["(iteration, axis) [int]"] = {
-#     "arr": np.arange(4 * 5).reshape((4, 5)),
-#     "kwargs": {
-#         "grid_axes": [
-#             Axis(np.arange(4), name="iteration", label="iteration", axis_dim=0),
-#             Axis(
-#                 np.arange(4 * 5).reshape((4, 5)),
-#                 name="axis0",
-#                 label="axis0",
-#                 axis_dim=1,
-#             ),
-#         ]
-#     },
-#     "indexing": np.s_[2],
-#     "expected_arr": np.arange(4 * 5).reshape((4, 5))[2],
-#     "expected_kwargs": {
-#         "iteration": Axis(2, name="iteration", label="iteration", axis_dim=0),
-#         "grid_axes": [
-#             Axis(
-#                 np.arange(4 * 5).reshape((4, 5))[2],
-#                 name="axis0",
-#                 label="axis0",
-#                 axis_dim=1,
-#             ),
-#         ],
-#     },
-# }
-
-
-# @pytest.mark.parametrize(
-#     "case",
-#     _testCases_GridDataset_getitem.values(),
-#     ids=_testCases_GridDataset_getitem.keys(),
-# )
-# def test_GridDataset_getitem(case):
-#     arr = case["arr"]
-#     kwargs = case["kwargs"] if "kwargs" in case else {}
-
-#     indexing = case["indexing"]
-
-#     expected_arr = case["expected_arr"]
-#     expected_args = case["expected_args"] if "expected_args" in case else ()
-#     expected_kwargs = case["expected_kwargs"] if "expected_kwargs" in case else {}
-
-#     grid = GridDataset.from_array(arr, **kwargs)
-#     subgrid = grid[indexing]
-#     expected_subgrid = GridDataset.from_array(
-#         expected_arr, *expected_args, **expected_kwargs
-#     )
-
-#     assert isinstance(subgrid, GridDataset)
-#     assert subgrid.is_equiv_to(expected_subgrid)
-#     np.testing.assert_array_equal(subgrid, expected_subgrid)
-
-#     if expected_subgrid.axes.time:
-#         assert subgrid.axes.time.is_equiv_to(expected_subgrid.axes.time, verbose=True)
-#         np.testing.assert_array_equal(subgrid.axes.time, expected_subgrid.axes.time)
-#     else:
-#         assert subgrid.axes.time is None
-
-#     if expected_subgrid.axes.iteration:
-#         assert subgrid.axes.iteration.is_equiv_to(
-#             expected_subgrid.axes.iteration, verbose=True
-#         )
-#         np.testing.assert_array_equal(
-#             subgrid.axes.iteration, expected_subgrid.axes.iteration
-#         )
-#     else:
-#         assert subgrid.axes.iteration is None
diff --git a/tests/containers/test_ParticleDatset.py b/tests/containers/test_ParticleDatset.py
deleted file mode 100644
index c18e856..0000000
--- a/tests/containers/test_ParticleDatset.py
+++ /dev/null
@@ -1,164 +0,0 @@
-# -*- coding: utf-8 -*-
-# from pathlib import Path
-# from typing import Mapping
-# from typing import Union
-
-# import numpy as np
-# import pytest
-
-# from nata.containers import ParticleDataset
-# from nata.types import AxisType
-# from nata.types import ParticleBackendType
-# from nata.types import ParticleDatasetType
-
-
-# @pytest.fixture(name="TestParticleBackend")
-# def _TestParticleBackend():
-#     """Fixture for returning ParticleBackend"""
-
-#     class TestParticleBackend:
-#         """Test backend for ParticleDatasets"""
-
-#         name = "TestParticleBackend"
-#         location = None
-
-#         dataset_name = "test_dataset_name"
-#         num_particles = 10
-
-#         quantity_names = ("quant1", "quant2", "quant3")
-#         quantity_labels = ("quant1 label", "quant2 label", "quant3 label")
-#         quantity_units = ("quant1 unit", "quant2 unit", "quant3 unit")
-
-#         iteration = 42
-
-#         time_step = 12.3
-#         time_unit = "time unit"
-
-#         dtype = np.dtype(
-#             [("quant1", np.int), ("quant2", np.float), ("quant3", np.int)]
-#         )
-
-#         @staticmethod
-#         def is_valid_backend(location: Union[Path, str]) -> bool:
-#             return (
-#                 True
-#                 if Path(location) == Path("TestParticleBackend_location")
-#                 else False
-#             )
-
-#         def __init__(self, location) -> None:
-#             pass
-
-#     # makes sure dummy backend is of valid type
-#     assert isinstance(TestParticleBackend, ParticleBackendType)
-
-#     ParticleDataset.add_backend(TestParticleBackend)
-#     yield TestParticleBackend
-#     # teardown
-#     ParticleDataset.remove_backend(TestParticleBackend)
-
-
-# def test_ParticleDataset_isinstance_ParticleDatasetType():
-#     """Checks ParticleDataset passes isinstance test of ParticleDatasetType"""
-#     assert isinstance(ParticleDataset, ParticleDatasetType)
-
-
-# def test_ParticleDataset_registration(TestParticleBackend):
-#     """Check if fixture registers backend properly"""
-#     assert TestParticleBackend.name in ParticleDataset.get_backends()
-
-
-# @pytest.mark.parametrize(
-#     "attr, value",
-#     [("backend", "TestParticleBackend"), ("name", "test_dataset_name")],
-#     ids=["backend", "name"],
-# )
-# def test_ParticleDataset_attr_propagation_from_Backend(
-#     TestParticleBackend, attr, value
-# ):
-#     """Parameterized check for different props of ParticleDataset"""
-#     ds = ParticleDataset("TestParticleBackend_location")
-#     assert getattr(ds, attr) == value
-
-
-# def test_ParticleDataset_num_particles_from_Backend(TestParticleBackend):
-#     """Tests extraction correct type for . and its proper extraction"""
-#     ds = ParticleDataset("TestParticleBackend_location")
-#     assert isinstance(ds.num_particles, AxisType)
-
-
-# def test_ParticleDataset_axes_from_Backend(TestParticleBackend):
-#     """Tests extraction correct type for .axes and its proper extraction"""
-#     ds = ParticleDataset("TestParticleBackend_location")
-
-#     assert isinstance(ds.axes, Mapping)
-
-#     for axes_name, type_ in [
-#         ("iteration", AxisType),
-#         ("time", AxisType),
-#     ]:
-#         assert axes_name in ds.axes
-#         assert isinstance(ds.axes.get(axes_name), type_)
-
-
-# @pytest.mark.parametrize(
-#     "attr, value",
-#     [("name", "iteration"), ("label", "iteration"), ("unit", "")],
-#     ids=["name", "label", "unit"],
-# )
-# def test_ParticleDataset_iteration_axis_from_Backend(
-#     TestParticleBackend, attr, value
-# ):
-#     """Extraction is correct for iteration axis. Check attributes for axis"""
-#     ds = ParticleDataset("TestParticleBackend_location")
-#     assert getattr(ds.axes["iteration"], attr) == value
-
-
-# @pytest.mark.parametrize(
-#     "attr, value",
-#     [("name", "time"), ("label", "time"), ("unit", "time unit")],
-#     ids=["name", "label", "unit"],
-# )
-# def test_ParticleDataset_time_axis_from_Backend(
-#     TestParticleBackend, attr, value
-# ):
-#     """Extraction is correct for iteration axis. Check attributes for axis"""
-#     ds = ParticleDataset("TestParticleBackend_location")
-#     assert getattr(ds.axes["time"], attr) == value
-
-
-# def test_ParticleDataset_quantities_from_Backend(TestParticleBackend):
-#     """Checks if quantities propagate from backend to ParticleDataset"""
-#     ds = ParticleDataset("TestParticleBackend_location")
-
-#     assert isinstance(ds.quantities, Mapping)
-
-#     expected_names = TestParticleBackend.quantity_names
-#     expected_labels = TestParticleBackend.quantity_labels
-#     expected_units = TestParticleBackend.quantity_units
-
-#     # check key entries
-#     for key, expected_name in zip(ds.quantities.keys(), expected_names):
-#         assert key == expected_name
-
-#     # check value entries
-#     for quant, expected_name in zip(ds.quantities.values(), expected_names):
-#         assert quant.name == expected_name
-
-#     for quant, expected_label in zip(ds.quantities.values(), expected_labels):
-#         assert quant.label == expected_label
-
-#     for quant, expected_unit in zip(ds.quantities.values(), expected_units):
-#         assert quant.unit == expected_unit
-
-
-# @pytest.mark.skip
-# def test_ParticleDataset_from_array():
-#     """Check init for ParticleDataset from array"""
-#     pass
-
-
-# @pytest.mark.skip
-# def test_ParticleDataset_from_path():
-#     """Check init for ParticleDataset from path"""
-#     pass
diff --git a/tests/containers/utils_test.py b/tests/containers/utils_test.py
index 0c3957d..ba34e6c 100644
--- a/tests/containers/utils_test.py
+++ b/tests/containers/utils_test.py
@@ -1,32 +1,20 @@
 # -*- coding: utf-8 -*-
-from nata.containers.utils import get_doc_heading
+import dask.array as da
+import numpy as np
+from numpy.lib import recfunctions
 
+from nata.containers.utils import unstructured_to_structured
 
-def test_get_doc_heading_without_docs():
-    def func():
-        pass
 
-    assert get_doc_heading(func) == ""
+def test_unstructured_to_structured():
+    arr = da.array([1, 2, 3])
+    structured_dtype = np.dtype([("f1", int), ("f2", float), ("f3", np.bool8)])
 
+    # expected is based on doing numpy 'unstructured_to_structured'
+    expected = recfunctions.unstructured_to_structured(arr.compute(), structured_dtype)
 
-def test_get_doc_heading_without_newline():
-    def func():
-        """Some long string
+    # do own 'unstructured_to_structured'
+    converted_arr = unstructured_to_structured(arr, structured_dtype)
 
-        This should be hidden
-        """
-        pass
-
-    assert get_doc_heading(func) == "Some long string"
-
-
-def test_get_doc_heading_with_newline():
-    def func():
-        """
-        Some long string
-
-        This should be hidden
-        """
-        pass
-
-    assert get_doc_heading(func) == "Some long string"
+    assert isinstance(converted_arr, da.Array)
+    np.testing.assert_array_equal(converted_arr, expected)
diff --git a/tests/plugins/fft_test.py b/tests/plugins/fft_test.py
index eebcaa3..2a3057b 100644
--- a/tests/plugins/fft_test.py
+++ b/tests/plugins/fft_test.py
@@ -20,14 +20,14 @@ def test_array_fft_invalid_axis():
         with pytest.raises(ValueError, match="invalid axis index"):
             GridArray.from_array(
                 np.arange(100).reshape((10, 10)),
-                axes=[Axis(np.arange(10))] * 2,
+                axes=[Axis.from_array(np.arange(10))] * 2,
             ).fft(axes=[invalid_axis])
 
     for invalid_axis in ["axis2", "abc"]:
         with pytest.raises(ValueError, match="could not be found"):
             GridArray.from_array(
                 np.arange(100).reshape((10, 10)),
-                axes=[Axis(np.arange(10))] * 2,
+                axes=[Axis.from_array(np.arange(10))] * 2,
             ).fft(axes=[invalid_axis])
 
 
@@ -35,7 +35,7 @@ def test_array_fft_shape_1d():
     x = np.arange(10)
 
     for axes in [
-        [Axis(x)],
+        [Axis.from_array(x)],
         # [Axis([x] * 2)],
     ]:
         grid = GridArray.from_array(x, axes=axes)
@@ -49,7 +49,7 @@ def test_array_fft_shape_2d():
     data = np.arange(100).reshape((10, 10))
 
     for axes in [
-        [Axis(x)] * 2,
+        [Axis.from_array(x)] * 2,
         # [Axis([x] * 2)] * 2,
     ]:
         grid = GridArray.from_array(data, axes=axes)
@@ -60,7 +60,7 @@ def test_array_fft_shape_2d():
 
 def test_array_fft_comps_1d():
     x = np.linspace(0, 10 * np.pi, 101)
-    grid = GridArray.from_array(np.sin(x), axes=[Axis(x)])
+    grid = GridArray.from_array(np.sin(x), axes=[Axis.from_array(x)])
 
     for comp, fn in zip(
         ["full", "real", "imag", "abs"], [lambda x: x, np.real, np.imag, np.abs]
@@ -72,7 +72,7 @@ def test_array_fft_comps_1d():
 
 def test_array_fft_peak_1d():
     x = np.linspace(0, 10 * np.pi, 101)
-    grid = GridArray.from_array(np.sin(x), axes=[Axis(x)])
+    grid = GridArray.from_array(np.sin(x), axes=[Axis.from_array(x)])
     fft_grid = grid.fft()
 
     assert (
@@ -88,7 +88,7 @@ def test_dataset_fft_peak_1d():
 
     grid = GridDataset.from_array(
         [np.sin(k_i * x) for k_i in k_modes],
-        axes=[Axis(time), Axis(np.tile(x, (len(time), 1)))],
+        axes=[Axis.from_array(time), Axis.from_array(np.tile(x, (len(time), 1)))],
     )
     fft_grid = grid.fft()