Repository: EIT-ALIVE/eitprocessing (language: python)

Issue:
parse medibus data into waveform data for draeger files
https://github.com/EIT-ALIVE/eitprocessing/blob/27023526d46a6935911bab76f3c62e981e0b93c0/eitprocessing/binreader/sequence.py#L441-L444
Add labeling attributes to `Sequence`
@psomhorst , was there a reason to remove `label` etc from `Sequence`? I re-added them now, because it's very useful to have it in the dashboard. I will also update the docstring and then open this PR for review, just wanted to check in already whether there was actually a reason to not want labels on Sequences.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 9d5f89fe5..7d09fdae6 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -3,8 +3,8 @@ name: Python package
 on:
   push:
     branches:
-    - main
-    - drcandacemakedamoore/docker
+      - main
+      - drcandacemakedamoore/docker
   pull_request:
     types: [opened, synchronize, reopened, ready_for_review]
     paths-ignore:
@@ -12,18 +12,17 @@ on:
       - "**.rst"
       - "**.ipynb"
     branches:
-    - main
+      - main
 
 jobs:
-
   build:
     if: github.event.pull_request.draft == false
     runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false
       matrix:
-        os: ['ubuntu-latest']
-        python-version: ['3.10']
+        os: ["ubuntu-latest"]
+        python-version: ["3.10"]
     name: Build for ${{ matrix.python-version }}, ${{ matrix.os }}
     steps:
       - uses: actions/checkout@v3
@@ -57,7 +56,7 @@ jobs:
       - name: Set up Python 3.10
         uses: actions/setup-python@v3
         with:
-          python-version: '3.10'
+          python-version: "3.10"
       - name: Python info
         shell: bash -l {0}
         run: |
@@ -67,10 +66,8 @@ jobs:
         run: |
           python3 -m pip install --upgrade pip setuptools
           python3 -m pip install .[dev,publishing]
-      - name: Check style against standards using prospector
-        run: prospector
-      - name: Check import order
-        run: isort --check-only eitprocessing --diff
+      - name: Check linting with ruff
+        run: ruff .
 
   coveralls:
     if: github.event.pull_request.draft == false
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2e4ae5642..05565d4c1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,14 +1,36 @@
 {
-    "[python]": {
-        "editor.codeActionsOnSave": {
-            "source.organizeImports": "explicit"
-        },
-        "editor.formatOnSave": true,
+  // Python
+  "[python]": {
+    "editor.formatOnSave": true,
+    "editor.codeActionsOnSave": {
+      "source.fixAll": "explicit"
     },
-    "notebook.lineNumbers": "on",
-    "python.testing.pytestArgs": [
-        "tests"
-    ],
-    "python.testing.unittestEnabled": false,
-    "python.testing.pytestEnabled": true,
+    "editor.defaultFormatter": "charliermarsh.ruff",
+    "editor.rulers": [120]
+  },
+  "autoDocstring.docstringFormat": "google",
+
+  // Notebooks
+  "notebook.lineNumbers": "on",
+  "notebook.formatOnSave.enabled": true,
+  "notebook.codeActionsOnSave": {
+    "notebook.source.fixAll": "explicit"
+  },
+  "notebook.diff.ignoreMetadata": true,
+
+  // Pytest
+  "python.testing.unittestEnabled": false,
+  "python.testing.pytestEnabled": true,
+
+  // Format all files on save
+  "rewrap.wrappingColumn": 120,
+  "rewrap.autoWrap.enabled": true,
+  "editor.formatOnSave": true,
+  "editor.defaultFormatter": "esbenp.prettier-vscode",
+  "[ignore]": {
+    "editor.defaultFormatter": "foxundermoon.shell-format"
+  },
+  "[properties]": {
+    "editor.defaultFormatter": "foxundermoon.shell-format"
+  }
 }
diff --git a/README.dev.md b/README.dev.md
index 2469cd134..aa01b7193 100644
--- a/README.dev.md
+++ b/README.dev.md
@@ -9,10 +9,11 @@ We welcome all contributions to this open-source project, as long as they follow
 We appreciate it if you adhere to our naming and style [conventions](#conventions) below.
 
 Please follow these steps:
-1. (**important**) announce your plan to the rest of the community *before you start working*. This announcement should be in the form of a (new) issue;
+
+1. (**important**) announce your plan to the rest of the community _before you start working_. This announcement should be in the form of a (new) issue;
 1. (**important**) wait until some kind of consensus is reached about your idea being a good idea;
 1. if needed, fork the repository to your own Github profile and create your own feature branch off of the latest master commit. While working on your feature branch, make sure to stay up to date with the master branch by pulling in changes, possibly from the 'upstream' repository (follow the instructions [here](https://help.github.com/articles/configuring-a-remote-for-a-fork/) and [here](https://help.github.com/articles/syncing-a-fork/));
-1. make sure the existing tests still work by running ``pytest`` (see also [here](#testing-locally));
+1. make sure the existing tests still work by running `pytest` (see also [here](#testing-locally));
 1. add your own tests (if necessary);
 1. update or expand the documentation;
 1. update the `CHANGELOG.md` file with change;
@@ -21,7 +22,6 @@ Please follow these steps:
 
 In case you feel like you've made a valuable contribution, but you don't know how to write or run tests for it, or how to generate the documentation: don't let this discourage you from making the pull request; we can help you! Just go ahead and submit the pull request, but keep in mind that you might be asked to append additional commits to your pull request.
 
-
 ### Conventions
 
 #### Readability vs complexity/correctness
@@ -73,9 +73,9 @@ We have set up continuous integration for linting and testing, among other thing
 that all checks pass before requesting code review.
 
 Please create a "draft PR" until your work is ready for review, as this will avoid triggering
-the CI prematurely (which uses unnecessary computing power, see [here](<https://blog.esciencecenter.nl/reduce-reuse-recycle-save-the-planet-one-github-action-at-a-time-4ab602255c3f>)).
+the CI prematurely (which uses unnecessary computing power, see [here](https://blog.esciencecenter.nl/reduce-reuse-recycle-save-the-planet-one-github-action-at-a-time-4ab602255c3f)).
 
-You can run the [tests](#testing-locally) and [linter](#linting-locally) locally before activating
+You can run the [tests](#testing-locally) and [linter](#linting-and-formatting) locally before activating
 the CI.
 
 #### Testing locally
@@ -101,26 +101,15 @@ coverage report
 
 `coverage` can also generate output in HTML and other formats; see `coverage help` for more information.
 
-#### Linting locally
+#### Linting and Formatting
 
-For linting we will use [prospector](https://pypi.org/project/prospector/).
-To sort imports we will use [isort](https://pycqa.github.io/isort/). Note that if you use VS Code,
-sorting is automated upon file saving.
+We use [ruff](https://docs.astral.sh/ruff/) for linting, sorting imports and formatting of python (notebook) files. The configurations of `ruff` are set in [pyproject.toml](pyproject.toml) file.
 
-```shell
-# linter
-prospector
+If you are using VS code, please install and activate the [Ruff extension](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff) to automatically format and check linting.
 
-# recursively check import style for the eitprocessing module only
-isort --check-only eitprocessing
+Otherwise, please ensure check both linting (`ruff fix .`) and formatting (`ruff format .`) before requesting a review.
 
-# recursively check import style for the eitprocessing module only and show
-# any proposed changes as a diff
-isort --check-only --diff eitprocessing
-
-# recursively fix import style for the eitprocessing module only
-isort eitprocessing
-```
+We use [prettier](https://prettier.io/) for formatting most other files. If you are editing or adding non-python files and using VS code, the [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) can be installed to auto-format these files as well.
 
 # The following sections are untested
 
diff --git a/eitprocessing/__init__.py b/eitprocessing/__init__.py
index 0d76cc88d..e69de29bb 100644
--- a/eitprocessing/__init__.py
+++ b/eitprocessing/__init__.py
@@ -1,9 +0,0 @@
-"""Documentation about eitprocessing"""
-import logging
-
-
-logging.getLogger(__name__).addHandler(logging.NullHandler())
-
-__author__ = "Dani Bodor"
-__email__ = "d.bodor@esciencecenter.nl"
-__version__ = "0.1.0"
diff --git a/eitprocessing/binreader/__init__.py b/eitprocessing/binreader/__init__.py
index 58c326a02..e69de29bb 100644
--- a/eitprocessing/binreader/__init__.py
+++ b/eitprocessing/binreader/__init__.py
@@ -1,24 +0,0 @@
-"""Loading and processing binary EIT data from the Dräger Pulmovista 500"""
-
-__version__ = "0.1"
-
-from .frameset import Frameset
-from .phases import MaxValue
-from .phases import MinValue
-from .phases import QRSMark
-from .sequence import DraegerSequence
-from .sequence import Sequence
-from .sequence import TimpelSequence
-from .sequence import Vendor
-
-
-__all__ = [
-    "Frameset",
-    "MaxValue",
-    "MinValue",
-    "QRSMark",
-    "Sequence",
-    "DraegerSequence",
-    "TimpelSequence",
-    "Vendor",
-]
diff --git a/eitprocessing/binreader/event.py b/eitprocessing/binreader/event.py
deleted file mode 100644
index 0e03412a6..000000000
--- a/eitprocessing/binreader/event.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""
-Copyright 2023 Netherlands eScience Center and Erasmus University Medical Center.
-Licensed under the Apache License, version 2.0. See LICENSE for details.
-
-This file contains methods to deal with events when electrical impedance tomographs are read.
-"""
-
-
-from dataclasses import dataclass
-
-
-@dataclass
-class Event:
-    index: int
-    time: float
-    marker: int
-    text: str
diff --git a/eitprocessing/binreader/frameset.py b/eitprocessing/binreader/frameset.py
deleted file mode 100644
index 8bc76cc90..000000000
--- a/eitprocessing/binreader/frameset.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""
-Copyright 2023 Netherlands eScience Center and Erasmus University Medical Center.
-Licensed under the Apache License, version 2.0. See LICENSE for details.
-
-This file contains methods related to when electrical impedance tomographs are read.
-"""
-
-import copy
-import warnings
-from dataclasses import dataclass
-from dataclasses import field
-import numpy as np
-from IPython.display import HTML
-from IPython.display import display
-from matplotlib import animation
-from matplotlib import pyplot as plt
-from tqdm import tqdm
-from tqdm.notebook import tqdm as notebook_tqdm
-
-
-@dataclass
-class Frameset:
-    name: str
-    description: str
-    params: dict = field(default_factory=dict)
-    pixel_values: np.ndarray = field(repr=False, default=None)
-    waveform_data: dict = field(repr=False, default_factory=dict)
-
-    def __len__(self):
-        return self.pixel_values.shape[0]
-
-    def __eq__(self, other):
-        for attr in ["name", "description", "params"]:
-            if getattr(self, attr) != getattr(other, attr):
-                return False
-
-        for attr in ["pixel_values"]:
-            # NaN values are not equal. Check whether values are equal or both NaN.
-            s = getattr(self, attr)
-            o = getattr(other, attr)
-            if not np.all((s == o) | (np.isnan(s) & np.isnan(o))):
-                return False
-
-        for attr in ["waveform_data"]:
-            s = getattr(self, attr)
-            o = getattr(other, attr)
-
-            # check whether they contain the same types of data
-            if set(s.keys()) != set(o.keys()):
-                return False
-
-            # NaN values are not equal. Check whether values are equal or both NaN
-            for key, s_values in s.items():
-                o_values = o[key]
-                if not np.all(
-                    (s_values == o_values) | (np.isnan(s_values) & np.isnan(o_values))
-                ):
-                    return False
-
-        return True
-
-    def select_by_indices(self, indices):
-        obj = self.deepcopy()
-        obj.pixel_values = self.pixel_values[indices, :, :]
-        for key, values in self.waveform_data.items():
-            obj.waveform_data[key] = values[indices]
-        return obj
-
-    __getitem__ = select_by_indices
-
-    @property
-    def global_baseline(self):
-        return np.nanmin(self.pixel_values)
-
-    @property
-    def pixel_values_global_offset(self):
-        return self.pixel_values - self.global_baseline
-
-    @property
-    def pixel_baseline(self):
-        return np.nanmin(self.pixel_values, axis=0)
-
-    @property
-    def pixel_values_individual_offset(self):
-        return self.pixel_values - np.min(self.pixel_values, axis=0)
-
-    @property
-    def global_impedance(self):
-        return np.nansum(self.pixel_values, axis=(1, 2))
-
-    def plot_waveforms(self, waveforms=None):
-        if waveforms is None:
-            waveforms = list(self.waveform_data.keys())
-
-        n_waveforms = len(waveforms)
-        fig, axes = plt.subplots(
-            n_waveforms, 1, sharex=True, figsize=(8, 3 * n_waveforms)
-        )
-        fig.tight_layout()
-
-        for ax, key in zip(axes, waveforms):
-            ax.plot(self.waveform_data[key])
-            ax.set_title(key)
-
-    def animate(
-        self,
-        cmap: str = "plasma",
-        show_progress: bool | str = "notebook",
-        waveforms: bool | list[str] = False,
-    ):  # pylint: disable = too-many-locals
-        if waveforms is True:
-            waveforms = list(self.waveform_data.keys())
-
-        if waveforms:
-            n_waveforms = len(waveforms)
-            fig = plt.figure(figsize=(10, 5))
-            ax = fig.add_subplot(1, 2, 1)
-        else:
-            fig = plt.figure(figsize=(5, 5))
-            ax = fig.add_subplot(1, 1, 1)
-
-        array = self.pixel_values
-
-        vmin = np.nanmin(array)
-        vmax = np.nanmax(array)
-
-        im = ax.imshow(array[0, :, :], vmin=vmin, vmax=vmax, cmap=cmap)
-        plt.colorbar(im)
-
-        if waveforms:
-            wf_axes = []
-            wf_lines = []
-            last_wf_ax = None
-            for n, key in enumerate(reversed(waveforms)):
-                wf_ax = fig.add_subplot(n_waveforms, 2, 2 * (n + 1), sharex=last_wf_ax)
-                wf_axes.append(wf_ax)
-                if n == 0:
-                    last_wf_ax = wf_ax
-
-                wf_data = self.waveform_data[key][0]
-                wf_lines.append(wf_ax.plot([0], wf_data))
-                wf_ax.set_xlim((0, len(self)))
-                wf_ax.set_ylim((wf_data.min(), wf_data.max()))
-
-        if show_progress:
-            if show_progress == "notebook":
-                progress_bar = notebook_tqdm(total=len(self))
-            else:
-                progress_bar = tqdm(total=len(self))
-            progress_bar.update(1)
-
-        def update(i):
-            im.set(data=array[i, :, :])
-
-            if waveforms:
-                for key, line in zip(waveforms, wf_lines):
-                    line[0].set_xdata(range(i))
-                    line[0].set_ydata(self.waveform_data[key][: i + 1])
-
-            if show_progress:
-                progress_bar.update(1)
-
-        anim = animation.FuncAnimation(
-            fig, update, frames=range(1, len(self)), repeat=False
-        )
-        display(HTML(anim.to_jshtml(self.params["framerate"])))
-
-        plt.close()
-
-    @classmethod
-    def merge(cls, a, b):
-        if (a_ := a.name) != (b_ := b.name):
-            raise ValueError(f"Frameset names don't match: {a_}, {b_}")
-
-        if (a_ := a.description) != (b_ := b.description):
-            raise ValueError(f"Frameset descriptions don't match: {a_}, {b_}")
-
-        if (a_ := a.params) != (b_ := b.params):
-            raise ValueError(f"Frameset params don't match: {a_}, {b_}")
-
-        a_waveform_keys = set(a.waveform_data.keys())
-        b_waveform_keys = set(b.waveform_data.keys())
-        shared_waveform_keys = a_waveform_keys & b_waveform_keys
-        not_shared_waveform_keys = a_waveform_keys ^ b_waveform_keys
-
-        if len(not_shared_waveform_keys):
-            warnings.warn(
-                f"Some waveforms are not available in both framesets: {not_shared_waveform_keys}",
-                UserWarning,
-            )
-
-        waveform_data = {}
-        for key in shared_waveform_keys:
-            waveform_data[key] = np.concatenate(
-                [a.waveform_data[key], b.waveform_data[key]]
-            )
-
-        # for waveforms in a but not in b
-        for key in a_waveform_keys - b_waveform_keys:
-            b_values = np.full((len(b),), np.nan)
-            waveform_data[key] = np.concatenate([a.waveform_data[key], b_values])
-
-        # for waveforms in b but not in a
-        for key in b_waveform_keys - a_waveform_keys:
-            a_values = np.full((len(a),), np.nan)
-            waveform_data[key] = np.concatenate([a_values, b.waveform_data[key]])
-
-        return cls(
-            name=a.name,
-            description=a.description,
-            params=a.params,
-            pixel_values=np.concatenate([a.pixel_values, b.pixel_values], axis=0),
-            waveform_data=waveform_data,
-        )
-
-    deepcopy = copy.deepcopy
diff --git a/eitprocessing/binreader/reader.py b/eitprocessing/binreader/reader.py
index 0eb564caa..16d122d07 100644
--- a/eitprocessing/binreader/reader.py
+++ b/eitprocessing/binreader/reader.py
@@ -1,43 +1,86 @@
-"""
-Copyright 2023 Netherlands eScience Center and Erasmus University Medical Center.
-Licensed under the Apache License, version 2.0. See LICENSE for details.
-
-This file contains methods related to when electrical impedance tomographs are read.
-"""
-
+import io
 import struct
-from functools import partialmethod
+from dataclasses import dataclass
+from typing import Any, TypeVar
+
 import numpy as np
+from numpy.typing import NDArray
 
+T = TypeVar("T")
+N = TypeVar("N", bound=np.number)
 
+
+@dataclass
 class Reader:
-    file_handle = None
+    file_handle: io.BufferedReader
+    endian: str | None = None
+
+    def read_single(self, type_code: str, cast: type[T]) -> T:
+        data = self._read_full_type_code(type_code)
+        return cast(data[0])
+
+    def read_list(self, type_code: str, cast: type[T], length: int) -> list[T]:
+        full_type_code = f"{length}{type_code}"
+        data = self._read_full_type_code(full_type_code)
+        return [cast(d) for d in data]
+
+    def read_array(
+        self,
+        type_code: str,
+        cast: type[N],
+        length: int,
+    ) -> NDArray[N]:
+        full_type_code = f"{length}{type_code}"
+        data = self._read_full_type_code(full_type_code)
+        return np.array(data, dtype=cast)
+
+    def read_string(self, length=1):
+        full_type_code = f"{length}s"
+        data = self._read_full_type_code(full_type_code)
+        return data[0].decode().rstrip()
 
-    def __init__(self, file_handle):
-        self.file_handle = file_handle
+    def _read_full_type_code(self, full_type_code) -> tuple[Any, ...]:
+        if self.endian:
+            if self.endian not in ["little", "big"]:
+                msg = f"Endian type '{self.endian}' not recognized. Allowed values are 'little' and 'big'."
+                raise ValueError(msg)
+
+            prefix = "<" if self.endian == "little" else ">"
+            full_type_code = prefix + full_type_code
 
-    def read(self, type_code, length=1, cast=None):
-        if len(type_code) > 1:
-            full_type_code = type_code * length
-        else:
-            full_type_code = f"{length}{type_code}"
         data_size = struct.calcsize(full_type_code)
         packed_data = self.file_handle.read(data_size)
-        data = struct.unpack(full_type_code, packed_data)
+        return struct.unpack(full_type_code, packed_data)
 
-        if length == 1:
-            data = data[0]
+    def float32(self) -> float:
+        return self.read_single(type_code="f", cast=float)
 
-        if cast:
-            return cast(data)
+    def float64(self) -> float:
+        return self.read_single(type_code="d", cast=float)
 
-        return data
+    def npfloat32(self, length=1) -> NDArray[np.float32]:
+        return self.read_array(type_code="f", cast=np.float32, length=length)
 
-    @staticmethod
-    def cast_string(data):
-        return data[0].decode().rstrip()
+    def npfloat64(self, length=1) -> NDArray[np.float64]:
+        return self.read_array(type_code="d", cast=np.float64, length=length)
+
+    def int32(self) -> int:
+        return self.read_single(type_code="i", cast=int)
+
+    def npint32(self, length=1) -> NDArray[np.int32]:
+        return self.read_array(type_code="i", cast=np.int32, length=length)
+
+    def string(self, length=1) -> str:
+        return self.read_string(length=length)
+
+    def uint8(self) -> int:
+        return self.read_single(type_code="B", cast=int)
+
+    def uint16(self) -> int:
+        return self.read_single(type_code="H", cast=int)
+
+    def uint32(self) -> int:
+        return self.read_single(type_code="I", cast=int)
 
-    float32 = partialmethod(read, type_code="f", cast=np.float32)
-    float64 = partialmethod(read, type_code="d", cast=np.float64)
-    int32 = partialmethod(read, type_code="i", cast=np.int32)
-    string = partialmethod(read, type_code="s", cast=cast_string)
+    def uint64(self) -> int:
+        return self.read_single(type_code="Q", cast=int)
diff --git a/eitprocessing/binreader/sequence.py b/eitprocessing/binreader/sequence.py
deleted file mode 100644
index 56e073d28..000000000
--- a/eitprocessing/binreader/sequence.py
+++ /dev/null
@@ -1,626 +0,0 @@
-"""
-Copyright 2023 Netherlands eScience Center and Erasmus University Medical Center.
-Licensed under the Apache License, version 2.0. See LICENSE for details.
-
-This file contains methods related to parts of electrical impedance tomographs
-as they are read.
-"""
-from __future__ import annotations
-import bisect
-import copy
-import functools
-import warnings
-from dataclasses import dataclass
-from dataclasses import field
-from enum import auto
-from pathlib import Path
-import numpy as np
-from numpy.typing import NDArray
-from strenum import LowercaseStrEnum
-from .event import Event
-from .frameset import Frameset
-from .phases import MaxValue
-from .phases import MinValue
-from .phases import PhaseIndicator
-from .phases import QRSMark
-from .reader import Reader
-from .timing_error import TimingError
-
-
-class Vendor(LowercaseStrEnum):
-    """Enum indicating the vendor (manufacturer) of the EIT device with which the data was gathered."""
-
-    DRAEGER = auto()
-    TIMPEL = auto()
-    SENTEC = auto()
-    DRAGER = DRAEGER
-    DRÄGER = DRAEGER  # pylint: disable = non-ascii-name
-
-
-@dataclass(eq=False)
-class Sequence:
-    """Sequence of timepoints containing EIT and/or waveform data.
-
-    A Sequence is a representation of a continuous set of data points, either EIT frames,
-    waveform data, or both. A Sequence can consist of an entire measurement, a section of a
-    measurement, a single breath, or even a portion of a breath.
-    A sequence can be split up into separate sections of a measurement or multiple (similar)
-    Sequence objects can be merged together to form a single Sequence.
-
-    EIT data is contained within Framesets. A Frameset shares the time axis with a Sequence.
-
-    Args:
-        path (Path | str | list[Path | str]): path(s) to data file.
-        vendor (Vendor | str): vendor indicating the device used.
-        label (str): description of object for human interpretation.
-            Defaults to "Sequence_<unique_id>".
-        time (NDArray[float]): list of time label for each data point (can be
-            true time or relative time)
-        max_frames (int): number of frames in sequence
-        framerate (int, optional): framerate at which the data was recorded.
-            Defaults to 20 if vendor == DRAEGER
-            Defaults to 50 if vendor == TIMPEL
-        framesets (dict[str, Frameset]): dictionary of framesets
-        events (list[Event]): list of Event objects in data
-        timing_errors (list[TimingError]): list of TimingError objects in data
-        phases (list[PhaseIndicator]): list of PhaseIndicator objects in data
-    """
-
-    path: Path | str | list[Path | str] | None = None
-    vendor: Vendor | str | None = None
-    label: str | None = None
-    time: NDArray | None = None
-    nframes: int | None = None
-    framerate: int | None = None
-    framesets: dict[str, Frameset] = field(default_factory=dict)
-    events: list[Event] = field(default_factory=list, repr=False)
-    timing_errors: list[TimingError] = field(default_factory=list, repr=False)
-    phases: list[PhaseIndicator] = field(default_factory=list, repr=False)
-
-    def __post_init__(self):
-        if self.label is None:
-            self.label = f"Sequence_{id(self)}"
-
-        self._set_vendor_class()
-
-    def __len__(self) -> int:
-        return self.nframes
-
-    def __eq__(self, other) -> bool:
-        try:
-            self.check_equivalence(self, other)
-        except (TypeError, ValueError, AttributeError):
-            return False
-
-        for attr in ["nframes", "framerate", "framesets", "vendor"]:
-            if getattr(self, attr) != getattr(other, attr):
-                return False
-        for attr in ["time", "phases", "events", "timing_errors"]:
-            self_attr, other_attr = getattr(self, attr), getattr(other, attr)
-            if len(self_attr) != len(other_attr):
-                return False
-            if not np.all(np.equal(self_attr, other_attr)):
-                return False
-
-        return True
-
-    def _set_vendor_class(self):
-        """Re-assign Sequence class to child class for selected Vendor.
-
-        Raises:
-            NotImplementedError: if the child class for the selected vendor
-                has not yet been implemented.
-            TypeError: if the `vendor` argument does not correspond to the expected
-                vendor for `type(self)`.
-        """
-
-        if isinstance(self.vendor, str):
-            self.vendor = Vendor(self.vendor.lower())
-
-        if (
-            isinstance(self, Sequence)
-            and self.__class__ is not Sequence
-            and self.__class__.vendor != self.vendor
-        ):
-            raise TypeError(
-                f"`vendor` for {type(self)} cannot be set as {self.vendor}."
-            )
-
-        # Note that this way of re-assigning classes is considered to be a bad practice
-        # (https://tinyurl.com/2x2cea6h), but the objections raised don't seem to be prohibtive.
-        if self.vendor == Vendor.DRAEGER:
-            self.__class__ = DraegerSequence
-        elif self.vendor == Vendor.TIMPEL:
-            self.__class__ = TimpelSequence
-        elif self.vendor is not None:
-            raise NotImplementedError(f"vendor {self.vendor} is not implemented")
-
-    @staticmethod
-    def check_equivalence(a: Sequence, b: Sequence):
-        """Checks whether content of two Sequence objects is equivalent."""
-
-        if (a_ := a.vendor) != (b_ := b.vendor):
-            raise TypeError(f"Vendors are not equal: {a_}, {b_}")
-        if (a_ := a.framerate) != (b_ := b.framerate):
-            raise ValueError(f"Framerates are not equal: {a_}, {b_}")
-        if (a_ := a.framesets.keys()) != (b_ := b.framesets.keys()):
-            raise AttributeError(
-                f"Sequences do not contain the same framesets: {a_}, {b_}"
-            )
-        return True
-
-    def __add__(self, other: Sequence) -> Sequence:
-        return self.merge(self, other)
-
-    @classmethod
-    def merge(
-        cls,
-        a: Sequence,
-        b: Sequence,
-        label: str | None = None,
-    ) -> Sequence:
-        """Create a merge of two Sequence objects."""
-
-        try:
-            Sequence.check_equivalence(a, b)
-        except Exception as e:
-            raise type(e)(f"Sequences could not be merged: {e}") from e
-
-        a_path = a.path if isinstance(a.path, list) else [a.path]
-        b_path = b.path if isinstance(b.path, list) else [b.path]
-        path = a_path + b_path
-        nframes = len(a) + len(b)
-        time = np.concatenate((a.time, b.time))
-        framesets = {
-            name: Frameset.merge(a.framesets[name], b.framesets[name])
-            for name in a.framesets
-        }
-
-        def merge_attribute(attr: str) -> list:
-            a_items = getattr(a, attr)
-            b_items = getattr(b.deepcopy(), attr)  # deepcopy avoids overwriting
-            for item in b_items:
-                item.index += a.nframes
-                item.time = time[item.index]
-            return a_items + b_items
-
-        label = f"Merge of <{a.label}> and <{b.label}>" if label is None else label
-
-        return cls(
-            path=path,
-            vendor=a.vendor,
-            label=label,
-            time=time,
-            nframes=nframes,
-            framerate=a.framerate,
-            framesets=framesets,
-            events=merge_attribute("events"),
-            timing_errors=merge_attribute("timing_errors"),
-            phases=merge_attribute("phases"),
-        )
-
-    @classmethod
-    def from_path(  # pylint: disable=too-many-arguments, unused-argument
-        cls,
-        path: Path | str | list[Path | str],
-        vendor: Vendor | str | None = None,
-        label: str | None = None,
-        framerate: int | None = None,
-        first_frame: int = 0,
-        max_frames: int | None = None,
-    ) -> Sequence:
-        """Load sequence from path(s)
-
-        Args:
-            path (Path | str | list[Path | str]): path(s) to data file.
-            vendor (Vendor | str): vendor indicating the device used.
-            label (str): description of object for human interpretation.
-                Defaults to "Sequence_<unique_id>".
-            framerate (int, optional): framerate at which the data was recorded.
-                Default for Draeger: 20
-                Default for Timpel: 50
-            first_frame (int, optional): index of first time point of sequence
-                (i.e. NOT the timestamp).
-                Defaults to 0.
-            max_frames (int, optional): maximum number of frames to load.
-                The actual number of frames can be lower than this if this
-                would surpass the final frame.
-
-        Raises:
-            NotImplementedError: is raised when there is no loading method for
-            the given vendor.
-
-        Returns:
-            Sequence: a sequence containing the loaded data from all files in path
-        """
-
-        params = list(locals().values())[1:]  # list input parameters
-        sequences = []
-
-        if not isinstance(path, list):
-            path = [path]
-        for single_path in path:
-            Path(single_path).resolve(strict=True)  # checks that file exists
-            params[0] = single_path
-            sequences.append(cls._load_file(*params))
-        return functools.reduce(cls.merge, sequences)
-
-    @classmethod
-    def _load_file(  # pylint: disable=too-many-arguments
-        cls,
-        path: Path | str,
-        vendor: Vendor | str,
-        label: str | None = None,
-        framerate: int | None = None,
-        first_frame: int = 0,
-        max_frames: int | None = None,
-    ) -> Sequence:
-        """Method used by `from_path` that initiates the object and calls
-        child method for loading the data.
-
-        See `from_path` method for arguments."""
-
-        if first_frame is None:
-            first_frame = 0
-        if int(first_frame) != first_frame:
-            raise TypeError(
-                f"`first_frame` must be an int, but was given as"
-                f" {first_frame} (type: {type(first_frame)})"
-            )
-        if not first_frame >= 0:
-            raise ValueError(
-                f"`first_frame` can not be negative, but was given as {first_frame}"
-            )
-        first_frame = int(first_frame)
-
-        obj = cls(
-            path=Path(path),
-            vendor=vendor,
-            nframes=max_frames,
-            label=label,
-        )
-        obj._set_vendor_class()
-        if framerate:
-            obj.framerate = framerate
-        elif obj.vendor == Vendor.DRAEGER:
-            obj.framerate = 20
-        elif obj.vendor == Vendor.TIMPEL:
-            obj.framerate = 50
-        else:
-            raise NotImplementedError(
-                f"No default `framerate` for {obj.vendor} data is implemented."
-                "\n`framerate` must be specified when calling `_load_file` for"
-                "this vendor."
-            )
-
-        # function from child class, which will load and assign
-        # time, nframes, framesets, events, timing_errors, phases
-        obj._load_data(first_frame)
-
-        return obj
-
-    def _load_data(self, first_frame: int | None):
-        """Needs to be implemented in child class."""
-        raise NotImplementedError(f"Data loading for {self.vendor} is not implemented")
-
-    def select_by_index(
-        self,
-        indices: slice,
-        label: str | None = None,
-    ):
-        if not isinstance(indices, slice):
-            raise NotImplementedError("Slicing only implemented using a slice object")
-        if indices.step not in (None, 1):
-            raise NotImplementedError(
-                "Skipping intermediate frames while slicing is not implemented."
-            )
-        if indices.start is None:
-            indices = slice(0, indices.stop, indices.step)
-        if indices.stop is None:
-            indices = slice(indices.start, self.nframes, indices.step)
-
-        obj = self.deepcopy()
-        obj.time = self.time[indices]
-        obj.nframes = len(obj.time)
-        obj.framesets = {k: v[indices] for k, v in self.framesets.items()}
-        obj.label = (
-            f"Slice ({indices.start}-{indices.stop}) of <{self.label}>"
-            if label is None
-            else label
-        )
-
-        range_ = range(indices.start, indices.stop)
-        for attr in ["events", "timing_errors", "phases"]:
-            setattr(obj, attr, [x for x in getattr(obj, attr) if x.index in range_])
-            for x in getattr(obj, attr):
-                x.index -= indices.start
-
-        return obj
-
-    def __getitem__(self, indices: slice):
-        return self.select_by_index(indices)
-
-    def select_by_time(  # pylint: disable=too-many-arguments
-        self,
-        start: float | int | None = None,
-        end: float | int | None = None,
-        start_inclusive: bool = True,
-        end_inclusive: bool = False,
-        label: str | None = None,
-    ) -> Sequence:
-        """Select subset of sequence by the `Sequence.time` information (i.e.
-        based on the time stamp).
-
-        Args:
-            start (float | int | None, optional): starting time point.
-                Defaults to None.
-            end (float | int | None, optional): ending time point.
-                Defaults to None.
-            start_inclusive (bool, optional): include starting timepoint if
-                `start` is present in `Sequence.time`.
-                Defaults to True.
-            end_inclusive (bool, optional): include ending timepoint if
-                `end` is present in `Sequence.time`.
-                Defaults to False.
-
-        Raises:
-            ValueError: if the Sequence.time is not sorted
-
-        Returns:
-            Sequence: a slice of `self` based on time information given.
-        """
-
-        if not any((start, end)):
-            warnings.warn("No starting or end timepoint was selected.")
-            return self
-        if not np.all(np.sort(self.time) == self.time):
-            raise ValueError(
-                f"Time stamps for {self} are not sorted and therefor data"
-                "cannot be selected by time."
-            )
-
-        if start is None:
-            start_index = 0
-        elif start_inclusive:
-            start_index = bisect.bisect_left(self.time, start)
-        else:
-            start_index = bisect.bisect_right(self.time, start)
-
-        if end is None:
-            end_index = len(self)
-        elif end_inclusive:
-            end_index = bisect.bisect_right(self.time, end) - 1
-        else:
-            end_index = bisect.bisect_left(self.time, end) - 1
-
-        return self.select_by_index(slice(start_index, end_index), label=label)
-
-    def deepcopy(
-        self,
-        label: str | None = None,
-        relabel: bool | None = True,
-    ) -> Sequence:
-        """Create a deep copy of `Sequence` object.
-
-        Args:
-            label (str): Create a new `label` for the copy.
-                Defaults to None, which will trigger behavior described for relabel (below)
-            relabel (bool): If `True` (default), the label of self is re-used for the copy,
-                otherwise the following label is assigned f"Deepcopy of {self.label}".
-                Note that this setting is ignored if a label is given.
-
-        Returns:
-            Sequence: a deep copy of self
-        """
-
-        obj = copy.deepcopy(self)
-        if label:
-            obj.label = label
-        elif relabel:
-            obj.label = f"Copy of <{self.label}>"
-        return obj
-
-
-@dataclass(eq=False)
-class DraegerSequence(Sequence):
-    """Sequence object for DRAEGER data."""
-
-    vendor: Vendor = Vendor.DRAEGER
-
-    def _load_data(self, first_frame: int):
-        """Load data for DRAEGER files."""
-        FRAME_SIZE_BYTES = 4358
-
-        file_size = self.path.stat().st_size
-        if file_size % FRAME_SIZE_BYTES:
-            raise OSError(
-                f"File size {file_size} of file {str(self.path)} not divisible by {FRAME_SIZE_BYTES}.\n"
-                f"Make sure this is a valid and uncorrupted Draeger data file."
-            )
-        total_frames = file_size // FRAME_SIZE_BYTES
-
-        if first_frame > total_frames:
-            raise ValueError(
-                f"Invalid input: `first_frame` {first_frame} is larger than the "
-                f"total number of frames in the file {total_frames}."
-            )
-
-        # We need to load 1 frame before first actual frame to check if there
-        # is an event marker. Data for the pre-first (dummy) frame will be
-        # removed from self at the end of this function.
-        first_load = max(0, first_frame - 1)
-        loaded_frames = min(
-            total_frames - first_load, self.nframes or total_frames - first_load
-        )
-
-        self.time = np.zeros(loaded_frames)
-        pixel_values = np.zeros((loaded_frames, 32, 32))
-
-        with open(self.path, "br") as fh:
-            fh.seek(first_load * FRAME_SIZE_BYTES)
-            reader = Reader(fh)
-
-            previous_marker = None
-            for index in range(loaded_frames):
-                previous_marker = self._read_frame(
-                    reader,
-                    index - (first_frame > 0),  # only adjusts for firstframe > 0
-                    pixel_values,
-                    previous_marker,
-                )
-
-        if first_frame > 0:
-            # remove data for dummy frame if first frame in file was not loaded
-            loaded_frames -= 1
-            self.time = self.time[:-1]
-            pixel_values = pixel_values[:-1, :, :]
-
-        if self.nframes != loaded_frames:
-            if self.nframes:
-                warnings.warn(
-                    f"The number of frames requested ({self.nframes}) is larger "
-                    f"than the available number ({loaded_frames}) of frames after "
-                    f"the first frame selected ({first_frame}, total frames: "
-                    f"{total_frames}).\n {loaded_frames} frames have been loaded."
-                )
-            self.nframes = loaded_frames
-
-        params = {"framerate": self.framerate}
-        self.framesets["raw"] = Frameset(
-            name="raw",
-            description="raw impedance data",
-            params=params,
-            pixel_values=pixel_values,
-        )
-
-    def _read_frame(
-        self,
-        reader: Reader,
-        index: int,
-        pixel_values: NDArray,
-        previous_marker: int | None,
-    ):
-        """Read frame by frame data from DRAEGER files."""
-
-        current_time = round(reader.float64() * 24 * 60 * 60, 3)
-
-        _ = reader.float32()
-        pixel_values[index, :, :] = self.reshape_frame(reader.float32(length=1024))
-        min_max_flag = reader.int32()
-        event_marker = reader.int32()
-        event_text = reader.string(length=30)
-        timing_error = reader.int32()
-
-        # TODO (#79): parse medibus data into waveform data
-        medibus_data = reader.float32(  # noqa; variable will be used in future version
-            length=52
-        )
-
-        if index >= 0:
-            # The event marker stays the same until the next event occurs. Therefore, check whether the
-            # event marker has changed with respect to the most recent event. If so, create a new event.
-            if (previous_marker is not None) and (event_marker > previous_marker):
-                self.events.append(Event(index, current_time, event_marker, event_text))
-            if timing_error:
-                self.timing_errors.append(
-                    TimingError(index, current_time, timing_error)
-                )
-            if min_max_flag == 1:
-                self.phases.append(MaxValue(index, current_time))
-            elif min_max_flag == -1:
-                self.phases.append(MinValue(index, current_time))
-            self.time[index] = current_time
-
-        return event_marker
-
-    @staticmethod
-    def reshape_frame(frame):
-        """Convert linear array into 2D (32x32) image-like array."""
-        return np.reshape(frame, (32, 32), "C")
-
-
-@dataclass(eq=False)
-class TimpelSequence(Sequence):
-    """Sequence object for TIMPEL data."""
-
-    vendor: Vendor = Vendor.TIMPEL
-
-    def _load_data(self, first_frame: int):
-        """Load data for TIMPEL files."""
-
-        COLUMN_WIDTH = 1030
-
-        try:
-            data = np.loadtxt(
-                self.path,
-                dtype=float,
-                delimiter=",",
-                skiprows=first_frame,
-                max_rows=self.nframes,
-            )
-        except UnicodeDecodeError as e:
-            raise OSError(
-                f"File {self.path} could not be read as Timpel data.\n"
-                "Make sure this is a valid and uncorrupted Timpel data file.\n"
-                f"Original error message: {e}"
-            ) from e
-
-        data: NDArray
-        if data.shape[1] != COLUMN_WIDTH:
-            raise OSError(
-                f"Input does not have a width of {COLUMN_WIDTH} columns.\n"
-                "Make sure this is a valid and uncorrupted Timpel data file."
-            )
-        if data.shape[0] == 0:
-            raise ValueError(
-                f"Invalid input: `first_frame` {first_frame} is larger than the "
-                f"total number of frames in the file."
-            )
-        if data.shape[0] != self.nframes:
-            if self.nframes:
-                warnings.warn(
-                    f"The number of frames requested ({self.nframes}) is larger "
-                    f"than the available number ({data.shape[0]}) of frames after "
-                    f"the first frame selected ({first_frame}).\n"
-                    f"{data.shape[0]} frames have been loaded."
-                )
-            self.nframes = data.shape[0]
-
-        # TODO (#80): QUESTION: check whether below issue was only a Drager problem or also
-        # applicable to Timpel.
-        # The implemented method seems convoluted: it's easier to create an array
-        # with nframes and add a time_offset. However, this results in floating
-        # point errors, creating issues with comparing times later on.
-        self.time = np.arange(self.nframes + first_frame) / self.framerate
-        self.time = self.time[first_frame:]
-
-        pixel_data = data[:, :1024]
-        pixel_data = np.reshape(pixel_data, newshape=(-1, 32, 32), order="C")
-        pixel_data = np.where(pixel_data == -1000, np.nan, pixel_data)
-
-        # extract waveform data
-        waveform_data = {
-            "airway_pressure": data[:, 1024],
-            "flow": data[:, 1025],
-            "volume": data[:, 1026],
-        }
-
-        # extract breath start, breath end and QRS marks
-        for index in np.flatnonzero(data[:, 1027] == 1):
-            self.phases.append(MinValue(index, self.time[index]))
-
-        for index in np.flatnonzero(data[:, 1028] == 1):
-            self.phases.append(MaxValue(index, self.time[index]))
-
-        for index in np.flatnonzero(data[:, 1029] == 1):
-            self.phases.append(QRSMark(index, self.time[index]))
-
-        self.phases.sort(key=lambda x: x.index)
-
-        self.framesets["raw"] = Frameset(
-            name="raw",
-            description="raw timpel data",
-            params={"framerate": self.framerate},
-            pixel_values=pixel_data,
-            waveform_data=waveform_data,
-        )
diff --git a/eitprocessing/binreader/timing_error.py b/eitprocessing/binreader/timing_error.py
deleted file mode 100644
index e32ce1f0c..000000000
--- a/eitprocessing/binreader/timing_error.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""
-Copyright 2023 Netherlands eScience Center and Erasmus University Medical Center.
-Licensed under the Apache License, version 2.0. See LICENSE for details.
-
-This file contains methods related to timing errors when electrical impedance tomographs are read.
-"""
-
-from dataclasses import dataclass
-
-
-@dataclass
-class TimingError:
-    index: int
-    time: float
-    timing_error: int
diff --git a/eitprocessing/continuous_data/__init__.py b/eitprocessing/continuous_data/__init__.py
new file mode 100644
index 000000000..639526f50
--- /dev/null
+++ b/eitprocessing/continuous_data/__init__.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from eitprocessing.mixins.equality import Equivalence
+from eitprocessing.mixins.slicing import SelectByTime
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+    from typing_extensions import Any, Self
+
+
+@dataclass
+class ContinuousData(Equivalence, SelectByTime):
+    """Data class for (non-EIT) data with a continuous time axis.
+
+    Args:
+        label: Computer readable naming of the instance.
+        name: Human readable naming of the instance.
+        unit: Unit for the data.
+        category: Category the data falls into, e.g. 'airway pressure'.
+        description: Human readable extended description of the data.
+        parameters: Parameters used to derive this data.
+        derived_from: Traceback of intermediates from which the current data was derived.
+        values: Data points.
+    """
+
+    label: str
+    name: str
+    unit: str
+    category: str
+    description: str = ""
+    parameters: dict[str, Any] = field(default_factory=dict)
+    derived_from: Any | list[Any] = field(default_factory=list)
+    time: np.ndarray = field(kw_only=True)
+    values: np.ndarray = field(kw_only=True)
+
+    def __post_init__(self) -> None:
+        if self.loaded:
+            self.lock()
+        self.lock("time")
+
+    def __setattr__(self, attr: str, value: Any):  # noqa: ANN401
+        try:
+            old_value = getattr(self, attr)
+        except AttributeError:
+            pass
+        else:
+            if isinstance(old_value, np.ndarray) and old_value.flags["WRITEABLE"] is False:
+                msg = f"Attribute '{attr}' is locked and can't be overwritten."
+                raise AttributeError(msg)
+        super().__setattr__(attr, value)
+
+    def copy(
+        self,
+        label: str,
+        *,
+        name: str | None = None,
+        unit: str | None = None,
+        description: str | None = None,
+        parameters: dict | None = None,
+    ) -> Self:
+        """Create a copy.
+
+        Whenever data is altered, it should probably be copied first. The alterations should then be made in the copy.
+        """
+        obj = self.__class__(
+            label=label,
+            name=name or label,
+            unit=unit or self.unit,
+            description=description or f"Derived from {self.name}",
+            parameters=self.parameters | (parameters or {}),
+            derived_from=[*self.derived_from, self],
+            category=self.category,
+            # copying data can become inefficient with large datasets if the
+            # data is not directly edited afer copying but overridden instead;
+            # consider creating a view and locking it, requiring the user to
+            # make a copy if they want to edit the data directly
+            time=np.copy(self.time),
+            values=np.copy(self.values),
+        )
+        obj.unlock()
+        return obj
+
+    def derive(self, label: str, function: Callable, func_args: dict, **kwargs) -> Self:
+        """Create a copy deriving data from values attribute.
+
+        Args:
+            label: New label for the derived object.
+            function: Function that takes the values and returns the derived values.
+            func_args: Arguments to pass to function.
+            **kwargs: New values for attributes of
+
+        Example:
+        ```
+        def convert_data(x, add=None, subtract=None, multiply=None, divide=None):
+            if add:
+                x += add
+            if subtract:
+                x -= subtract
+            if multiply:
+                x *= multiply
+            if divide:
+                x /= divide
+            return x
+
+
+        data = ContinuousData(
+            name="Lung volume (in mL)", label="volume_mL", unit="mL", category="volume", values=some_loaded_data
+        )
+        derived = data.derive("volume_L", convert_data, {"divide": 1000}, name="Lung volume (in L)", unit="L")
+        ```
+        """
+        copy = self.copy(label, **kwargs)
+        copy.values = function(copy.values, **func_args)
+        return copy
+
+    def lock(self, *attr: str) -> None:
+        """Lock attributes, essentially rendering them read-only.
+
+        Locked attributes cannot be overwritten. Attributes can be unlocked using `unlock()`.
+
+        Args:
+            *attr: any number of attributes can be passed here, all of which will be locked. Defaults to "values".
+
+        Examples:
+            >>> # lock the `values` attribute of `data`
+            >>> data.lock()
+            >>> data.values = [1, 2, 3] # will result in an AttributeError
+            >>> data.values[0] = 1      # will result in a RuntimeError
+        """
+        if not len(attr):
+            # default values are not allowed when using *attr, so set a default here if none is supplied
+            attr = ["values"]
+        for attr_ in attr:
+            getattr(self, attr_).flags["WRITEABLE"] = False
+
+    def unlock(self, *attr: str) -> None:
+        """Unlock attributes, rendering them editable.
+
+        Locked attributes cannot be overwritten, but can be unlocked with this function to make them editable.
+
+        Args:
+            *attr: any number of attributes can be passed here, all of which will be unlocked. Defaults to "values".
+
+        Examples:
+            >>> # lock the `values` attribute of `data`
+            >>> data.lock()
+            >>> data.values = [1, 2, 3] # will result in an AttributeError
+            >>> data.values[0] = 1      # will result in a RuntimeError
+            >>> data.unlock()
+            >>> data.values = [1, 2, 3]
+            >>> print(data.values)
+            [1,2,3]
+            >>> data.values[0] = 1      # will result in a RuntimeError
+            >>> print(data.values)
+            1
+        """
+        if not len(attr):
+            # default values are not allowed when using *attr, so set a default here if none is supplied
+            attr = ["values"]
+        for attr_ in attr:
+            getattr(self, attr_).flags["WRITEABLE"] = True
+
+    @property
+    def locked(self) -> bool:
+        """Return whether the values attribute is locked.
+
+        See lock().
+        """
+        return not self.values.flags["WRITEABLE"]
+
+    @property
+    def loaded(self) -> bool:
+        """Return whether the data was loaded from disk, or derived from elsewhere."""
+        return len(self.derived_from) == 0
+
+    def __len__(self):
+        return len(self.time)
+
+    def _sliced_copy(
+        self,
+        start_index: int,
+        end_index: int,
+        label: str,
+    ) -> Self:
+        # TODO: check correct implementation
+        cls = self.__class__
+        time = self.time[start_index:end_index]
+        values = self.values[start_index:end_index]
+        description = f"Slice ({start_index}-{end_index}) of <{self.description}>"
+
+        return cls(
+            label=label,
+            name=self.name,
+            unit=self.unit,
+            category=self.category,
+            description=description,
+            derived_from=[*self.derived_from, self],
+            time=time,
+            values=values,
+        )
diff --git a/eitprocessing/data_collection/__init__.py b/eitprocessing/data_collection/__init__.py
new file mode 100644
index 000000000..5650c36e2
--- /dev/null
+++ b/eitprocessing/data_collection/__init__.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Generic, TypeVar
+
+from eitprocessing.continuous_data import ContinuousData
+from eitprocessing.eit_data import EITData
+from eitprocessing.mixins.equality import Equivalence
+from eitprocessing.sparse_data import SparseData
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
+V = TypeVar("V", EITData, ContinuousData, SparseData)
+
+
+class DataCollection(dict, Equivalence, Generic[V]):
+    """A collection of a single type of data with unique labels.
+
+    This collection functions as a dictionary in most part. When initializing, a data type has to be passed. EITData,
+    ContinuousData or SparseData is expected as the data type. Other types are allowed, but not supported. The objects
+    added to the collection need to have a `label` attribute and a `concatenate()` method.
+
+    When adding an item to the collection, the type of the value has to match the data type of the collection.
+    Furthermore, the key has to match the attribute 'label' attached to the value.
+
+    The convenience method `add()` adds an item by setting the key to `value.label`.
+
+    Args:
+        data_type: the type of data stored in this collection. Expected to be one of EITData, ContinuousData or
+        SparseData.
+    """
+
+    data_type: type
+
+    def __init__(self, data_type: type[V], *args, **kwargs):
+        if not any(issubclass(data_type, cls) for cls in V.__constraints__):
+            msg = f"Type {data_type} not expected to be stored in a DataCollection."
+            raise ValueError(msg)
+        self.data_type = data_type
+        super().__init__(*args, **kwargs)
+
+    def __setitem__(self, __key: str, __value: V) -> None:
+        self._check_item(__value, key=__key)
+        return super().__setitem__(__key, __value)
+
+    def add(self, *item: V, overwrite: bool = False) -> None:
+        """Add one or multiple item(s) to the collection."""
+        for item_ in item:
+            self._check_item(item_, overwrite=overwrite)
+            super().__setitem__(item_.label, item_)
+
+    def _check_item(
+        self,
+        item: V,
+        key: str | None = None,
+        overwrite: bool = False,
+    ) -> None:
+        """Check whether the item can be added to the collection.
+
+        In order to be added to the collection, the data type of the item has to match the data type set in the
+        collection. They key that is used to store the item in the collection has to match the label of the item itself.
+        By default, existing keys can not be overridden.
+
+        Args:
+            item: Object to be added to the collection.
+            key: Key of the item. Has to match `item.label`.
+            overwrite: If False, the key can not already exist in the collection. Set to True to allow overwriting an
+            existing object in the collection.
+
+        Raises:
+            TypeError: If the type of the item does not match the type set in the collection.
+            KeyError: If the key does not match `item.label`, or when the key already exists in de collection and
+            overwrite is set to False.
+        """
+        if not isinstance(item, self.data_type):
+            msg = f"Type of `data` is {type(item)}, not '{self.data_type}'"
+            raise TypeError(msg)
+
+        if key and key != item.label:
+            # It is expected that an item in this collection has a key equal to the label of the value.
+            msg = f"'{key}' does not match label '{item.label}'."
+            raise KeyError(msg)
+
+        if not overwrite and key in self:
+            # Generally it is not expected one would want to overwrite existing data with different/derived data. One
+            # should probably change the label instead over overwriting existing data.
+            msg = f"Item with label {key} already exists. Use `overwrite=True` to overwrite."
+            raise KeyError(msg)
+
+    def get_loaded_data(self) -> dict[str, V]:
+        """Return all data that was directly loaded from disk."""
+        return {k: v for k, v in self.items() if v.loaded}
+
+    def get_data_derived_from(self, obj: V) -> dict[str, V]:
+        """Return all data that was derived from a specific source."""
+        return {k: v for k, v in self.items() if obj in v.derived_from}
+
+    def get_derived_data(self) -> dict[str, V]:
+        """Return all data that was derived from any source."""
+        return {k: v for k, v in self.items() if v.derived_from}
+
+    def concatenate(self: Self[V], other: Self[V]) -> Self[V]:
+        """Concatenate this collection with an equivalent collection.
+
+        Each item of self of concatenated with the item of other with the same key.
+        """
+        self.isequivalent(other, raise_=True)
+
+        concatenated = self.__class__(self.data_type)
+        for key in self:
+            concatenated[key] = self[key].concatenate(other[key])
+
+        return concatenated
diff --git a/eitprocessing/eit_data/__init__.py b/eitprocessing/eit_data/__init__.py
new file mode 100644
index 000000000..577e0191f
--- /dev/null
+++ b/eitprocessing/eit_data/__init__.py
@@ -0,0 +1,324 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from functools import reduce
+from pathlib import Path
+from typing import TYPE_CHECKING, TypeVar
+
+import numpy as np
+from typing_extensions import Self, override
+
+from eitprocessing.eit_data.vendor import Vendor
+from eitprocessing.mixins.equality import Equivalence
+from eitprocessing.mixins.slicing import SelectByTime
+
+if TYPE_CHECKING:
+    from numpy.typing import NDArray
+
+    from eitprocessing.data_collection import DataCollection
+
+T = TypeVar("T", bound="EITData")
+
+
+@dataclass(eq=False)
+class EITData(SelectByTime, Equivalence, ABC):
+    """Container for EIT data.
+
+    This class holds the pixel impedance from an EIT measurement, as well as metadata describing the measurement. The
+    class is meant to hold data from (part of) a singular continuous measurement.
+
+    This class can't be initialized directly. Instead, use `EITData.from_path(...)` to load data from disk.
+    Currently, loading data from three vendors is supported. You can either pass the vendor when using
+    `EITData.from_path(..., vendor="timpel")`, or use one of the available subclasses of EITData:
+    `SentecEITData.from_path(...)`.
+
+    Several convenience methods are supplied for calculating global impedance, calculating or removing baselines, etc.
+    """
+
+    path: Path | list[Path]
+    nframes: int
+    time: NDArray
+    framerate: float
+    vendor: Vendor
+    phases: list = field(default_factory=list)
+    events: list = field(default_factory=list)
+    label: str | None = None
+    pixel_impedance: NDArray = field(repr=False, kw_only=True)
+
+    def __post_init__(self):
+        if not self.label:
+            self.label = f"{self.__class__.__name__}_{id(self)}"
+        self._check_equivalence = ["vendor", "framerate"]
+
+    @classmethod
+    def from_path(
+        cls,
+        path: str | Path | list[str | Path],
+        vendor: Vendor | str,
+        framerate: float | None = None,
+        first_frame: int = 0,
+        max_frames: int | None = None,
+        return_non_eit_data: bool = False,
+    ) -> DataCollection | tuple[DataCollection, DataCollection, DataCollection]:
+        """Load sequence from path(s).
+
+        Args:
+            path: relative or absolute path(s) to data file.
+            vendor: vendor indicating the device used.
+            label: description of object for human interpretation.
+                Defaults to "Sequence_<unique_id>".
+            framerate: framerate at which the data was recorded.
+                Default for Draeger: 20
+                Default for Timpel: 50
+                Default for Sentec: 50.2
+            first_frame: index of first frame to load.
+                Defaults to 0.
+            max_frames: maximum number of frames to load.
+                The actual number of frames can be lower than this if this
+                would surpass the final frame.
+            return_non_eit_data: whether to load available continuous and sparse data.
+
+        Raises:
+            NotImplementedError: is raised when there is no loading method for
+            the given vendor.
+
+        Returns:
+            EITData: container for the loaded data and metadata from all files in path.
+        """
+        from eitprocessing.data_collection import DataCollection
+
+        vendor = cls._ensure_vendor(vendor)
+        vendor_class = cls._get_vendor_class(vendor)
+
+        first_frame = cls._check_first_frame(first_frame)
+
+        paths = cls._ensure_path_list(path)
+
+        eit_datasets: list[DataCollection] = []
+        continuous_datasets: list[DataCollection] = []
+        sparse_datasets: list[DataCollection] = []
+
+        for single_path in paths:
+            single_path.resolve(strict=True)  # raise error if any file does not exist
+
+        for single_path in paths:
+            loaded_data = vendor_class._from_path(  # noqa: SLF001
+                path=single_path,
+                framerate=framerate,
+                first_frame=first_frame,
+                max_frames=max_frames,
+                return_non_eit_data=return_non_eit_data,
+            )
+
+            if return_non_eit_data:
+                from eitprocessing.continuous_data import ContinuousData
+                from eitprocessing.sparse_data import SparseData
+
+                if type(loaded_data) is not tuple:
+                    eit = loaded_data
+                    continuous = DataCollection(ContinuousData)
+                    sparse = DataCollection(SparseData)
+                else:
+                    eit, continuous, sparse = loaded_data
+
+                eit: DataCollection
+                continuous: DataCollection
+                sparse: DataCollection
+
+                eit_datasets.append(eit)
+                continuous_datasets.append(continuous)
+                sparse_datasets.append(sparse)
+
+            else:
+                assert isinstance(loaded_data, DataCollection)  # noqa: S101
+                eit_datasets.append(loaded_data)
+
+        if return_non_eit_data:
+            return tuple(
+                reduce(DataCollection.concatenate, datasets)
+                for datasets in (eit_datasets, continuous_datasets, sparse_datasets)
+            )
+
+        return reduce(DataCollection.concatenate, eit_datasets)
+
+    @classmethod
+    @abstractmethod
+    def _from_path(
+        cls: type[Self],
+        path: Path,
+        framerate: float | None = None,
+        first_frame: int | None = None,
+        max_frames: int | None = None,
+        return_non_eit_data: bool = False,
+    ) -> DataCollection | tuple[DataCollection, DataCollection, DataCollection]: ...
+
+    @staticmethod
+    def _ensure_path_list(path: str | Path | list[str | Path]) -> list[Path]:
+        if isinstance(path, list):
+            return [Path(p) for p in path]
+        return [Path(path)]
+
+    @staticmethod
+    def _get_vendor_class(vendor: Vendor) -> type[EITData_]:
+        from eitprocessing.eit_data.draeger import DraegerEITData
+        from eitprocessing.eit_data.sentec import SentecEITData
+        from eitprocessing.eit_data.timpel import TimpelEITData
+
+        vendor_classes: dict[Vendor, type[EITData_]] = {
+            Vendor.DRAEGER: DraegerEITData,
+            Vendor.TIMPEL: TimpelEITData,
+            Vendor.SENTEC: SentecEITData,
+        }
+        return vendor_classes[vendor]
+
+    @staticmethod
+    def _check_first_frame(first_frame: int | None) -> int:
+        if first_frame is None:
+            first_frame = 0
+        if int(first_frame) != first_frame:
+            msg = f"`first_frame` must be an int, but was given as {first_frame} (type: {type(first_frame)})"
+            raise TypeError(msg)
+        if first_frame < 0:
+            msg = f"`first_frame` can not be negative, but was given as {first_frame}"
+            raise ValueError(msg)
+        return int(first_frame)
+
+    @staticmethod
+    def _ensure_vendor(vendor: Vendor | str) -> Vendor:
+        """Check whether vendor exists, and assure it's a Vendor object."""
+        try:
+            return Vendor(vendor)
+        except ValueError as e:
+            msg = f"Unknown vendor {vendor}."
+            raise UnknownVendorError(msg) from e
+
+    def concatenate(self: T, other: T, label: str | None = None) -> T:  # noqa: D102, will be removed soon
+        cls = self.__class__
+        self.isequivalent(other, raise_=True)
+
+        a_path = cls._ensure_path_list(self.path)
+        b_path = cls._ensure_path_list(other.path)
+        path = a_path + b_path
+
+        if np.min(other.time) <= np.max(self.time):
+            msg = f"{other} (b) starts before {self} (a) ends."
+            raise ValueError(msg)
+        time = np.concatenate((self.time, other.time))
+
+        pixel_impedance = np.concatenate((self.pixel_impedance, other.pixel_impedance), axis=0)
+
+        if self.label != other.label:
+            msg = "Can't concatenate data with different labels."
+            raise ValueError(msg)
+
+        label = self.label
+        framerate = self.framerate
+        nframes = self.nframes + other.nframes
+
+        cls_ = cls._get_vendor_class(self.vendor)
+
+        phases = self.phases + other.phases
+        events = self.events + other.events
+
+        return cls_(
+            path=path,
+            label=label,
+            framerate=framerate,
+            nframes=nframes,
+            time=time,
+            pixel_impedance=pixel_impedance,
+            phases=phases,
+            events=events,
+        )
+
+    def _sliced_copy(
+        self,
+        start_index: int,
+        end_index: int,
+        label: str,
+    ) -> Self:
+        cls = self.__class__
+        time = self.time[start_index:end_index]
+        nframes = len(time)
+
+        phases = list(filter(lambda p: start_index <= p.index < end_index, self.phases))
+        events = list(filter(lambda e: start_index <= e.index < end_index, self.events))
+
+        pixel_impedance = self.pixel_impedance[start_index:end_index, :, :]
+
+        return cls(
+            path=self.path,
+            nframes=nframes,
+            time=time,
+            framerate=self.framerate,
+            phases=phases,
+            events=events,
+            label=label,
+            pixel_impedance=pixel_impedance,
+        )
+
+    def __len__(self):
+        return self.pixel_impedance.shape[0]
+
+    @property
+    def global_baseline(self) -> np.ndarray:
+        """Return the global baseline, i.e. the minimum pixel impedance across all pixels."""
+        return np.nanmin(self.pixel_impedance)
+
+    @property
+    def pixel_impedance_global_offset(self) -> np.ndarray:
+        """Return the pixel impedance with the global baseline removed.
+
+        In the resulting array the minimum impedance across all pixels is set to 0.
+        """
+        return self.pixel_impedance - self.global_baseline
+
+    @property
+    def pixel_baseline(self) -> np.ndarray:
+        """Return the lowest value in each individual pixel over time."""
+        return np.nanmin(self.pixel_impedance, axis=0)
+
+    @property
+    def pixel_impedance_individual_offset(self) -> np.ndarray:
+        """Return the pixel impedance with the baseline of each individual pixel removed.
+
+        Each pixel in the resulting array has a minimum value of 0.
+        """
+        return self.pixel_impedance - self.pixel_baseline
+
+    @property
+    def global_impedance(self) -> np.ndarray:
+        """Return the global impedance, i.e. the sum of all pixels at each frame."""
+        return np.nansum(self.pixel_impedance, axis=(1, 2))
+
+
+@dataclass(eq=False)
+class EITData_(EITData):  # noqa: N801, D101
+    vendor: Vendor = field(init=False)
+
+    def __add__(self: T, other: T) -> T:
+        return self.concatenate(self, other)
+
+    @override  # remove vendor as argument
+    @classmethod
+    def from_path(
+        cls,
+        path: str | Path | list[str | Path],
+        framerate: float | None = None,
+        first_frame: int = 0,
+        max_frames: int | None = None,
+        return_non_eit_data: bool = False,
+    ) -> DataCollection | tuple[DataCollection, DataCollection, DataCollection]:
+        return super().from_path(
+            path=path,
+            vendor=cls.vendor,
+            framerate=framerate,
+            first_frame=first_frame,
+            max_frames=max_frames,
+            return_non_eit_data=return_non_eit_data,
+        )
+
+
+class UnknownVendorError(ValueError):
+    """Raised when an unknown vendor is provided when trying to load data."""
diff --git a/eitprocessing/eit_data/draeger.py b/eitprocessing/eit_data/draeger.py
new file mode 100644
index 000000000..3a4a57b4a
--- /dev/null
+++ b/eitprocessing/eit_data/draeger.py
@@ -0,0 +1,272 @@
+from __future__ import annotations
+
+import sys
+import warnings
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, NamedTuple
+
+import numpy as np
+
+from eitprocessing.binreader.reader import Reader
+from eitprocessing.continuous_data import ContinuousData
+from eitprocessing.data_collection import DataCollection
+from eitprocessing.eit_data import EITData_
+from eitprocessing.eit_data.event import Event
+from eitprocessing.eit_data.phases import MaxValue, MinValue
+from eitprocessing.eit_data.vendor import Vendor
+from eitprocessing.sparse_data import SparseData
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from numpy.typing import NDArray
+
+_FRAME_SIZE_BYTES = 4358
+
+
+@dataclass(eq=False)
+class DraegerEITData(EITData_):
+    """Container for EIT data recorded using the Dräger Pulmovista PV500."""
+
+    vendor: Vendor = field(default=Vendor.DRAEGER, init=False)
+    framerate: float = 20
+
+    @classmethod
+    def _from_path(
+        cls,
+        path: Path,
+        framerate: float | None = 20,
+        first_frame: int = 0,
+        max_frames: int | None = None,
+        return_non_eit_data: bool = False,
+    ) -> DataCollection | tuple[DataCollection, DataCollection, DataCollection]:
+        file_size = path.stat().st_size
+        if file_size % _FRAME_SIZE_BYTES:
+            msg = (
+                f"File size {file_size} of file {path!s} not divisible by {_FRAME_SIZE_BYTES}.\n"
+                f"Make sure this is a valid and uncorrupted Dräger data file."
+            )
+            raise OSError(msg)
+        total_frames = file_size // _FRAME_SIZE_BYTES
+
+        if (f0 := first_frame) > (fn := total_frames):
+            msg = f"Invalid input: `first_frame` ({f0}) is larger than the total number of frames in the file ({fn})."
+            raise ValueError(msg)
+
+        n_frames = min(total_frames - first_frame, max_frames or sys.maxsize)
+
+        if max_frames and max_frames != n_frames:
+            msg = (
+                f"The number of frames requested ({max_frames}) is larger "
+                f"than the available number ({n_frames}) of frames after "
+                f"the first frame selected ({first_frame}, total frames: "
+                f"{total_frames}).\n {n_frames} frames will be loaded."
+            )
+            warnings.warn(msg)
+
+        # We need to load 1 frame before first actual frame to check if there is an event marker. Data for the pre-first
+        # (dummy) frame will be removed from self at the end of this function.
+        load_dummy_frame = first_frame > 0
+        first_frame_to_load = first_frame - 1 if load_dummy_frame else 0
+
+        pixel_impedance = np.zeros((n_frames, 32, 32))
+        time = np.zeros((n_frames,))
+        events = []
+        phases = []
+        medibus_data = np.zeros((52, n_frames))
+
+        with path.open("br") as fh:
+            fh.seek(first_frame_to_load * _FRAME_SIZE_BYTES)
+            reader = Reader(fh)
+            previous_marker = None
+
+            first_index = -1 if load_dummy_frame else 0
+            for index in range(first_index, n_frames):
+                previous_marker = cls._read_frame(
+                    reader,
+                    index,
+                    time,
+                    pixel_impedance,
+                    medibus_data,
+                    events,
+                    phases,
+                    previous_marker,
+                )
+
+        if not framerate:
+            framerate = cls.framerate
+
+        eit_data_collection = DataCollection(cls)
+        eit_data_collection.add(
+            cls(
+                path=path,
+                framerate=framerate,
+                nframes=n_frames,
+                time=time,
+                phases=phases,
+                events=events,
+                label="raw",
+                pixel_impedance=pixel_impedance,
+            ),
+        )
+        if return_non_eit_data:
+            (
+                continuous_data_collection,
+                sparse_data_collections,
+            ) = cls._convert_medibus_data(medibus_data, time)
+
+            return (
+                eit_data_collection,
+                continuous_data_collection,
+                sparse_data_collections,
+            )
+
+        return eit_data_collection
+
+    @classmethod
+    def _convert_medibus_data(
+        cls,
+        medibus_data: NDArray,
+        time: NDArray,
+    ) -> tuple[DataCollection, DataCollection]:
+        continuous_data_collection = DataCollection(ContinuousData)
+        sparse_data_collection = DataCollection(SparseData)
+
+        for field_info, data in zip(_medibus_fields, medibus_data, strict=True):
+            if field_info.continuous:
+                continuous_data = ContinuousData(
+                    label=field_info.signal_name,
+                    name=field_info.signal_name,
+                    description=f"Continuous {field_info.signal_name} data loaded from file",
+                    unit=field_info.unit,
+                    time=time,
+                    values=data,
+                    category=field_info.signal_name,
+                )
+                continuous_data.lock()
+                continuous_data_collection.add(continuous_data)
+
+            else:
+                # TODO parse sparse data
+                ...
+
+        return continuous_data_collection, sparse_data_collection
+
+    @classmethod
+    def _read_frame(  # noqa: PLR0913
+        cls,
+        reader: Reader,
+        index: int,
+        time: NDArray,
+        pixel_impedance: NDArray,
+        medibus_data: NDArray,
+        events: list,
+        phases: list,
+        previous_marker: int | None,
+    ) -> int:
+        """Read frame by frame data from DRAEGER files.
+
+        This method adds the loaded data to the provided arrays `time` and
+        `pixel_impedance` and the provided lists `events` and `phases` when the
+        index is non-negative. When the index is negative, no data is saved. In
+        any case, the event marker is returned.
+        """
+        frame_time = round(reader.float64() * 24 * 60 * 60, 3)
+        _ = reader.float32()
+        frame_pixel_impedance = reader.npfloat32(length=1024)
+        frame_pixel_impedance = np.reshape(frame_pixel_impedance, (32, 32), "C")
+        min_max_flag = reader.int32()
+        event_marker = reader.int32()
+        event_text = reader.string(length=30)
+        timing_error = reader.int32()
+
+        frame_medibus_data = reader.npfloat32(length=52)  # noqa;
+
+        if index < 0:
+            # do not keep any loaded data, just return the event marker
+            return event_marker
+
+        time[index] = frame_time
+        pixel_impedance[index, :, :] = frame_pixel_impedance
+        medibus_data[:, index] = frame_medibus_data
+
+        # The event marker stays the same until the next event occurs.
+        # Therefore, check whether the event marker has changed with
+        # respect to the most recent event. If so, create a new event.
+        if (previous_marker is not None) and (event_marker > previous_marker):
+            events.append(Event(index, frame_time, event_marker, event_text))
+        if timing_error:
+            warnings.warn("A timing error was encountered during loading.")
+            # TODO: expand on what timing errors are in some documentation.
+        if min_max_flag == 1:
+            phases.append(MaxValue(index, frame_time))
+        elif min_max_flag == -1:
+            phases.append(MinValue(index, frame_time))
+
+        return event_marker
+
+
+class _MedibusField(NamedTuple):
+    signal_name: str
+    unit: str
+    continuous: bool
+
+
+_medibus_fields = [
+    _MedibusField("airway pressure", "mbar", True),
+    _MedibusField("flow", "L/min", True),
+    _MedibusField("volume", "mL", True),
+    _MedibusField("CO2", "%", True),
+    _MedibusField("CO2", "kPa", True),
+    _MedibusField("CO2", "mmHg", True),
+    _MedibusField("dynamic compliance", "mL/mbar", False),
+    _MedibusField("resistance", "mbar/L/s", False),
+    _MedibusField("r^2", "", False),
+    _MedibusField("spontaneous inspiratory time", "s", False),
+    _MedibusField("minimal pressure", "mbar", False),
+    _MedibusField("P0.1", "mbar", False),
+    _MedibusField("mean pressure", "mbar", False),
+    _MedibusField("plateau pressure", "mbar", False),
+    _MedibusField("PEEP", "mbar", False),
+    _MedibusField("intrinsic PEEP", "mbar", False),
+    _MedibusField("mandatory respiratory rate", "/min", False),
+    _MedibusField("mandatory minute volume", "L/min", False),
+    _MedibusField("peak inspiratory pressure", "mbar", False),
+    _MedibusField("mandatory tidal volume", "L", False),
+    _MedibusField("spontaneous tidal volume", "L", False),
+    _MedibusField("trapped volume", "mL", False),
+    _MedibusField("mandatory expiratory tidal volume", "mL", False),
+    _MedibusField("spontaneous expiratory tidal volume", "mL", False),
+    _MedibusField("mandatory inspiratory tidal volume", "mL", False),
+    _MedibusField("tidal volume", "mL", False),
+    _MedibusField("spontaneous inspiratory tidal volume", "mL", False),
+    _MedibusField("negative inspiratory force", "mbar", False),
+    _MedibusField("leak minute volume", "L/min", False),
+    _MedibusField("leak percentage", "%", False),
+    _MedibusField("spontaneous respiratory rate", "/min", False),
+    _MedibusField("percentage of spontaneous minute volume", "%", False),
+    _MedibusField("spontaneous minute volume", "L/min", False),
+    _MedibusField("minute volume", "L/min", False),
+    _MedibusField("airway temperature", "degrees C", False),
+    _MedibusField("rapid shallow breating index", "1/min/L", False),
+    _MedibusField("respiratory rate", "/min", False),
+    _MedibusField("inspiratory:expiratory ratio", "", False),
+    _MedibusField("CO2 flow", "mL/min", False),
+    _MedibusField("dead space volume", "mL", False),
+    _MedibusField("percentage dead space of expiratory tidal volume", "%", False),
+    _MedibusField("end-tidal CO2", "%", False),
+    _MedibusField("end-tidal CO2", "kPa", False),
+    _MedibusField("end-tidal CO2", "mmHg", False),
+    _MedibusField("fraction inspired O2", "%", False),
+    _MedibusField("spontaneous inspiratory:expiratory ratio", "", False),
+    _MedibusField("elastance", "mbar/L", False),
+    _MedibusField("time constant", "s", False),
+    _MedibusField(
+        "ratio between upper 20% pressure range and total dynamic compliance",
+        "",
+        False,
+    ),
+    _MedibusField("end-inspiratory pressure", "mbar", False),
+    _MedibusField("expiratory tidal volume", "mL", False),
+    _MedibusField("time at low pressure", "s", False),
+]
diff --git a/eitprocessing/eit_data/event.py b/eitprocessing/eit_data/event.py
new file mode 100644
index 000000000..b5a2a0f26
--- /dev/null
+++ b/eitprocessing/eit_data/event.py
@@ -0,0 +1,9 @@
+from dataclasses import dataclass
+
+
+@dataclass
+class Event:
+    index: int
+    time: float
+    marker: int
+    text: str
diff --git a/eitprocessing/binreader/phases.py b/eitprocessing/eit_data/phases.py
similarity index 51%
rename from eitprocessing/binreader/phases.py
rename to eitprocessing/eit_data/phases.py
index e17effc02..e13389c04 100644
--- a/eitprocessing/binreader/phases.py
+++ b/eitprocessing/eit_data/phases.py
@@ -1,10 +1,3 @@
-"""
-Copyright 2023 Netherlands eScience Center and Erasmus University Medical Center.
-Licensed under the Apache License, version 2.0. See LICENSE for details.
-
-This file contains methods related to when electrical impedance tomographs are read.
-"""
-
 from dataclasses import dataclass
 
 
diff --git a/eitprocessing/eit_data/sentec.py b/eitprocessing/eit_data/sentec.py
new file mode 100644
index 000000000..10aa30ee4
--- /dev/null
+++ b/eitprocessing/eit_data/sentec.py
@@ -0,0 +1,206 @@
+from __future__ import annotations
+
+import mmap
+import os
+import warnings
+from dataclasses import dataclass, field
+from enum import IntEnum
+from typing import TYPE_CHECKING, BinaryIO
+
+import numpy as np
+
+from eitprocessing.binreader.reader import Reader
+from eitprocessing.continuous_data import ContinuousData
+from eitprocessing.data_collection import DataCollection
+from eitprocessing.eit_data import EITData_
+from eitprocessing.eit_data.vendor import Vendor
+from eitprocessing.sparse_data import SparseData
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from numpy.typing import NDArray
+
+
+@dataclass(eq=False)
+class SentecEITData(EITData_):
+    """Container for EIT data recorded using the Sentec."""  # TODO: which model?
+
+    vendor: Vendor = field(default=Vendor.SENTEC, init=False)
+    framerate: float = 50.2
+
+    @classmethod
+    def _from_path(  # noqa: C901
+        cls,
+        path: Path,
+        framerate: float | None = 50.2,
+        first_frame: int = 0,
+        max_frames: int | None = None,
+        return_non_eit_data: bool = False,
+    ) -> DataCollection | tuple[DataCollection, DataCollection, DataCollection]:
+        with path.open("br") as fo, mmap.mmap(fo.fileno(), length=0, access=mmap.ACCESS_READ) as fh:
+            file_length = os.fstat(fo.fileno()).st_size
+            reader = Reader(fh, endian="little")
+            version = reader.uint8()
+
+            time = []
+            max_n_images = int(file_length / 32 / 32 / 4)
+            image = np.full(shape=(max_n_images, 32, 32), fill_value=np.nan)
+            index = 0
+            n_images_added = 0
+
+            # while there are still data to be read and the number of read data points is higher
+            # than the maximum specified, keep reading
+            while fh.tell() < file_length and (max_frames is None or len(time) < max_frames):
+                _ = reader.uint64()  # skip timestamp reading
+                domain_id = reader.uint8()
+                number_data_fields = reader.uint8()
+
+                for _ in range(number_data_fields):
+                    data_id = reader.uint8()
+                    payload_size = reader.uint16()
+
+                    if payload_size == 0:
+                        continue
+
+                    if domain_id == Domain.MEASUREMENT:
+                        if data_id == MeasurementDataID.TIMESTAMP:
+                            time_of_caption = reader.uint32()
+                            time.append(time_of_caption)
+
+                        elif data_id == MeasurementDataID.ZERO_REF_IMAGE:
+                            index += 1
+
+                            ref = cls._read_frame(
+                                fh,
+                                version,
+                                index,
+                                payload_size,
+                                reader,
+                                first_frame,
+                            )
+
+                            if ref is not None:
+                                image[n_images_added, :, :] = ref
+                                n_images_added += 1
+                        else:
+                            fh.seek(payload_size, os.SEEK_CUR)
+
+                    # read the framerate from the file, if present
+                    # (domain 64 = configuration, data 5 = framerate)
+                    elif domain_id == Domain.CONFIGURATION and data_id == ConfigurationDataID.FRAMERATE:
+                        framerate = reader.float32()
+                        msg = f"Framerate value found in file. The framerate value will be set to {framerate:.2f}"
+                        warnings.warn(msg)
+
+                    else:
+                        fh.seek(payload_size, os.SEEK_CUR)
+        image = image[:n_images_added, :, :]
+        n_frames = len(image) if image is not None else 0
+
+        if (f0 := first_frame) > (fn := index):
+            msg = f"Invalid input: `first_frame` ({f0}) is larger than the total number of frames in the file ({fn})."
+            raise ValueError(msg)
+
+        if max_frames and n_frames != max_frames:
+            msg = (
+                f"The number of frames requested ({max_frames}) is larger "
+                f"than the available number ({n_frames}) of frames after "
+                f"the first frame selected ({first_frame}, total frames: "
+                f"{index}).\n {n_frames} frames will be loaded."
+            )
+            warnings.warn(msg)
+
+        if not framerate:
+            framerate = cls.framerate
+
+        eit_data_collection = DataCollection(cls)
+        eit_data_collection.add(
+            cls(
+                path=path,
+                framerate=framerate,
+                nframes=n_frames,
+                time=np.unwrap(np.array(time), period=np.iinfo(np.uint32).max) / 1000000,
+                label="raw",
+                pixel_impedance=image,
+            ),
+        )
+
+        if return_non_eit_data:
+            return eit_data_collection, DataCollection(ContinuousData), DataCollection(SparseData)
+        return eit_data_collection
+
+    @classmethod
+    def _read_frame(
+        cls,
+        fh: BinaryIO,
+        version: int,
+        index: int,
+        payload_size: int,
+        reader: Reader,
+        first_frame: int = 0,
+    ) -> NDArray | None:
+        """
+        Read a single frame in the file.
+
+        The current position of the file has to be already set to the point where the image should be read (data_id 5).
+
+        Args:
+                    fh: opened file object
+                    version: version of the Sentec file
+                    index: current number of read frames
+                    payload_size: size of the payload of the data to be read.
+                    reader: bites reader object
+                    first_frame: index of first time point of sequence.
+
+        Returns: A 32 x 32 matrix, containing the pixels values.
+
+        """
+        if index < first_frame:
+            fh.seek(payload_size, os.SEEK_CUR)
+            return None
+
+        if version > 1:
+            # read quality index. We don't use it, so we skip the bytes
+            _ = reader.uint8()
+            n_pixels = (payload_size - 3) // 4
+        else:
+            n_pixels = (payload_size - 2) // 4
+
+        image_width = reader.uint8()
+        image_height = reader.uint8()
+
+        # the sign of the zero_ref values has to be inverted
+        zero_ref = -reader.npfloat32(n_pixels)
+
+        if image_width * image_height != n_pixels:
+            msg = (
+                f"The length of image array is "
+                f"{n_pixels} which is not equal to the "
+                f"product of the width ({image_width}) and "
+                f"height ({image_height}) of the frame."
+                f"Image will not be stored."
+            )
+            raise OSError(msg)
+
+        return np.reshape(zero_ref, (image_width, image_height), order="C")
+
+
+class Domain(IntEnum):
+    """Domain loaded data falls in."""
+
+    MEASUREMENT = 16
+    CONFIGURATION = 64
+
+
+class MeasurementDataID(IntEnum):
+    """ID of measured data."""
+
+    TIMESTAMP = 0
+    ZERO_REF_IMAGE = 5
+
+
+class ConfigurationDataID(IntEnum):
+    """ID of configuration data."""
+
+    FRAMERATE = 1
diff --git a/eitprocessing/eit_data/timpel.py b/eitprocessing/eit_data/timpel.py
new file mode 100644
index 000000000..c0929dc9a
--- /dev/null
+++ b/eitprocessing/eit_data/timpel.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+import warnings
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+from eitprocessing.continuous_data import ContinuousData
+from eitprocessing.data_collection import DataCollection
+from eitprocessing.eit_data import EITData_
+from eitprocessing.eit_data.phases import MaxValue, MinValue, QRSMark
+from eitprocessing.eit_data.vendor import Vendor
+from eitprocessing.sparse_data import SparseData
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from numpy.typing import NDArray
+
+
+_COLUMN_WIDTH = 1030
+_NAN_VALUE = -1000
+
+
+@dataclass(eq=False)
+class TimpelEITData(EITData_):
+    """Container for data measured by Timpel EIT device."""
+
+    framerate: float = 50
+    vendor: Vendor = field(default=Vendor.TIMPEL, init=False)
+
+    @classmethod
+    def _from_path(  # noqa: C901
+        cls,
+        path: Path,
+        framerate: float | None = 20,
+        first_frame: int = 0,
+        max_frames: int | None = None,
+        return_non_eit_data: bool = False,
+    ) -> DataCollection | tuple[DataCollection, DataCollection, DataCollection]:
+        if not framerate:
+            framerate = cls.framerate
+
+        try:
+            data: NDArray = np.loadtxt(
+                str(path),
+                dtype=float,
+                delimiter=",",
+                skiprows=first_frame,
+                max_rows=max_frames,
+            )
+        except UnicodeDecodeError as e:
+            msg = (
+                f"File {path} could not be read as Timpel data.\n"
+                "Make sure this is a valid and uncorrupted Timpel data file.\n"
+                f"Original error message: {e}"
+            )
+            raise OSError(msg) from e
+
+        if data.shape[1] != _COLUMN_WIDTH:
+            msg = (
+                f"Input does not have a width of {_COLUMN_WIDTH} columns.\n"
+                "Make sure this is a valid and uncorrupted Timpel data file."
+            )
+            raise OSError(msg)
+        if data.shape[0] == 0:
+            msg = f"Invalid input: `first_frame` {first_frame} is larger than the total number of frames in the file."
+            raise ValueError(msg)
+
+        if max_frames and data.shape[0] == max_frames:
+            nframes = max_frames
+        else:
+            if max_frames:
+                warnings.warn(
+                    f"The number of frames requested ({max_frames}) is larger "
+                    f"than the available number ({data.shape[0]}) of frames after "
+                    f"the first frame selected ({first_frame}).\n"
+                    f"{data.shape[0]} frames have been loaded.",
+                )
+            nframes = data.shape[0]
+
+        # TODO (#80): QUESTION: check whether below issue was only a Drager problem or also
+        # applicable to Timpel.
+        # The implemented method seems convoluted: it's easier to create an array
+        # with nframes and add a time_offset. However, this results in floating
+        # point errors, creating issues with comparing times later on.
+        time = np.arange(nframes + first_frame) / framerate
+        time = time[first_frame:]
+
+        pixel_impedance = data[:, :1024]
+        pixel_impedance = np.reshape(pixel_impedance, newshape=(-1, 32, 32), order="C")
+
+        pixel_impedance = np.where(pixel_impedance == _NAN_VALUE, np.nan, pixel_impedance)
+
+        # extract waveform data
+        # TODO: properly export waveform data
+
+        continuous_data_collection = DataCollection(ContinuousData)
+        continuous_data_collection.add(
+            ContinuousData(
+                "airway_pressure_(timpel)",
+                "Airway pressure",
+                "cmH2O",
+                "pressure",
+                "Airway pressure measured by Timpel device",
+                loaded=True,
+                values=data[:, 1024],
+            ),
+        )
+
+        continuous_data_collection.add(
+            ContinuousData(
+                "flow_(timpel)",
+                "Flow",
+                "L/s",
+                "flow",
+                "FLow measures by Timpel device",
+                loaded=True,
+                values=data[:, 1025],
+            ),
+        )
+
+        continuous_data_collection.add(
+            ContinuousData(
+                "volume_(timpel)",
+                "Volume",
+                "L",
+                "volume",
+                "Volume measured by Timpel device",
+                loaded=True,
+                values=data[:, 1026],
+            ),
+        )
+
+        # extract breath start, breath end and QRS marks
+        phases = []
+        for index in np.flatnonzero(data[:, 1027] == 1):
+            phases.append(MinValue(index, time[int(index)]))  # noqa: PERF401
+
+        for index in np.flatnonzero(data[:, 1028] == 1):
+            phases.append(MaxValue(index, time[int(index)]))  # noqa: PERF401
+
+        for index in np.flatnonzero(data[:, 1029] == 1):
+            phases.append(QRSMark(index, time[int(index)]))  # noqa: PERF401
+
+        phases.sort(key=lambda x: x.index)
+
+        eit_data_collection = DataCollection(cls)
+        eit_data_collection.add(
+            cls(
+                label="raw",
+                path=path,
+                nframes=nframes,
+                time=time,
+                framerate=framerate,
+                phases=phases,
+                pixel_impedance=pixel_impedance,
+            ),
+        )
+
+        if return_non_eit_data:
+            return eit_data_collection, continuous_data_collection, DataCollection(SparseData)
+
+        return eit_data_collection
diff --git a/eitprocessing/eit_data/vendor.py b/eitprocessing/eit_data/vendor.py
new file mode 100644
index 000000000..530eeff24
--- /dev/null
+++ b/eitprocessing/eit_data/vendor.py
@@ -0,0 +1,13 @@
+from enum import auto
+
+from strenum import LowercaseStrEnum
+
+
+class Vendor(LowercaseStrEnum):
+    """Enum indicating the vendor (manufacturer) of the source EIT device."""
+
+    DRAEGER = auto()
+    TIMPEL = auto()
+    SENTEC = auto()
+    DRAGER = DRAEGER
+    DRÄGER = DRAEGER
diff --git a/eitprocessing/filters/__init__.py b/eitprocessing/filters/__init__.py
index cddc8a480..ba04c123a 100644
--- a/eitprocessing/filters/__init__.py
+++ b/eitprocessing/filters/__init__.py
@@ -1,5 +1,9 @@
+from typing import NoReturn
+
+
 class TimeDomainFilter:
     available_in_gui = True
-    
-    def apply_filter(self, input_data):
-        raise NotImplementedError("Implement in subclass")
+
+    def apply_filter(self, input_data) -> NoReturn:
+        msg = "Implement in subclass"
+        raise NotImplementedError(msg)
diff --git a/eitprocessing/filters/butterworth_filters.py b/eitprocessing/filters/butterworth_filters.py
index 62344d713..de101f8ed 100644
--- a/eitprocessing/filters/butterworth_filters.py
+++ b/eitprocessing/filters/butterworth_filters.py
@@ -1,10 +1,11 @@
-from dataclasses import InitVar
-from dataclasses import dataclass
+from dataclasses import InitVar, dataclass
 from typing import Literal
+
 import numpy as np
 import numpy.typing as npt
 from scipy import signal
-from . import TimeDomainFilter
+
+from eitprocessing.filters import TimeDomainFilter
 
 
 @dataclass(kw_only=True)
@@ -14,13 +15,13 @@ class ButterworthFilter(TimeDomainFilter):
     Generates a low-pass, high-pass, band-pass or band-stop digital Butterworth filter of order
     `order`. Filters are created using cascaded second-order sections representation, providing
     better stability compared to the traditionally used transfer function (numerator/denominator or
-    b/a representation). 
+    b/a representation).
 
     The `apply_filter()` method applies the filter to the provided data using forward-backward
     filtering. This minimizes the phase shift, and effectively doubles the order of the filter.
 
-    ``ButterworthFilter`` is a wrapper of the `scipy.butter()` and `scipy.filtfilt()` functions: 
-        - https://docs.scipy.org/doc/scipy-1.10.1/reference/generated/scipy.signal.butter.html 
+    ``ButterworthFilter`` is a wrapper of the `scipy.butter()` and `scipy.filtfilt()` functions:
+        - https://docs.scipy.org/doc/scipy-1.10.1/reference/generated/scipy.signal.butter.html
         - https://docs.scipy.org/doc/scipy-1.10.1/reference/generated/scipy.signal.filtfilt.html
 
     Args:
@@ -31,7 +32,7 @@ class ButterworthFilter(TimeDomainFilter):
             `cutoff_frequency` is a sequence containing two frequencies.
         order: Order of the filter. The effective order size is twice the given order, due to
             forward-backward filtering. Higher orders improve the effectiveness of a filter, but
-            can result in unstable or incorrect filtering. 
+            can result in unstable or incorrect filtering.
         sample_frequency: The sample frequency of the data to be filtered (in Hz).
         ignore_max_order: Whether to raise an exception if the order is larger than the maximum of
             10. Defaults to False.
@@ -58,22 +59,21 @@ def __post_init__(self, ignore_max_order):
         self._check_init(ignore_max_order)
         self._set_filter_type_class()
 
-    def _set_filter_type_class(self):
+    def _set_filter_type_class(self) -> None:
         if (
             isinstance(self, ButterworthFilter)
             and self.__class__ != ButterworthFilter
             and self.__class__.filter_type != self.filter_type
         ):
-            raise TypeError(
-                f"conflicting type info; `filter_type={self.filter_type}` does not match {self.__class__}."
-            )
+            msg = f"conflicting type info; `filter_type={self.filter_type}` does not match {self.__class__}."
+            raise TypeError(msg)
 
         # Note that this way of re-assigning classes is considered to be a bad practice
         # (https://tinyurl.com/2x2cea6h), but the objections raised don't seem to be prohibtive.
         cls = FILTER_TYPES[self.filter_type]
         self.__class__ = cls
 
-    def _check_init(self, ignore_max_order):
+    def _check_init(self, ignore_max_order) -> None:
         """
         Check the arguments of __init__ and raise exceptions when they don't meet requirements.
 
@@ -90,59 +90,48 @@ def _check_init(self, ignore_max_order):
             TypeError: if the sample frequency is not numeric.
             ValueError: if the sample frequency is 0 or negative.
         """
-
         if self.filter_type in ("lowpass", "highpass"):
-            if not isinstance(self.cutoff_frequency, (int, float)):
-                raise TypeError(
-                    "Invalid `cutoff_frequency`. "
-                    f"Must be an integer or float, not {type(self.cutoff_frequency)}."
-                )
+            if not isinstance(self.cutoff_frequency, int | float):
+                msg = "Invalid `cutoff_frequency`. " f"Must be an integer or float, not {type(self.cutoff_frequency)}."
+                raise TypeError(msg)
 
         elif self.filter_type in ("bandpass", "bandstop"):
             if isinstance(self.cutoff_frequency, list):
                 self.cutoff_frequency = tuple(self.cutoff_frequency)
             elif not isinstance(self.cutoff_frequency, tuple):
-                raise TypeError(
-                    "Invalid `cutoff_frequency`. "
-                    f"Must be a tuple, not {type(self.cutoff_frequency)}."
-                )
+                msg = "Invalid `cutoff_frequency`. " f"Must be a tuple, not {type(self.cutoff_frequency)}."
+                raise TypeError(msg)
 
             if len(self.cutoff_frequency) != 2:
-                raise ValueError(
-                    f"Invalid `cutoff_frequency` ({self.cutoff_frequency}). "
-                    "Must have length 2."
-                )
+                msg = f"Invalid `cutoff_frequency` ({self.cutoff_frequency}). " "Must have length 2."
+                raise ValueError(msg)
 
-            if not all(
-                isinstance(value, (int, float)) for value in self.cutoff_frequency
-            ):
-                raise TypeError(
-                    f"Invalid `cutoff_frequency` ({self.cutoff_frequency}). "
-                    "Must be a tuple containing two numbers."
+            if not all(isinstance(value, int | float) for value in self.cutoff_frequency):
+                msg = (
+                    f"Invalid `cutoff_frequency` ({self.cutoff_frequency}). " "Must be a tuple containing two numbers."
                 )
+                raise TypeError(msg)
         else:
-            raise ValueError(
-                f"Invalid `filter_type` ({self.filter_type}). "
-                f"Must be one of {', '.join(FILTER_TYPES.keys())}."
-            )
+            msg = f"Invalid `filter_type` ({self.filter_type}). Must be one of {', '.join(FILTER_TYPES.keys())}."
+            raise ValueError(msg)
 
         if not isinstance(self.order, int):
-            raise TypeError(f"Invalid `order`. Must be an int, not {type(self.order)}.")
+            msg = f"Invalid `order`. Must be an int, not {type(self.order)}."
+            raise TypeError(msg)
 
         if self.order < 1 or (self.order > MAX_ORDER and ignore_max_order is False):
-            raise ValueError(
+            msg = (
                 f"Invalid `order` ({self.order}). Must be between 1 and {MAX_ORDER}. "
                 "To use higher values, set `ignore_max_order` to `True`."
             )
+            raise ValueError(msg)
 
-        if not isinstance(self.sample_frequency, (int, float)):
-            raise TypeError(
-                f"Invalid `sample_frequency` ({self.sample_frequency}). Must be a number."
-            )
+        if not isinstance(self.sample_frequency, int | float):
+            msg = f"Invalid `sample_frequency` ({self.sample_frequency}). Must be a number."
+            raise TypeError(msg)
         if self.sample_frequency <= 0:
-            raise ValueError(
-                f"Invalid `sample_frequency` ({self.sample_frequency}). Must be positive"
-            )
+            msg = f"Invalid `sample_frequency` ({self.sample_frequency}). Must be positive"
+            raise ValueError(msg)
 
     def apply_filter(self, input_data: npt.ArrayLike, axis: int = -1) -> np.ndarray:
         """Apply the filter to the input data.
@@ -165,8 +154,7 @@ def apply_filter(self, input_data: npt.ArrayLike, axis: int = -1) -> np.ndarray:
             output="sos",
         )
 
-        filtered_data = signal.sosfiltfilt(sos, input_data, axis=axis)
-        return filtered_data
+        return signal.sosfiltfilt(sos, input_data, axis=axis)
 
 
 @dataclass(kw_only=True)
diff --git a/eitprocessing/helper.py b/eitprocessing/helper.py
new file mode 100644
index 000000000..889df6eb1
--- /dev/null
+++ b/eitprocessing/helper.py
@@ -0,0 +1,2 @@
+class NotConsecutive(Exception):
+    """Raised when trying to concatenate non-consecutive datasets."""
diff --git a/eitprocessing/mixins/__init__.py b/eitprocessing/mixins/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/eitprocessing/mixins/equality.py b/eitprocessing/mixins/equality.py
new file mode 100644
index 000000000..39facd55a
--- /dev/null
+++ b/eitprocessing/mixins/equality.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+from dataclasses import is_dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
+
+class Equivalence:
+    """Mixin class that adds an equality and equivalence check."""
+
+    # inspired by: https://stackoverflow.com/a/51743960/5170442
+    def __eq__(self, other: Self):
+        if self is other:
+            return True
+        if is_dataclass(self):
+            if self.__class__ is not other.__class__:
+                return NotImplemented
+            t1 = vars(self).values()
+            t2 = vars(other).values()
+            if len(t1) != len(t2):
+                return False
+            return all(Equivalence._array_safe_eq(a1, a2) for a1, a2 in zip(t1, t2, strict=True))
+        return Equivalence._array_safe_eq(self, other)
+
+    @staticmethod
+    def _array_safe_eq(a: Any, b: Any) -> bool:  # noqa: ANN401
+        """Check if a and b are equal, even if they are numpy arrays containing nans."""
+        if isinstance(a, np.ndarray) and isinstance(b, np.ndarray):
+            return a.shape == b.shape and np.array_equal(a, b, equal_nan=True)
+
+        if not isinstance(a, Equivalence) and not isinstance(b, Equivalence):
+            return a == b
+
+        if isinstance(a, dict) and isinstance(b, dict):
+            return dict.__eq__(a, b)
+
+        try:
+            # `a == b` could trigger an infinite loop when called on an instance of Equivalence
+            # object.__eq__() works for most objects, except those implemented seperately above
+            return object.__eq__(a, b)
+
+        except TypeError:
+            return NotImplemented
+
+    def isequivalent(self, other: Self, raise_: bool = False) -> bool:  # noqa: C901
+        """Test whether the data structure between two objects are equivalent.
+
+        Equivalence, in this case means that objects are compatible e.g. to be
+        merged. Data content can vary, but e.g. the category of data (e.g.
+        airway pressure, flow, tidal volume) and unit, etc., must match.
+
+
+        Args:
+            other: object that will be compared to self.
+            raise_: sets this method's behavior in case of non-equivalence. If
+                True, an `EquivalenceError` is raised, otherwise `False` is
+                returned.
+
+        Raises:
+            EquivalenceError: if `raise_ == True` and the objects are not
+            equivalent.
+
+        Returns:
+            bool describing result of equivalence comparison.
+        """
+        if self == other:
+            return True
+
+        try:
+            # check whether types match
+            if type(self) is not type(other):
+                msg = f"Types don't match: {type(self)}, {type(other)}"
+                raise EquivalenceError(msg)  # noqa: TRY301
+
+            # check keys in collection
+            if isinstance(self, dict):
+                if set(self.keys()) != set(other.keys()):
+                    msg = f"Keys don't match:\n\t{self.keys()},\n\t{other.keys()}"
+                    raise EquivalenceError(msg)  # noqa: TRY301
+
+                for key in self:
+                    if not self[key].isequivalent(other[key], False):
+                        msg = f"Data in {key} doesn't match: {self[key]}, {other[key]}"
+                        raise EquivalenceError(msg)  # noqa: TRY301
+
+            # check attributes of data
+            else:
+                self._check_equivalence: list[str]
+                for attr in self._check_equivalence:
+                    if (s := getattr(self, attr)) != (o := getattr(other, attr)):
+                        raise f"{attr.capitalize()}s don't match: {s}, {o}"
+
+        # raise or return if a check fails
+        except EquivalenceError:
+            if raise_:
+                raise
+            return False
+
+        # if all checks pass
+        return True
+
+
+class EquivalenceError(TypeError, ValueError):
+    """Raised if objects are not equivalent."""
diff --git a/eitprocessing/mixins/slicing.py b/eitprocessing/mixins/slicing.py
new file mode 100644
index 000000000..c44bcb6e3
--- /dev/null
+++ b/eitprocessing/mixins/slicing.py
@@ -0,0 +1,185 @@
+from __future__ import annotations
+
+import bisect
+import warnings
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+if TYPE_CHECKING:
+    from numpy.typing import NDArray
+    from typing_extensions import Self
+
+
+class SelectByIndex(ABC):
+    """Adds slicing functionality to subclass by implementing `__getitem__`.
+
+    Subclasses must implement a `_sliced_copy` function that defines what should
+    happen when the object is sliced. This class ensures that when calling a
+    slice between square brackets (as e.g. done for lists) then return the
+    expected sliced object.
+    """
+
+    label: str
+
+    def __getitem__(self, key: slice | int):
+        if isinstance(key, slice):
+            if key.step and key.step != 1:
+                msg = f"Can't slice {self.__class__} object with steps other than 1."
+                raise ValueError(msg)
+            start_index = key.start
+            end_index = key.stop
+            return self.select_by_index(start_index, end_index)
+
+        if isinstance(key, int):
+            return self.select_by_index(start=key, end=key + 1)
+
+        msg = f"Invalid slicing input. Should be `slice` or `int`, not {type(key)}."
+        raise TypeError(msg)
+
+    def select_by_index(
+        self,
+        start: int | None = None,
+        end: int | None = None,
+        label: str | None = None,
+    ) -> Self:
+        """De facto implementation of the `__getitem__ function.
+
+        This function can also be called directly to add a label to the sliced
+        object. Otherwise a default label describing the slice and original
+        object is attached.
+        """
+        if start is None and end is None:
+            warnings.warn("No starting or end timepoint was selected.")
+            return self
+
+        start = start or 0
+        if end is None:
+            end = len(self)
+
+        if label is None:
+            label = self.label
+
+        return self._sliced_copy(start_index=start, end_index=end, label=label)
+
+    @abstractmethod
+    def _sliced_copy(
+        self,
+        start_index: int,
+        end_index: int,
+        label: str,
+    ) -> Self:
+        """Slicing method that must be implemented by all subclasses.
+
+        Must return a copy of self object with all attached data within selected
+        indices.
+        """
+        ...
+
+
+class SelectByTime(SelectByIndex):
+    """Adds methods for slicing by time rather than index."""
+
+    time: NDArray
+
+    def select_by_time(
+        self,
+        start_time: float | None = None,
+        end_time: float | None = None,
+        start_inclusive: bool = True,
+        end_inclusive: bool = False,
+        label: str | None = None,
+    ) -> Self:
+        """Get a slice from start to end time stamps.
+
+        Given a start and end time stamp (i.e. its value, not its index),
+        return a slice of the original object, which must contain a time axis.
+
+        Args:
+            start_time: first time point to include. Defaults to first frame of sequence.
+            end_time: last time point. Defaults to last frame of sequence.
+            start_inclusive (default: `True`), end_inclusive (default `False`):
+                these arguments control the behavior if the given time stamp
+                does not match exactly with an existing time stamp of the input.
+                if `True`: the given time stamp will be inside the sliced object.
+                if `False`: the given time stamp will be outside the sliced object.
+            label: Description. Defaults to None, which will create a label based
+                on the original object label and the frames by which it is sliced.
+
+        Raises:
+            TypeError: if `self` does not contain a `time` attribute.
+            ValueError: if time stamps are not sorted.
+
+        Returns:
+            Slice of self.
+        """
+        if "time" not in vars(self):
+            msg = f"Object {self} has no time axis."
+            raise TypeError(msg)
+
+        if start_time is None and end_time is None:
+            warnings.warn("No starting or end timepoint was selected.")
+            return self
+
+        if not np.all(np.sort(self.time) == self.time):
+            msg = f"Time stamps for {self} are not sorted and therefore data cannot be selected by time."
+            raise ValueError(msg)
+
+        if start_time is None or start_time < self.time[0]:
+            start_index = 0
+        elif start_inclusive:
+            start_index = bisect.bisect_right(self.time, start_time) - 1
+        else:
+            start_index = bisect.bisect_left(self.time, start_time)
+
+        if end_time is None:
+            end_index = len(self.time)
+        elif end_inclusive:
+            end_index = bisect.bisect_left(self.time, end_time) + 1
+        else:
+            end_index = bisect.bisect_left(self.time, end_time)
+
+        return self.select_by_index(
+            start=start_index,
+            end=end_index,
+            label=label,
+        )
+
+    @property
+    def t(self) -> TimeIndexer:
+        """Time Indexer property."""  # TODO: add a short explanation of what this is used for.
+        return TimeIndexer(self)
+
+
+@dataclass
+class TimeIndexer:
+    """Helper class for slicing an object using the time axis instead of indices.
+
+    Example:
+    ```
+    >>> data = EITData.from_path(<path>, ...)
+    >>> tp_start = data.time[1]
+    >>> tp_end = data.time[4]
+    >>> time_slice = data.t[tp_start:tp_end]
+    >>> index_slice = data[1:4]
+    >>> time_slice == index_slice
+    True
+    ```
+    """
+
+    obj: SelectByTime
+
+    def __getitem__(self, key: slice | float):
+        if isinstance(key, slice):
+            if key.step:
+                msg = "Can't slice by time using specific step sizes."
+                raise ValueError(msg)
+            return self.obj.select_by_time(key.start, key.stop)
+
+        if isinstance(key, int | float):
+            return self.obj.select_by_time(start=key, end=key, end_inclusive=True)
+
+        msg = f"Invalid slicing input. Should be `slice` or `int` or `float`, not {type(key)}."
+        raise TypeError(msg)
diff --git a/eitprocessing/plotting/animate.py b/eitprocessing/plotting/animate.py
new file mode 100644
index 000000000..47f64a6f6
--- /dev/null
+++ b/eitprocessing/plotting/animate.py
@@ -0,0 +1,73 @@
+import numpy as np
+from matplotlib import pyplot as plt
+
+from eitprocessing.eit_data.eit_data_variant import EITDataVariant
+
+
+def animate_EITDataVariant(
+    eit_data_variant: EITDataVariant,
+    cmap: str = "plasma",
+    show_progress: bool | str = "notebook",
+    waveforms: bool | list[str] = False,
+) -> None:
+    # TODO: find other way to couple waveform data
+    if waveforms is True:
+        waveforms = list(eit_data_variant.waveform_data.keys())
+
+    array = eit_data_variant.pixel_impedance
+
+    vmin = np.nanmin(array)
+    vmax = np.nanmax(array)
+
+    if waveforms:
+        n_waveforms = len(waveforms)
+        fig = plt.figure(figsize=(10, 5))
+        ax = fig.add_subplot(1, 2, 1)
+        wf_axes = []
+        wf_lines = []
+        last_wf_ax = None
+        for n, key in enumerate(reversed(waveforms)):
+            wf_ax = fig.add_subplot(n_waveforms, 2, 2 * (n + 1), sharex=last_wf_ax)
+            wf_axes.append(wf_ax)
+            if n == 0:
+                last_wf_ax = wf_ax
+
+            wf_data = eit_data_variant.waveform_data[key][0]
+            wf_lines.append(wf_ax.plot([0], wf_data))
+            wf_ax.set_xlim((0, len(eit_data_variant)))
+            wf_ax.set_ylim((wf_data.min(), wf_data.max()))
+
+    else:
+        fig = plt.figure(figsize=(5, 5))
+        ax = fig.add_subplot(1, 1, 1)
+
+    im = ax.imshow(array[0, :, :], vmin=vmin, vmax=vmax, cmap=cmap)
+    plt.colorbar(im)
+
+    if show_progress:
+        if show_progress == "notebook":
+            progress_bar = notebook_tqdm(total=len(eit_data_variant))
+        else:
+            progress_bar = tqdm(total=len(eit_data_variant))
+        progress_bar.update(1)
+
+    def update(i) -> None:
+        im.set(data=array[i, :, :])
+
+        if waveforms:
+            for key, line in zip(waveforms, wf_lines, strict=False):
+                line[0].set_xdata(range(i))
+                line[0].set_ydata(eit_data_variant.waveform_data[key][: i + 1])
+
+        if show_progress:
+            progress_bar.update(1)
+
+    anim = animation.FuncAnimation(
+        fig,
+        update,
+        frames=range(1, len(eit_data_variant)),
+        repeat=False,
+    )
+    display(HTML(anim.to_jshtml(eit_data_variant.params["framerate"])))
+
+    plt.close()
diff --git a/eitprocessing/plotting/plot.py b/eitprocessing/plotting/plot.py
new file mode 100644
index 000000000..ea4803bd1
--- /dev/null
+++ b/eitprocessing/plotting/plot.py
@@ -0,0 +1,14 @@
+from matplotlib import pyplot as plt
+
+
+def plot_waveforms(self, waveforms=None) -> None:
+    if waveforms is None:
+        waveforms = list(self.waveform_data.keys())
+
+    n_waveforms = len(waveforms)
+    fig, axes = plt.subplots(n_waveforms, 1, sharex=True, figsize=(8, 3 * n_waveforms))
+    fig.tight_layout()
+
+    for ax, key in zip(axes, waveforms, strict=False):
+        ax.plot(self.waveform_data[key])
+        ax.set_title(key)
diff --git a/eitprocessing/sequence/__init__.py b/eitprocessing/sequence/__init__.py
new file mode 100644
index 000000000..23b6e86eb
--- /dev/null
+++ b/eitprocessing/sequence/__init__.py
@@ -0,0 +1,108 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from eitprocessing.continuous_data import ContinuousData
+from eitprocessing.data_collection import DataCollection
+from eitprocessing.eit_data import EITData
+from eitprocessing.mixins.equality import Equivalence
+from eitprocessing.mixins.slicing import SelectByTime
+from eitprocessing.sparse_data import SparseData
+
+if TYPE_CHECKING:
+    import numpy as np
+    from typing_extensions import Self
+
+
+@dataclass(eq=False)
+class Sequence(Equivalence, SelectByTime):
+    """Sequence of timepoints containing respiratory data.
+
+    A Sequence object is a representation of data points over time. These data can consist of any combination of EIT
+    frames (`EITData`), waveform data (`ContinuousData`) from different sources, or individual events (`SparseData`)
+    occurring at any given timepoint.
+    A Sequence can consist of an entire measurement, a section of a measurement, a single breath, or even a portion of a
+    breath. A Sequence can consist of multiple sets of each type of data from the same time-points or can be a single
+    measurement from just one source.
+
+    A Sequence can be split up into separate sections of a measurement or multiple (similar) Sequence objects can be
+    merged together to form a single Sequence.
+
+    Args:
+        label: Computer readable naming of the instance.
+        name: Human readable naming of the instance.
+        description: Human readable extended description of the data.
+        eit_data: Collection of one or more sets of EIT data frames.
+        continuous_data: Collection of one or more sets of continuous data points.
+        sparse_data: Collection of one or more sets of individual data points.
+    """
+
+    label: str
+    name: str = ""
+    description: str = ""
+    eit_data: DataCollection = field(default_factory=lambda: DataCollection(EITData))
+    continuous_data: DataCollection = field(default_factory=lambda: DataCollection(ContinuousData))
+    sparse_data: DataCollection = field(default_factory=lambda: DataCollection(SparseData))
+
+    def __post_init__(self):
+        self.name = self.name or self.label
+
+    @property
+    def time(self) -> np.ndarray:
+        """Time axis from either EITData or ContinuousData."""
+        if len(self.eit_data):
+            return self.eit_data["raw"].time
+        if len(self.continuous_data):
+            return next(self.continuous_data.values())
+
+        msg = "Sequence has no timed data"
+        raise AttributeError(msg)
+
+    def __len__(self):
+        return len(self.time)
+
+    def __add__(self, other: Sequence) -> Sequence:
+        return self.concatenate(self, other)
+
+    @classmethod
+    def concatenate(
+        cls,
+        a: Sequence,
+        b: Sequence,
+    ) -> Sequence:
+        """Create a merge of two Sequence objects."""
+        # TODO: rewrite
+
+        eit_data = a.eit_data.concatenate(b.eit_data) if a.eit_data and b.eit_data else None
+
+        # TODO: add concatenation of other attached objects
+
+        return a.__class__(eit_data=eit_data)
+
+    def _sliced_copy(self, start_index: int, end_index: int, label: str) -> Self:
+        eit_data = DataCollection(EITData)
+        for value in self.eit_data.values():
+            eit_data.add(value[start_index:end_index])
+
+        continuous_data = DataCollection(ContinuousData)
+        for value in self.continuous_data.values():
+            continuous_data.add(value[start_index:end_index])
+
+        sparse_data = DataCollection(SparseData)
+        if start_index >= len(self.time):
+            msg = "start_index larger than length of time axis"
+            raise ValueError(msg)
+
+        time = self.time[start_index:end_index]
+        for value in self.sparse_data.values():
+            sparse_data.add(value.t[time[0], time[-1]])
+
+        return self.__class__(
+            label=label,
+            name=f"Sliced copy of <{self.name}>",
+            description=f"Sliced copy of <{self.description}>",
+            eit_data=eit_data,
+            continuous_data=continuous_data,
+            sparse_data=sparse_data,
+        )
diff --git a/eitprocessing/sparse_data/__init__.py b/eitprocessing/sparse_data/__init__.py
new file mode 100644
index 000000000..473e5c4d6
--- /dev/null
+++ b/eitprocessing/sparse_data/__init__.py
@@ -0,0 +1,2 @@
+class SparseData:
+    ...
diff --git a/pyproject.toml b/pyproject.toml
index 7c7e68915..c44b6fd82 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,6 +2,74 @@
 requires = ["setuptools", "wheel"]
 build-backend = "setuptools.build_meta"
 
+[project]
+name = "eitprocessing"
+version = "0.1.0"
+description = "Processing of lung image data from electrical impedance tomography."
+readme = "README.md"
+requires-python = ">=3.10"
+keywords = [
+    "Mechanical lung ventilation",
+    "Electrical impedance tomography",
+    "Image/data processing",
+    "Advanced respiratory care",
+]
+authors = [
+    { name = "Dani Bodor", email = "d.bodor@esciencecenter.nl" },
+    { name = "Peter Somhorst", email = "p.somhorst@erasmusmc.nl" },
+    { name = "Walter Baccinelli", email = "w.baccinelli@esciencecenter.nl" },
+]
+license = { text = "Apache-2.0 license" }
+classifiers = [
+    "Development Status :: 2 - Pre-Alpha",
+    "Intended Audience :: Developers",
+    "License :: OSI Approved :: Apache Software License",
+    "Natural Language :: English",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+]
+dependencies = [
+    # "flit >= 3.8.0",
+    "matplotlib >= 3.7.1",
+    "numpy >= 1.24.2",
+    "scipy >= 1.10.1",
+    "tqdm >= 4.65.0",
+    "strenum >= 0.4.10",
+    "ruff >= 0.1.13",
+]
+
+[project.optional-dependencies]
+# development dependency groups
+dev = [
+    "pytest >= 7.4.0",
+    "bump2version",
+    "coverage",
+    "pycodestyle",
+    "pytest-cov",
+    "pytest-runner",
+    "coveralls",
+    # "isort",
+    "sphinx",
+    "sphinx_rtd_theme",
+    "sphinx-autoapi",
+    "tox",
+    "myst_parser",
+]
+notebooks = [
+    "ipykernel >= 6.15.2",
+    "ipython >= 8.4.0",
+    "jupyter >= 1.0.0",
+    "jupytext >= 1.14.5",
+    "notebook >= 6.4.12",
+]
+publishing = ["build", "twine", "wheel"]
+
+[project.urls]
+Documentation = "https://github.com/EIT-ALIVE/eitprocessing/blob/main/README.md"
+Repository = "https://github.com/EIT-ALIVE/eitprocessing/"
+Changelog = "https://github.com/EIT-ALIVE/eitprocessing/blob/main/CHANGELOG.md"
+
 [tool.pytest.ini_options]
 testpaths = ["tests"]
 
@@ -10,6 +78,10 @@ branch = true
 source = ["eitprocessing"]
 command_line = "-m pytest"
 
+[tool.setuptools.packages.find]
+include = ["eitprocessing*", "eitprocessing.*"]
+exclude = ["tests*", "*tests.*", "*tests"]
+
 [tool.tox]
 legacy_tox_ini = """
 [tox]
@@ -19,3 +91,76 @@ skip_missing_interpreters = true
 commands = pytest
 extras = dev
 """
+
+[tool.ruff]
+line-length = 120
+select = ["ALL"]
+ignore = [
+    # Unwanted (potentially)
+    "FBT",    # Using boolean arguments
+    "ANN002", # Missing type annotation for `*args`
+    "ANN003", # Missing type annotation for `**kwargs`
+    "ANN101", # Missing type annotation for `self` in method
+    "ANN102", # Missing type annotation for `cls` in classmethod
+    "ANN204", # Missing return type annotation for special (dunder) method
+    "S105",   # Possible hardcoded password
+    "S311",   # insecure random generators
+    "PT011",  # pytest-raises-too-broad
+    "TD",     # TODOs
+    "FIX002", # Resolve TODOs
+    "B028",   # No explicit `stacklevel` keyword argument found in warning
+    # "SIM108", # Use ternary operator (not sure if we want to ignore this)
+    # Unneeded docstrings
+    "D100", # Missing module docstring
+    "D104", # Missing public package docstring
+    "D105", # Missing docstring in magic method
+    "D107", # Missing docstring in `__init__`
+    # Rules irrelevant to the Google style
+    "D203", # 1 blank line required before class docstring
+    "D204",
+    "D212", # Multi-line docstring summary should start at the first line
+    "D213", # Multi-line docstring summary should start at the second line
+    "D215",
+    "D400",
+    "D401",
+    "D404", # First word of the docstring should not be This
+    "D406",
+    "D407",
+    "D408",
+    "D409",
+    "D413",
+]
+
+# Allow autofix for all enabled rules.
+fixable = ["ALL"]
+unfixable = ["F401"] # unused imports (should not disappear while editing)
+
+[tool.ruff.lint.per-file-ignores]
+"*.ipynb" = ["ERA001"] # Commented out code
+"tests/*" = [
+    "S101",   # Use of `assert` detected
+    "ANN201", # Missing return type
+    "D103",   # Missing function docstring
+]
+"docs/*" = ["ALL"]
+
+[tool.ruff.lint]
+extend-safe-fixes = [
+    "D415",   # First line should end with a period, question mark, or exclamation point
+    "D300",   # Use triple double quotes `"""`
+    "D200",   # One-line docstring should fit on one line
+    "TCH",    # Format type checking only imports
+    "ISC001", # Implicitly concatenated strings on a single line
+    "EM",     # Exception message variables
+    "RUF013", # Implicit Optional
+    "B006",   # Mutable default argument
+    "TID252", # Absolute imports over relative imports
+]
+pylint.max-args = 7
+
+[tool.ruff.isort]
+known-first-party = ["eitprocessing"]
+
+[tool.ruff.lint.flake8-tidy-imports]
+# Disallow all relative imports.
+ban-relative-imports = "all"
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 435a41f48..000000000
--- a/setup.cfg
+++ /dev/null
@@ -1,82 +0,0 @@
-# see documentation, e.g.
-# - https://packaging.python.org/tutorials/packaging-projects/#configuring-metadata
-# - https://setuptools.readthedocs.io/en/latest/userguide/declarative_config.html
-# - https://www.python.org/dev/peps/pep-0314/
-
-[metadata]
-author = Dani Bodor
-author_email = d.bodor@esciencecenter.nl
-classifiers =
-    Development Status :: 2 - Pre-Alpha
-    Intended Audience :: Developers
-    License :: OSI Approved :: Apache Software License
-    Natural Language :: English
-    Programming Language :: Python :: 3
-    Programming Language :: Python :: 3.10
-    Programming Language :: Python :: 3.11
-description = Processing of lung image data from electrical impedance tomography
-keywords =
-    - Mechanical lung ventilation
-    - Electrical impedance tomography
-    - Image/data processing
-    - Advanced respiratory care
-long_description = file: README.md
-long_description_content_type = text/markdown
-name = eitprocessing
-project_urls =
-    Bug Tracker = git@github.com:EIT-ALIVE/eitprocessing/issues
-url = git@github.com:EIT-ALIVE/eitprocessing
-version = 0.1.0
-
-[options]
-zip_safe = False
-python_requires = >=3.10
-include_package_data = True
-packages = find:
-install_requires =
-    flit >= 3.8.0
-    ipykernel >= 6.15.2
-    ipython >= 8.4.0
-    jupyter >= 1.0.0
-    jupytext >= 1.14.5
-    matplotlib >= 3.7.1
-    notebook >= 6.4.12
-    numpy >= 1.24.2
-    scipy >= 1.10.1
-    tqdm >= 4.65.0
-    strenum >= 0.4.10
-
-[options.data_files]
-# This section requires setuptools>=40.6.0
-# It remains empty for now
-# Check if MANIFEST.in works for your purposes
-
-[options.extras_require]
-dev =
-    bump2version
-    coverage
-    coveralls
-    prospector[with_pyroma]
-    isort
-    pytest
-    pytest-cov
-    sphinx
-    sphinx_rtd_theme
-    sphinx-autoapi
-    tox
-    myst_parser
-    pyroma
-publishing =
-    twine
-    wheel
-
-[options.packages.find]
-include = eitprocessing, eitprocessing.*
-
-[isort]
-lines_after_imports = 2
-force_single_line = 1
-no_lines_before = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
-known_first_party = eitprocessing
-src_paths = eitprocessing,tests
-line_length = 120
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 64c3869bf..000000000
--- a/setup.py
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/usr/bin/env python
-import os
-from setuptools import setup
-
-
-# see setup.cfg
-setup()


Test patch:
diff --git a/eitprocessing/mixins/test_eq.ipynb b/eitprocessing/mixins/test_eq.ipynb
new file mode 100644
index 000000000..95c3802da
--- /dev/null
+++ b/eitprocessing/mixins/test_eq.ipynb
@@ -0,0 +1,200 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from eitprocessing.mixins.slicing import SelectByIndex\n",
+    "from dataclasses import dataclass, is_dataclass\n",
+    "from eitprocessing.eit_data.vendor import Vendor\n",
+    "from eitprocessing.eit_data.draeger import DraegerEITData\n",
+    "from eitprocessing.eit_data.timpel import TimpelEITData\n",
+    "from eitprocessing.eit_data.eit_data_variant import EITDataVariant\n",
+    "from typing_extensions import Self\n",
+    "from eitprocessing.eit_data import EITData\n",
+    "from eitprocessing.mixins.equality import EquivalenceError\n",
+    "\n",
+    "import os\n",
+    "import pytest\n",
+    "from pprint import pprint\n",
+    "import bisect\n",
+    "import numpy as np\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([72758.105, 72758.155, 72758.205, ..., 73357.955, 73358.005,\n",
+       "       73358.055])"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data = DraegerEITData.from_path('/home/dbodor/git/EIT-ALIVE/eitprocessing/tests/test_data/Draeger_Test2.bin')\n",
+    "data2 = DraegerEITData.from_path('/home/dbodor/git/EIT-ALIVE/eitprocessing/tests/test_data/Draeger_Test3.bin')\n",
+    "timpel_data = TimpelEITData.from_path('/home/dbodor/git/EIT-ALIVE/eitprocessing/tests/test_data/Timpel_Test.txt')\n",
+    "\n",
+    "# pprint(data)\n",
+    "data.time\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "should be True\n",
+      "True\n",
+      "True\n",
+      "True\n",
+      "True\n",
+      "\n",
+      "should be False\n",
+      "False\n",
+      "False\n",
+      "False\n",
+      "False\n"
+     ]
+    },
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "/home/dbodor/git/EIT-ALIVE/eitprocessing/eitprocessing/mixins/slicing.py:52: UserWarning: No starting or end timepoint was selected.\n",
+      "  warnings.warn(\"No starting or end timepoint was selected.\")\n"
+     ]
+    }
+   ],
+   "source": [
+    "print ('should be True')\n",
+    "print(data == data)\n",
+    "print(data[10] == data[10])\n",
+    "print(data[:10] == data[0:10])\n",
+    "print(data[:] == data)\n",
+    "\n",
+    "\n",
+    "print('\\nshould be False')\n",
+    "print(data == data2)\n",
+    "print(data[:10] == data[10])\n",
+    "print(data[:10] == data[2:10])\n",
+    "print(data[:10] == data)\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "should all be True\n",
+      "True\n",
+      "True\n",
+      "True\n",
+      "\n",
+      "should all be False\n",
+      "False\n",
+      "False\n",
+      "False\n",
+      "False\n",
+      "\n",
+      "error was correctly raised\n"
+     ]
+    }
+   ],
+   "source": [
+    "print('should all be True')\n",
+    "print(data.isequivalent(data))\n",
+    "print(data.isequivalent(data2))\n",
+    "print(DraegerEITData.isequivalent(data, data2))\n",
+    "\n",
+    "print('\\nshould all be False')\n",
+    "print(data.isequivalent(timpel_data, False))\n",
+    "print(timpel_data.isequivalent(data))\n",
+    "print(EITData.isequivalent(timpel_data, data))\n",
+    "print(DraegerEITData.isequivalent(timpel_data, data))\n",
+    "\n",
+    "try:\n",
+    "    _ = DraegerEITData.isequivalent(timpel_data, data, True)\n",
+    "    print('\\nno error was raised, but it should have!')\n",
+    "except EquivalenceError:\n",
+    "    print('\\nerror was correctly raised')\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "should print False/True/False and then catch error\n",
+      "\n",
+      "False\n",
+      "True\n",
+      "False\n",
+      "\n",
+      "error was correctly raised\n"
+     ]
+    }
+   ],
+   "source": [
+    "data_new = DraegerEITData.from_path('/home/dbodor/git/EIT-ALIVE/eitprocessing/tests/test_data/Draeger_Test3.bin')\n",
+    "\n",
+    "print('should print False/True/False and then catch error\\n')\n",
+    "\n",
+    "print(data_new == data)\n",
+    "print(data_new.isequivalent(data))\n",
+    "\n",
+    "data_new.framerate = 25\n",
+    "print(data_new.isequivalent(data))\n",
+    "\n",
+    "try:\n",
+    "    _ = data_new.isequivalent(data, raise_ = True)\n",
+    "    print('\\nno error was raised, but it should have!')\n",
+    "except EquivalenceError:\n",
+    "    print('\\nerror was correctly raised')\n"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "alive",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.11"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/eitprocessing/mixins/test_slicing.ipynb b/eitprocessing/mixins/test_slicing.ipynb
new file mode 100644
index 000000000..ca80b7491
--- /dev/null
+++ b/eitprocessing/mixins/test_slicing.ipynb
@@ -0,0 +1,310 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from eitprocessing.mixins.slicing import SelectByIndex\n",
+    "from dataclasses import dataclass, is_dataclass\n",
+    "from eitprocessing.eit_data.vendor import Vendor\n",
+    "from eitprocessing.eit_data.draeger import DraegerEITData\n",
+    "from eitprocessing.eit_data.eit_data_variant import EITDataVariant\n",
+    "from typing_extensions import Self\n",
+    "\n",
+    "import os\n",
+    "import pytest\n",
+    "from pprint import pprint\n",
+    "import bisect\n",
+    "import numpy as np\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([75628.471, 75628.521, 75628.571, ..., 75719.321, 75719.371,\n",
+       "       75719.421])"
+      ]
+     },
+     "execution_count": 2,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "\n",
+    "draeger_file1 = '/home/dbodor/git/EIT-ALIVE/eitprocessing/tests/test_data/Draeger_Test3.bin'\n",
+    "data = DraegerEITData.from_path(draeger_file1)\n",
+    "\n",
+    "# pprint(data)\n",
+    "data.time\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "DraegerEITData_140210416651520\n",
+      "Frame (10) of <DraegerEITData_140210416651520>\n",
+      "Frame (10) of <DraegerEITData_140210416651520>\n",
+      "Frames (0-9) of <DraegerEITData_140210416651520>\n",
+      "No frames selected from <DraegerEITData_140210416651520>\n",
+      "Frames (10-11) of <DraegerEITData_140210416651520>\n"
+     ]
+    }
+   ],
+   "source": [
+    "cutoff = 10\n",
+    "print(data.label)\n",
+    "print(data[cutoff].label)\n",
+    "print(data[cutoff:cutoff+1].label)\n",
+    "print(data[0:cutoff].label)\n",
+    "print(data[cutoff:0].label)\n",
+    "print(data[cutoff:cutoff+2].label)\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Slicing at exact timepoints\n",
+      "These should all be identical\n",
+      "[75628.471 75628.521]\n",
+      "[75628.471 75628.521]\n",
+      "[75628.471 75628.521]\n",
+      "\n",
+      "[75628.471 75628.521]\n",
+      "[75628.471 75628.521]\n",
+      "[75628.471 75628.521]\n",
+      "[75628.471 75628.521 75628.571]\n"
+     ]
+    }
+   ],
+   "source": [
+    "print('Slicing at exact timepoints')\n",
+    "print('These should all be identical')\n",
+    "\n",
+    "tp0 = 75628.471\n",
+    "tp2 = 75628.571\n",
+    "\n",
+    "print(data.select_by_time(0,tp2).time)\n",
+    "print(data.select_by_time(0,tp2, start_inclusive=False).time)\n",
+    "print(data.select_by_time(0,tp2, end_inclusive=False).time)\n",
+    "print(\"\")\n",
+    "print(data.select_by_time(tp0,tp2).time)\n",
+    "print(data.select_by_time(tp0,tp2, start_inclusive=False).time)\n",
+    "print(data.select_by_time(tp0,tp2, start_inclusive=False, end_inclusive=False).time)\n",
+    "print(data.select_by_time(tp0,tp2, start_inclusive=False, end_inclusive=True).time)\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Slicing in between timepoints\n",
+      "for each `False` should exclude be 1 shorter\n",
+      "True True: [75628.471 75628.521 75628.571]\n",
+      "True False: [75628.471 75628.521]\n",
+      "False True: [75628.521 75628.571]\n",
+      "False False: [75628.521]\n"
+     ]
+    }
+   ],
+   "source": [
+    "print('Slicing in between timepoints')\n",
+    "print('for each `False` should exclude be 1 shorter')\n",
+    "\n",
+    "tp0_ = tp0 + 0.01\n",
+    "tp2_ = tp2 - 0.01\n",
+    "\n",
+    "for start_incl in (True, False):\n",
+    "    for end_incl in (True, False):\n",
+    "        print(start_incl, end_incl, end=\": \")\n",
+    "        print(data.select_by_time(tp0_,tp2_,  start_incl, end_incl).time)\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Slicing before first or after last timepoint\n",
+      "each set of 3 should be identical\n",
+      "\n",
+      "75628.471\n",
+      "slice before 1st timepoint 75628.46100000001\n",
+      "[75628.471 75628.521 75628.571]\n",
+      "[75628.471 75628.521]\n",
+      "[75628.471 75628.521]\n",
+      "\n",
+      "slice after last timepoint: 75719.431\n",
+      "[75719.321 75719.371 75719.421]\n",
+      "[75719.321 75719.371 75719.421]\n",
+      "[75719.321 75719.371 75719.421]\n"
+     ]
+    }
+   ],
+   "source": [
+    "print('Slicing before first or after last timepoint')\n",
+    "print('each set of 3 should be identical')\n",
+    "print('')\n",
+    "\n",
+    "tp0__ = tp0 - 0.01\n",
+    "tp_2 = 75719.321\n",
+    "tpx__ = 75719.421 + 0.01\n",
+    "\n",
+    "print(tp0)\n",
+    "print(f'slice before 1st timepoint {tp0__}')\n",
+    "print(data.time[:3])\n",
+    "print(data.select_by_time(tp0, tp2, start_inclusive=True).time)\n",
+    "print(data.select_by_time(tp0__, tp2, start_inclusive=False).time)\n",
+    "\n",
+    "print('')\n",
+    "\n",
+    "print(f'slice after last timepoint: {tpx__}')\n",
+    "print(data.time[-3:])\n",
+    "print(data.select_by_time(tp_2, tpx__, end_inclusive=True).time)\n",
+    "print(data.select_by_time(tp_2, tpx__, end_inclusive=False).time)\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([75628.521])"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data[1].time\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([75628.521])"
+      ]
+     },
+     "execution_count": 14,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data.select_by_time(75628.521, 75628.521, end_inclusive=True).time\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "75628.521\n",
+      "75628.621\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "array([75628.471, 75628.521, 75628.571])"
+      ]
+     },
+     "execution_count": 9,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "tp1_ = data.time[1] - 0.01\n",
+    "tp4_ = data.time[3] - 0.01\n",
+    "\n",
+    "print(data.time[1])\n",
+    "print(data.time[3])\n",
+    "data.select_by_time(tp1_, tp4_).time\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([75628.521, 75628.571])"
+      ]
+     },
+     "execution_count": 17,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "data.t[75628.521:75628.621].time\n"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "alive",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.11"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tests/mixins/__init__.py b/tests/mixins/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/mixins/test_eq.py b/tests/mixins/test_eq.py
new file mode 100644
index 000000000..66a37ee4f
--- /dev/null
+++ b/tests/mixins/test_eq.py
@@ -0,0 +1,25 @@
+# OLD FILE. TESTS NOT YET FUNCTIONAL
+
+import bisect
+import os
+from dataclasses import dataclass, is_dataclass
+from pprint import pprint
+
+import numpy as np
+import pytest
+from typing_extensions import Self
+
+from eitprocessing.eit_data.draeger import DraegerEITData
+from eitprocessing.eit_data.vendor import Vendor
+from eitprocessing.mixins.slicing import SelectByIndex
+
+
+def test_eq():
+    data = DraegerEITData.from_path(
+        "/home/dbodor/git/EIT-ALIVE/eitprocessing/tests/test_data/Draeger_Test3.bin",
+    )
+    data2 = DraegerEITData.from_path(
+        "/home/dbodor/git/EIT-ALIVE/eitprocessing/tests/test_data/Draeger_Test3.bin",
+    )
+
+    data.isequivalent(data2)
diff --git a/tests/mixins/test_slicing.py b/tests/mixins/test_slicing.py
new file mode 100644
index 000000000..8592dde01
--- /dev/null
+++ b/tests/mixins/test_slicing.py
@@ -0,0 +1,36 @@
+import os
+
+import numpy as np
+import pytest
+
+from eitprocessing.eit_data.draeger import DraegerEITData
+
+environment = os.environ.get(
+    "EIT_PROCESSING_TEST_DATA",
+    os.path.abspath(os.path.dirname(os.path.dirname(__file__))),
+)
+data_directory = os.path.join(environment, "test_data")
+draeger_file1 = os.path.join(data_directory, "Draeger_Test3.bin")
+draeger_file2 = os.path.join(data_directory, "Draeger_Test.bin")
+timpel_file = os.path.join(data_directory, "Timpel_Test.txt")
+dummy_file = os.path.join(data_directory, "not_a_file.dummy")
+
+
+@pytest.fixture(scope="module")
+def draeger_data1():
+    return DraegerEITData.from_path(draeger_file1)
+
+
+def test_slicing(draeger_data1):
+    cutoff = 10
+    data: DraegerEITData = draeger_data1
+
+    assert data[cutoff] == data[cutoff]
+    assert data[0:cutoff] == data[:cutoff]
+    # assert data[cutoff : len(data)] == data[cutoff:]
+
+    # assert Sequence.merge(data[:cutoff], data[cutoff:]) == data
+    # assert len(data[:cutoff]) == cutoff
+
+    # assert len(data) == len(data[cutoff:]) + len(data[-cutoff:])
+    # assert len(data) == len(data[:cutoff]) + len(data[:-cutoff])
diff --git a/tests/test_butterworth_filter.py b/tests/test_butterworth_filter.py
index ff7b3ed15..9ddf4b402 100644
--- a/tests/test_butterworth_filter.py
+++ b/tests/test_butterworth_filter.py
@@ -1,22 +1,21 @@
-"""Tests for the Butterworth time domain filter"""
-from typing import Literal
-from typing import TypeAlias
+from typing import Literal, TypeAlias
+
 import numpy as np
 import pytest
 from scipy import signal
-from eitprocessing.filters.butterworth_filters import BandPassFilter
-from eitprocessing.filters.butterworth_filters import BandStopFilter
-from eitprocessing.filters.butterworth_filters import ButterworthFilter
-from eitprocessing.filters.butterworth_filters import HighPassFilter
-from eitprocessing.filters.butterworth_filters import LowPassFilter
 
+from eitprocessing.filters.butterworth_filters import (
+    BandPassFilter,
+    BandStopFilter,
+    ButterworthFilter,
+    HighPassFilter,
+    LowPassFilter,
+)
 
-SpecifiedFilter: TypeAlias = type[
-    LowPassFilter | HighPassFilter | BandPassFilter | BandStopFilter
-]
+SpecifiedFilter: TypeAlias = type[LowPassFilter | HighPassFilter | BandPassFilter | BandStopFilter]
 
 
-@pytest.fixture
+@pytest.fixture()
 def filter_arguments():
     return {
         "filter_type": "lowpass",
@@ -186,7 +185,7 @@ def test_specified_butterworth_equivalence(filter_arguments):
     assert filter7 == filter8
 
 
-def test_butterworth_functionality():  # pylint: disable=too-many-locals
+def test_butterworth_functionality():
     """Tests the functionality of the Butterworth filters.
 
     This function tests whether a filter created by initializing a ButterworthFilter does the same
@@ -196,7 +195,6 @@ def test_butterworth_functionality():  # pylint: disable=too-many-locals
     initialized, and is equal to using the normal scipy functions without the ButterworthFilter
     wrapper.
     """
-
     sample_frequency = 50
     freq_low = 1
     freq_medium = 4
@@ -223,9 +221,9 @@ def compare_filters(
         class_: SpecifiedFilter,
         data: np.ndarray,
         axis: int,
-    ):
+    ) -> None:
         """Compare filters created using ButterworthFilter to filters created using the
-        corresponding subclass
+        corresponding subclass.
 
         This function creates two filter instances, one using the ButterworthFilter, and one using
         either of the four subclasses. It then compares whether those filters are equal, and have
@@ -247,14 +245,20 @@ def compare_filters(
             sample_frequency=sample_frequency,
         )
         filter2 = class_(
-            cutoff_frequency=cutoff, order=order, sample_frequency=sample_frequency
+            cutoff_frequency=cutoff,
+            order=order,
+            sample_frequency=sample_frequency,
         )
         result1 = filter1.apply_filter(data, axis=axis)
         result2 = filter2.apply_filter(data, axis=axis)
         assert np.array_equal(result1, result2)
 
         sos = signal.butter(
-            order, cutoff, filter_type, fs=sample_frequency, output="sos"
+            order,
+            cutoff,
+            filter_type,
+            fs=sample_frequency,
+            output="sos",
         )
         scipy_result = signal.sosfiltfilt(sos, data, axis=axis)
         assert result1.shape == scipy_result.shape
@@ -271,8 +275,16 @@ def compare_filters(
         compare_filters(lowpass_cutoff, "lowpass", LowPassFilter, signal_, axis)
         compare_filters(highpass_cutoff, "highpass", HighPassFilter, signal_, axis)
         compare_filters(
-            (lowpass_cutoff, highpass_cutoff), "bandpass", BandPassFilter, signal_, axis
+            (lowpass_cutoff, highpass_cutoff),
+            "bandpass",
+            BandPassFilter,
+            signal_,
+            axis,
         )
         compare_filters(
-            (lowpass_cutoff, highpass_cutoff), "bandstop", BandStopFilter, signal_, axis
+            (lowpass_cutoff, highpass_cutoff),
+            "bandstop",
+            BandStopFilter,
+            signal_,
+            axis,
         )
diff --git a/tests/test_sequence.py b/tests/test_sequence.py
index a13f0a287..36716977f 100644
--- a/tests/test_sequence.py
+++ b/tests/test_sequence.py
@@ -1,152 +1,133 @@
-"""Tests for the eitprocessing.my_module module.
-"""
 import copy
 import os
-import pytest  # noqa
-from eitprocessing.binreader.sequence import DraegerSequence
-from eitprocessing.binreader.sequence import Sequence
-from eitprocessing.binreader.sequence import TimpelSequence
-from eitprocessing.binreader.sequence import Vendor
+from pathlib import Path
 
+import pytest
+
+from eitprocessing.eit_data import EITData, Vendor
+from eitprocessing.eit_data.draeger import DraegerEITData
+from eitprocessing.eit_data.timpel import TimpelEITData
+from eitprocessing.sequence import Sequence
 
 environment = os.environ.get(
     "EIT_PROCESSING_TEST_DATA",
-    os.path.abspath(os.path.dirname(os.path.dirname(__file__))),
+    Path.resolve(Path(__file__).parent.parent),
 )
-data_directory = os.path.join(environment, "tests", "test_data")
-draeger_file1 = os.path.join(data_directory, "Draeger_Test3.bin")
-draeger_file2 = os.path.join(data_directory, "Draeger_Test.bin")
-timpel_file = os.path.join(data_directory, "Timpel_Test.txt")
-dummy_file = os.path.join(data_directory, "not_a_file.dummy")
+data_directory = Path(environment) / "tests" / "test_data"
+draeger_file1 = Path(data_directory) / "Draeger_Test3.bin"
+draeger_file2 = Path(data_directory) / "Draeger_Test.bin"
+timpel_file = Path(data_directory) / "Timpel_Test.txt"
+dummy_file = Path(data_directory) / "not_a_file.dummy"
 
 
 @pytest.fixture(scope="module")
 def draeger_data1():
-    return Sequence.from_path(draeger_file1, vendor="draeger")
+    return Sequence(EITData.from_path(draeger_file1, vendor="draeger"))
 
 
 @pytest.fixture(scope="module")
 def draeger_data2():
-    return Sequence.from_path(draeger_file2, vendor="draeger")
+    return Sequence(EITData.from_path(draeger_file2, vendor="draeger"))
 
 
 @pytest.fixture(scope="module")
 def draeger_data_both():
-    return Sequence.from_path([draeger_file1, draeger_file2], vendor="draeger")
+    return Sequence(EITData.from_path([draeger_file2, draeger_file1], vendor="draeger"))
 
 
 @pytest.fixture(scope="module")
 def timpel_data():
-    return Sequence.from_path(timpel_file, vendor="timpel")
+    return Sequence(EITData.from_path(timpel_file, vendor="timpel"))
 
 
 @pytest.fixture()
 def timpel_data_double():
-    return Sequence.from_path([timpel_file, timpel_file], vendor="timpel")
+    return Sequence(EITData.from_path([timpel_file, timpel_file], vendor="timpel"))
 
 
 def test_from_path_draeger(
-    draeger_data1: DraegerSequence,
-    draeger_data2: DraegerSequence,
-    draeger_data_both: DraegerSequence,
+    draeger_data1: Sequence,
+    draeger_data2: Sequence,
+    draeger_data_both: Sequence,
 ):
-    assert isinstance(draeger_data1, DraegerSequence)
     assert isinstance(draeger_data1, Sequence)
-    assert not isinstance(draeger_data1, TimpelSequence)
-    assert draeger_data1.framerate == 20
-    assert len(draeger_data1) == len(draeger_data1.time)
-    assert len(draeger_data2.time) == 20740
+    assert isinstance(draeger_data1.eit_data["raw"], DraegerEITData)
+    assert draeger_data1.eit_data["raw"].framerate == 20  # noqa: PLR2004
+    assert len(draeger_data1.eit_data["raw"]) == len(draeger_data1.eit_data["raw"].time)
+    assert len(draeger_data2.eit_data["raw"].time) == 20740  # noqa: PLR2004
+
     assert draeger_data1 != draeger_data2
 
     # Load multiple
-    assert len(draeger_data_both) == len(draeger_data1) + len(draeger_data2)
-
-    draeger_inverted = Sequence.from_path(
-        path=[draeger_file2, draeger_file1], vendor="draeger"
+    assert len(draeger_data_both.eit_data["raw"]) == len(draeger_data1.eit_data["raw"]) + len(
+        draeger_data2.eit_data["raw"],
     )
-    assert len(draeger_data_both) == len(draeger_inverted)
-    assert draeger_data_both != draeger_inverted
+
+    # draeger_inverted = Sequence(EITData.from_path([draeger_file1, draeger_file2], vendor="draeger"))  #noqa: ERA001
+    # assert len(draeger_data_both) == len(draeger_inverted)  #noqa: ERA001
+    # assert draeger_data_both != draeger_inverted  #noqa: ERA001
 
 
 def test_from_path_timpel(
-    draeger_data1: DraegerSequence,
-    timpel_data: TimpelSequence,
-    timpel_data_double: TimpelSequence,
+    draeger_data1: Sequence,
+    timpel_data: Sequence,
+    # timpel_data_double: Sequence,  # does not currently work, because it won't load due to the time axes overlapping
 ):
-    using_vendor = Sequence.from_path(timpel_file, vendor=Vendor.TIMPEL)
+    using_vendor = Sequence(EITData.from_path(timpel_file, vendor=Vendor.TIMPEL))
     assert timpel_data == using_vendor
-    assert isinstance(timpel_data, TimpelSequence)
     assert isinstance(timpel_data, Sequence)
-    assert not isinstance(timpel_data, DraegerSequence)
-    assert timpel_data.vendor != draeger_data1.vendor
+    assert isinstance(timpel_data.eit_data["raw"], TimpelEITData)
+    assert timpel_data.eit_data["raw"].vendor != draeger_data1.eit_data["raw"].vendor
 
     # Load multiple
-    assert isinstance(timpel_data_double, TimpelSequence)
-    assert len(timpel_data_double) == 2 * len(timpel_data)
+    # assert isinstance(timpel_data_double, Sequence)  #noqa: ERA001
+    # assert len(timpel_data_double) == 2 * len(timpel_data)  #noqa: ERA001
 
 
 def test_illegal_from_path():
     # non existing
     for vendor in ["draeger", "timpel"]:
         with pytest.raises(FileNotFoundError):
-            _ = Sequence.from_path(dummy_file, vendor=vendor)
+            _ = Sequence(EITData.from_path(dummy_file, vendor=vendor))
 
     # incorrect vendor
     with pytest.raises(OSError):
-        _ = Sequence.from_path(draeger_file1, vendor="timpel")
+        _ = Sequence(EITData.from_path(draeger_file1, vendor="timpel"))
     with pytest.raises(OSError):
-        _ = Sequence.from_path(timpel_file, vendor="draeger")
-
-    # not implemented
-    with pytest.raises(NotImplementedError):
-        _ = Sequence.from_path(timpel_file, vendor="sentec")
-
-
-def test_illegal_vendor():
-    _ = DraegerSequence(vendor="draeger")
-    with pytest.raises(TypeError):
-        _ = DraegerSequence(vendor="timpel")
-        _ = DraegerSequence(vendor="sentec")
-
-    _ = TimpelSequence(vendor="timpel")
-    with pytest.raises(TypeError):
-        _ = TimpelSequence(vendor="draeger")
-        _ = TimpelSequence(vendor="sentec")
+        _ = Sequence(EITData.from_path(timpel_file, vendor="draeger"))
 
 
-def test_merge(  # pylint: disable=too-many-locals
-    draeger_data1: DraegerSequence,
-    draeger_data2: DraegerSequence,
-    draeger_data_both: DraegerSequence,
-    timpel_data: TimpelSequence,
-    timpel_data_double: TimpelSequence,
+def test_merge(
+    draeger_data1: Sequence,
+    draeger_data2: Sequence,
+    draeger_data_both: Sequence,
+    timpel_data: Sequence,
+    # timpel_data_double: Sequence,
 ):
-    merged_draeger = Sequence.merge(draeger_data1, draeger_data2)
-    assert len(merged_draeger) == len(draeger_data2) + len(draeger_data1)
+    merged_draeger = Sequence.concatenate(draeger_data2, draeger_data1)
+    assert len(merged_draeger.eit_data["raw"]) == len(draeger_data2.eit_data["raw"]) + len(
+        draeger_data1.eit_data["raw"],
+    )
     assert merged_draeger == draeger_data_both
-    added_draeger = draeger_data1 + draeger_data2
+    added_draeger = draeger_data2 + draeger_data1
     assert added_draeger == merged_draeger
 
-    draeger_load_double = Sequence.from_path([draeger_file1, draeger_file1], "draeger")
+    draeger_load_double = Sequence(EITData.from_path([draeger_file1, draeger_file1], "draeger"))
     draeger_merge_double = Sequence.merge(draeger_data1, draeger_data1)
     assert draeger_load_double == draeger_merge_double
     added_draeger_double = draeger_data1 + draeger_data1
     assert added_draeger_double == draeger_merge_double
 
     draeger_merged_twice = Sequence.merge(draeger_merge_double, draeger_merge_double)
-    draeger_load_four_times = Sequence.from_path([draeger_file1] * 4, "draeger")
-    assert (
-        isinstance(draeger_merged_twice.path, list)
-        and len(draeger_merged_twice.path) == 4
-    )
+    draeger_load_four_times = Sequence(EITData.from_path([draeger_file1] * 4, "draeger"))
+    assert isinstance(draeger_merged_twice.path, list)
+    assert len(draeger_merged_twice.path) == 4  # noqa: PLR2004
     assert draeger_merged_twice == draeger_load_four_times
 
     draeger_merge_thrice = Sequence.merge(draeger_merge_double, draeger_data1)
-    draeger_load_thrice = Sequence.from_path([draeger_file1] * 3, "draeger")
-    assert (
-        isinstance(draeger_merge_thrice.path, list)
-        and len(draeger_merge_thrice.path) == 3
-    )
+    draeger_load_thrice = Sequence(EITData.from_path([draeger_file1] * 3, "draeger"))
+    assert isinstance(draeger_merge_thrice.path, list)
+    assert len(draeger_merge_thrice.path) == 3  # noqa: PLR2004
     assert draeger_merge_thrice == draeger_load_thrice
     added_draeger_triple = draeger_data1 + draeger_data1 + draeger_data1
     assert draeger_merge_thrice == added_draeger_triple
@@ -159,35 +140,34 @@ def test_merge(  # pylint: disable=too-many-locals
 
     with pytest.raises(TypeError):
         _ = Sequence.merge(timpel_data, draeger_data1)
+
+    draeger_data1.framerate = 50
     with pytest.raises(ValueError):
-        draeger_data1.framerate = 50
         _ = Sequence.merge(draeger_data1, draeger_data2)
+
+    draeger_data1.vendor = Vendor.TIMPEL
     with pytest.raises(ValueError):
         # TODO (#77): update this to AttributeError, once equivalence check for
         # framesets is implemented.
-        draeger_data1.vendor = Vendor.TIMPEL
         _ = Sequence.merge(draeger_data1, timpel_data)
 
 
 def test_copy(
-    draeger_data1: DraegerSequence,
-    timpel_data: TimpelSequence,
+    draeger_data1: Sequence,
+    timpel_data: Sequence,
 ):
     data: Sequence
     for data in [draeger_data1, timpel_data]:
-        print(data.vendor)
-        data_copy = data.deepcopy()
+        data_copy = copy.deepcopy(data)
         assert data == data_copy
 
 
 def test_equals(
-    draeger_data1: DraegerSequence,
-    timpel_data: TimpelSequence,
+    draeger_data1: Sequence,
+    timpel_data: Sequence,
 ):
     data: Sequence
     for data in [draeger_data1, timpel_data]:
-        print(data.vendor)
-
         data_copy = Sequence()
         data_copy.path = copy.deepcopy(data.path)
         data_copy.time = copy.deepcopy(data.time)
@@ -221,18 +201,19 @@ def test_equals(
 
 
 def test_slicing(
-    draeger_data1: DraegerSequence,
-    timpel_data: TimpelSequence,
+    draeger_data1: Sequence,
+    timpel_data: Sequence,
 ):
     cutoff = 100
 
     data: Sequence
     for data in [draeger_data1, timpel_data]:
-        print(data.vendor)
         assert data[0:cutoff] == data[:cutoff]
         assert data[cutoff : len(data)] == data[cutoff:]
 
-        assert Sequence.merge(data[:cutoff], data[cutoff:]) == data
+        concatenated = Sequence.concatenate(data[:cutoff], data[cutoff:])
+        concatenated.eit_data["raw"].path = data.eit_data["raw"].path
+        assert concatenated == data
         assert len(data[:cutoff]) == cutoff
 
         assert len(data) == len(data[cutoff:]) + len(data[-cutoff:])
@@ -240,8 +221,8 @@ def test_slicing(
 
 
 def test_load_partial(
-    draeger_data2: DraegerSequence,
-    timpel_data: TimpelSequence,
+    draeger_data2: Sequence,
+    timpel_data: Sequence,
 ):
     cutoff = 58
     # Keep cutoff at 58 for draeger_data2 as there is an event mark at this
@@ -254,8 +235,8 @@ def test_load_partial(
     # file for this situation.
 
     # Timpel
-    timpel_first_part = Sequence.from_path(timpel_file, "timpel", max_frames=cutoff)
-    timpel_second_part = Sequence.from_path(timpel_file, "timpel", first_frame=cutoff)
+    timpel_first_part = Sequence(EITData.from_path(timpel_file, "timpel", max_frames=cutoff))
+    timpel_second_part = Sequence(EITData.from_path(timpel_file, "timpel", first_frame=cutoff))
 
     assert timpel_first_part == timpel_data[:cutoff]
     assert timpel_second_part == timpel_data[cutoff:]
@@ -263,9 +244,14 @@ def test_load_partial(
     assert Sequence.merge(timpel_second_part, timpel_first_part) != timpel_data
 
     # Draeger
-    draeger_first_part = Sequence.from_path(draeger_file2, "draeger", max_frames=cutoff)
-    draeger_second_part = Sequence.from_path(
-        draeger_file2, "draeger", first_frame=cutoff
+    draeger_first_part = Sequence(EITData.from_path(draeger_file2, "draeger", max_frames=cutoff))
+    draeger_second_part = Sequence(
+        "",
+        EITData.from_path(
+            draeger_file2,
+            "draeger",
+            first_frame=cutoff,
+        ),
     )
 
     assert draeger_first_part == draeger_data2[:cutoff]
@@ -277,14 +263,14 @@ def test_load_partial(
 def test_illegal_first():
     for ff in [0.5, -1, "fdw"]:
         with pytest.raises((TypeError, ValueError)):
-            _ = Sequence.from_path(draeger_file1, "draeger", first_frame=ff)
+            _ = Sequence(EITData.from_path(draeger_file1, "draeger", first_frame=ff))
 
     for ff2 in [0, 0.0, 1.0, None]:
-        _ = Sequence.from_path(draeger_file1, "draeger", first_frame=ff2)
+        _ = Sequence(EITData.from_path(draeger_file1, "draeger", first_frame=ff2))
 
 
 def test_select_by_time(
-    draeger_data2: DraegerSequence,
+    draeger_data2: Sequence,
 ):
     # TODO (#82): this function is kinda ugly. Would be nice to refactor it
     # but I am struggling to think of a logical way to loop through.
@@ -370,100 +356,71 @@ def test_select_by_time(
 
 
 def test_label(
-    draeger_data1: DraegerSequence,
-    draeger_data2: DraegerSequence,
+    draeger_data1: Sequence,
+    draeger_data2: Sequence,
 ):
     assert isinstance(draeger_data1.label, str), "default label is not a string"
-    assert (
-        draeger_data1.label == f"Sequence_{id(draeger_data1)}"
-    ), "unexpected default label"
+    assert draeger_data1.label == f"Sequence_{id(draeger_data1)}", "unexpected default label"
 
-    assert (
-        draeger_data1.label != draeger_data2.label
-    ), "different data has identical label"
+    assert draeger_data1.label != draeger_data2.label, "different data has identical label"
 
-    timpel_1 = Sequence.from_path(timpel_file, vendor="timpel")
-    timpel_2 = Sequence.from_path(timpel_file, vendor="timpel")
+    timpel_1 = Sequence(EITData.from_path(timpel_file, vendor="timpel"))
+    timpel_2 = Sequence(EITData.from_path(timpel_file, vendor="timpel"))
     assert timpel_1.label != timpel_2.label, "reloaded data has identical label"
 
     test_label = "test_label"
-    timpel_3 = Sequence.from_path(timpel_file, vendor="timpel", label=test_label)
-    timpel_4 = Sequence.from_path(timpel_file, vendor="timpel", label=test_label)
+    timpel_3 = Sequence(EITData.from_path(timpel_file, vendor="timpel", label=test_label))
+    timpel_4 = Sequence(EITData.from_path(timpel_file, vendor="timpel", label=test_label))
     assert timpel_3.label == test_label, "label attribute does not match given label"
-    assert (
-        timpel_3.label == timpel_4.label
-    ), "re-used test label not recognized as identical"
+    assert timpel_3.label == timpel_4.label, "re-used test label not recognized as identical"
 
     timpel_copy = timpel_1.deepcopy()
     assert timpel_1.label != timpel_copy.label, "deepcopied data has identical label"
-    assert (
-        timpel_copy.label == f"Copy of <{timpel_1.label}>"
-    ), "deepcopied data has unexpected label"
+    assert timpel_copy.label == f"Copy of <{timpel_1.label}>", "deepcopied data has unexpected label"
     timpel_copy_relabel = timpel_1.deepcopy(label=test_label)
-    assert (
-        timpel_1.label != timpel_copy_relabel.label
-    ), "deepcopied data with new label has identical label"
+    assert timpel_1.label != timpel_copy_relabel.label, "deepcopied data with new label has identical label"
     timpel_copy_relabel = timpel_1.deepcopy(relabel=False)
-    assert (
-        timpel_1.label == timpel_copy_relabel.label
-    ), "deepcopied data did not keep old label"
+    assert timpel_1.label == timpel_copy_relabel.label, "deepcopied data did not keep old label"
     timpel_copy_relabel = timpel_1.deepcopy(label=test_label, relabel=False)
-    assert (
-        timpel_1.label != timpel_copy_relabel.label
-    ), "combo of label and relabel not working as intended"
+    assert timpel_1.label != timpel_copy_relabel.label, "combo of label and relabel not working as intended"
 
 
 def test_relabeling(
-    timpel_data: TimpelSequence,
-    draeger_data2: DraegerSequence,
+    timpel_data: Sequence,
+    draeger_data2: Sequence,
 ):
     test_label = "test label"
 
     # merging
     merged_timpel = Sequence.merge(timpel_data, timpel_data)
+    assert merged_timpel.label != timpel_data.label, "merging does not assign new label by default"
     assert (
-        merged_timpel.label != timpel_data.label
-    ), "merging does not assign new label by default"
-    assert (
-        merged_timpel.label
-        == f"Merge of <{timpel_data.label}> and <{timpel_data.label}>"
+        merged_timpel.label == f"Merge of <{timpel_data.label}> and <{timpel_data.label}>"
     ), "merging generates unexpected default label"
     added_timpel = timpel_data + timpel_data
     assert (
-        added_timpel.label
-        == f"Merge of <{timpel_data.label}> and <{timpel_data.label}>"
+        added_timpel.label == f"Merge of <{timpel_data.label}> and <{timpel_data.label}>"
     ), "adding generates unexpected default label"
     merged_timpel_2 = Sequence.merge(timpel_data, timpel_data, label=test_label)
-    assert (
-        merged_timpel_2.label == test_label
-    ), "incorrect label assigned when merging data with new label"
+    assert merged_timpel_2.label == test_label, "incorrect label assigned when merging data with new label"
 
     # slicing
     indices = slice(0, 10)
     sliced_timpel = timpel_data[indices]
+    assert sliced_timpel.label != timpel_data.label, "slicing does not assign new label by default"
     assert (
-        sliced_timpel.label != timpel_data.label
-    ), "slicing does not assign new label by default"
-    assert (
-        sliced_timpel.label
-        == f"Slice ({indices.start}-{indices.stop}) of <{timpel_data.label}>"
+        sliced_timpel.label == f"Slice ({indices.start}-{indices.stop}) of <{timpel_data.label}>"
     ), "slicing generates unexpected default label"
     sliced_timpel_2 = timpel_data.select_by_index(indices=indices, label=test_label)
-    assert (
-        sliced_timpel_2.label == test_label
-    ), "incorrect label assigned when slicing data with new label"
+    assert sliced_timpel_2.label == test_label, "incorrect label assigned when slicing data with new label"
 
     # select_by_time
     t22 = 55825.268
     t52 = 55826.768
     time_sliced = draeger_data2.select_by_time(t22, t52 + 0.001)
-    assert (
-        time_sliced.label != draeger_data2.label
-    ), "time slicing does not assign new label by default"
+    assert time_sliced.label != draeger_data2.label, "time slicing does not assign new label by default"
     assert (
         time_sliced.label == f"Slice (22-52) of <{draeger_data2.label}>"
     ), "slicing generates unexpected default label"
     time_sliced_2 = draeger_data2.select_by_time(t22, t52, label=test_label)
-    assert (
-        time_sliced_2.label == test_label
-    ), "incorrect label assigned when time slicing data with new label"
+    assert time_sliced_2.label == test_label, "incorrect label assigned when time slicing data with new label"