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), ])
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")
class SomeComponent(TelescopeComponent): tel_param1 = FloatTelescopeParameter(default_value=6.0).tag( config=True)
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, )
class SomeComponentFloat(Component): tel_param = FloatTelescopeParameter(default_value=1.5)
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"], )
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")
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, )
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)
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)