diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4dd4ead125023ce692323ba9b9d8c2b76589cdfd..14d968e3a6397ebd2e8b682e2c4d5722c9db38d1 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -3,6 +3,13 @@ Unreleased changes
 
 Version 0
 ---------
+0.27.0 / 2022-07-20
+~~~~~~~~~~~~~~~~~~~
+* Adds the TimeConverter class to ``src/km3io/tools.py``
+* Update km3net-dataformat requirement to version 0.3.6 or higher
+* Update Black requirement to version 22.3.0 or higher, to prevent ``ImportError: cannot import name '_unicodefun' from 'click'``
+* Remove ``requirements`` folder (all requirements are now configured in ``setup.cfg``)
+
 0.26.1 / 2022-07-06
 ~~~~~~~~~~~~~~~~~~~
 * The warning from OpenMP/Numba is now silenced
@@ -12,7 +19,7 @@ Version 0
 * Added ``km3io.tools.is_nanobeacon()`` to check if the nanobeacon trigger bit is set
 * Added ``km3io.tools.get_w2list_idx()`` to get the w2list index according to the
   simulation program
-
+  
 0.25.2 / 2022-03-27
 ~~~~~~~~~~~~~~~~~~~
 * Fixes the version
diff --git a/requirements/dev.txt b/requirements/dev.txt
deleted file mode 100644
index 696eed8cdd8d7bdbd34cb6c095a145640fe8fec1..0000000000000000000000000000000000000000
--- a/requirements/dev.txt
+++ /dev/null
@@ -1,19 +0,0 @@
-black==21.6b0
-km3net-testdata>=0.2.26
-ipykernel
-matplotlib
-memory_profiler
-numpydoc==0.9.2
-pillow
-pytest
-pytest-cov
-pytest-flake8
-pytest-pylint
-pytest-watch
-scipy
-sphinx
-sphinx-autoapi
-sphinx-gallery>=0.1.12
-sphinx_rtd_theme
-sphinxcontrib-versioning
-wheel
diff --git a/requirements/install.txt b/requirements/install.txt
deleted file mode 100644
index 4b721157ceb134c959f0e7d2873b4ee74c58c44a..0000000000000000000000000000000000000000
--- a/requirements/install.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-docopt
-numba>=0.50
-awkward>=1.0.0rc2
-awkward0
-uproot3>=3.11.1
-uproot>=4.2.2
-setuptools_scm
diff --git a/setup.cfg b/setup.cfg
index 0ac79263df9768b05ddaab59f213065411652475..cf24510bed455a476cb6e9aa16f5a86e17c0514b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -53,8 +53,8 @@ where = src
 [options.extras_require]
 all =
 dev =
-    black==21.6b0
-    km3net-testdata>=0.3.3
+    black>=22.3.0
+    km3net-testdata>=0.3.6
     ipykernel
     matplotlib
     memory_profiler
diff --git a/src/km3io/online.py b/src/km3io/online.py
index ad080f96a31e9f84a123f29aff60600406271f4e..cc8439f8359233aff73053628ed9a12b3e6276ad 100644
--- a/src/km3io/online.py
+++ b/src/km3io/online.py
@@ -7,9 +7,9 @@ import numpy as np
 
 import numba as nb
 
-TIMESLICE_FRAME_BASKET_CACHE_SIZE = 523 * 1024 ** 2  # [byte]
-SUMMARYSLICE_FRAME_BASKET_CACHE_SIZE = 523 * 1024 ** 2  # [byte]
-BASKET_CACHE_SIZE = 110 * 1024 ** 2
+TIMESLICE_FRAME_BASKET_CACHE_SIZE = 523 * 1024**2  # [byte]
+SUMMARYSLICE_FRAME_BASKET_CACHE_SIZE = 523 * 1024**2  # [byte]
+BASKET_CACHE_SIZE = 110 * 1024**2
 BASKET_CACHE = uproot3.cache.ThreadSafeArrayCache(BASKET_CACHE_SIZE)
 
 # Parameters for PMT rate conversions, since the rates in summary slices are
diff --git a/src/km3io/tools.py b/src/km3io/tools.py
index 30e32c7c2e0bdc5591229ecf344ce04a4ac241ae..4a46b96b3d743ea4b3ec2bc2126af371842b14ad 100644
--- a/src/km3io/tools.py
+++ b/src/km3io/tools.py
@@ -13,7 +13,7 @@ from km3io.definitions import w2list_genhen as kw2gen
 from km3io.definitions import w2list_gseagen as kw2gsg
 
 # 110 MB based on the size of the largest basket found so far in km3net
-BASKET_CACHE_SIZE = 110 * 1024 ** 2
+BASKET_CACHE_SIZE = 110 * 1024**2
 BASKET_CACHE = uproot3.cache.ThreadSafeArrayCache(BASKET_CACHE_SIZE)
 
 
@@ -404,6 +404,30 @@ def _mask_atleast(arr, atleast):
     return out
 
 
+def has_jmuon(tracks):
+    """Check if given tracks contain JMUON reconstruction."""
+    m = mask(tracks.rec_stages, minmax=(krec.JMUONBEGIN, krec.JMUONEND))
+    return ak.any(m, axis=m.ndim - 1)
+
+
+def has_jshower(tracks):
+    """Check if given tracks contain JSHOWER reconstruction."""
+    m = mask(tracks.rec_stages, minmax=(krec.JSHOWERBEGIN, krec.JSHOWEREND))
+    return ak.any(m, axis=m.ndim - 1)
+
+
+def has_aashower(tracks):
+    """Check if given tracks contain AASHOWER reconstruction."""
+    m = mask(tracks.rec_stages, minmax=(krec.AASHOWERBEGIN, krec.AASHOWEREND))
+    return ak.any(m, axis=m.ndim - 1)
+
+
+def has_dusjshower(tracks):
+    """Check if given tracks contain AASHOWER reconstruction."""
+    m = mask(tracks.rec_stages, minmax=(krec.DUSJSHOWERBEGIN, krec.DUSJSHOWEREND))
+    return ak.any(m, axis=m.ndim - 1)
+
+
 def best_jmuon(tracks):
     """Select the best JMUON track."""
     return best_track(tracks, minmax=(krec.JMUONBEGIN, krec.JMUONEND))
@@ -566,3 +590,53 @@ def is_nanobeacon(trigger_mask):
       A value or an array of the trigger_mask, either of an event, or a hit.
     """
     return is_bit_set(trigger_mask, ktrg.JTRIGGERNB)
+
+
+class TimeConverter(object):
+    """
+    Auxiliary class to convert Monte Carlo hit times to DAQ/triggered hit times.
+    """
+
+    FRAME_TIME_NS = 1e8  # [ns]
+
+    def __init__(self, event):
+
+        self.__t0 = event.mc_t  # [ns]
+        self.__t1 = self.get_time_of_frame(event.frame_index)  # [ns]
+
+    def get_time_of_frame(self, frame_index):
+        """
+        Get start time of frame in ns since start of run for a given frame index
+
+        Parameters
+        ----------
+        frame_index: int
+          The index of the DAQ frame
+        """
+
+        if frame_index > 0:
+            return (frame_index - 1) * self.FRAME_TIME_NS  # [ns]
+        else:
+            return 0  # [ns]
+
+    def get_DAQ_time(self, t0):
+        """
+        Get DAQ/triggered hit time
+
+        Parameters
+        ----------
+        t0: float or array(float)
+          Simulated time [ns]
+        """
+        return t0 + (self.__t0 - self.__t1)  # [ns]
+
+    def get_MC_time(self, t0):
+        """
+        Get Monte Carlo hit time
+
+        Parameters
+        ----------
+        t0: float or array(float)
+          DAQ/trigger hit time [ns]
+        """
+        return t0 - (self.__t0 - self.__t1)  # [ns]
diff --git a/tests/test_tools.py b/tests/test_tools.py
index c55cf6fecf52fc0b039a5faecb4124cdcec9ec61..8711153686df4cc1df3e56ae0caaf3754e6192e5 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -21,6 +21,10 @@ from km3io.tools import (
     best_track,
     get_w2list_param,
     get_multiplicity,
+    has_jmuon,
+    has_jshower,
+    has_aashower,
+    has_dusjshower,
     best_jmuon,
     best_jshower,
     best_aashower,
@@ -32,8 +36,10 @@ from km3io.tools import (
     is_mxshower,
     is_3dmuon,
     is_nanobeacon,
+    TimeConverter,
 )
 
+
 OFFLINE_FILE = OfflineReader(data_path("offline/km3net_offline.root"))
 OFFLINE_USR = OfflineReader(data_path("offline/usr-sample.root"))
 OFFLINE_MC_TRACK_USR = OfflineReader(
@@ -41,6 +47,11 @@ OFFLINE_MC_TRACK_USR = OfflineReader(
         "offline/mcv5.11r2.gsg_muonCChigherE-CC_50-5000GeV.km3_AAv1.jterbr00004695.jchain.aanet.498.root"
     )
 )
+OFFLINE_JMERGEFIT = OfflineReader(
+    data_path(
+        "offline/mcv5.0.gsg_elec-CC_10-100GeV.km3sim.JDK.jte.jmergefit.orca.aanet.909.evtsample.root"
+    )
+)
 GENHEN_OFFLINE_FILE = OfflineReader(
     data_path("offline/mcv5.1.genhen_anumuNC.sirene.jte.jchain.aashower.sample.root")
 )
@@ -286,16 +297,44 @@ class TestBestTrackSelection(unittest.TestCase):
             best_track(self.events.tracks, startend=(1, 4), stages=[1, 3, 5, 4])
 
 
+class TestHasJmuon(unittest.TestCase):
+    def test_has_jmuon(self):
+        assert ak.sum(has_jmuon(OFFLINE_JMERGEFIT.events.tracks)) == len(
+            OFFLINE_JMERGEFIT.events.tracks
+        )
+
+
+class TestHasJshower(unittest.TestCase):
+    def test_has_jshower(self):
+        assert ak.sum(has_jshower(OFFLINE_JMERGEFIT.events.tracks)) == len(
+            OFFLINE_JMERGEFIT.events.tracks
+        )
+
+
+class TestHasAashower(unittest.TestCase):
+    def test_has_aashower(self):
+        # there are no aashower events in this file
+        assert ak.sum(has_aashower(OFFLINE_JMERGEFIT.events.tracks)) == 0
+
+
+class TestHasDusjshower(unittest.TestCase):
+    def test_has_dusjshower(self):
+        # there are no dusj events in this file
+        assert ak.sum(has_dusjshower(OFFLINE_JMERGEFIT.events.tracks)) == 0
+
+
 class TestBestJmuon(unittest.TestCase):
     def test_best_jmuon(self):
         best = best_jmuon(OFFLINE_FILE.events.tracks)
 
         assert len(best) == 10
 
-        assert best.rec_stages[0].tolist() == [1, 3, 5, 4]
-        assert best.rec_stages[1].tolist() == [1, 3, 5, 4]
-        assert best.rec_stages[2].tolist() == [1, 3, 5, 4]
-        assert best.rec_stages[3].tolist() == [1, 3, 5, 4]
+        jmuon_stages = [1, 3, 5, 4]
+
+        assert best.rec_stages[0].tolist() == jmuon_stages
+        assert best.rec_stages[1].tolist() == jmuon_stages
+        assert best.rec_stages[2].tolist() == jmuon_stages
+        assert best.rec_stages[3].tolist() == jmuon_stages
 
         assert best.lik[0] == ak.max(OFFLINE_FILE.events.tracks.lik[0])
         assert best.lik[1] == ak.max(OFFLINE_FILE.events.tracks.lik[1])
@@ -304,19 +343,25 @@ class TestBestJmuon(unittest.TestCase):
 
 class TestBestJshower(unittest.TestCase):
     def test_best_jshower(self):
-        # there are no jshower events in this file
-        best = best_jshower(OFFLINE_FILE.events.tracks)
+        best = best_jshower(OFFLINE_JMERGEFIT.events.tracks)
 
         assert len(best) == 10
 
-        assert best.rec_stages[0] is None
-        assert best.rec_stages[1] is None
-        assert best.rec_stages[2] is None
-        assert best.rec_stages[3] is None
+        jshower_stages = [101, 106, 102, 105, 107, 103]
 
-        assert best.lik[0] is None
-        assert best.lik[1] is None
-        assert best.lik[2] is None
+        assert best.rec_stages[0].tolist() == jshower_stages
+        assert best.rec_stages[1].tolist() == jshower_stages
+        assert best.rec_stages[2].tolist() == jshower_stages
+        assert best.rec_stages[3].tolist() == jshower_stages
+
+        jshower_mask = mask(
+            OFFLINE_JMERGEFIT.events.tracks.rec_stages, sequence=jshower_stages
+        )
+        jshower_tracks = OFFLINE_JMERGEFIT.events.tracks[jshower_mask]
+
+        assert best.lik[0] == ak.max(jshower_tracks.lik[0])
+        assert best.lik[1] == ak.max(jshower_tracks.lik[1])
+        assert best.lik[2] == ak.max(jshower_tracks.lik[2])
 
 
 class TestBestAashower(unittest.TestCase):
@@ -338,7 +383,7 @@ class TestBestAashower(unittest.TestCase):
 
 class TestBestDusjshower(unittest.TestCase):
     def test_best_dusjshower(self):
-        # there are no aashower events in this file
+        # there are no dusj events in this file
         best = best_dusjshower(OFFLINE_FILE.events.tracks)
 
         assert len(best) == 10
@@ -653,3 +698,29 @@ class TestTriggerMaskChecks(unittest.TestCase):
             [False, False, False, False, False, False, False, False, False, False],
             list(is_nanobeacon(GENHEN_OFFLINE_FILE.events.trigger_mask)),
         )
+
+
+class TestTimeConverter(unittest.TestCase):
+    def setUp(self):
+        self.one_event = GENHEN_OFFLINE_FILE.events[5]
+        self.tconverter = TimeConverter(self.one_event)
+
+    def test_get_time_of_frame(self):
+        t_frame = self.tconverter.get_time_of_frame(self.one_event.frame_index)
+
+        assert t_frame < self.one_event.mc_t
+        assert t_frame % self.tconverter.FRAME_TIME_NS == 0
+
+    def test_get_DAQ_time(self):
+        t_DAQ = self.tconverter.get_DAQ_time(self.one_event.mc_hits.t)
+
+        assert all(t_DAQ < self.tconverter.FRAME_TIME_NS)
+
+    def test_get_MC_time(self):
+        t_frame = self.tconverter.get_time_of_frame(self.one_event.frame_index)
+        t_MC = self.tconverter.get_MC_time(self.one_event.hits.t)
+
+        assert all(
+            np.fabs(self.one_event.mc_t + t_MC - t_frame)
+            < self.tconverter.FRAME_TIME_NS
+        )