diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 24b12ee57447094bec3d20ea77940cf6c7091e56..6a5606c681ea6fd39476929d9034592d65fdd4d8 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -65,6 +65,25 @@ check-clang-format:
     - if: $CI_COMMIT_BRANCH
   allow_failure: true
 
+check-python-quality:
+  image: corsika/devel:u-22.04
+  stage: quality
+  tags:
+    - corsika
+  script:
+    - cd ${CI_PROJECT_DIR}/python  # change into the Python directory
+    - pip3 install --user -e '.[test]'  # install the package + test deps
+    - python3 --version
+    - python3 -m black -t py37 --check corsika tests
+    - python3 -m flake8 -v corsika tests
+    - python3 -m isort --atomic --check-only corsika tests
+    - python3 -m mypy corsika
+  rules:
+    - if: $CI_MERGE_REQUEST_ID
+    - if: $CI_COMMIT_TAG
+    - if: $CI_COMMIT_BRANCH
+  allow_failure: true
+
 ### CodeQuality tool ####
 #include:
 #  - template: Code-Quality.gitlab-ci.yml
@@ -76,7 +95,8 @@ check-clang-format:
 #      when: never
 #    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # Run code quality job in merge request pipelines
 #    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'      # Run code quality job in pipelines on the master branch (but not in other branch pipelines)
-#    - if: '$CI_COMMIT_TAG'                               # Run code quality job in pipelines for tags
+#    - if: '$CI_COMMIT_TAG' 
+
 
 ####### CONFIG ##############
 
@@ -163,13 +183,11 @@ build_test-clang-14:
       artifacts: false
 
 
-
 ####### BUILD-TEST-EXAMPLE (only non-Draft/non-WIP)  ##############
 
 ##########################################################
 # generic example template job
 # normal pipeline for each commit
-# artefacts are needed as input for python jobs
 .build_test_example:
   stage: build_test_example
   tags:
@@ -204,8 +222,10 @@ build_test-clang-14:
       junit:
         - ${CI_PROJECT_DIR}/build/test_outputs/junit*.xml
     paths:
-      - ${CI_PROJECT_DIR}/build/build_examples/example_outputs
+      - ${CI_PROJECT_DIR}/build/build_examples/example_outputs #python tests need this
       - ${CI_PROJECT_DIR}/build/test_outputs/junit*.xml
+      - ${CI_PROJECT_DIR}/build/CMakeCache.txt #python tests need this
+      - ${CI_PROJECT_DIR}/build/bin #python tests need this
 
 # build_test_example for gcc
 build_test_example-u-22_04:
@@ -225,9 +245,6 @@ build_test_example-clang-14:
       artifacts: false
 
 
-
-
-
 ####### OPTIONAL ##############
 
 ##########################################################
@@ -288,7 +305,7 @@ release-full-clang-14:
       artifacts: false
 
 
-
+###### COVERAGE ##########
 
 ##########################################################
 # the coverage generation should either run when manually requested, OR always on the master
@@ -330,7 +347,7 @@ coverage:
 
 
 
-
+######  SANITY ##########
 
 ##########################################################
 sanity:
@@ -378,19 +395,17 @@ sanity:
     - python3 -m black -t py37 corsika tests
     - python3 -m flake8 corsika tests
     - python3 -m pytest --cov=corsika tests
-    - cd ${CI_PROJECT_DIR}  # reset the directory
   coverage: '/^TOTAL\s*\d+\s*\d+\s*(.*\%)/'
 
-# the default Python version Ubuntu 22.04 is Python3.8
-python-3.8:
+python-tests:
   extends: .python
-  image: corsika/analysis:python-3.9.5
+  image: corsika/devel:u-22.04
   needs:
     - job: build_test_example-u-22_04
       artifacts: true
   artifacts:
     when: always
-    expire_in: 3 month
+    expire_in: 1 month
     paths:
-      - ${CI_PROJECT_DIR}/Python/python-test.log
-  allow_failure: true
+      - ${CI_PROJECT_DIR}/python/python-test.log
+  allow_failure: false
diff --git a/modules/data b/modules/data
index 331c0aad7c6c17fe456c4a80f8b2575ef341ce90..aa320e6a1203436766a374878e83e3685eae78e4 160000
--- a/modules/data
+++ b/modules/data
@@ -1 +1 @@
-Subproject commit 331c0aad7c6c17fe456c4a80f8b2575ef341ce90
+Subproject commit aa320e6a1203436766a374878e83e3685eae78e4
diff --git a/python/corsika/io/__init__.py b/python/corsika/io/__init__.py
index 296572d6dee46a4b95a61b8ef18c549f120a1205..7c8d197d724ed4a45e6f51b2a6ae31e8c064ee27 100644
--- a/python/corsika/io/__init__.py
+++ b/python/corsika/io/__init__.py
@@ -8,8 +8,7 @@
  the license.
 """
 
-from .hist import read_hist
 from .library import Library
 
 # all exported objects
-__all__ = ["read_hist", "Library"]
+__all__ = ["Library"]
diff --git a/python/corsika/io/converters/__init__.py b/python/corsika/io/converters/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..034aa1d4643e1cf19fe728e21790c4862b0626e3
--- /dev/null
+++ b/python/corsika/io/converters/__init__.py
@@ -0,0 +1,9 @@
+"""
+ The 'io' module provides for reading CORSIKA8 output files.
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
diff --git a/python/corsika/io/converters/arrow_to_numpy.py b/python/corsika/io/converters/arrow_to_numpy.py
new file mode 100644
index 0000000000000000000000000000000000000000..dddb34b8a740633755d38e055dd2c5e507a8df3d
--- /dev/null
+++ b/python/corsika/io/converters/arrow_to_numpy.py
@@ -0,0 +1,59 @@
+"""
+ Converter for the pyarrow data types to numpy ones
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+import numpy as np
+import pyarrow
+
+
+def convert_to_numpy(pyarrow_table: pyarrow.lib.Table) -> np.ndarray:
+    """
+    Converts a pyarrow Table to a numpy structured array
+
+    Parameters
+    ----------
+    pyarrow_table: pyarrow.lib.Table
+        PyArrow table of any dimension to be sliced
+
+    Returns
+    -------
+    np.ndarray:
+        converted table with the same column labels and data types
+
+    """
+
+    # Type conversions for pyarrow data types to numpy ones
+    # https://arrow.apache.org/docs/python/data.html
+    # https://numpy.org/doc/stable/reference/arrays.dtypes.html#arrays-dtypes-constructing
+    type_conversions = {
+        pyarrow.int8(): "int8",
+        pyarrow.int16(): "int16",
+        pyarrow.int32(): "int32",
+        pyarrow.int64(): "int64",
+        pyarrow.uint8(): "uint8",
+        pyarrow.uint16(): "uint16",
+        pyarrow.uint32(): "uint32",
+        pyarrow.uint64(): "uint64",
+        pyarrow.float16(): "float16",
+        pyarrow.float32(): "float32",
+        pyarrow.float64(): "float64",
+    }
+
+    # Perform type conversion of all fields
+    column_types = [
+        type_conversions[pyarrow_table[key].type] for key in pyarrow_table.column_names
+    ]
+    dtypes = [(x, y) for (x, y) in zip(pyarrow_table.column_names, column_types)]
+
+    # Make an empty array and then fill the values
+    np_table = np.zeros(pyarrow_table.num_rows, dtype=dtypes)
+    for key in pyarrow_table.column_names:
+        np_table[key] = pyarrow_table[key]
+
+    return np_table
diff --git a/python/corsika/io/hist.py b/python/corsika/io/hist.py
deleted file mode 100644
index afa1418418f636eac2f00d77c58d58b5808c4875..0000000000000000000000000000000000000000
--- a/python/corsika/io/hist.py
+++ /dev/null
@@ -1,76 +0,0 @@
-"""
- This file supports reading boost_histograms from CORSIKA8.
-
- (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
-
- This software is distributed under the terms of the GNU General Public
- Licence version 3 (GPL Version 3). See file LICENSE for a full version of
- the license.
-"""
-
-import boost_histogram as bh
-import numpy as np
-
-
-def read_hist(filename: str) -> bh.Histogram:
-    """
-    Read a histogram produced with CORSIKA8's `SaveBoostHistogram()` function.
-
-    Parameters
-    ----------
-    filename: str
-        The filename of the .npy file containing the histogram.
-
-    Returns
-    -------
-    hist: bh.Histogram
-        An initialized bh.Histogram instance.
-
-    Throws
-    ------
-    ValueError:
-        If the histogram type is not supported.
-    """
-
-    # load the filenames
-    d = np.load(filename)
-
-    # extract the axis and overflows information
-    axistypes = d["axistypes"].view("c")
-    overflow = d["overflow"]
-    underflow = d["underflow"]
-
-    # this is where we store the axes that we extract from the file.
-    axes = []
-
-    # we now loop over the axes
-    for i, (at, has_overflow, has_underflow) in enumerate(
-        zip(axistypes, overflow, underflow)
-    ):
-
-        # continuous axis
-        if at == b"c":
-            axes.append(
-                bh.axis.Variable(
-                    d[f"binedges_{i}"], overflow=has_overflow, underflow=has_underflow
-                )
-            )
-        # discrete axis
-        elif at == b"d":
-            axes.append(bh.axis.IntCategory(d[f"bins_{i}"], growth=(not has_overflow)))
-
-        # and unknown histogram type
-        else:
-            raise ValueError(f"'{at}' is not a valid C8 histogram axistype.")
-
-    # create the histogram and fill it in
-    data = d["data"]
-    if data.dtype.names: # structured array -> mean and variance
-        h = bh.Histogram(*axes, storage=bh.storage.Weight())
-        h.view(flow=True)[:]['value'] = data['mean']
-        h.view(flow=True)[:]['variance'] = data['variance']
-    else:
-        h = bh.Histogram(*axes)
-        h.view(flow=True)[:] = data
-
-    return h
diff --git a/python/corsika/io/library.py b/python/corsika/io/library.py
index 1c2ff38e025e9a728bde4b9c7a5e0cad5982deb1..486181e5a7422e811721370955383385e8165526 100644
--- a/python/corsika/io/library.py
+++ b/python/corsika/io/library.py
@@ -58,13 +58,6 @@ class Library(object):
         """
         return list(self.__outputs.keys())
 
-    @property
-    def modules(self) -> Dict[str, str]:
-        """
-        Return the list of registered outputs.
-        """
-        pass
-
     def get(self, name: str) -> Optional[outputs.Output]:
         """
         Return the output with a given name.
@@ -73,7 +66,7 @@ class Library(object):
             return self.__outputs[name]
         else:
             msg = f"Output with name '{name}' not available in this library."
-            logging.getLogger("corsika").warn(msg)
+            logging.getLogger("corsika").warning(msg)
             return None
 
     @staticmethod
@@ -181,7 +174,6 @@ class Library(object):
 
         # loop over the subdirectories
         for subdir in dirs:
-
             # read the config file for this output
             config = Library.load_config(op.join(path, subdir))
 
@@ -197,13 +189,12 @@ class Library(object):
                     f"'{subdir}' does not contain a valid config."
                     "Missing 'type' or 'name' keyword."
                 )
-                logging.getLogger("corsika").warn(msg)
+                logging.getLogger("corsika").warning(msg)
                 continue  # skip to the next output, don't error
 
             # we now have a valid component type, get the corresponding
             # type from the proccesses subdirectory
             try:
-
                 # instantiate the output and store it in our dict
                 component = getattr(outputs, out_type)(op.join(path, subdir))
 
@@ -213,7 +204,7 @@ class Library(object):
                         f"'{name}' encountered an error while reading. "
                         "This process will be not be loaded."
                     )
-                    logging.getLogger("corsika").warn(msg)
+                    logging.getLogger("corsika").warning(msg)
                 else:
                     components[name] = component
 
@@ -222,8 +213,8 @@ class Library(object):
                     f"Unable to instantiate an instance of '{out_type}' "
                     f"for a process called '{name}'"
                 )
-                logging.getLogger("corsika").warn(msg)
-                logging.getLogger("corsika").warn(e)
+                logging.getLogger("corsika").warning(msg)
+                logging.getLogger("corsika").warning(e)
                 continue  # skip to the next output, don't error
 
         # and we are done building - return the constructed outputs
diff --git a/python/corsika/io/outputs/__init__.py b/python/corsika/io/outputs/__init__.py
index 81ecd786e25aef93a2b84cea6e6419b133078518..54731dd42d775073d5d15e3d21e914706b5a7755 100644
--- a/python/corsika/io/outputs/__init__.py
+++ b/python/corsika/io/outputs/__init__.py
@@ -7,16 +7,15 @@
  the license.
 """
 
-from .observation_plane import ObservationPlane
-from .track_writer import TrackWriter
-from .longitudinal_profile import LongitudinalProfile
 from .bethe_bloch import BetheBlochPDG
-from .particle_cut import ParticleCut
 from .energy_loss import EnergyLoss
+from .longitudinal_profile import LongitudinalProfile
+from .observation_plane import ObservationPlane
 from .output import Output
+from .particle_cut import ParticleCut
+from .primary import Particle, PrimaryParticle
 from .radio_process import RadioProcess
 from .track_writer import TrackWriter
-from .primary import PrimaryParticle, Particle
 
 __all__ = [
     "Output",
@@ -25,7 +24,8 @@ __all__ = [
     "LongitudinalProfile",
     "BetheBlochPDG",
     "ParticleCut",
-    "EnergyLoss" "RadioProcess",
+    "EnergyLoss",
+    "RadioProcess",
     "PrimaryParticle",
     "Particle",
 ]
diff --git a/python/corsika/io/outputs/bethe_bloch.py b/python/corsika/io/outputs/bethe_bloch.py
index b12d29036de5553d139c9cee0f7c8120f4fd4f7b..42e65943c4a2ac5e82752534a996e2d018edf089 100644
--- a/python/corsika/io/outputs/bethe_bloch.py
+++ b/python/corsika/io/outputs/bethe_bloch.py
@@ -13,6 +13,7 @@ from typing import Any
 
 import pyarrow.parquet as pq
 
+from ..converters import arrow_to_numpy
 from .output import Output
 
 
@@ -72,11 +73,13 @@ class BetheBlochPDG(Output):
             return self.__data
         elif dtype == "pandas":
             return self.__data.to_pandas()
+        elif dtype == "numpy":
+            return arrow_to_numpy.convert_to_numpy(self.__data)
         else:
             raise ValueError(
                 (
                     f"Unknown format '{dtype}' for BetheBlochPDG. "
-                    "We currently only support ['arrow', 'pandas']."
+                    "We currently only support ['arrow', 'pandas', 'numpy']."
                 )
             )
 
diff --git a/python/corsika/io/outputs/energy_loss.py b/python/corsika/io/outputs/energy_loss.py
index 76846dd2c83d497f68aaf38d3f3e4823b91ee8a3..82b98b0fe2d02530b10231890341359161d4c418 100644
--- a/python/corsika/io/outputs/energy_loss.py
+++ b/python/corsika/io/outputs/energy_loss.py
@@ -13,6 +13,7 @@ from typing import Any
 
 import pyarrow.parquet as pq
 
+from ..converters import arrow_to_numpy
 from .output import Output
 
 
@@ -72,11 +73,13 @@ class EnergyLoss(Output):
             return self.__data
         elif dtype == "pandas":
             return self.__data.to_pandas()
+        elif dtype == "numpy":
+            return arrow_to_numpy.convert_to_numpy(self.__data)
         else:
             raise ValueError(
                 (
                     f"Unknown format '{dtype}' for EnergyLoss. "
-                    "We currently only support ['arrow', 'pandas']."
+                    "We currently only support ['arrow', 'pandas', 'numpy']."
                 )
             )
 
@@ -84,4 +87,4 @@ class EnergyLoss(Output):
         """
         Return a string representation of this class.
         """
-        return f"EnergyLess('{self.config['name']}')"
+        return f"EnergyLoss('{self.config['name']}')"
diff --git a/python/corsika/io/outputs/longitudinal_profile.py b/python/corsika/io/outputs/longitudinal_profile.py
index 00000567d827a12e94c198a0ef1102b1c010a74e..f4126c05a30d2460bdb3c1e1534a01b57a38d8fb 100644
--- a/python/corsika/io/outputs/longitudinal_profile.py
+++ b/python/corsika/io/outputs/longitudinal_profile.py
@@ -13,6 +13,7 @@ from typing import Any
 
 import pyarrow.parquet as pq
 
+from ..converters import arrow_to_numpy
 from .output import Output
 
 
@@ -72,11 +73,13 @@ class LongitudinalProfile(Output):
             return self.__data
         elif dtype == "pandas":
             return self.__data.to_pandas()
+        elif dtype == "numpy":
+            return arrow_to_numpy.convert_to_numpy(self.__data)
         else:
             raise ValueError(
                 (
                     f"Unknown format '{dtype}' for LongitudinalProfile. "
-                    "We currently only support ['arrow', 'pandas']."
+                    "We currently only support ['arrow', 'pandas', 'numpy']."
                 )
             )
 
diff --git a/python/corsika/io/outputs/observation_plane.py b/python/corsika/io/outputs/observation_plane.py
index 271d145b4f076f1c7d747d6cc5e05b4a183d3655..d98c31646d73dad382e23e17070a0aa4b27df0c2 100644
--- a/python/corsika/io/outputs/observation_plane.py
+++ b/python/corsika/io/outputs/observation_plane.py
@@ -13,6 +13,7 @@ from typing import Any
 
 import pyarrow.parquet as pq
 
+from ..converters import arrow_to_numpy
 from .output import Output
 
 
@@ -72,11 +73,13 @@ class ObservationPlane(Output):
             return self.__data
         elif dtype == "pandas":
             return self.__data.to_pandas()
+        elif dtype == "numpy":
+            return arrow_to_numpy.convert_to_numpy(self.__data)
         else:
             raise ValueError(
                 (
                     f"Unknown format '{dtype}' for ObservationPlane. "
-                    "We currently only support ['arrow', 'pandas']."
+                    "We currently only support ['arrow', 'pandas', 'numpy']."
                 )
             )
 
diff --git a/python/corsika/io/outputs/particle_cut.py b/python/corsika/io/outputs/particle_cut.py
index a55b5fe718cf788fdba051dfa891e77b2d75ede7..d797923374febd1098126dc04f9d9541e886285d 100644
--- a/python/corsika/io/outputs/particle_cut.py
+++ b/python/corsika/io/outputs/particle_cut.py
@@ -13,6 +13,7 @@ from typing import Any
 
 import pyarrow.parquet as pq
 
+from ..converters import arrow_to_numpy
 from .output import Output
 
 
@@ -72,11 +73,13 @@ class ParticleCut(Output):
             return self.__data
         elif dtype == "pandas":
             return self.__data.to_pandas()
+        elif dtype == "numpy":
+            return arrow_to_numpy.convert_to_numpy(self.__data)
         else:
             raise ValueError(
                 (
                     f"Unknown format '{dtype}' for ParticleCut. "
-                    "We currently only support ['arrow', 'pandas']."
+                    "We currently only support ['arrow', 'pandas', 'numpy']."
                 )
             )
 
diff --git a/python/corsika/io/outputs/radio_process.py b/python/corsika/io/outputs/radio_process.py
index 62ceb3f1905ccaf938b1a6f8fba799e655eb7750..152b6d6347a5803481bb519f016c9f10deeebc1c 100644
--- a/python/corsika/io/outputs/radio_process.py
+++ b/python/corsika/io/outputs/radio_process.py
@@ -10,8 +10,9 @@
 import logging
 import os.path as op
 from typing import Any
-import pyarrow.parquet as pq
+
 import pandas as pd
+import pyarrow.parquet as pq
 
 from .output import Output
 
@@ -47,8 +48,7 @@ class RadioProcess(Output):
                 f"An error occured loading a RadioProcess: {e}"
             )
 
-
-    def load_data(self, path: str):
+    def load_data(self, path: str) -> dict:
         """
         Load the data associated with this radio process.
 
@@ -59,7 +59,7 @@ class RadioProcess(Output):
 
         """
         data = pq.read_table(op.join(path, "antennas.parquet"))
-        nshowers = data.to_pandas()['shower'].iloc[-1] + 1
+        nshowers = data.to_pandas()["shower"].iloc[-1] + 1
         antennas = list(self.config["antennas"].keys())
 
         # check that we got some events
@@ -78,13 +78,17 @@ class RadioProcess(Output):
             # loop over each of the antennas
             for name in antennas:
                 sampling_period = self.config["antennas"][name]["number of bins"]
-                antenna_data = data[ant_nr*sampling_period:(ant_nr+1)*sampling_period].to_pandas()
+                start = ant_nr * sampling_period
+                stop = (ant_nr + 1) * sampling_period
+                antenna_data = data[start:stop].to_pandas()
                 times = antenna_data["Time"]
                 Ex = antenna_data["Ex"]
                 Ey = antenna_data["Ey"]
                 Ez = antenna_data["Ez"]
-                dictionary[name] = pd.DataFrame({'time': times, 'Ex': Ex, 'Ey': Ey, 'Ez': Ez})
-                ant_nr = ant_nr+ 1
+                dictionary[name] = pd.DataFrame(
+                    {"time": times, "Ex": Ex, "Ey": Ey, "Ez": Ez}
+                )
+                ant_nr = ant_nr + 1
 
             dataset[str(i)] = dictionary
 
@@ -126,14 +130,14 @@ class RadioProcess(Output):
                 )
             )
 
-    def get_antenna_list(self):
+    def get_antenna_list(self) -> list:
         """
         Return a list with the names of the antennas of this RadioProcess.
         """
         antennas = list(self.config["antennas"].keys())
         return antennas
 
-    def get_antennas(self):
+    def get_antennas(self) -> dict:
         """
         Return a pandas dataframe with all the information for the antennas
         of this RadioProcess. This information is shower number, antenna
@@ -150,13 +154,22 @@ class RadioProcess(Output):
             nr_bins = self.config["antennas"][name]["number of bins"]
             sampling_frequency = self.config["antennas"][name]["sampling frequency"]
             location = self.config["antennas"][name]["location"]
-            dictionary[name] = pd.DataFrame({'type': type, 'start time': start_time, 'duration': duration,
-                                             'number of bins': nr_bins, 'sampling frequency': sampling_frequency,
-                                             'x': [location[0]], 'y': [location[1]], 'z': [location[2]]})
+            dictionary[name] = pd.DataFrame(
+                {
+                    "type": type,
+                    "start time": start_time,
+                    "duration": duration,
+                    "number of bins": nr_bins,
+                    "sampling frequency": sampling_frequency,
+                    "x": [location[0]],
+                    "y": [location[1]],
+                    "z": [location[2]],
+                }
+            )
 
         return dictionary
 
-    def get_units(self):
+    def get_units(self) -> dict:
         """
         Return the units of the antennas of this RadioProcess.
         """
diff --git a/python/corsika/io/outputs/track_writer.py b/python/corsika/io/outputs/track_writer.py
index 0859660d590182578e08ad4beaef5bb9a5c53498..7b4fd4f414d56f6716817ddb78c688806f5d9f56 100644
--- a/python/corsika/io/outputs/track_writer.py
+++ b/python/corsika/io/outputs/track_writer.py
@@ -13,6 +13,7 @@ from typing import Any
 
 import pyarrow.parquet as pq
 
+from ..converters import arrow_to_numpy
 from .output import Output
 
 
@@ -72,11 +73,13 @@ class TrackWriter(Output):
             return self.__data
         elif dtype == "pandas":
             return self.__data.to_pandas()
+        elif dtype == "numpy":
+            return arrow_to_numpy.convert_to_numpy(self.__data)
         else:
             raise ValueError(
                 (
                     f"Unknown format '{dtype}' for TrackWriter. "
-                    "We currently only support ['arrow', 'pandas']."
+                    "We currently only support ['arrow', 'pandas', 'numpy']."
                 )
             )
 
diff --git a/python/setup.cfg b/python/setup.cfg
index 2c8d668ffdf9a3a8135dc52fc6e4c79b66c06e72..e0a574a473491369997c050a47a5fc51c9a9e570 100644
--- a/python/setup.cfg
+++ b/python/setup.cfg
@@ -67,3 +67,7 @@ ignore_missing_imports = True
 # ignore missing types for pyarow
 [mypy-pyarrow.*]
 ignore_missing_imports = True
+
+# ignore missing types for pandas
+[mypy-pandas.*]
+ignore_missing_imports = True
diff --git a/python/setup.py b/python/setup.py
index c1460c9d6cb5f759b50abd609e31061ef0d1751b..20da6eaa249c572540bd66ba574a4995705d5578 100644
--- a/python/setup.py
+++ b/python/setup.py
@@ -32,7 +32,7 @@ setup(
     keywords=["cosmic ray", "physics", "air shower", "simulation"],
     packages=find_packages(),
     python_requires=">=3.6, <4",
-    install_requires=["numpy", "pyyaml", "pyarrow", "boost_histogram", "xarray"],
+    install_requires=["numpy", "pyyaml", "pyarrow", "pandas"],
     extras_require={
         "test": [
             "pytest",
@@ -42,8 +42,9 @@ setup(
             "coverage",
             "pytest-cov",
             "flake8",
+            "types-PyYAML",
+            "pandas-stubs",
         ],
-        "pandas": ["pandas"],
     },
     scripts=[],
     project_urls={"code": "https://gitlab.iap.kit.edu/AirShowerPhysics/corsika"},
diff --git a/python/tests/__init__.py b/python/tests/__init__.py
index c7d9b672b4c1bde7cc7725c619c0390a79e682c7..4e71ff9c93bb28bec001dad43656772ca9709fef 100644
--- a/python/tests/__init__.py
+++ b/python/tests/__init__.py
@@ -29,25 +29,34 @@ def find_build_directory() -> str:
         If the build directory cannot be found or it is empty.
     """
 
-    # check if we are running on Gitlab
-    gitlab_build_dir = os.getenv("CI_BUILDS_DIR")
-
     # if we are running on Gitlab
-    if gitlab_build_dir is not None:
-        build_dir = op.abspath(gitlab_build_dir)
+    is_on_CI = os.getenv("CI_PROJECT_DIR") is not None
+    if is_on_CI:
+        build_dir = op.abspath(op.join(os.getenv("CI_PROJECT_DIR"), "build"))
+
     else:  # otherwise, we are running locally
-        build_dir = op.abspath(
-            op.join(
-                op.dirname(__file__),
-                op.pardir,
-                op.pardir,
-                "build",
+        here = op.dirname(os.path.realpath(__file__))
+        for name in ["build", "corsika-build"]:
+            build_dir = op.realpath(
+                op.join(
+                    here,
+                    op.pardir,
+                    op.pardir,
+                    op.pardir,
+                    "corsika-build",
+                )
             )
-        )
+            if op.isdir(build_dir):
+                break
 
     # check that the build directory contains 'CMakeCache.txt'
     if not op.exists(op.join(build_dir, "CMakeCache.txt")):
-        raise RuntimeError("Python tests cannot find C8 build directory.")
+        msg = "Python tests cannot find C8 build dir.\n"
+        msg += f"Is on CI: {is_on_CI}\n"
+        msg += f"Path for CI: {os.getenv('CI_PROJECT_DIR')}\n"
+        msg += f"Checked in {build_dir}\n"
+        msg += f"Contents: {os.listdir(build_dir)}"
+        raise RuntimeError(msg)
 
     return build_dir
 
diff --git a/python/tests/io/test_hist.py b/python/tests/io/test_hist.py
deleted file mode 100644
index 3bd11003ca663337f322ed9fea4a7f7559e75d03..0000000000000000000000000000000000000000
--- a/python/tests/io/test_hist.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
- Tests for `corsika.io.hist`
-
- (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
-
- This software is distributed under the terms of the GNU General Public
- Licence version 3 (GPL Version 3). See file LICENSE for a full version of
- the license.
-"""
-import os
-import os.path as op
-import subprocess
-
-import corsika
-
-from .. import build_directory
-
-# the directory containing 'testSaveBoostHistogram'
-bindir = op.join(build_directory, "bin")
-
-
-def generate_hist() -> str:
-    """
-    Generate a test with `testSaveBoostHistogram`.
-
-    Returns
-    -------
-    str
-        The path to the generated histogram.
-    bool
-        If True, this file was regenerated.
-    """
-
-    # we construct the name of the bin
-    bin = op.join(bindir, "testFramework")
-
-    # check if a histogram already exists
-    if op.exists(op.join(bin, "hist.npz")):
-        return op.join(bin, "hist.npz"), False
-    else:
-        # run the program - this generates "hist.npz" in the CWD
-        subprocess.call([bin, "saveHistogram"])
-
-        return op.join(os.getcwd(), "hist.npz"), True
-
-
-def test_corsika_io() -> None:
-    """
-    Test I can corsika.io without a further import.
-    """
-    corsika.io.read_hist
-
-
-def test_corsika_read_hist() -> None:
-    """
-    Check that I can read in the test histograms with `read_hist`.
-    """
-
-    # generate a test histogram
-    filename, delete = generate_hist()
-
-    # and try and read in a histogram
-    h = corsika.io.read_hist(filename)
-
-    # and delete the generated histogram
-    if delete:
-        os.remove(filename)
-
-    # and check that it isn't empty
-    assert not h.empty()
diff --git a/python/tests/io/test_library.py b/python/tests/io/test_library.py
new file mode 100644
index 0000000000000000000000000000000000000000..d029c59e33f889724a47806a40b7cfdd7fc50339
--- /dev/null
+++ b/python/tests/io/test_library.py
@@ -0,0 +1,72 @@
+"""
+ Tests for `corsika.io.outputs.energy_loss`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import os
+import os.path as op
+import subprocess
+
+import pytest
+
+from corsika.io import Library
+
+from .. import build_directory
+
+bindir = op.join(build_directory, "bin")
+
+
+def generate_data() -> str:
+    """
+    Generate a test with `testOutput`.
+
+    Returns
+    -------
+    str
+        The path to the generated data.
+    """
+
+    # Expected output directory that is made from the testOutput binary
+    output_dir = op.join(os.getcwd(), "out_test/check")
+
+    if not op.exists(output_dir):  # only make if not already run
+        binary = op.join(bindir, "testOutput")
+
+        # ensure that the binary exists (not trivial on the CI)
+        if not op.exists(binary):
+            msg = f"Could not find testOutput binary at {binary}\n"
+            msg += f"Binary dir contains {os.listdir(bindir)}"
+            raise RuntimeError(msg)
+
+        subprocess.call([binary, "OutputManager"])
+
+        # Check if it still doesn't exist
+        if not op.exists(output_dir):
+            msg = "After running binary, could not find expected"
+            msg += f" output dir {output_dir}\n"
+            msg += "The binary did not execute successfully or the"
+            msg += " OutputManager tests have changed"
+            raise RuntimeError(msg)
+
+    return output_dir
+
+
+def test_basic_Library() -> None:
+    dir_to_test = generate_data()
+    lib = Library(dir_to_test)
+
+    assert 0 == len(lib.names)
+    assert len(lib.summary)
+    assert len(lib.config)
+
+    # Check what happens for an unknown output subdir
+    assert lib.get("Does not exits") is None
+
+
+def test_bad_Library() -> None:
+    with pytest.raises(ValueError):
+        Library("This does not exist")