Ejemplo n.º 1
0
    class SomeComponent(Component):
        tel_param1 = IntTelescopeParameter(
            default_value=[("type", "*", 10), ("type", "LST*", 100)])

        tel_param2 = FloatTelescopeParameter(default_value=[
            ("type", "*", 10.0),
            ("type", "LST_LST_LSTCam", 100.0),
            ("id", 3, 200.0),
        ])

        tel_param3 = FloatTelescopeParameter(default_value=[
            ("type", "*", 10.0),
            ("type", "LST_LST_LSTCam", 100.0),
            ("type", "*", 200.0),  # should overwrite everything with 200.0
            ("id", 100, 300.0),
        ])
Ejemplo n.º 2
0
class TwoPassWindowSum(ImageExtractor):
    """Extractor based on [1]_ which integrates the waveform a second time using
    a time-gradient linear fit. This is in particular the version implemented
    in the CTA-MARS analysis pipeline [2]_.

    Notes
    -----

    #. slide a 3-samples window through the waveform, finding max counts sum;
       the range of the sliding is the one allowing extension from 3 to 5;
       add 1 sample on each side and integrate charge in the 5-sample window;
       time is obtained as a charge-weighted average of the sample numbers;
       No information from neighboouring pixels is used.
    #. Preliminary image cleaning via simple tailcut with minimum number
       of core neighbours set at 1,
    #. Only the biggest cluster of pixels is kept.
    #. Parametrize following Hillas approach only if the resulting image has 3
       or more pixels.
    #. Do a linear fit of pulse time vs. distance along major image axis
       (CTA-MARS uses ROOT "robust" fit option,
       aka Least Trimmed Squares, to get rid of far outliers - this should
       be implemented in 'timing_parameters', e.g scipy.stats.siegelslopes).
    #. For all pixels except the core ones in the main island, integrate
       the waveform once more, in a fixed window of 5 samples set at the time
       "predicted" by the linear time fit.
       If the predicted time for a pixel leads to a window outside the readout
       window, then integrate the last (or first) 5 samples.
    #. The result is an image with main-island core pixels calibrated with a
       1st pass and non-core pixels re-calibrated with a 2nd pass.

    References
    ----------
    .. [1] J. Holder et al., Astroparticle Physics, 25, 6, 391 (2006)
    .. [2] https://forge.in2p3.fr/projects/step-by-step-reference-mars-analysis/wiki

    """

    # Get thresholds for core-pixels depending on telescope type.
    # WARNING: default values are not yet optimized
    core_threshold = FloatTelescopeParameter(
        default_value=[
            ("type", "*", 6.0),
            ("type", "LST*", 6.0),
            ("type", "MST*", 8.0),
            ("type", "SST*", 4.0),
        ],
        help="Picture threshold for internal tail-cuts pass",
    ).tag(config=True)

    disable_second_pass = Bool(
        default_value=False,
        help="only run the first pass of the extractor, for debugging purposes",
    ).tag(config=True)

    apply_integration_correction = BoolTelescopeParameter(
        default_value=True,
        help="Apply the integration window correction").tag(config=True)

    @lru_cache(maxsize=4096)
    def _calculate_correction(self, telid, width, shift):
        """Obtain the correction for the integration window specified for each
        pixel.

        The TwoPassWindowSum image extractor applies potentially different
        parameters for the integration window to each pixel, depending on the
        position of the peak. It has been decided to apply gain selection
        directly here. For basic definitions look at the documentation of
        `integration_correction`.

        Parameters
        ----------
        telid : int
            Index of the telescope in use.
        width : int
            Width of the integration window in samples
        shift : int
            Window shift to the left of the pulse peak in samples

        Returns
        -------
        correction : ndarray
            Value of the pixel-wise gain-selected integration correction.

        """
        readout = self.subarray.tel[telid].camera.readout
        # Calculate correction of first pixel for both channels
        return integration_correction(
            readout.reference_pulse_shape,
            readout.reference_pulse_sample_width.to_value("ns"),
            (1 / readout.sampling_rate).to_value("ns"),
            width,
            shift,
        )

    def _apply_first_pass(self, waveforms,
                          telid) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
        """
        Execute step 1.

        Parameters
        ----------
        waveforms : array of size (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Shape: (n_pix)
        """
        # STEP 1

        # Starting from DL0, the channel is already selected (if more than one)
        # event.dl0.tel[tel_id].waveform object has shape (N_pixels, N_samples)

        # For each pixel, we slide a 3-samples window through the
        # waveform summing each time the ADC counts contained within it.

        peak_search_window_width = 3
        sums = convolve1d(waveforms,
                          np.ones(peak_search_window_width),
                          axis=1,
                          mode="nearest")
        # 'sums' has now still shape of (N_pixels, N_samples)
        # each element is the center-sample of each 3-samples sliding window

        # For each pixel, we check where the peak search window encountered
        # the maximum number of ADC counts.
        # We want to stop before the edge of the readout window in order to
        # later extend the search window to a 1+3+1 integration window.
        # Since in 'sums' the peak index corresponds to the center of the
        # search window, we shift it on the right by 2 samples so to get the
        # correspondent sample index in each waveform.
        peak_index = np.argmax(sums[:, 2:-2], axis=1) + 2
        # Now peak_index has the shape of (N_pixels).

        # The final 5-samples window will be 1+3+1, centered on the 3-samples
        # window in which the highest amount of ADC counts has been found
        window_width = peak_search_window_width + 2
        window_shift = 2

        # this function is applied to all pixels together
        charge_1stpass, pulse_time_1stpass = extract_around_peak(
            waveforms,
            peak_index,
            window_width,
            window_shift,
            self.sampling_rate[telid],
        )

        # Get integration correction factors
        if self.apply_integration_correction.tel[telid]:
            correction = self._calculate_correction(telid, window_width,
                                                    window_shift)
        else:
            correction = np.ones(waveforms.shape[0])

        return charge_1stpass, pulse_time_1stpass, correction

    def _apply_second_pass(
        self,
        waveforms,
        telid,
        selected_gain_channel,
        charge_1stpass_uncorrected,
        pulse_time_1stpass,
        correction,
    ) -> Tuple[np.ndarray, np.ndarray]:
        """
        Follow steps from 2 to 7.

        Parameters
        ----------
        waveforms : array of shape (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.
        selected_gain_channel: array of shape (N_channels, N_pixels)
            Array containing the index of the selected gain channel for each
            pixel (0 for low gain, 1 for high gain).
        charge_1stpass_uncorrected : array of shape N_pixels
            Pixel charges reconstructed with the 1st pass, but not corrected.
        pulse_time_1stpass : array of shape N_pixels
            Pixel-wise pulse times reconstructed with the 1st pass.
        correction: array of shape N_pixels
            Charge correction from 1st pass.

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Note that in the case of a very bright full-camera image this can
            coincide the 1st pass information.
            Also in the case of very dim images the 1st pass will be recycled,
            but in this case the resulting image should be discarded
            from further analysis.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Same specifications as above.
            Shape: (n_pix)
        """
        # STEP 2

        # Apply correction to 1st pass charges
        charge_1stpass = charge_1stpass_uncorrected * correction[
            selected_gain_channel]

        # Set thresholds for core-pixels depending on telescope
        core_th = self.core_threshold.tel[telid]
        # Boundary thresholds will be half of core thresholds.

        # Preliminary image cleaning with simple two-level tail-cut
        camera_geometry = self.subarray.tel[telid].camera.geometry
        mask_1 = tailcuts_clean(
            camera_geometry,
            charge_1stpass,
            picture_thresh=core_th,
            boundary_thresh=core_th / 2,
            keep_isolated_pixels=False,
            min_number_picture_neighbors=1,
        )
        image_1 = charge_1stpass.copy()
        image_1[~mask_1] = 0

        # STEP 3

        # find all islands using this cleaning
        num_islands, labels = number_of_islands(camera_geometry, mask_1)
        if num_islands == 0:
            image_2 = image_1.copy()  # no islands = image unchanged
        else:
            # ...find the biggest one
            mask_biggest = largest_island(labels)
            image_2 = image_1.copy()
            image_2[~mask_biggest] = 0

        # Indexes of pixels that will need the 2nd pass
        non_core_pixels_ids = np.where(image_2 < core_th)[0]
        non_core_pixels_mask = image_2 < core_th

        # STEP 4

        # if the resulting image has less then 3 pixels
        # or there are more than 3 pixels but all contain a number of
        # photoelectrons above the core threshold
        if np.count_nonzero(image_2) < 3:
            # we return the 1st pass information
            # NOTE: In this case, the image was not bright enough!
            # We should label it as "bad and NOT use it"
            return charge_1stpass, pulse_time_1stpass
        elif len(non_core_pixels_ids) == 0:
            # Since all reconstructed charges are above the core threshold,
            # there is no need to perform the 2nd pass.
            # We return the 1st pass information.
            # NOTE: In this case, even if this is 1st pass information,
            # the image is actually very bright! We should label it as "good"!
            return charge_1stpass, pulse_time_1stpass

        # otherwise we proceed by parametrizing the image
        hillas = hillas_parameters(camera_geometry, image_2)

        # STEP 5

        # linear fit of pulse time vs. distance along major image axis
        # using only the main island surviving the preliminary
        # image cleaning
        timing = timing_parameters(camera_geometry, image_2,
                                   pulse_time_1stpass, hillas)

        # If the fit returns nan
        if np.isnan(timing.slope):
            return charge_1stpass, pulse_time_1stpass

        # get projected distances along main image axis
        long, _ = camera_to_shower_coordinates(camera_geometry.pix_x,
                                               camera_geometry.pix_y, hillas.x,
                                               hillas.y, hillas.psi)

        # get the predicted times as a linear relation
        predicted_pulse_times = (timing.slope * long[non_core_pixels_ids] +
                                 timing.intercept)

        predicted_peaks = np.zeros(len(predicted_pulse_times))

        # Convert time in ns to sample index using the sampling rate from
        # the readout.
        # Approximate the value obtained to nearest integer, then cast to
        # int64 otherwise 'extract_around_peak' complains.
        sampling_rate = self.sampling_rate[telid]
        np.rint(predicted_pulse_times.value * sampling_rate, predicted_peaks)
        predicted_peaks = predicted_peaks.astype(np.int64)

        # Due to the fit these peak indexes can now be also outside of the
        # readout window, so later we check for this.

        # STEP 6

        # select only the waveforms correspondent to the non-core pixels
        # of the main island survived from the 1st pass image cleaning
        non_core_waveforms = waveforms[non_core_pixels_ids]

        # Build 'width' and 'shift' arrays that adapt on the position of the
        # window along each waveform

        # As before we will integrate the charge in a 5-sample window centered
        # on the peak
        window_width_default = 5
        window_shift_default = 2

        # now let's deal with some edge cases: the predicted peak falls before
        # or after the readout window:
        peak_before_window = predicted_peaks < 0
        peak_after_window = predicted_peaks > (non_core_waveforms.shape[1] - 1)

        # BUT, if the resulting 5-samples window falls outside of the readout
        # window then we take the first (or last) 5 samples
        window_width_before = 5
        window_shift_before = 0

        # in the case where the window is after, shift backward
        window_width_after = 5
        window_shift_after = 5

        # and put them together:
        window_widths = np.full(non_core_waveforms.shape[0],
                                window_width_default)
        window_widths[peak_before_window] = window_width_before
        window_widths[peak_after_window] = window_width_after
        window_shifts = np.full(non_core_waveforms.shape[0],
                                window_shift_default)
        window_shifts[peak_before_window] = window_shift_before
        window_shifts[peak_after_window] = window_shift_after

        # Now we can also (re)define the patological predicted times
        # because (we needed them to define the corrispective widths
        # and shifts)
        # set sample to 0 (beginning of the waveform) if predicted time
        # falls before
        predicted_peaks[predicted_peaks < 0] = 0
        # set sample to max-1 (first sample has index 0)
        # if predicted time falls after
        predicted_peaks[predicted_peaks > (waveforms.shape[1] - 1)] = (
            waveforms.shape[1] - 1)

        # re-calibrate non-core pixels using the fixed 5-samples window
        charge_no_core, pulse_times_no_core = extract_around_peak(
            non_core_waveforms,
            predicted_peaks,
            window_widths,
            window_shifts,
            self.sampling_rate[telid],
        )

        if self.apply_integration_correction.tel[telid]:
            # Modify integration correction factors only for non-core pixels
            # now we compute 3 corrections for the default, before, and after cases:
            correction = self._calculate_correction(
                telid, window_width_default, window_shift_default
            )[selected_gain_channel][non_core_pixels_mask]

            correction_before = self._calculate_correction(
                telid, window_width_before, window_shift_before
            )[selected_gain_channel][non_core_pixels_mask]

            correction_after = self._calculate_correction(
                telid, window_width_after, window_shift_after
            )[selected_gain_channel][non_core_pixels_mask]

            correction[peak_before_window] = correction_before[
                peak_before_window]
            correction[peak_after_window] = correction_after[peak_after_window]

            charge_no_core *= correction

        # STEP 7

        # Combine core and non-core pixels in the final output

        # this is the biggest cluster from the cleaned image
        # it contains the core pixels (which we leave untouched)
        # plus possibly some non-core pixels
        charge_2ndpass = image_2.copy()
        # Now we overwrite the charges of all non-core pixels in the camera
        # plus all those pixels which didn't survive the preliminary
        # cleaning.
        # We apply also their corrections.
        charge_2ndpass[non_core_pixels_mask] = charge_no_core

        # Same approach for the pulse times
        pulse_time_2ndpass = pulse_time_1stpass  # core + non-core pixels
        pulse_time_2ndpass[
            non_core_pixels_mask] = pulse_times_no_core  # non-core pixels

        return charge_2ndpass, pulse_time_2ndpass

    def __call__(self, waveforms, telid, selected_gain_channel):
        """
        Call this ImageExtractor.

        Parameters
        ----------
        waveforms : array of shape (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.
        selected_gain_channel: array of shape (N_channels, N_pixels)
            Array containing the index of the selected gain channel for each
            pixel (0 for low gain, 1 for high gain).

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Shape: (n_pix)
        """

        charge1, pulse_time1, correction1 = self._apply_first_pass(
            waveforms, telid)

        # FIXME: properly make sure that output is 32Bit instead of downcasting here
        if self.disable_second_pass:
            return (
                (charge1 *
                 correction1[selected_gain_channel]).astype("float32"),
                pulse_time1.astype("float32"),
            )

        charge2, pulse_time2 = self._apply_second_pass(waveforms, telid,
                                                       selected_gain_channel,
                                                       charge1, pulse_time1,
                                                       correction1)
        # FIXME: properly make sure that output is 32Bit instead of downcasting here
        return charge2.astype("float32"), pulse_time2.astype("float32")
Ejemplo n.º 3
0
 class SomeComponent(TelescopeComponent):
     tel_param1 = FloatTelescopeParameter(default_value=6.0).tag(
         config=True)
Ejemplo n.º 4
0
class LSTR0Corrections(TelescopeComponent):
    """
    The base R0-level calibrator. Changes the r0 container.

    The R0 calibrator performs the camera-specific R0 calibration that is
    usually performed on the raw data by the camera server.
    This calibrator exists in lstchain for testing and prototyping purposes.
    """
    offset = IntTelescopeParameter(
        default_value=0,
        help=(
            'Define offset to be subtracted from the waveform *additionally*'
            ' to the drs4 pedestal offset. This only needs to be given when'
            ' the drs4 pedestal calibration is not applied or the offset of the'
            ' drs4 run is different from the data run'
        )
    ).tag(config=True)

    r1_sample_start = IntTelescopeParameter(
        default_value=3,
        help='Start sample for r1 waveform',
        allow_none=True,
    ).tag(config=True)

    r1_sample_end = IntTelescopeParameter(
        default_value=39,
        help='End sample for r1 waveform',
        allow_none=True,
    ).tag(config=True)

    drs4_pedestal_path = TelescopeParameter(
        trait=Path(exists=True, directory_ok=False),
        allow_none=True,
        default_value=None,
        help=(
            'Path to the LST pedestal file'
            ', required when `apply_drs4_pedestal_correction=True`'
        ),
    ).tag(config=True)

    calibration_path = Path(
        exists=True, directory_ok=False,
        help='Path to LST calibration file',
    ).tag(config=True)

    drs4_time_calibration_path = TelescopeParameter(
        trait=Path(exists=True, directory_ok=False),
        help='Path to the time calibration file',
        default_value=None,
        allow_none=True,
    ).tag(config=True)

    calib_scale_high_gain = FloatTelescopeParameter(
        default_value=1.0,
        help='High gain waveform is multiplied by this number'
    ).tag(config=True)

    calib_scale_low_gain = FloatTelescopeParameter(
        default_value=1.0,
        help='Low gain waveform is multiplied by this number'
    ).tag(config=True)

    select_gain = Bool(
        default_value=True,
        help='Set to False to keep both gains.'
    ).tag(config=True)

    apply_drs4_pedestal_correction = Bool(
        default_value=True,
        help=(
            'Set to False to disable drs4 pedestal correction.'
            ' Providing the drs4_pedestal_path is required to perform this calibration'
        ),
    ).tag(config=True)

    apply_timelapse_correction = Bool(
        default_value=True,
        help='Set to False to disable drs4 timelapse correction'
    ).tag(config=True)

    apply_spike_correction = Bool(
        default_value=True,
        help='Set to False to disable drs4 spike correction'
    ).tag(config=True)

    add_calibration_timeshift = Bool(
        default_value=True,
        help=(
            'If true, time correction from the calibration'
            ' file is added to calibration.dl1.time'
        ),
    ).tag(config=True)

    gain_selection_threshold = Float(
        default_value=3500,
        help='Threshold for the ThresholdGainSelector.'
    ).tag(config=True)

    def __init__(self, subarray, config=None, parent=None, **kwargs):
        """
        The R0 calibrator for LST data.
        Fill the r1 container.

        Parameters
        ----------
        """
        super().__init__(
            subarray=subarray, config=config, parent=parent, **kwargs
        )

        self.mon_data = None
        self.last_readout_time = {}
        self.first_cap = {}
        self.first_cap_old = {}
        self.fbn = {}
        self.fan = {}

        for tel_id in self.subarray.tel:
            shape = (N_GAINS, N_PIXELS, N_CAPACITORS_PIXEL)
            self.last_readout_time[tel_id] = np.zeros(shape, dtype='uint64')

            shape = (N_GAINS, N_PIXELS)
            self.first_cap[tel_id] = np.zeros(shape, dtype=int)
            self.first_cap_old[tel_id] = np.zeros(shape, dtype=int)

        if self.select_gain:
            self.gain_selector = ThresholdGainSelector(
                threshold=self.gain_selection_threshold,
                parent=self
            )
        else:
            self.gain_selector = None

        if self.calibration_path is not None:
            self.mon_data = self._read_calibration_file(self.calibration_path)

    def apply_drs4_corrections(self, event: LSTArrayEventContainer):
        self.update_first_capacitors(event)

        for tel_id, r0 in event.r0.tel.items():
            r1 = event.r1.tel[tel_id]
            # If r1 was not yet filled, copy of r0 converted
            if r1.waveform is None:
                r1.waveform = r0.waveform

            # float32 can represent all values of uint16 exactly,
            # so this does not loose precision.
            r1.waveform = r1.waveform.astype(np.float32, copy=False)

            # apply drs4 corrections
            if self.apply_drs4_pedestal_correction:
                self.subtract_pedestal(event, tel_id)

            if self.apply_timelapse_correction:
                self.time_lapse_corr(event, tel_id)

            if self.apply_spike_correction:
                self.interpolate_spikes(event, tel_id)

            # remove samples at beginning / end of waveform
            start = self.r1_sample_start.tel[tel_id]
            end = self.r1_sample_end.tel[tel_id]
            r1.waveform = r1.waveform[..., start:end]

            if self.offset.tel[tel_id] != 0:
                r1.waveform -= self.offset.tel[tel_id]

            mon = event.mon.tel[tel_id]
            if r1.selected_gain_channel is None:
                r1.waveform[mon.pixel_status.hardware_failing_pixels] = 0.0
            else:
                broken = mon.pixel_status.hardware_failing_pixels[r1.selected_gain_channel, PIXEL_INDEX]
                r1.waveform[broken] = 0.0


    def update_first_capacitors(self, event: LSTArrayEventContainer):
        for tel_id, lst in event.lst.tel.items():
            self.first_cap_old[tel_id] = self.first_cap[tel_id]
            self.first_cap[tel_id] = get_first_capacitors_for_pixels(
                lst.evt.first_capacitor_id,
                lst.svc.pixel_ids,
            )

    def calibrate(self, event: LSTArrayEventContainer):
        for tel_id in event.r0.tel:
            r1 = event.r1.tel[tel_id]
            # if `apply_drs4_corrections` is False, we did not fill in the
            # waveform yet.
            if r1.waveform is None:
                r1.waveform = event.r0.tel[tel_id].waveform

            r1.waveform = r1.waveform.astype(np.float32, copy=False)

            # do gain selection before converting to pe
            # like eventbuilder will do
            if self.select_gain and r1.selected_gain_channel is None:
                r1.selected_gain_channel = self.gain_selector(r1.waveform)
                r1.waveform = r1.waveform[r1.selected_gain_channel, PIXEL_INDEX]

            # apply monitoring data corrections,
            # subtract pedestal and convert to pe
            if self.mon_data is not None:
                calibration = self.mon_data.tel[tel_id].calibration
                convert_to_pe(
                    waveform=r1.waveform,
                    calibration=calibration,
                    selected_gain_channel=r1.selected_gain_channel
                )

            broken_pixels = event.mon.tel[tel_id].pixel_status.hardware_failing_pixels
            if r1.selected_gain_channel is None:
                r1.waveform[broken_pixels] = 0.0
            else:
                r1.waveform[broken_pixels[r1.selected_gain_channel, PIXEL_INDEX]] = 0.0

            # store calibration data needed for dl1 calibration in ctapipe
            # first drs4 time shift (zeros if no calib file was given)
            time_shift = self.get_drs4_time_correction(
                tel_id, self.first_cap[tel_id],
                selected_gain_channel=r1.selected_gain_channel,
            )

            # time shift from flat fielding
            if self.mon_data is not None and self.add_calibration_timeshift:
                time_corr = self.mon_data.tel[tel_id].calibration.time_correction
                # time_shift is subtracted in ctapipe,
                # but time_correction should be added
                if r1.selected_gain_channel is not None:
                    time_shift -= time_corr[r1.selected_gain_channel, PIXEL_INDEX].to_value(u.ns)
                else:
                    time_shift -= time_corr.to_value(u.ns)

            event.calibration.tel[tel_id].dl1.time_shift = time_shift

            # needed for charge scaling in ctpaipe dl1 calib
            if r1.selected_gain_channel is not None:
                relative_factor = np.empty(N_PIXELS)
                relative_factor[r1.selected_gain_channel == HIGH_GAIN] = self.calib_scale_high_gain.tel[tel_id]
                relative_factor[r1.selected_gain_channel == LOW_GAIN] = self.calib_scale_low_gain.tel[tel_id]
            else:
                relative_factor = np.empty((N_GAINS, N_PIXELS))
                relative_factor[HIGH_GAIN] = self.calib_scale_high_gain.tel[tel_id]
                relative_factor[LOW_GAIN] = self.calib_scale_low_gain.tel[tel_id]

            event.calibration.tel[tel_id].dl1.relative_factor = relative_factor

    @staticmethod
    def _read_calibration_file(path):
        """
        Read the correction from hdf5 calibration file
        """
        mon = MonitoringContainer()

        with tables.open_file(path) as f:
            tel_ids = [
                int(key[4:]) for key in f.root._v_children.keys()
                if key.startswith('tel_')
            ]

        for tel_id in tel_ids:
            with HDF5TableReader(path) as h5_table:
                base = f'/tel_{tel_id}'
                # read the calibration data
                table = base + '/calibration'
                next(h5_table.read(table, mon.tel[tel_id].calibration))

                # read pedestal data
                table = base + '/pedestal'
                next(h5_table.read(table, mon.tel[tel_id].pedestal))

                # read flat-field data
                table = base + '/flatfield'
                next(h5_table.read(table, mon.tel[tel_id].flatfield))

                # read the pixel_status container
                table = base + '/pixel_status'
                next(h5_table.read(table, mon.tel[tel_id].pixel_status))

        return mon

    @staticmethod
    def load_drs4_time_calibration_file(path):
        """
        Function to load calibration file.
        """
        with tables.open_file(path, 'r') as f:
            fan = f.root.fan[:]
            fbn = f.root.fbn[:]

        return fan, fbn

    def load_drs4_time_calibration_file_for_tel(self, tel_id):
        self.fan[tel_id], self.fbn[tel_id] = self.load_drs4_time_calibration_file(
            self.drs4_time_calibration_path.tel[tel_id]
        )

    def get_drs4_time_correction(self, tel_id, first_capacitors, selected_gain_channel=None):
        """
        Return pulse time after time correction.
        """

        if self.drs4_time_calibration_path.tel[tel_id] is None:
            if selected_gain_channel is None:
                return np.zeros((N_GAINS, N_PIXELS))
            else:
                return np.zeros(N_PIXELS)

        # load calib file if not already done
        if tel_id not in self.fan:
            self.load_drs4_time_calibration_file_for_tel(tel_id)

        if selected_gain_channel is not None:
            return calc_drs4_time_correction_gain_selected(
                first_capacitors,
                selected_gain_channel,
                self.fan[tel_id],
                self.fbn[tel_id],
            )
        else:
            return calc_drs4_time_correction_both_gains(
                first_capacitors,
                self.fan[tel_id],
                self.fbn[tel_id],
            )

    @staticmethod
    @lru_cache(maxsize=4)
    def _get_drs4_pedestal_data(path):
        """
        Function to load pedestal file.

        To make boundary conditions unnecessary,
        the first N_SAMPLES values are repeated at the end of the array

        The result is cached so we can repeatedly call this method
        using the configured path without reading it each time.
        """
        if path is None:
            raise ValueError(
                "DRS4 pedestal correction requested"
                " but no file provided for telescope"
            )

        pedestal_data = np.empty(
            (N_GAINS, N_PIXELS_MODULE * N_MODULES, N_CAPACITORS_PIXEL + N_SAMPLES),
            dtype=np.int16
        )
        with fits.open(path) as f:
            pedestal_data[:, :, :N_CAPACITORS_PIXEL] = f[1].data

        pedestal_data[:, :, N_CAPACITORS_PIXEL:] = pedestal_data[:, :, :N_SAMPLES]

        return pedestal_data

    def subtract_pedestal(self, event, tel_id):
        """
        Subtract cell offset using pedestal file.
        Fill the R1 container.
        Parameters
        ----------
        event : `ctapipe` event-container
        tel_id : id of the telescope
        """
        pedestal = self._get_drs4_pedestal_data(
            self.drs4_pedestal_path.tel[tel_id]
        )

        if event.r1.tel[tel_id].selected_gain_channel is None:
            subtract_pedestal(
                event.r1.tel[tel_id].waveform,
                self.first_cap[tel_id],
                pedestal,
            )
        else:
            subtract_pedestal_gain_selected(
                event.r1.tel[tel_id].waveform,
                self.first_cap[tel_id],
                pedestal,
                event.r1.tel[tel_id].selected_gain_channel,
            )


    def time_lapse_corr(self, event, tel_id):
        """
        Perform time lapse baseline corrections.
        Fill the R1 container or modifies R0 container.
        Parameters
        ----------
        event : `ctapipe` event-container
        tel_id : id of the telescope
        """
        lst = event.lst.tel[tel_id]

        # If R1 container exists, update it inplace
        if isinstance(event.r1.tel[tel_id].waveform, np.ndarray):
            container = event.r1.tel[tel_id]
        else:
            # Modify R0 container. This is to create pedestal files.
            container = event.r0.tel[tel_id]

        waveform = container.waveform.copy()

        # We have 2 functions: one for data from 2018/10/10 to 2019/11/04 and
        # one for data from 2019/11/05 (from Run 1574) after update firmware.
        # The old readout (before 2019/11/05) is shifted by 1 cell.
        run_id = event.lst.tel[tel_id].svc.configuration_id

        # not yet gain selected
        if event.r1.tel[tel_id].selected_gain_channel is None:
            apply_timelapse_correction(
                waveform=waveform,
                local_clock_counter=lst.evt.local_clock_counter,
                first_capacitors=self.first_cap[tel_id],
                last_readout_time=self.last_readout_time[tel_id],
                expected_pixels_id=lst.svc.pixel_ids,
                run_id=run_id,
            )
        else:
            apply_timelapse_correction_gain_selected(
                waveform=waveform,
                local_clock_counter=lst.evt.local_clock_counter,
                first_capacitors=self.first_cap[tel_id],
                last_readout_time=self.last_readout_time[tel_id],
                expected_pixels_id=lst.svc.pixel_ids,
                selected_gain_channel=event.r1.tel[tel_id].selected_gain_channel,
                run_id=run_id,
            )

        container.waveform = waveform

    def interpolate_spikes(self, event, tel_id):
        """
        Interpolates spike A & B.
        Fill the R1 container.
        Parameters
        ----------
        event : `ctapipe` event-container
        tel_id : id of the telescope
        """
        run_id = event.lst.tel[tel_id].svc.configuration_id

        r1 = event.r1.tel[tel_id]
        if r1.selected_gain_channel is None:
            interpolate_spikes(
                waveform=r1.waveform,
                first_capacitors=self.first_cap[tel_id],
                previous_first_capacitors=self.first_cap_old[tel_id],
                run_id=run_id,
            )
        else:
            interpolate_spikes_gain_selected(
                waveform=r1.waveform,
                first_capacitors=self.first_cap[tel_id],
                previous_first_capacitors=self.first_cap_old[tel_id],
                selected_gain_channel=r1.selected_gain_channel,
                run_id=run_id,
            )
Ejemplo n.º 5
0
 class SomeComponentFloat(Component):
     tel_param = FloatTelescopeParameter(default_value=1.5)
Ejemplo n.º 6
0
class MuonIntensityFitter(TelescopeComponent):
    spe_width = FloatTelescopeParameter(
        help="Width of a single photo electron distribution", default_value=0.5
    ).tag(config=True)

    min_lambda_m = FloatTelescopeParameter(
        help="Minimum wavelength for Cherenkov light in m", default_value=300e-9,
    ).tag(config=True)

    max_lambda_m = FloatTelescopeParameter(
        help="Minimum wavelength for Cherenkov light in m", default_value=600e-9,
    ).tag(config=True)

    hole_radius_m = FloatTelescopeParameter(
        help="Hole radius of the reflector in m",
        default_value=[
            ("type", "LST_*", 0.308),
        ],
    ).tag(config=True)

    oversampling = IntTelescopeParameter(
        help="Oversampling for the line integration", default_value=3
    ).tag(config=True)

    def __call__(
        self, tel_id, center_x, center_y, radius, image, pedestal, mask
    ):
        """

        Parameters
        ----------
        center_x: Angle quantity
            Initial guess for muon ring center in telescope frame
        center_y: Angle quantity
            Initial guess for muon ring center in telescope frame
        radius: Angle quantity
            Radius of muon ring from circle fitting
        pixel_x: ndarray
            X position of pixels in image from circle fitting
        pixel_y: ndarray
            Y position of pixel in image from circle fitting
        image: ndarray
            Amplitude of image pixels
        pedestal: ndarray
            pedestal RMS
        mask: ndarray
            mask marking pixels to be used in the likelihood fit

        Returns
        -------
        MuonEfficiencyContainer
        """
        telescope = self.subarray.tel[tel_id]
        if telescope.optics.num_mirrors != 1:
            raise NotImplementedError(
                "Currently only single mirror telescopes"
                f" are supported in {self.__class__.__name__}"
            )

        negative_log_likelihood = build_negative_log_likelihood(
            image,
            telescope,
            mask,
            oversampling=self.oversampling.tel[tel_id],
            min_lambda=self.min_lambda_m.tel[tel_id] * u.m,
            max_lambda=self.max_lambda_m.tel[tel_id] * u.m,
            spe_width=self.spe_width.tel[tel_id],
            pedestal=pedestal,
            hole_radius=self.hole_radius_m.tel[tel_id] * u.m,
        )

        initial_guess = create_initial_guess(center_x, center_y, radius, telescope,)

        step_sizes = {}
        step_sizes["error_impact_parameter"] = 0.5
        step_sizes["error_phi"] = np.deg2rad(0.5)
        step_sizes["error_ring_width"] = 0.001 * radius.to_value(u.rad)
        step_sizes["error_optical_efficiency_muon"] = 0.05

        constraints = {}
        constraints["limit_impact_parameter"] = (0, None)
        constraints["limit_phi"] = (-np.pi, np.pi)
        constraints["fix_radius"] = True
        constraints["fix_center_x"] = True
        constraints["fix_center_y"] = True
        constraints["limit_ring_width"] = (0.0, None)
        constraints["limit_optical_efficiency_muon"] = (0.0, None)

        # Create Minuit object with first guesses at parameters
        # strip away the units as Minuit doesnt like them

        minuit = Minuit(
            negative_log_likelihood,
            # forced_parameters=parameter_names,
            **initial_guess,
            **step_sizes,
            **constraints,
            errordef=0.5,
            print_level=0,
            pedantic=True,
        )

        # Perform minimisation
        minuit.migrad()

        # Get fitted values
        result = minuit.values

        return MuonEfficiencyContainer(
            impact=result["impact_parameter"] * u.m,
            impact_x=result["impact_parameter"] * np.cos(result["phi"]) * u.m,
            impact_y=result["impact_parameter"] * np.sin(result["phi"]) * u.m,
            width=u.Quantity(np.rad2deg(result["ring_width"]), u.deg),
            optical_efficiency=result["optical_efficiency_muon"],
        )
Ejemplo n.º 7
0
class TwoPassWindowSum(ImageExtractor):
    """Extractor based on [1]_ which integrates the waveform a second time using
    a time-gradient linear fit. This is in particular the version implemented
    in the CTA-MARS analysis pipeline [2]_.

    Notes
    -----

    #. slide a 3-samples window through the waveform, finding max counts sum;
       the range of the sliding is the one allowing extension from 3 to 5;
       add 1 sample on each side and integrate charge in the 5-sample window;
       time is obtained as a charge-weighted average of the sample numbers;
       No information from neighboouring pixels is used.
    #. Preliminary image cleaning via simple tailcut with minimum number
       of core neighbours set at 1,
    #. Only the biggest cluster of pixels is kept.
    #. Parametrize following Hillas approach only if the resulting image has 3
       or more pixels.
    #. Do a linear fit of pulse time vs. distance along major image axis
       (CTA-MARS uses ROOT "robust" fit option,
       aka Least Trimmed Squares, to get rid of far outliers - this should
       be implemented in 'timing_parameters', e.g scipy.stats.siegelslopes).
    #. For all pixels except the core ones in the main island, integrate
       the waveform once more, in a fixed window of 5 samples set at the time
       "predicted" by the linear time fit.
       If the predicted time for a pixel leads to a window outside the readout
       window, then integrate the last (or first) 5 samples.
    #. The result is an image with main-island core pixels calibrated with a
       1st pass and non-core pixels re-calibrated with a 2nd pass.

    References
    ----------
    .. [1] J. Holder et al., Astroparticle Physics, 25, 6, 391 (2006)
    .. [2] https://forge.in2p3.fr/projects/step-by-step-reference-mars-analysis/wiki

    """

    # Get thresholds for core-pixels depending on telescope type.
    # WARNING: default values are not yet optimized
    core_threshold = FloatTelescopeParameter(
        default_value=[
            ("type", "*", 6.0),
            ("type", "LST*", 6.0),
            ("type", "MST*", 8.0),
            ("type", "SST*", 4.0),
        ],
        help="Picture threshold for internal tail-cuts pass",
    ).tag(config=True)

    # Boolean that is used to disable the 2np pass and return the 1st pass
    disable_second_pass = False

    def _calculate_correction(self, telid, widths, shifts,
                              selected_gain_channel):
        """Obtain the correction for the integration window specified for each
        pixel.

        The TwoPassWindowSum image extractor applies potentially different
        parameters for the integration window to each pixel, depending on the
        position of the peak. It has been decided to apply gain selection
        directly here. For basic definitions look at the documentation of
        `integration_correction`.

        Parameters
        ----------
        telid : int
            Index of the telescope in use.
        widths : array of shape N_pixels
            Width of the integration window (in units of n_samples)
        shifts : array of shape N_pixels
            Values of the window shifts per pixel.

        Returns
        -------
        correction : ndarray
            Value of the pixel-wise gain-selected integration correction.

        """
        readout = self.subarray.tel[telid].camera.readout
        # Calculate correction of first pixel for both channels
        correction = integration_correction(
            readout.reference_pulse_shape,
            readout.reference_pulse_sample_width.to_value("ns"),
            (1 / readout.sampling_rate).to_value("ns"),
            widths[0],
            shifts[0],
        )
        # then do the same for each remaining pixel and attach the result as
        # a column containing information from both channels
        for pixel in range(len(selected_gain_channel)):
            new_pixel_both_channels = integration_correction(
                readout.reference_pulse_shape,
                readout.reference_pulse_sample_width.to_value("ns"),
                (1 / readout.sampling_rate).to_value("ns"),
                widths[pixel],
                shifts[pixel],
            )
            # stack the columns (i.e pixels) so the final correction array
            # is N_channels X N_pixels
            correction = np.column_stack((correction, new_pixel_both_channels))

        # select the right channel per pixel
        correction = np.asarray([
            correction[:, pix_id][selected_gain_channel[pix_id]]
            for pix_id in range(len(selected_gain_channel))
        ])
        return correction

    def _apply_first_pass(self, waveforms, telid, selected_gain_channel):
        """
        Execute step 1.

        Parameters
        ----------
        waveforms : array of size (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.
        selected_gain_channel: array of size (N_channels, N_pixels)
            Array containing the index of the selected gain channel for each
            pixel (0 for low gain, 1 for high gain).

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Shape: (n_pix)
        """
        # STEP 1

        # Starting from DL0, the channel is already selected (if more than one)
        # event.dl0.tel[tel_id].waveform object has shape (N_pixels, N_samples)

        # For each pixel, we slide a 3-samples window through the
        # waveform without touching the extremes (so later we can increase it
        # to 5), summing each time the ADC counts contained within it.

        # 'width' could be configurable in a generalized version
        # Right now this image extractor is optimized for LSTCam and NectarCam
        width = 3
        sums = np.apply_along_axis(slide_window, 1, waveforms[:, 1:-1], width)
        # Note that the input waveforms are clipped at the extremes because
        # we want to extend this 3-samples window to 5 samples
        # 'sums' has now the shape of (N_pixels, N_samples-4)

        # For each pixel, in each of the (N_samples - 4) positions, we check
        # where the window encountered the maximum number of ADC counts
        startWindows = np.apply_along_axis(np.argmax, 1, sums)
        # Now startWindows has the shape of (N_pixels).
        # Note that the index values stored in startWindows come from 'sums'
        # of which the first index (0) corresponds of index 1 of each waveform
        # since we clipped them before.

        # Since we have to add 1 sample on each side, window_shift will always
        # be (-)1, while window_width will always be window1_width + 1
        # so we the final 5-samples window will be 1+3+1
        window_widths = np.full_like(startWindows, width + 1)
        window_shifts = np.full_like(startWindows, 1)

        # the 'peak_index' argument of 'extract_around_peak' has a different
        # meaning here: it's the start of the 3-samples window.
        # Since since the "sums" arrays started from index 1 of each waveform,
        # then each peak index has to be increased by one
        charge_1stpass, pulse_time_1stpass = extract_around_peak(
            waveforms,
            startWindows + 1,
            window_widths,
            window_shifts,
            self.sampling_rate[telid],
        )

        # Get integration correction factors
        correction = self._calculate_correction(telid, window_widths,
                                                window_shifts,
                                                selected_gain_channel)

        return charge_1stpass, pulse_time_1stpass, correction

    def _apply_second_pass(
        self,
        waveforms,
        telid,
        selected_gain_channel,
        charge_1stpass,
        pulse_time_1stpass,
        correction,
    ):
        """
        Follow steps from 2 to 7.

        Parameters
        ----------
        waveforms : array of shape (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.
        selected_gain_channel: array of shape (N_channels, N_pixels)
            Array containing the index of the selected gain channel for each
            pixel (0 for low gain, 1 for high gain).
        charge_1stpass : array of shape N_pixels
            Pixel charges reconstructed with the 1st pass, but not corrected.
        pulse_time_1stpass : array of shape N_pixels
            Pixel-wise pulse times reconstructed with the 1st pass.
        correction: array of shape N_pixels
            Charge correction from 1st pass.

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Note that in the case of a very bright full-camera image this can
            coincide the 1st pass information.
            Also in the case of very dim images the 1st pass will be recycled,
            but in this case the resulting image should be discarded
            from further analysis.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Same specifications as above.
            Shape: (n_pix)
        """
        # STEP 2

        # Apply correction to 1st pass charges
        charge_1stpass = charge_1stpass * correction

        # Set thresholds for core-pixels depending on telescope
        core_th = self.core_threshold.tel[telid]
        # Boundary thresholds will be half of core thresholds.

        # Preliminary image cleaning with simple two-level tail-cut
        camera_geometry = self.subarray.tel[telid].camera.geometry
        mask_1 = tailcuts_clean(
            camera_geometry,
            charge_1stpass,
            picture_thresh=core_th,
            boundary_thresh=core_th / 2,
            keep_isolated_pixels=False,
            min_number_picture_neighbors=1,
        )
        image_1 = charge_1stpass.copy()
        image_1[~mask_1] = 0

        # STEP 3

        # find all islands using this cleaning
        num_islands, labels = number_of_islands(camera_geometry, mask_1)
        if num_islands == 0:
            image_2 = image_1.copy()  # no islands = image unchanged
        else:
            # ...find the biggest one
            mask_biggest = largest_island(labels)
            image_2 = image_1.copy()
            image_2[~mask_biggest] = 0

        # Indexes of pixels that will need the 2nd pass
        nonCore_pixels_ids = np.where(image_2 < core_th)[0]
        nonCore_pixels_mask = image_2 < core_th

        # STEP 4

        # if the resulting image has less then 3 pixels
        # or there are more than 3 pixels but all contain a number of
        # photoelectrons above the core threshold
        if np.count_nonzero(image_2) < 3:
            # we return the 1st pass information
            # NOTE: In this case, the image was not bright enough!
            # We should label it as "bad and NOT use it"
            return charge_1stpass, pulse_time_1stpass
        elif len(nonCore_pixels_ids) == 0:
            # Since all reconstructed charges are above the core threshold,
            # there is no need to perform the 2nd pass.
            # We return the 1st pass information.
            # NOTE: In this case, even if this is 1st pass information,
            # the image is actually very bright! We should label it as "good"!
            return charge_1stpass, pulse_time_1stpass

        # otherwise we proceed by parametrizing the image
        hillas = hillas_parameters(camera_geometry, image_2)

        # STEP 5

        # linear fit of pulse time vs. distance along major image axis
        # using only the main island surviving the preliminary
        # image cleaning
        # WARNING: in case of outliers, the fit can perform better if
        # it is a robust algorithm.
        timing = timing_parameters(camera_geometry, image_2,
                                   pulse_time_1stpass, hillas)

        # get projected distances along main image axis
        long, _ = camera_to_shower_coordinates(
            camera_geometry.pix_x,
            camera_geometry.pix_y,
            hillas.x,
            hillas.y,
            hillas.psi,
        )

        # get the predicted times as a linear relation
        predicted_pulse_times = (timing.slope * long[nonCore_pixels_ids] +
                                 timing.intercept)

        predicted_peaks = np.zeros(len(predicted_pulse_times))

        # Convert time in ns to sample index using the sampling rate from
        # the readout.
        # Approximate the value obtained to nearest integer, then cast to
        # int64 otherwise 'extract_around_peak' complains.
        sampling_rate = self.sampling_rate[telid]
        np.rint(predicted_pulse_times.value * sampling_rate, predicted_peaks)
        predicted_peaks = predicted_peaks.astype(np.int64)

        # Due to the fit these peak indexes can now be also outside of the
        # readout window, so later we check for this.

        # STEP 6

        # select only the waveforms correspondent to the non-core pixels
        # of the main island survived from the 1st pass image cleaning
        nonCore_waveforms = waveforms[nonCore_pixels_ids]

        # Build 'width' and 'shift' arrays that adapt on the position of the
        # window along each waveform

        # Now the definition of peak_index is really the peak.
        # We have to add 2 samples each side, so the shist will always
        # be (-)2, while width will always end 4 samples to the right.
        # This "always" refers to a 5-samples window of course
        window_widths = np.full_like(predicted_peaks, 4, dtype=np.int64)
        window_shifts = np.full_like(predicted_peaks, 2, dtype=np.int64)

        # BUT, if the resulting 5-samples window falls outside of the readout
        # window then we take the first (or last) 5 samples
        window_widths[predicted_peaks < 0] = 4
        window_shifts[predicted_peaks < 0] = 0
        window_widths[predicted_peaks > (waveforms.shape[1] - 1)] = 4
        window_shifts[predicted_peaks > (waveforms.shape[1] - 1)] = 4

        # Now we can also (re)define the patological predicted times
        # because (we needed them to define the corrispective widths
        # and shifts)

        # set sample to 0 (beginning of the waveform) if predicted time
        # falls before
        predicted_peaks[predicted_peaks < 0] = 0
        # set sample to max-1 (first sample has index 0)
        # if predicted time falls after
        predicted_peaks[predicted_peaks > (waveforms.shape[1] - 1)] = (
            waveforms.shape[1] - 1)

        # re-calibrate non-core pixels using the fixed 5-samples window
        charge_noCore, pulse_times_noCore = extract_around_peak(
            nonCore_waveforms,
            predicted_peaks,
            window_widths,
            window_shifts,
            self.sampling_rate[telid],
        )

        # Modify integration correction factors only for non-core pixels
        correction_2ndPass = self._calculate_correction(
            telid,
            window_widths,
            window_shifts,
            selected_gain_channel[nonCore_pixels_ids],
        )
        np.put(correction, [nonCore_pixels_ids], correction_2ndPass)

        # STEP 7

        # Combine core and non-core pixels in the final output

        # this is the biggest cluster from the cleaned image
        # it contains the core pixels (which we leave untouched)
        # plus possibly some non-core pixels
        charge_2ndpass = image_2.copy()
        # Now we overwrite the charges of all non-core pixels in the camera
        # plus all those pixels which didn't survive the preliminary
        # cleaning.
        # We apply also their corrections.
        charge_2ndpass[
            nonCore_pixels_mask] = charge_noCore * correction_2ndPass

        # Same approach for the pulse times
        pulse_time_2npass = pulse_time_1stpass  # core + non-core pixels
        pulse_time_2npass[
            nonCore_pixels_mask] = pulse_times_noCore  # non-core pixels

        return charge_2ndpass, pulse_time_2npass

    def __call__(self, waveforms, telid, selected_gain_channel):
        """
        Call this ImageExtractor.

        Parameters
        ----------
        waveforms : array of shape (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.
        selected_gain_channel: array of shape (N_channels, N_pixels)
            Array containing the index of the selected gain channel for each
            pixel (0 for low gain, 1 for high gain).

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Shape: (n_pix)
        """

        charge1, pulse_time1, correction1 = self._apply_first_pass(
            waveforms, telid, selected_gain_channel)

        # FIXME: properly make sure that output is 32Bit instead of downcasting here
        if self.disable_second_pass:
            return (
                (charge1 * correction1).astype("float32"),
                pulse_time1.astype("float32"),
            )

        charge2, pulse_time2 = self._apply_second_pass(
            waveforms,
            telid,
            selected_gain_channel,
            charge1,
            pulse_time1,
            correction1,
        )
        # FIXME: properly make sure that output is 32Bit instead of downcasting here
        return charge2.astype("float32"), pulse_time2.astype("float32")
Ejemplo n.º 8
0
class TwoPassWindowSum(ImageExtractor):
    """Extractor based on [1]_ which integrates the waveform a second time using
    a time-gradient linear fit. This is in particular the version implemented
    in the CTA-MARS analysis pipeline [2]_.

    Notes
    -----

    #. slide a 3-samples window through the waveform, finding max counts sum;
       the range of the sliding is the one allowing extension from 3 to 5;
       add 1 sample on each side and integrate charge in the 5-sample window;
       time is obtained as a charge-weighted average of the sample numbers;
       No information from neighboouring pixels is used.
    #. Preliminary image cleaning via simple tailcut with minimum number
       of core neighbours set at 1,
    #. Only the brightest cluster of pixels is kept.
    #. Parametrize following Hillas approach only if the resulting image has 3
       or more pixels.
    #. Do a linear fit of pulse time vs. distance along major image axis
       (CTA-MARS uses ROOT "robust" fit option,
       aka Least Trimmed Squares, to get rid of far outliers - this should
       be implemented in 'timing_parameters', e.g scipy.stats.siegelslopes).
    #. For all pixels except the core ones in the main island, integrate
       the waveform once more, in a fixed window of 5 samples set at the time
       "predicted" by the linear time fit.
       If the predicted time for a pixel leads to a window outside the readout
       window, then integrate the last (or first) 5 samples.
    #. The result is an image with main-island core pixels calibrated with a
       1st pass and non-core pixels re-calibrated with a 2nd pass.

    References
    ----------
    .. [1] J. Holder et al., Astroparticle Physics, 25, 6, 391 (2006)
    .. [2] https://forge.in2p3.fr/projects/step-by-step-reference-mars-analysis/wiki

    """

    # Get thresholds for core-pixels depending on telescope type.
    # WARNING: default values are not yet optimized
    core_threshold = FloatTelescopeParameter(
        default_value=[
            ("type", "*", 6.0),
            ("type", "LST*", 6.0),
            ("type", "MST*", 8.0),
            ("type", "SST*", 4.0),
        ],
        help="Picture threshold for internal tail-cuts pass",
    ).tag(config=True)

    disable_second_pass = Bool(
        default_value=False,
        help="only run the first pass of the extractor, for debugging purposes",
    ).tag(config=True)

    apply_integration_correction = BoolTelescopeParameter(
        default_value=True,
        help="Apply the integration window correction").tag(config=True)

    @lru_cache(maxsize=4096)
    def _calculate_correction(self, telid, width, shift):
        """Obtain the correction for the integration window specified for each
        pixel.

        The TwoPassWindowSum image extractor applies potentially different
        parameters for the integration window to each pixel, depending on the
        position of the peak. It has been decided to apply gain selection
        directly here. For basic definitions look at the documentation of
        `integration_correction`.

        Parameters
        ----------
        telid : int
            Index of the telescope in use.
        width : int
            Width of the integration window in samples
        shift : int
            Window shift to the left of the pulse peak in samples

        Returns
        -------
        correction : ndarray
            Value of the pixel-wise gain-selected integration correction.

        """
        readout = self.subarray.tel[telid].camera.readout
        # Calculate correction of first pixel for both channels
        return integration_correction(
            readout.reference_pulse_shape,
            readout.reference_pulse_sample_width.to_value("ns"),
            (1 / readout.sampling_rate).to_value("ns"),
            width,
            shift,
        )

    def _apply_first_pass(self, waveforms,
                          telid) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
        """
        Execute step 1.

        Parameters
        ----------
        waveforms : array of size (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Shape: (n_pix)
        correction : ndarray
            pixel-wise integration correction
        """
        # STEP 1

        # Starting from DL0, the channel is already selected (if more than one)
        # event.dl0.tel[tel_id].waveform object has shape (N_pixels, N_samples)

        # For each pixel, we slide a 3-samples window through the
        # waveform summing each time the ADC counts contained within it.

        peak_search_window_width = 3
        sums = convolve1d(waveforms,
                          np.ones(peak_search_window_width),
                          axis=1,
                          mode="nearest")
        # 'sums' has now still shape of (N_pixels, N_samples)
        # each element is the center-sample of each 3-samples sliding window

        # For each pixel, we check where the peak search window encountered
        # the maximum number of ADC counts.
        # We want to stop before the edge of the readout window in order to
        # later extend the search window to a 1+3+1 integration window.
        # Since in 'sums' the peak index corresponds to the center of the
        # search window, we shift it on the right by 2 samples so to get the
        # correspondent sample index in each waveform.
        peak_index = np.argmax(sums[:, 2:-2], axis=1) + 2
        # Now peak_index has the shape of (N_pixels).

        # The final 5-samples window will be 1+3+1, centered on the 3-samples
        # window in which the highest amount of ADC counts has been found
        window_width = peak_search_window_width + 2
        window_shift = 2

        # this function is applied to all pixels together
        charge_1stpass, pulse_time_1stpass = extract_around_peak(
            waveforms,
            peak_index,
            window_width,
            window_shift,
            self.sampling_rate_ghz[telid],
        )

        # Get integration correction factors
        if self.apply_integration_correction.tel[telid]:
            correction = self._calculate_correction(telid, window_width,
                                                    window_shift)
        else:
            correction = np.ones(waveforms.shape[0])

        return charge_1stpass, pulse_time_1stpass, correction

    def _apply_second_pass(
        self,
        waveforms,
        telid,
        selected_gain_channel,
        charge_1stpass_uncorrected,
        pulse_time_1stpass,
        correction,
    ) -> Tuple[np.ndarray, np.ndarray]:
        """
        Follow steps from 2 to 7.

        Parameters
        ----------
        waveforms : array of shape (N_pixels, N_samples)
            DL0-level waveforms of one event.
        telid : int
            Index of the telescope.
        selected_gain_channel: array of shape (N_channels, N_pixels)
            Array containing the index of the selected gain channel for each
            pixel (0 for low gain, 1 for high gain).
        charge_1stpass_uncorrected : array of shape N_pixels
            Pixel charges reconstructed with the 1st pass, but not corrected.
        pulse_time_1stpass : array of shape N_pixels
            Pixel-wise pulse times reconstructed with the 1st pass.
        correction: array of shape N_pixels
            Charge correction from 1st pass.

        Returns
        -------
        charge : array_like
            Integrated charge per pixel.
            Note that in the case of a very bright full-camera image this can
            coincide the 1st pass information.
            Also in the case of very dim images the 1st pass will be recycled,
            but in this case the resulting image should be discarded
            from further analysis.
            Shape: (n_pix)
        pulse_time : array_like
            Samples in which the waveform peak has been recognized.
            Same specifications as above.
            Shape: (n_pix)
        is_valid: bool
            True=second-pass succeeded, False=second-pass failed, first pass used
        """
        # STEP 2

        # Apply correction to 1st pass charges
        charge_1stpass = charge_1stpass_uncorrected * correction[
            selected_gain_channel]

        # Set thresholds for core-pixels depending on telescope
        core_th = self.core_threshold.tel[telid]
        # Boundary thresholds will be half of core thresholds.

        # Preliminary image cleaning with simple two-level tail-cut
        camera_geometry = self.subarray.tel[telid].camera.geometry
        mask_clean = tailcuts_clean(
            camera_geometry,
            charge_1stpass,
            picture_thresh=core_th,
            boundary_thresh=core_th / 2,
            keep_isolated_pixels=False,
            min_number_picture_neighbors=1,
        )

        # STEP 3

        # find all islands using this cleaning
        num_islands, labels = number_of_islands(camera_geometry, mask_clean)

        if num_islands > 0:
            # ...find the brightest one
            mask_brightest_island = brightest_island(num_islands, labels,
                                                     charge_1stpass)
        else:
            mask_brightest_island = mask_clean

        # for all pixels except the core ones in the main island of the
        # preliminary image, the waveform will be integrated once more (2nd pass)

        mask_2nd_pass = ~mask_brightest_island | (mask_brightest_island &
                                                  (charge_1stpass < core_th))

        # STEP 4

        # if the resulting image has less then 3 pixels
        if np.count_nonzero(mask_brightest_island) < 3:
            # we return the 1st pass information
            return charge_1stpass, pulse_time_1stpass, False

        # otherwise we proceed by parametrizing the image
        camera_geometry_brightest = camera_geometry[mask_brightest_island]
        charge_brightest = charge_1stpass[mask_brightest_island]
        hillas = hillas_parameters(camera_geometry_brightest, charge_brightest)

        # STEP 5

        # linear fit of pulse time vs. distance along major image axis
        # using only the main island surviving the preliminary
        # image cleaning
        timing = timing_parameters(
            geom=camera_geometry_brightest,
            image=charge_brightest,
            peak_time=pulse_time_1stpass[mask_brightest_island],
            hillas_parameters=hillas,
        )

        # If the fit returns nan
        if np.isnan(timing.slope):
            return charge_1stpass, pulse_time_1stpass, False

        # get projected distances along main image axis
        longitude, _ = camera_to_shower_coordinates(camera_geometry.pix_x,
                                                    camera_geometry.pix_y,
                                                    hillas.x, hillas.y,
                                                    hillas.psi)

        # get the predicted times as a linear relation
        predicted_pulse_times = (timing.slope * longitude[mask_2nd_pass] +
                                 timing.intercept)

        # Convert time in ns to sample index using the sampling rate from
        # the readout.
        # Approximate the value obtained to nearest integer, then cast to
        # int64 otherwise 'extract_around_peak' complains.

        predicted_peaks = np.rint(predicted_pulse_times.value *
                                  self.sampling_rate_ghz[telid]).astype(
                                      np.int64)

        # Due to the fit these peak indexes can lead to an integration window
        # outside the readout window, so in the next step we check for this.

        # STEP 6

        # select all pixels except the core ones in the main island
        waveforms_to_repass = waveforms[mask_2nd_pass]

        # Build 'width' and 'shift' arrays that adapt on the position of the
        # window along each waveform

        # As before we will integrate the charge in a 5-sample window centered
        # on the peak
        window_width_default = 5
        window_shift_default = 2

        # first we find where the integration window edges WOULD BE
        integration_windows_start = predicted_peaks - window_shift_default
        integration_windows_end = integration_windows_start + window_width_default

        # then we define 2 possible edge cases
        # the predicted integration window falls before the readout window
        integration_before_readout = integration_windows_start < 0
        # or after
        integration_after_readout = integration_windows_end > (
            waveforms_to_repass.shape[1] - 1)

        # If the resulting 5-samples window falls before the readout
        # window we take the first 5 samples
        window_width_before = 5
        window_shift_before = 0

        # If the resulting 5-samples window falls after the readout
        # window we take the last 5 samples
        window_width_after = 6
        window_shift_after = 4

        # put all values of widths and shifts for 2nd pass pixels together
        window_widths = np.full(waveforms_to_repass.shape[0],
                                window_width_default)
        window_widths[integration_before_readout] = window_width_before
        window_widths[integration_after_readout] = window_width_after
        window_shifts = np.full(waveforms_to_repass.shape[0],
                                window_shift_default)
        window_shifts[integration_before_readout] = window_shift_before
        window_shifts[integration_after_readout] = window_shift_after

        # Now we have to (re)define the pathological predicted times for which
        # - either the peak itself falls outside of the readout window
        # - or is within the first or last 2 samples (so that at least 1 sample
        # of the integration window is outside of the readout window)
        # We place them at the first or last sample, so the special window
        # widhts and shifts that we defined earlier put the integration window
        # for these 2 cases either in the first 5 samples or the last

        # set sample to 0 (beginning of the waveform)
        # if predicted time falls before
        # but also if it's so near the edge that the integration window falls
        # outside
        predicted_peaks[predicted_peaks < 2] = 0

        # set sample to max-1 (first sample has index 0)
        # if predicted time falls after
        predicted_peaks[predicted_peaks > (waveforms_to_repass.shape[1] -
                                           3)] = (
                                               waveforms_to_repass.shape[1] -
                                               1)

        # re-calibrate 2nd pass pixels using the fixed 5-samples window
        reintegrated_charge, reestimated_pulse_times = extract_around_peak(
            waveforms_to_repass,
            predicted_peaks,
            window_widths,
            window_shifts,
            self.sampling_rate_ghz[telid],
        )

        if self.apply_integration_correction.tel[telid]:
            # Modify integration correction factors only for non-core pixels
            # now we compute 3 corrections for the default, before, and after cases:
            correction = self._calculate_correction(
                telid, window_width_default,
                window_shift_default)[selected_gain_channel][mask_2nd_pass]

            correction_before = self._calculate_correction(
                telid, window_width_before,
                window_shift_before)[selected_gain_channel][mask_2nd_pass]

            correction_after = self._calculate_correction(
                telid, window_width_after,
                window_shift_after)[selected_gain_channel][mask_2nd_pass]

            correction[integration_before_readout] = correction_before[
                integration_before_readout]
            correction[integration_after_readout] = correction_after[
                integration_after_readout]

            reintegrated_charge *= correction

        # STEP 7

        # Combine in the final output with,
        # - core pixels from the main cluster
        # - rest of the pixels which have been passed a second time

        # Start from a copy of the 1st pass charge
        charge_2ndpass = charge_1stpass.copy()
        # Overwrite the charges of pixels marked for second pass
        # leaving untouched the core pixels of the main island
        # from the preliminary (cleaned) image
        charge_2ndpass[mask_2nd_pass] = reintegrated_charge

        # Same approach for the pulse times
        pulse_time_2ndpass = pulse_time_1stpass.copy()
        pulse_time_2ndpass[mask_2nd_pass] = reestimated_pulse_times

        return charge_2ndpass, pulse_time_2ndpass, True

    def __call__(self, waveforms, telid, selected_gain_channel):
        charge1, pulse_time1, correction1 = self._apply_first_pass(
            waveforms, telid)

        # FIXME: properly make sure that output is 32Bit instead of downcasting here
        if self.disable_second_pass:
            return DL1CameraContainer(
                image=(charge1 *
                       correction1[selected_gain_channel]).astype("float32"),
                peak_time=pulse_time1.astype("float32"),
                is_valid=True,
            )

        charge2, pulse_time2, is_valid = self._apply_second_pass(
            waveforms, telid, selected_gain_channel, charge1, pulse_time1,
            correction1)
        # FIXME: properly make sure that output is 32Bit instead of downcasting here
        return DL1CameraContainer(
            image=charge2.astype("float32"),
            peak_time=pulse_time2.astype("float32"),
            is_valid=is_valid,
        )
Ejemplo n.º 9
0
class GlobalPeakWindowSum(ImageExtractor):
    """
    Extractor which sums in a window about the
    peak from the global average waveform.

    To reduce the influence of noise pixels, the average can be calculated
    only on the ``pixel_fraction`` brightest pixels.
    The "brightest" pixels are determined by sorting the waveforms by their
    maximum value.
    """

    window_width = IntTelescopeParameter(
        default_value=7,
        help="Define the width of the integration window").tag(config=True)

    window_shift = IntTelescopeParameter(
        default_value=3,
        help="Define the shift of the integration window from the peak_index "
        "(peak_index - shift)",
    ).tag(config=True)

    apply_integration_correction = BoolTelescopeParameter(
        default_value=True,
        help="Apply the integration window correction").tag(config=True)

    pixel_fraction = FloatTelescopeParameter(
        default_value=1.0,
        help=
        ("Fraction of pixels to use for finding the integration window."
         " By default, the full camera is used."
         " If fraction is smaller 1, only the brightest pixels will be averaged"
         " to find the peak position"),
    ).tag(config=True)

    @lru_cache(maxsize=128)
    def _calculate_correction(self, telid):
        """
        Calculate the correction for the extracted change such that the value
        returned would equal 1 for a noise-less unit pulse.

        This method is decorated with @lru_cache to ensure it is only
        calculated once per telescope.

        Parameters
        ----------
        telid : int

        Returns
        -------
        correction : ndarray
        The correction to apply to an extracted charge using this ImageExtractor
        Has size n_channels, as a different correction value might be required
        for different gain channels.
        """
        readout = self.subarray.tel[telid].camera.readout
        return integration_correction(
            readout.reference_pulse_shape,
            readout.reference_pulse_sample_width.to_value("ns"),
            (1 / readout.sampling_rate).to_value("ns"),
            self.window_width.tel[telid],
            self.window_shift.tel[telid],
        )

    def __call__(self, waveforms, telid, selected_gain_channel):
        if self.pixel_fraction.tel[telid] == 1.0:
            # average over pixels then argmax over samples
            peak_index = waveforms.mean(axis=-2).argmax()
        else:
            n_pixels = int(self.pixel_fraction.tel[telid] *
                           waveforms.shape[-2])
            brightest = np.argsort(waveforms.max(axis=-1))[..., -n_pixels:]

            # average over brightest pixels then argmax over samples
            peak_index = waveforms[brightest].mean(axis=-2).argmax()

        charge, peak_time = extract_around_peak(
            waveforms,
            peak_index,
            self.window_width.tel[telid],
            self.window_shift.tel[telid],
            self.sampling_rate_ghz[telid],
        )
        if self.apply_integration_correction.tel[telid]:
            charge *= self._calculate_correction(
                telid=telid)[selected_gain_channel]
        return DL1CameraContainer(image=charge,
                                  peak_time=peak_time,
                                  is_valid=True)
Ejemplo n.º 10
0
class TimeWaveformFitter(TelescopeComponent):
    """
    Class used to perform event reconstruction by fitting of a model on waveforms.
    """
    sigma_s = FloatTelescopeParameter(
        default_value=1,
        help='Width of the single photo-electron peak distribution.',
        allow_none=False).tag(config=True)
    crosstalk = FloatTelescopeParameter(default_value=0,
                                        help='Average pixel crosstalk.',
                                        allow_none=False).tag(config=True)
    sigma_space = Float(
        4,
        help=
        'Size of the region on which the fit is performed relative to the image extension.',
        allow_none=False).tag(config=True)
    sigma_time = Float(
        3,
        help=
        'Time window on which the fit is performed relative to the image temporal extension.',
        allow_none=False).tag(config=True)
    time_before_shower = FloatTelescopeParameter(
        default_value=10,
        help='Additional time at the start of the fit temporal window.',
        allow_none=False).tag(config=True)
    time_after_shower = FloatTelescopeParameter(
        default_value=20,
        help='Additional time at the end of the fit temporal window.',
        allow_none=False).tag(config=True)
    use_weight = Bool(
        False,
        help=
        'If True, the brightest sample is twice as important as the dimmest pixel in the '
        'likelihood. If false all samples are equivalent.',
        allow_none=False).tag(config=True)
    no_asymmetry = Bool(
        False,
        help='If true, the asymmetry of the spatial model is fixed to 0.',
        allow_none=False).tag(config=True)
    use_interleaved = Path(
        None,
        help=
        'Location of the dl1 file used to estimate the pedestal exploiting interleaved'
        ' events.',
        allow_none=True).tag(config=True)
    n_peaks = Int(
        0,
        help=
        'Maximum brightness (p.e.) for which the full likelihood computation is used. '
        'If the Poisson term for Np.e.>n_peak is more than 1e-6 a Gaussian approximation is used.',
        allow_none=False).tag(config=True)
    bound_charge_factor = FloatTelescopeParameter(
        default_value=4,
        help='Maximum relative change to the fitted charge parameter.',
        allow_none=False).tag(config=True)
    bound_t_cm_value = FloatTelescopeParameter(
        default_value=10,
        help='Maximum change to the t_cm parameter.',
        allow_none=False).tag(config=True)
    bound_centroid_control_parameter = FloatTelescopeParameter(
        default_value=1,
        help='Maximum change of the centroid coordinated in '
        'number of seed length',
        allow_none=False).tag(config=True)
    bound_max_length_factor = FloatTelescopeParameter(
        default_value=2,
        help='Maximum relative increase to the fitted length parameter.',
        allow_none=False).tag(config=True)
    bound_length_asymmetry = FloatTelescopeParameter(
        default_value=9,
        help='Bounds for the fitted rl parameter.',
        allow_none=False).tag(config=True)
    bound_max_v_cm_factor = FloatTelescopeParameter(
        default_value=2,
        help='Maximum relative increase to the fitted v_cm parameter.',
        allow_none=False).tag(config=True)
    default_seed_t_cm = FloatTelescopeParameter(
        default_value=0,
        help='Default starting value of t_cm when the seed extraction failed.',
        allow_none=False).tag(config=True)
    default_seed_v_cm = FloatTelescopeParameter(
        default_value=40,
        help='Default starting value of v_cm when the seed extraction failed.',
        allow_none=False).tag(config=True)
    verbose = Int(
        0,
        help='4 - used for tests: create debug plots\n'
        '3 - create debug plots, wait for input after each event, increase minuit verbose level\n'
        '2 - create debug plots, increase minuit verbose level\n'
        '1 - increase minuit verbose level\n'
        '0 - silent',
        allow_none=False).tag(config=True)

    def __init__(self, subarray, config=None, parent=None, **kwargs):
        super().__init__(subarray=subarray,
                         config=config,
                         parent=parent,
                         **kwargs)
        self.subarray = subarray
        self.template_dict = {}
        self.template_time_of_max_dict = {}
        for tel_id in subarray.tel:
            self.template_dict[
                tel_id] = NormalizedPulseTemplate.load_from_eventsource(
                    subarray.tel[tel_id].camera.readout)
            self.template_time_of_max_dict[tel_id] = self.template_dict[
                tel_id].compute_time_of_max()
        poisson_peaks = np.arange(self.n_peaks + 1, dtype=int)
        poisson_peaks[0] = 1
        self.factorial = np.cumprod(poisson_peaks, dtype='u8')
        # Find the transition charge between full likelihood computation and Gaussian approximation
        # The maximum charge is selected such that each Poisson terms in the full likelihood computation
        # above the n_peaks limit account for less than (1/n_peaks)%
        transition_charges = {}
        for config_crosstalk in self.crosstalk:
            # if n_peaks is set to 0, only the Gaussian approximation is used
            transition_charges[config_crosstalk[2]] = 0.0 if self.n_peaks == 0\
                else self.find_transition_charge(config_crosstalk[2], 1e-2/self.n_peaks)
        self.transition_charges = {}
        for tel_id in subarray.tel:
            self.transition_charges[tel_id] = transition_charges[
                self.crosstalk.tel[tel_id]]

        self.start_parameters = None
        self.names_parameters = None
        self.end_parameters = None
        self.error_parameters = None
        self.bound_parameters = None
        self.fcn = None

    def call_setup(self, event, telescope_id, dl1_container):
        """
        Extract all event dependent quantities used for the fit.

        Parameters
        ----------
        event: ctapipe event container
            Current event container.
        telescope_id: int
            Id of the telescope
        dl1_container: DL1ParametersContainer
            Contains the Hillas parameters used as seed for the fit

        Returns
        -------
        focal_length: astropy.units.Quantity
            Focal length of the telescope
        fit_params: array
            Array containing all the variable needed to compute the likelihood
            during the fir excluding the model parameters
        """

        geometry = self.subarray.tel[telescope_id].camera.geometry
        unit = geometry.pix_x.unit
        pix_x = geometry.pix_x.to_value(unit)
        pix_y = geometry.pix_y.to_value(unit)
        r_max = geometry.guess_radius().to_value(unit)
        pix_radius = np.sqrt(geometry.pix_area[0].to_value(unit**2) /
                             np.pi)  # find linear size of a pixel
        readout = self.subarray.tel[telescope_id].camera.readout
        sampling_rate = readout.sampling_rate.to_value(u.GHz)
        dt = (1.0 / sampling_rate)
        template = self.template_dict[telescope_id]
        image = event.dl1.tel[telescope_id].image
        hillas_signal_pixels = event.dl1.tel[telescope_id].image_mask
        start_x_cm, start_y_cm = init_centroid(dl1_container,
                                               geometry[hillas_signal_pixels],
                                               unit,
                                               image[hillas_signal_pixels],
                                               self.no_asymmetry)

        waveform = event.r1.tel[telescope_id].waveform

        dl1_calib = event.calibration.tel[telescope_id].dl1
        time_shift = dl1_calib.time_shift
        # TODO check if this is correct here or if it is applied to r1 waveform earlier
        if dl1_calib.pedestal_offset is not None:
            waveform = waveform - dl1_calib.pedestal_offset[:, np.newaxis]

        n_pixels, n_samples = waveform.shape
        times = np.arange(0, n_samples) * dt
        selected_gains = event.r1.tel[telescope_id].selected_gain_channel
        is_high_gain = (selected_gains == 0)

        # We assume that the time gradient is given in unit of 'geometry spatial unit'/ns
        v = dl1_container.time_gradient
        psi = dl1_container.psi.to_value(u.rad)
        # We use only positive time gradients and psi is projected in [-pi,pi] from [-pi/2,pi/2]
        if v < 0:
            if psi >= 0:
                psi = psi - np.pi
            else:
                psi = psi + np.pi

        start_length = max(dl1_container.length.to_value(unit), pix_radius)
        # With current likelihood computation, order and type of the parameters are important
        start_parameters = {
            'charge':
            dl1_container.intensity,
            't_cm':
            dl1_container.intercept -
            self.template_time_of_max_dict[telescope_id],
            'x_cm':
            start_x_cm.to_value(unit),
            'y_cm':
            start_y_cm.to_value(unit),
            'length':
            start_length,
            'wl':
            max(dl1_container.wl, 0.01),
            'psi':
            psi,
            'v':
            np.abs(v),
            'rl':
            0.0
        }

        # Temporal parameters extraction fails when cleaning select only 2 pixels, we use defaults values in this case
        if np.isnan(start_parameters['t_cm']):
            start_parameters['t_cm'] = self.default_seed_t_cm.tel[telescope_id]
        if np.isnan(start_parameters['v']):
            start_parameters['v'] = self.default_seed_v_cm.tel[telescope_id]

        t_max = n_samples * dt
        v_min, v_max = 0, max(
            self.bound_max_v_cm_factor.tel[telescope_id] *
            start_parameters['v'], 50)
        rl_min, rl_max = -self.bound_length_asymmetry.tel[
            telescope_id], self.bound_length_asymmetry.tel[telescope_id]
        if self.no_asymmetry:
            rl_min, rl_max = 0.0, 0.0
        bound_centroid = self.bound_centroid_control_parameter.tel[
            telescope_id] * start_length

        bound_parameters = {
            'charge': (dl1_container.intensity /
                       self.bound_charge_factor.tel[telescope_id],
                       dl1_container.intensity *
                       self.bound_charge_factor.tel[telescope_id]),
            't_cm': (-self.bound_t_cm_value.tel[telescope_id],
                     t_max + self.bound_t_cm_value.tel[telescope_id]),
            'x_cm': (start_x_cm.to_value(unit) - bound_centroid,
                     start_x_cm.to_value(unit) + bound_centroid),
            'y_cm': (start_y_cm.to_value(unit) - bound_centroid,
                     start_y_cm.to_value(unit) + bound_centroid),
            'length':
            (pix_radius,
             min(self.bound_max_length_factor.tel[telescope_id] * start_length,
                 r_max)),
            'wl': (0.001, 1.0),
            'psi': (-np.pi * 2.0, np.pi * 2.0),
            'v': (v_min, v_max),
            'rl': (rl_min, rl_max)
        }

        mask_pixel, mask_time = self.clean_data(pix_x, pix_y, pix_radius,
                                                times, start_parameters,
                                                telescope_id)
        spatial_ones = np.ones(np.sum(mask_pixel))

        is_high_gain = is_high_gain[mask_pixel]
        sig_s = spatial_ones * self.sigma_s.tel[telescope_id]
        crosstalks = spatial_ones * self.crosstalk.tel[telescope_id]

        times = (np.arange(0, n_samples) * dt)[mask_time]
        time_shift = time_shift[mask_pixel]

        p_x = pix_x[mask_pixel]
        p_y = pix_y[mask_pixel]
        pix_area = geometry.pix_area[mask_pixel].to_value(unit**2)

        data = waveform
        error = None  # TODO include option to use calibration data

        filter_pixels = np.nonzero(~mask_pixel)
        filter_times = np.nonzero(~mask_time)

        if error is None:
            std = np.std(data[~mask_pixel])
            error = np.full(data.shape[0], std)

        data = np.delete(data, filter_pixels, axis=0)
        data = np.delete(data, filter_times, axis=1)
        error = np.delete(error, filter_pixels, axis=0)

        # Fill the set of non-fitted parameters needed to compute the likelihood. Order and type sensitive.
        fit_params = [
            data, error, is_high_gain, sig_s, crosstalks, times,
            np.float32(time_shift), p_x, p_y,
            np.float64(pix_area), template.dt, template.t0,
            template.amplitude_LG, template.amplitude_HG, self.n_peaks,
            self.transition_charges[telescope_id], self.use_weight,
            self.factorial
        ]

        self.start_parameters = start_parameters
        self.names_parameters = start_parameters.keys()
        self.bound_parameters = bound_parameters

        return unit, fit_params

    def __call__(self, event, telescope_id, dl1_container):
        # setup angle to distance conversion on the camera plane for the current telescope
        focal_length = self.subarray.tel[
            telescope_id].optics.equivalent_focal_length
        angle_dist_eq = [
            (u.rad, u.m, lambda x: np.tan(x) * focal_length.to_value(u.m),
             lambda x: np.arctan(x / focal_length.to_value(u.m))),
            (u.rad**2, u.m**2, lambda x:
             (np.tan(np.sqrt(x)) * focal_length.to_value(u.m))**2, lambda x:
             (np.arctan(np.sqrt(x) / focal_length.to_value(u.m)))**2)
        ]
        with u.set_enabled_equivalencies(angle_dist_eq):
            self.start_parameters = None
            self.names_parameters = None
            unit_cam, fit_params = self.call_setup(event, telescope_id,
                                                   dl1_container)
            self.end_parameters = None
            self.error_parameters = None
            self.fcn = None

            return self.predict(unit_cam, fit_params)

    def clean_data(self, pix_x, pix_y, pix_radius, times, start_parameters,
                   telescope_id):
        """
        Method used to select pixels and time samples used in the fitting procedure.
        The spatial selection takes pixels in an ellipsis obtained from the seed Hillas parameters extended by one pixel
        size and multiplied by a factor sigma_space.
        The temporal selection takes a time window centered on the seed time of center of mass and of duration equal to
        the time of propagation of the signal along the length of the ellipsis times a factor sigma_time.
        An additional fixed duration is also added before and after this time window through the time_before_shower and
        time_after_shower arguments.

        Parameters
        ----------
        pix_x, pix_y: array-like
            Pixels positions
        pix_radius: float
        times: array-like
            Sampling times before timeshift corrections
        start_parameters: dict
            Seed parameters derived from the Hillas parameters
        telescope_id: int

        Returns
        ----------
        mask_pixel, mask_time: array-like
            Mask used to select pixels and times for the fit

        """
        x_cm = start_parameters['x_cm']
        y_cm = start_parameters['y_cm']
        length = start_parameters['length']
        width = start_parameters['wl'] * length
        psi = start_parameters['psi']

        dx = pix_x - x_cm
        dy = pix_y - y_cm

        lon = dx * np.cos(psi) + dy * np.sin(psi)
        lat = dx * np.sin(psi) - dy * np.cos(psi)

        mask_pixel = ((lon / (length + pix_radius))**2 +
                      (lat / (width + pix_radius))**2) < self.sigma_space**2

        v = start_parameters['v']
        t_start = (start_parameters['t_cm'] -
                   (np.abs(v) * length / 2 * self.sigma_time) -
                   self.time_before_shower.tel[telescope_id])
        t_end = (start_parameters['t_cm'] +
                 (np.abs(v) * length / 2 * self.sigma_time) +
                 self.time_after_shower.tel[telescope_id])

        mask_time = (times < t_end) * (times > t_start)

        return mask_pixel, mask_time

    def find_transition_charge(self, crosstalk, poisson_proba_min=1e-2):
        """
        Find the charge below which the full likelihood computation is performed and above which a Gaussian
        approximation is used. For a given pixel crosstalk it finds the maximum charge with a Generalised Poisson term
        below poisson_proba_min for n_peaks photo-electrons. n_peaks here is the configured maximum number of
        photo-electron considered in the full likelihood computation.

        Parameters
        ----------
        crosstalk : float
            Pixels crosstalk
        poisson_proba_min: float

        Returns
        -------
        transition_charge: float32
            Model charge of transition between full and approximated likelihood

        """
        transition_charge = self.n_peaks / (1 + crosstalk)
        step = transition_charge / 100

        def poisson(mu, cross_talk):
            return (mu * pow(mu + self.n_peaks * cross_talk,
                             (self.n_peaks - 1)) /
                    self.factorial[self.n_peaks] *
                    np.exp(-mu - self.n_peaks * cross_talk))

        while poisson(transition_charge, crosstalk) > poisson_proba_min:
            transition_charge -= step
        logger.info(
            f'Transition charge between full and approximated likelihood for camera '
            f'with crosstalk = {crosstalk:.4f} is,  {transition_charge:.4f}, p.e.'
        )
        return np.float32(transition_charge)

    def fit(self, fit_params):
        """
        Performs the fitting procedure.

        Parameters
        ----------
        fit_params: array
            Parameters used to compute the likelihood but not fitted

        """
        def f(*args):
            return -2 * self.log_likelihood(*args, fit_params=fit_params)

        print_level = 2 if self.verbose in [1, 2, 3] else 0
        m = Minuit(f,
                   name=self.names_parameters,
                   *self.start_parameters.values())
        for key, val in self.bound_parameters.items():
            m.limits[key] = val
        m.print_level = print_level
        m.errordef = 0.5
        m.simplex().migrad()
        self.end_parameters = m.values.to_dict()
        self.fcn = m.fval
        self.error_parameters = m.errors.to_dict()

    def predict(self, unit_cam, fit_params):
        """
            Call the fitting procedure and fill the results.

        Parameters
        ----------
        unit_cam: astropy.units.unit
            Unit used for the camera geometry and for spatial variable in the fit
        fit_params: array
            Parameters used to compute the likelihood but not fitted

        Returns
        ----------
        container: DL1LikelihoodParametersContainer
            Filled parameter container

        """
        container = DL1LikelihoodParametersContainer(lhfit_call_status=1)
        try:
            self.fit(fit_params)
            container.lhfit_TS = self.fcn

            container.lhfit_x = (self.end_parameters['x_cm'] * unit_cam).to(
                u.m)
            container.lhfit_x_uncertainty = (self.error_parameters['x_cm'] *
                                             unit_cam).to(u.m)
            container.lhfit_y = (self.end_parameters['y_cm'] * unit_cam).to(
                u.m)
            container.lhfit_y_uncertainty = (self.error_parameters['y_cm'] *
                                             unit_cam).to(u.m)
            container.lhfit_r = np.sqrt(container.lhfit_x**2 +
                                        container.lhfit_y**2)
            container.lhfit_phi = np.arctan2(container.lhfit_y,
                                             container.lhfit_x)
            if self.end_parameters['psi'] > np.pi:
                self.end_parameters['psi'] -= 2 * np.pi
            if self.end_parameters['psi'] < -np.pi:
                self.end_parameters['psi'] += 2 * np.pi
            container.lhfit_psi = self.end_parameters['psi'] * u.rad
            container.lhfit_psi_uncertainty = self.error_parameters[
                'psi'] * u.rad
            length_asy = 1 + self.end_parameters['rl'] if self.end_parameters[
                'rl'] >= 0 else 1 / (1 - self.end_parameters['rl'])
            lhfit_length = ((
                (1.0 + length_asy) * self.end_parameters['length'] / 2.0) *
                            unit_cam).to(u.deg)
            container.lhfit_length = lhfit_length
            lhfit_length_rel_err = self.error_parameters[
                'length'] / self.end_parameters['length']
            # We assume that the relative error is the same in the fitted and saved unit
            container.lhfit_length_uncertainty = lhfit_length_rel_err * container.lhfit_length
            container.lhfit_width = self.end_parameters[
                'wl'] * container.lhfit_length

            container.lhfit_time_gradient = self.end_parameters['v']
            container.lhfit_time_gradient_uncertainty = self.error_parameters[
                'v']
            container.lhfit_ref_time = self.end_parameters['t_cm']
            container.lhfit_ref_time_uncertainty = self.error_parameters[
                't_cm']

            container.lhfit_wl = u.Quantity(self.end_parameters['wl'])
            container.lhfit_wl_uncertainty = u.Quantity(
                self.error_parameters['wl'])
            container.lhfit_intensity = self.end_parameters['charge']
            container.lhfit_intensity_uncertainty = self.error_parameters[
                'charge']
            container.lhfit_log_intensity = np.log10(container.lhfit_intensity)
            container.lhfit_t_68 = container.lhfit_length.value * container.lhfit_time_gradient
            container.lhfit_area = container.lhfit_length * container.lhfit_width
            container.lhfit_length_asymmetry = self.end_parameters['rl']
            container.lhfit_length_asymmetry_uncertainty = self.error_parameters[
                'rl']
        except ZeroDivisionError:
            # TODO Check occurrence rate and solve
            container = DL1LikelihoodParametersContainer(lhfit_call_status=-1)
            logger.error(
                'ZeroDivisionError encounter during the fitting procedure, skipping event.'
            )

        return container

    def __str__(self):
        """
            Define the print format of TimeWaveformFitter objects.

            Returns
            -------
            str: string
                Contains the starting and bound parameters used for the fit,
                and the end results with errors and associated log-likelihood
                in readable format.

        """
        s = 'Event processed\n'
        s += 'Start parameters :\n\t{}\n'.format(self.start_parameters)
        s += 'Bound parameters :\n\t{}\n'.format(self.bound_parameters)
        s += 'End parameters :\n\t{}\n'.format(self.end_parameters)
        s += 'Error parameters :\n\t{}\n'.format(self.error_parameters)
        s += '-2Log-Likelihood :\t{}'.format(self.fcn)

        return s

    @staticmethod
    def log_likelihood(*args, fit_params, **kwargs):
        """Compute the log-likelihood used in the fitting procedure."""
        llh = log_pdf(*args, *fit_params, **kwargs)
        return np.sum(llh)