diff --git a/src/km3irf/irf_tools.py b/src/km3irf/irf_tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b0c50240d5de3b2ec63d7a55809e02a08f02741
--- /dev/null
+++ b/src/km3irf/irf_tools.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import numpy as np
+import pandas as pd
+import numba as nb
+from numba import jit, prange 
+from km3pipe.math import azimuth, zenith
+import astropy.coordinates as ac
+from astropy.time import Time
+import astropy.units as u
+
+def calc_theta(table, mc=True):
+    """
+    Calculate the zenith angle theta of events given the direction coordinates x,y,z
+
+    table: pandas.Dataframe with the events. Needs to have the keys dir_x, dir_y, dir_z for the local event directions.
+
+    mc: wether to use the MonteCarlo (true) or the reconstructed directions (false).
+        The true directions need to have a "_mc" appended to the directions
+
+    -----
+
+    Returns: Array of zenith angles in rad
+
+    """
+
+    if not mc:
+        dir_x=table['dir_x'].to_numpy()
+        dir_y=table['dir_y'].to_numpy()
+        dir_z=table['dir_z'].to_numpy()
+    else:
+        dir_x=table['dir_x_mc'].to_numpy()
+        dir_y=table['dir_y_mc'].to_numpy()
+        dir_z=table['dir_z_mc'].to_numpy()
+
+    nu_directions=np.vstack([dir_x, dir_y, dir_z]).T
+
+    theta = zenith(nu_directions)                # zenith angles in rad [0:pi]
+
+    return theta
+
+
+def edisp_3D(e_bins, m_bins, t_bins, dataset, weights=1):
+    """
+        Calculate the 3-dimensional energy dispersion matrix.
+        This is just a historgram with the simulated events.
+        The normalization needs to be done afterwards.
+
+        e_bins: Array of energy bins in GeV
+
+        m_bins: Array of energy migration bins (reconstructed energy / true energy)
+
+        t_bins: Array of zenith angle bins in rad
+
+        dataset: pandas.Dataframe with the events
+
+        weights: Array of weights for each event
+
+        ------
+
+        Returns: 3-dimensional energy dispersion matrix. Binned in energy, energy migration and zenith angle
+
+    """
+
+    if 'theta_mc' not in dataset.keys():
+        dataset['theta_mc'] = calc_theta(dataset, mc=True)
+    if 'migra' not in dataset.keys():
+        dataset['migra'] = dataset.E / dataset.energy_mc
+
+    theta_bins = pd.cut(dataset.theta_mc, t_bins, labels=False).to_numpy()
+    energy_bins = pd.cut(dataset.energy_mc, e_bins, labels=False).to_numpy()
+    migra_bins = pd.cut(dataset.migra, m_bins, labels=False).to_numpy()
+
+    edisp = fill_edisp_3D(e_bins, m_bins, t_bins, energy_bins, migra_bins, theta_bins, weights)
+
+    return edisp
+
+@jit(nopython=True, fastmath=False, parallel=True)
+def fill_edisp_3D(e_bins, m_bins, t_bins, energy_bins, migra_bins, theta_bins, weights):
+    """
+    numba accelerated helper function to fill the events into the energy disperaion matrix.
+    Needed because numba does not work with pandas but needs numpy arrays.
+    fastmath is disabled because it gives different results.
+    """
+    
+    edisp = np.zeros((len(t_bins)-1, len(m_bins)-1, len(e_bins)-1))
+    for i in prange(len(t_bins)-1):
+        for j in range(len(m_bins)-1):
+            for k in range(len(e_bins)-1):
+                mask = (energy_bins == k) & (migra_bins == j) & (theta_bins == i)
+                edisp[i,j,k] = np.sum(mask * weights)
+
+    return edisp
+
+
+def psf_3D(e_bins, r_bins, t_bins, dataset, weights=1):
+    """
+        Calculate the 3-dimensional PSF matrix.
+        This is a historgram with the simulated evenets.
+        The normalization needs to be done afterwards.
+
+        e_bins: Array of energy bins in GeV
+
+        r_bins: Array of radial bins in deg (angle between true and reconstructed direction)
+
+        t_bins: Array of zenith angle bins in rad
+
+        dataset: pandas.Dataframe with the events
+
+        weights: Array of weights for each event
+
+        ------
+
+        Returns: 3-dimensional PSF matrix. Binned in energy, zenith angle and radial bins
+
+    """
+
+    if 'theta_mc' not in dataset.keys():
+        dataset['theta_mc'] = calc_theta(dataset, mc=True)
+
+    scalar_prod = dataset.dir_x*dataset.dir_x_mc + dataset.dir_y*dataset.dir_y_mc + dataset.dir_z*dataset.dir_z_mc
+    scalar_prod[scalar_prod>1.0] = 1.0
+    rad = np.arccos(scalar_prod) * 180/np.pi  # in deg now
+    dataset['rad'] = rad
+
+    theta_bins = pd.cut(dataset.theta_mc, t_bins, labels=False).to_numpy()
+    energy_bins = pd.cut(dataset.energy_mc, e_bins, labels=False).to_numpy()
+    rad_bins = pd.cut(rad, r_bins, labels=False).to_numpy()
+
+    psf = fill_psf_3D(e_bins, r_bins, t_bins, energy_bins, rad_bins, theta_bins, weights)
+
+    return psf
+
+@jit(nopython=True, fastmath=False, parallel=True)  # do not use fastmath=True -- gives 300 aditional entries and no gain
+def fill_psf_3D(e_bins, r_bins, t_bins, energy_bins, rad_bins, theta_bins, weights):
+    psf = np.zeros((len(r_bins)-1, len(t_bins)-1, len(e_bins)-1))
+    for j in prange(len(r_bins)-1):
+        for i in range(len(t_bins)-1):
+            for k in range(len(e_bins)-1):
+                mask = (energy_bins == k) & (rad_bins == j) & (theta_bins == i)
+                psf[j,i,k] = np.sum(mask * weights)
+
+    return psf
+
+
+def aeff_2D(e_bins, t_bins, dataset, gamma=1.4, nevents=2e7):
+    """
+    Calculate the effective area in energy and zenith angle bins. 
+
+    e_bins: Array of energy bins in GeV
+
+    t_bins: Array of zenith angle bins in rad
+
+    dataset: pandas.Dataframe with the events
+
+    gamma: spectral index of simulated events
+
+    nevents: number of generated events
+
+    ------
+
+    Returns: 2D-Array with the effective area in m^2 binned in energy and zenith angle bins
+
+    """
+
+    if 'theta_mc' not in dataset.keys():
+        dataset['theta_mc'] = calc_theta(dataset, mc=True)
+
+    theta_bins = pd.cut(dataset.theta_mc, t_bins, labels=False).to_numpy()
+    energy_bins = pd.cut(dataset.energy_mc, e_bins, labels=False).to_numpy()
+
+    w2 = dataset.weight_w2.to_numpy()
+    E = dataset.energy_mc.to_numpy()
+    aeff = fill_aeff_2D(e_bins, t_bins, energy_bins, theta_bins, w2, E, gamma, nevents)
+
+    return aeff
+
+@jit(nopython=True, fastmath=False, parallel=True)
+def fill_aeff_2D(e_bins, t_bins, energy_bins, theta_bins, w2, E, gamma, nevents):
+    T = 365*24*3600
+    aeff = np.empty((len(e_bins)-1, len(t_bins)-1))
+    for k in prange(len(e_bins)-1):
+        for i in range(len(t_bins)-1):
+            mask = (energy_bins == k) & (theta_bins == i)
+            d_omega = -(np.cos(t_bins[i+1]) - np.cos(t_bins[i]))
+            d_E = (e_bins[k+1])**(1-gamma) - (e_bins[k])**(1-gamma)
+            aeff[k,i] = (1-gamma) * np.sum(E[mask]**(-gamma)*w2[mask]) / (T*d_omega*d_E*nevents*2*np.pi)
+
+    return aeff
diff --git a/tests/test_calc.py b/tests/test_calc.py
index 05b8191eb6752db79152466b5ce44bf0201f23ef..4a88655dd6899817aef89b07839e8b25fa692ccb 100644
--- a/tests/test_calc.py
+++ b/tests/test_calc.py
@@ -47,8 +47,6 @@ class TestUtils(unittest.TestCase):
     def test_merge_fits(self):
         merge_fits()
         assert "all_in_one.fits" in listdir(self.test_path)
-        remove(path.join(self.test_path, "all_in_one.fits"))
-        assert "all_in_one.fits" not in listdir(self.test_path)
 
     def test_list_data(self):
         assert len(list_data()) == len(listdir(self.test_path))
\ No newline at end of file