class CalibrationCalculator(Component): """ Parent class for the camera calibration calculators. Fills the MonitoringCameraContainer on the base of calibration events Parameters ---------- flatfield_calculator: lstchain.calib.camera.flatfield The flatfield to use. If None, then FlatFieldCalculator will be used by default. pedestal_calculator: lstchain.calib.camera.pedestal The pedestal to use. If None, then PedestalCalculator will be used by default. kwargs """ squared_excess_noise_factor = Float( 1.222, help='Excess noise factor squared: 1+ Var(gain)/Mean(Gain)**2').tag( config=True) pedestal_product = traits.create_class_enum_trait( PedestalCalculator, default_value='PedestalIntegrator') flatfield_product = traits.create_class_enum_trait( FlatFieldCalculator, default_value='FlasherFlatFieldCalculator') classes = List([FlatFieldCalculator, PedestalCalculator] + traits.classes_with_traits(FlatFieldCalculator) + traits.classes_with_traits(PedestalCalculator)) def __init__(self, subarray, parent=None, config=None, **kwargs): """ Parent class for the camera calibration calculators. Fills the MonitoringCameraContainer on the base of calibration events Parameters ---------- flatfield_calculator: lstchain.calib.camera.flatfield The flatfield to use. If None, then FlatFieldCalculator will be used by default. pedestal_calculator: lstchain.calib.camera.pedestal The pedestal to use. If None, then PedestalCalculator will be used by default. """ super().__init__(parent=parent, config=config, **kwargs) self.flatfield = FlatFieldCalculator.from_name(self.flatfield_product, parent=self, subarray=subarray) self.pedestal = PedestalCalculator.from_name(self.pedestal_product, parent=self, subarray=subarray) msg = "tel_id not the same for all calibration components" if self.pedestal.tel_id != self.flatfield.tel_id: raise ValueError(msg) self.tel_id = self.flatfield.tel_id self.log.debug(f"{self.pedestal}") self.log.debug(f"{self.flatfield}")
class DataBinning(Component): """ Collects information on generating energy and angular bins for generating IRFs as per pyIRF requirements. """ true_energy_min = Float( help="Minimum value for True Energy bins in TeV units", default_value=0.01, ).tag(config=True) true_energy_max = Float( help="Maximum value for True Energy bins in TeV units", default_value=100, ).tag(config=True) true_energy_n_bins_per_decade = Float( help="Number of edges per decade for True Energy bins", default_value=5.5, ).tag(config=True) reco_energy_min = Float( help="Minimum value for Reco Energy bins in TeV units", default_value=0.01, ).tag(config=True) reco_energy_max = Float( help="Maximum value for Reco Energy bins in TeV units", default_value=100, ).tag(config=True) reco_energy_n_bins_per_decade = Float( help="Number of edges per decade for Reco Energy bins", default_value=5.5, ).tag(config=True) energy_migration_min = Float( help="Minimum value of Energy Migration matrix", default_value=0.2, ).tag(config=True) energy_migration_max = Float( help="Maximum value of Energy Migration matrix", default_value=5, ).tag(config=True) energy_migration_n_bins = Int( help="Number of bins in log scale for Energy Migration matrix", default_value=31, ).tag(config=True) fov_offset_min = Float( help="Minimum value for FoV Offset bins", default_value=0.1, ).tag(config=True) fov_offset_max = Float( help="Maximum value for FoV offset bins", default_value=1.1, ).tag(config=True) fov_offset_n_edges = Int( help="Number of edges for FoV offset bins", default_value=9, ).tag(config=True) bkg_fov_offset_min = Float( help="Minimum value for FoV offset bins for Background IRF", default_value=0, ).tag(config=True) bkg_fov_offset_max = Float( help="Maximum value for FoV offset bins for Background IRF", default_value=10, ).tag(config=True) bkg_fov_offset_n_edges = Int( help="Number of edges for FoV offset bins for Background IRF", default_value=21, ).tag(config=True) source_offset_min = Float( help="Minimum value for Source offset for PSF IRF", default_value=0.0001, ).tag(config=True) source_offset_max = Float( help="Maximum value for Source offset for PSF IRF", default_value=1.0001, ).tag(config=True) source_offset_n_edges = Int( help="Number of edges for Source offset for PSF IRF", default_value=1000, ).tag(config=True) def true_energy_bins(self): """ Creates bins per decade for true MC energy using pyirf function. The overflow binning added is not needed at the current stage It can be used as - add_overflow_bins(***)[1:-1] """ true_energy = create_bins_per_decade( self.true_energy_min * u.TeV, self.true_energy_max * u.TeV, self.true_energy_n_bins_per_decade, ) return true_energy def reco_energy_bins(self): """ Creates bins per decade for reconstructed MC energy using pyirf function. The overflow binning added is not needed at the current stage It can be used as - add_overflow_bins(***)[1:-1] """ reco_energy = create_bins_per_decade( self.reco_energy_min * u.TeV, self.reco_energy_max * u.TeV, self.reco_energy_n_bins_per_decade, ) return reco_energy def energy_migration_bins(self): """ Creates bins for energy migration. """ energy_migration = np.geomspace( self.energy_migration_min, self.energy_migration_max, self.energy_migration_n_bins, ) return energy_migration def fov_offset_bins(self): """ Creates bins for single/multiple FoV offset """ fov_offset = ( np.linspace( self.fov_offset_min, self.fov_offset_max, self.fov_offset_n_edges, ) * u.deg ) return fov_offset def bkg_fov_offset_bins(self): """ Creates bins for FoV offset for Background IRF, Using the same binning as in pyirf example. """ background_offset = ( np.linspace( self.bkg_fov_offset_min, self.bkg_fov_offset_max, self.bkg_fov_offset_n_edges, ) * u.deg ) return background_offset def source_offset_bins(self): """ Creates bins for source offset for generating PSF IRF. Using the same binning as in pyirf example. """ source_offset = ( np.linspace( self.source_offset_min, self.source_offset_max, self.source_offset_n_edges, ) * u.deg ) return source_offset
class SingleTelEventDisplay(Tool): name = "ctapipe-display-televents" description = Unicode(__doc__) infile = Path(help="input file to read", exists=True, directory_ok=False).tag(config=True) tel = Int(help="Telescope ID to display", default_value=0).tag(config=True) write = Bool(help="Write out images to PNG files", default_value=False).tag(config=True) clean = Bool(help="Apply image cleaning", default_value=False).tag(config=True) hillas = Bool(help="Apply and display Hillas parametrization", default_value=False).tag(config=True) samples = Bool(help="Show each sample", default_value=False).tag(config=True) display = Bool(help="Display results in interactive window", default_value=True).tag(config=True) delay = Float(help="delay between events in s", default_value=0.01, min=0.001).tag(config=True) progress = Bool(help="display progress bar", default_value=True).tag(config=True) aliases = Dict({ "infile": "SingleTelEventDisplay.infile", "tel": "SingleTelEventDisplay.tel", "max-events": "EventSource.max_events", "write": "SingleTelEventDisplay.write", "clean": "SingleTelEventDisplay.clean", "hillas": "SingleTelEventDisplay.hillas", "samples": "SingleTelEventDisplay.samples", "display": "SingleTelEventDisplay.display", "delay": "SingleTelEventDisplay.delay", "progress": "SingleTelEventDisplay.progress", }) classes = List([EventSource, CameraCalibrator]) def __init__(self, **kwargs): super().__init__(**kwargs) def setup(self): print("TOLLES INFILE", self.infile) self.event_source = EventSource.from_url(self.infile, parent=self) self.event_source.allowed_tels = {self.tel} self.calibrator = CameraCalibrator(parent=self, subarray=self.event_source.subarray) self.log.info(f"SELECTING EVENTS FROM TELESCOPE {self.tel}") def start(self): disp = None for event in tqdm( self.event_source, desc=f"Tel{self.tel}", total=self.event_source.max_events, disable=~self.progress, ): self.log.debug(event.trigger) self.log.debug(f"Energy: {event.simulation.shower.energy}") self.calibrator(event) if disp is None: geom = self.event_source.subarray.tel[self.tel].camera.geometry self.log.info(geom) disp = CameraDisplay(geom) # disp.enable_pixel_picker() disp.add_colorbar() if self.display: plt.show(block=False) # display the event disp.axes.set_title("CT{:03d} ({}), event {:06d}".format( self.tel, geom.camera_name, event.index.event_id)) if self.samples: # display time-varying event data = event.dl0.tel[self.tel].waveform for ii in range(data.shape[1]): disp.image = data[:, ii] disp.set_limits_percent(70) plt.suptitle(f"Sample {ii:03d}") if self.display: plt.pause(self.delay) if self.write: plt.savefig( f"CT{self.tel:03d}_EV{event.index.event_id:10d}" f"_S{ii:02d}.png") else: # display integrated event: im = event.dl1.tel[self.tel].image if self.clean: mask = tailcuts_clean(geom, im, picture_thresh=10, boundary_thresh=7) im[~mask] = 0.0 disp.image = im if self.hillas: try: ellipses = disp.axes.findobj(Ellipse) if len(ellipses) > 0: ellipses[0].remove() params = hillas_parameters(geom, image=im) disp.overlay_moments(params, color="pink", lw=3, with_label=False) except HillasParameterizationError: pass if self.display: plt.pause(self.delay) if self.write: plt.savefig( f"CT{self.tel:03d}_EV{event.index.event_id:010d}.png") self.log.info("FINISHED READING DATA FILE") if disp is None: self.log.warning( "No events for tel {} were found in {}. Try a " "different EventIO file or another telescope".format( self.tel, self.infile))
class SingleTelEventDisplay(Tool): name = "ctapipe-display-televents" description = Unicode(__doc__) infile = Unicode(help="input file to read", default='').tag(config=True) tel = Int(help='Telescope ID to display', default=0).tag(config=True) channel = Integer(help="channel number to display", min=0, max=1).tag(config=True) write = Bool(help="Write out images to PNG files", default=False).tag(config=True) clean = Bool(help="Apply image cleaning", default=False).tag(config=True) hillas = Bool(help="Apply and display Hillas parametrization", default=False).tag(config=True) samples = Bool(help="Show each sample", default=False).tag(config=True) display = Bool(help="Display results in interactive window", default_value=True).tag(config=True) delay = Float(help='delay between events in s', default_value=0.01, min=0.001).tag(config=True) progress = Bool(help='display progress bar', default_value=True).tag(config=True) aliases = Dict({ 'infile': 'SingleTelEventDisplay.infile', 'tel': 'SingleTelEventDisplay.tel', 'max-events': 'EventSource.max_events', 'channel': 'SingleTelEventDisplay.channel', 'write': 'SingleTelEventDisplay.write', 'clean': 'SingleTelEventDisplay.clean', 'hillas': 'SingleTelEventDisplay.hillas', 'samples': 'SingleTelEventDisplay.samples', 'display': 'SingleTelEventDisplay.display', 'delay': 'SingleTelEventDisplay.delay', 'progress': 'SingleTelEventDisplay.progress' }) classes = List([EventSource, CameraCalibrator]) def __init__(self, **kwargs): super().__init__(**kwargs) def setup(self): print('TOLLES INFILE', self.infile) self.event_source = EventSource.from_url(self.infile, parent=self) self.event_source.allowed_tels = { self.tel, } self.calibrator = CameraCalibrator(parent=self) self.log.info(f'SELECTING EVENTS FROM TELESCOPE {self.tel}') def start(self): disp = None for event in tqdm(self.event_source, desc=f'Tel{self.tel}', total=self.event_source.max_events, disable=~self.progress): self.log.debug(event.trig) self.log.debug(f"Energy: {event.mc.energy}") self.calibrator(event) if disp is None: geom = event.inst.subarray.tel[self.tel].camera self.log.info(geom) disp = CameraDisplay(geom) # disp.enable_pixel_picker() disp.add_colorbar() if self.display: plt.show(block=False) # display the event disp.axes.set_title('CT{:03d} ({}), event {:06d}'.format( self.tel, geom.cam_id, event.r0.event_id)) if self.samples: # display time-varying event data = event.dl0.tel[self.tel].waveform[self.channel] for ii in range(data.shape[1]): disp.image = data[:, ii] disp.set_limits_percent(70) plt.suptitle(f"Sample {ii:03d}") if self.display: plt.pause(self.delay) if self.write: plt.savefig( f'CT{self.tel:03d}_EV{event.r0.event_id:10d}' f'_S{ii:02d}.png') else: # display integrated event: im = event.dl1.tel[self.tel].image[self.channel] if self.clean: mask = tailcuts_clean(geom, im, picture_thresh=10, boundary_thresh=7) im[~mask] = 0.0 disp.image = im if self.hillas: try: ellipses = disp.axes.findobj(Ellipse) if len(ellipses) > 0: ellipses[0].remove() params = hillas_parameters(geom, image=im) disp.overlay_moments(params, color='pink', lw=3, with_label=False) except HillasParameterizationError: pass if self.display: plt.pause(self.delay) if self.write: plt.savefig( f'CT{self.tel:03d}_EV{event.r0.event_id:010d}.png') self.log.info("FINISHED READING DATA FILE") if disp is None: self.log.warning( 'No events for tel {} were found in {}. Try a ' 'different EventIO file or another telescope'.format( self.tel, self.infile), )
class CalibrationCalculator(Component): """ Parent class for the camera calibration calculators. Fills the MonitoringCameraContainer on the base of calibration events Parameters ---------- flatfield_calculator: lstchain.calib.camera.flatfield The flatfield to use. If None, then FlatFieldCalculator will be used by default. pedestal_calculator: lstchain.calib.camera.pedestal The pedestal to use. If None, then PedestalCalculator will be used by default. kwargs """ systematic_correction_path = Path( default_value=None, allow_none=True, exists=True, directory_ok=False, help='Path to systematic correction file ', ).tag(config=True) squared_excess_noise_factor = Float( 1.222, help='Excess noise factor squared: 1+ Var(gain)/Mean(Gain)**2').tag( config=True) pedestal_product = traits.create_class_enum_trait( PedestalCalculator, default_value='PedestalIntegrator') flatfield_product = traits.create_class_enum_trait( FlatFieldCalculator, default_value='FlasherFlatFieldCalculator') classes = ([FlatFieldCalculator, PedestalCalculator] + traits.classes_with_traits(FlatFieldCalculator) + traits.classes_with_traits(PedestalCalculator)) def __init__(self, subarray, parent=None, config=None, **kwargs): """ Parent class for the camera calibration calculators. Fills the MonitoringCameraContainer on the base of calibration events Parameters ---------- flatfield_calculator: lstchain.calib.camera.flatfield The flatfield to use. If None, then FlatFieldCalculator will be used by default. pedestal_calculator: lstchain.calib.camera.pedestal The pedestal to use. If None, then PedestalCalculator will be used by default. """ super().__init__(parent=parent, config=config, **kwargs) if self.squared_excess_noise_factor <= 0: msg = "Argument squared_excess_noise_factor must have a positive value" raise ValueError(msg) self.flatfield = FlatFieldCalculator.from_name(self.flatfield_product, parent=self, subarray=subarray) self.pedestal = PedestalCalculator.from_name(self.pedestal_product, parent=self, subarray=subarray) msg = "tel_id not the same for all calibration components" if self.pedestal.tel_id != self.flatfield.tel_id: raise ValueError(msg) self.tel_id = self.flatfield.tel_id # load systematic correction term B self.quadratic_term = 0 if self.systematic_correction_path is not None: try: with h5py.File(self.systematic_correction_path, 'r') as hf: self.quadratic_term = np.array(hf['B_term']) except: raise IOError( f"Problem in reading quadratic term file {self.systematic_correction_path}" ) self.log.debug(f"{self.pedestal}") self.log.debug(f"{self.flatfield}")
class SomeComponent(TelescopeComponent): path = TelescopeParameter(Path(), default_value=None).tag(config=True) val = TelescopeParameter(Float(), default_value=1.0).tag(config=True)
class TimeCorrectionCalculate(Component): """ The TimeCorrectionCalculate class to create h5py file with coefficients for time correction curve of chip DRS4. Description of this method: "Analysis techniques and performance of the Domino Ring Sampler version 4 based readout for the MAGIC telescopes [arxiv:1305.1007] """ minimum_charge = Float( 200, help='Cut on charge. Default 200 ADC').tag(config=True) tel_id = Int(1, help='Id of the telescope to calibrate').tag(config=True) n_combine = Int( 8, help='How many capacitors are combines in a single bin. Default 8' ).tag(config=True) n_harmonics = Int( 16, help='Number of harmonic for Fourier series expansion. Default 16' ).tag(config=True) n_capacitors = Int( 1024, help='Number of capacitors (1024 or 4096). Default 1024.').tag( config=True) charge_product = Unicode( 'LocalPeakWindowSum', help='Name of the charge extractor to be used').tag(config=True) calib_file_path = Unicode( '', allow_none=True, help='Path to the time calibration file').tag(config=True) def __init__(self, **kwargs): super().__init__(**kwargs) self.n_bins = int(self.n_capacitors / self.n_combine) self.mean_values_per_bin = np.zeros((n_gain, n_pixels, self.n_bins)) self.entries_per_bin = np.zeros((n_gain, n_pixels, self.n_bins)) self.first_cap_array = np.zeros((n_modules, n_gain, n_channel)) # load the waveform charge extractor self.extractor = ImageExtractor.from_name(self.charge_product, config=self.config) self.log.info(f"extractor {self.extractor}") self.sum_events = 0 def calibrate_pulse_time(self, event): """ Fill bins using time pulse from LocalPeakWindowSum. Parameters ---------- event : `ctapipe` event-container """ if event.r1.tel[self.tel_id].trigger_type == 1: for nr_module in prange(0, n_modules): self.first_cap_array[ nr_module, :, :] = self.get_first_capacitor( event, nr_module) pixel_ids = event.lst.tel[self.tel_id].svc.pixel_ids charge, pulse_time = self.extractor( event.r1.tel[self.tel_id].waveform) self.calib_pulse_time_jit(charge, pulse_time, pixel_ids, self.first_cap_array, self.mean_values_per_bin, self.entries_per_bin, n_cap=self.n_capacitors, n_combine=self.n_combine, min_charge=self.minimum_charge) self.sum_events += 1 @jit(parallel=True) def calib_pulse_time_jit(self, charge, pulse_time, pixel_ids, first_cap_array, mean_values_per_bin, entries_per_bin, n_cap, n_combine, min_charge): """ Numba function for calibration pulse time. Parameters ---------- pulse : ndarray Pulse time stored in a numpy array of shape (n_gain, n_pixels). charge : ndarray Charge in each pixel. (n_gain, n_pixels). pixel_ids: ndarray Array stored expected pixel id (n_pixels). first_cap_array : ndarray Value of first capacitor stored in a numpy array of shape (n_clus, n_gain, n_pix). mean_values_per_bin : ndarray Array to fill using pulse time stored in a numpy array of shape (n_gain, n_pixels, n_bins). entries_per_bin : ndarray Array to store number of entries per bin stored in a numpy array of shape (n_gain, n_pixels, n_bins). n_cap : int Number of capacitors n_combine : int Number of combine capacitors in a single bin """ for nr_module in prange(0, n_modules): for gain in prange(0, n_gain): for pix in prange(0, n_channel): pixel = pixel_ids[nr_module * 7 + pix] if charge[gain, pixel] > min_charge: # cut change fc = first_cap_array[nr_module, :, :] first_cap = (fc[gain, pix]) % n_cap bin = int(first_cap / n_combine) mean_values_per_bin[gain, pixel, bin] += pulse_time[gain, pixel] entries_per_bin[gain, pixel, bin] += 1 def finalize(self): if np.sum(self.entries_per_bin == 0) > 0: raise RuntimeError( "Not enough events to coverage all capacitor. " "Please use more events to time calibration file.") else: self.mean_values_per_bin = self.mean_values_per_bin / self.entries_per_bin self.save_to_hdf5_file() def fit(self, pixel_id, gain): """ Fit data bins using Fourier series expansion Parameters ---------- pixel_id : ndarray Array stored expected pixel id of shape (n_pixels). gain: int 0 for high gain, 1 for low gain """ self.pos = np.zeros(self.n_bins) for i in range(0, self.n_bins): self.pos[i] = (i + 0.5) * self.n_combine self.fan = np.zeros(self.n_harmonics) # cos coeff self.fbn = np.zeros(self.n_harmonics) # sin coeff for n in range(0, self.n_harmonics): self.integrate_with_trig(self.pos, self.mean_values_per_bin[gain, pixel_id], n, self.fan, self.fbn) def integrate_with_trig(self, x, y, n, an, bn): """ Function to expanding into Fourier series Parameters ---------- x : ndarray Array stored position in DRS4 ring of shape (n_bins). y: ndarray Array stored mean pulse time per bin of shape (n_bins) n : int n harmonic an: ndarray Array to fill with cos coeff of shape (n_harmonics) bn: ndarray Array to fill with sin coeff of shape (n_harmonics) """ suma = 0 sumb = 0 for i in range(0, self.n_bins): suma += y[i] * self.n_combine * np.cos( 2 * np.pi * n * (x[i] / float(self.n_capacitors))) sumb += y[i] * self.n_combine * np.sin( 2 * np.pi * n * (x[i] / float(self.n_capacitors))) an[n] = suma * (2. / (self.n_bins * self.n_combine)) bn[n] = sumb * (2. / (self.n_bins * self.n_combine)) def get_first_capacitor(self, event, nr): fc = np.zeros((n_gain, n_channel)) first_cap = event.lst.tel[ self.tel_id].evt.first_capacitor_id[nr * 8:(nr + 1) * 8] # First capacitor order according Dragon v5 board data format for i, j in zip([0, 1, 2, 3, 4, 5, 6], [0, 0, 1, 1, 2, 2, 3]): fc[high_gain, i] = first_cap[j] for i, j in zip([0, 1, 2, 3, 4, 5, 6], [4, 4, 5, 5, 6, 6, 7]): fc[low_gain, i] = first_cap[j] return fc def save_to_hdf5_file(self): """ Function to save Fourier series expansion coeff into hdf5 file """ fan_array = np.zeros((n_gain, n_pixels, self.n_harmonics)) fbn_array = np.zeros((n_gain, n_pixels, self.n_harmonics)) for pix_id in range(0, n_pixels): self.fit(pix_id, gain=high_gain) fan_array[high_gain, pix_id, :] = self.fan fbn_array[high_gain, pix_id, :] = self.fbn self.fit(pix_id, gain=low_gain) fan_array[low_gain, pix_id, :] = self.fan fbn_array[low_gain, pix_id, :] = self.fbn try: hf = h5py.File(self.calib_file_path, 'w') hf.create_dataset('fan', data=fan_array) hf.create_dataset('fbn', data=fbn_array) hf.attrs['n_events'] = self.sum_events hf.attrs['n_harm'] = self.n_harmonics except Exception as err: print("FAILED!", err) hf.close()
class DL3Cuts(Component): """ Selection cuts for DL2 to DL3 conversion """ global_gh_cut = Float( help="Global selection cut for gh_score (gammaness)", default_value=0.6, ).tag(config=True) gh_efficiency = Float( help="Gamma efficiency for optimized g/h cuts in %", default_value=0.95, ).tag(config=True) theta_containment = Float( help="Percentage containment region for theta cuts", default_value=0.68, ).tag(config=True) global_theta_cut = Float( help="Global selection cut for theta", default_value=0.2, ).tag(config=True) global_alpha_cut = Float( help="Global selection cut for alpha", default_value=20, ).tag(config=True) allowed_tels = List( help="List of allowed LST telescope ids", trait=Int(), default_value=[1], ).tag(config=True) def apply_global_gh_cut(self, data): """ Applying a global gammaness cut on a given data """ return data[data["gh_score"] > self.global_gh_cut] def energy_dependent_gh_cuts(self, data, energy_bins, min_value=0.1, max_value=0.99, smoothing=None, min_events=10): """ Evaluating energy-dependent gammaness cuts, in a given data, with provided reco energy bins, and other parameters to pass to the pyirf.cuts.calculate_percentile_cut function """ gh_cuts = calculate_percentile_cut( data["gh_score"], data["reco_energy"], bins=energy_bins, min_value=min_value, max_value=max_value, fill_value=data["gh_score"].max(), percentile=100 * (1 - self.gh_efficiency), smoothing=smoothing, min_events=min_events, ) return gh_cuts def apply_global_alpha_cut(self, data): """ Applying a global alpha cut on a given data """ return data[data["alpha"].to_value(u.deg) < self.global_alpha_cut] def apply_energy_dependent_gh_cuts(self, data, gh_cuts): """ Applying a given energy-dependent gh cuts to a data file, along the reco energy bins provided. """ data["selected_gh"] = evaluate_binned_cut( data["gh_score"], data["reco_energy"], gh_cuts, operator.ge, ) return data[data["selected_gh"]] def apply_global_theta_cut(self, data): """ Applying a global theta cut on a given data """ return data[data["theta"].to_value(u.deg) < self.global_theta_cut] def energy_dependent_theta_cuts(self, data, energy_bins, min_value=0.05 * u.deg, fill_value=0.32 * u.deg, max_value=0.32 * u.deg, smoothing=None, min_events=10): """ Evaluating an optimized energy-dependent theta cuts, in a given data, with provided reco energy bins, and other parameters to pass to the pyirf.cuts.calculate_percentile_cut function. Note: Using too fine binning will result in too un-smooth cuts. """ theta_cuts = calculate_percentile_cut( data["theta"], data["reco_energy"], bins=energy_bins, min_value=min_value, max_value=max_value, fill_value=fill_value, percentile=100 * self.theta_containment, smoothing=smoothing, min_events=min_events, ) return theta_cuts def apply_energy_dependent_theta_cuts(self, data, theta_cuts): """ Applying a given energy-dependent theta cuts to a data file, along the reco energy bins provided. """ data["selected_theta"] = evaluate_binned_cut( data["theta"], data["reco_energy"], theta_cuts, operator.le, ) return data[data["selected_theta"]] def allowed_tels_filter(self, data): """ Applying a filter on telescopes used for observation. """ mask = np.zeros(len(data), dtype=bool) for tel_id in self.allowed_tels: mask |= data["tel_id"] == tel_id return data[mask]
class SomeComponent(TelescopeComponent): tel_param = TelescopeParameter(Float(default_value=0.0, allow_none=True)) tel_param_int = IntTelescopeParameter()
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 CameraDL1Calibrator(Component): """ The calibrator for DL1 charge extraction. Fills the dl1 container. It handles the integration correction and, if required, the list of neighbours. Parameters ---------- config : traitlets.loader.Config Configuration specified by config file or cmdline arguments. Used to set traitlet values. Set to None if no configuration to pass. tool : ctapipe.core.Tool or None Tool executable that is calling this component. Passes the correct logger to the component. Set to None if no Tool to pass. extractor : ctapipe.calib.camera.charge_extractors.ChargeExtractor The extractor to use to extract the charge from the waveforms. By default the NeighbourPeakIntegrator with default configuration is used. cleaner : ctapipe.calib.camera.waveform_cleaners.Cleaner The waveform cleaner to use. By default no cleaning is applied to the waveforms. kwargs """ name = 'CameraCalibrator' radius = Float(None, allow_none=True, help='Pixels within radius from a pixel are considered ' 'neighbours to the pixel. Set to None for the default ' '(1.4 * min_pixel_seperation).').tag(config=True) clip_amplitude = Float(None, allow_none=True, help='Amplitude in p.e. above which the signal is ' 'clipped. Set to None for no ' 'clipping.').tag(config=True) def __init__(self, config, tool, extractor=None, cleaner=None, **kwargs): super().__init__(config=config, parent=tool, **kwargs) self.extractor = extractor if self.extractor is None: self.extractor = NeighbourPeakIntegrator(config, tool) self.cleaner = cleaner if self.cleaner is None: self.cleaner = NullWaveformCleaner(config, tool) self._dl0_empty_warn = False def check_dl0_exists(self, event, telid): """ Check that dl0 data exists. If it does not, then do not change dl1. This ensures that if the containers were filled from a file containing dl1 data, it is not overwritten by non-existant data. Parameters ---------- event : container A `ctapipe` event container telid : int The telescope id. Returns ------- bool True if dl0.tel[telid].pe_samples is not None, else false. """ dl0 = event.dl0.tel[telid].pe_samples if dl0 is not None: return True else: if not self._dl0_empty_warn: self.log.warning("Encountered an event with no DL0 data. " "DL1 is unchanged in this circumstance.") self._dl0_empty_warn = True return False @staticmethod def get_geometry(event, telid): """ Obtain the geometry for this telescope. Parameters ---------- event : container A `ctapipe` event container telid : int The telescope id. The neighbours are calculated once per telescope. Returns ------- `CameraGeometry` """ return CameraGeometry.guess(*event.inst.pixel_pos[telid], event.inst.optical_foclen[telid]) def get_correction(self, event, telid): """ Obtain the integration correction for this telescope. Parameters ---------- event : container A `ctapipe` event container telid : int The telescope id. The integration correction is calculated once per telescope. Returns ------- ndarray """ try: shift = self.extractor.window_shift width = self.extractor.window_width n_chan = event.inst.num_channels[telid] shape = event.mc.tel[telid].reference_pulse_shape step = event.mc.tel[telid].meta['refstep'] time_slice = event.mc.tel[telid].time_slice correction = integration_correction(n_chan, shape, step, time_slice, width, shift) return correction except (AttributeError, KeyError): # Don't apply correction when window_shift or window_width # does not exist in extractor, or when container does not have # a reference pulse shape return np.ones(event.inst.num_channels[telid]) def calibrate(self, event): """ Fill the dl1 container with the calibration data that results from the configuration of this calibrator. Parameters ---------- event : container A `ctapipe` event container """ for telid in event.dl0.tels_with_data: if self.check_dl0_exists(event, telid): waveforms = event.dl0.tel[telid].pe_samples n_samples = waveforms.shape[2] if n_samples == 1: # To handle ASTRI and dst corrected = waveforms[..., 0] window = np.ones(waveforms.shape) peakpos = np.zeros(waveforms.shape[0:2]) cleaned = waveforms else: # Clean waveforms cleaned = self.cleaner.apply(waveforms) # Extract charge if self.extractor.requires_neighbours(): e = self.extractor g = self.get_geometry(event, telid) e.neighbours = g.neighbor_matrix_where extract = self.extractor.extract_charge charge, peakpos, window = extract(cleaned) # Apply integration correction correction = self.get_correction(event, telid)[:, None] corrected = charge * correction # Clip amplitude if self.clip_amplitude: corrected[corrected > self.clip_amplitude] = \ self.clip_amplitude # Store into event container event.dl1.tel[telid].image = corrected event.dl1.tel[telid].extracted_samples = window event.dl1.tel[telid].peakpos = peakpos event.dl1.tel[telid].cleaned = cleaned
class LSTCalibrationCalculator(CalibrationCalculator): """ Calibration calculator for LST camera Fills the MonitoringCameraContainer on the base of calibration events Parameters: ---------- minimum_hg_charge_median : Temporary cut on HG charge till the calibox TIB do not work (default for filter 5.2) maximum_lg_charge_std Temporary cut on LG std against Lidar events till the calibox TIB do not work (default for filter 5.2) time_calibration_path: Path with the drs4 time calibration corrections """ minimum_hg_charge_median = Float( 5000, help= 'Temporary cut on HG charge till the calibox TIB do not work (default for filter 5.2)' ).tag(config=True) maximum_lg_charge_std = Float( 300, help= 'Temporary cut on LG std against Lidar events till the calibox TIB do not work (default for filter 5.2) ' ).tag(config=True) def calculate_calibration_coefficients(self, event): """ Calculate calibration coefficients from flatfield and pedestal statistics associated to the present event Parameters ---------- event: EventAndMonDataContainer """ ped_data = event.mon.tel[self.tel_id].pedestal ff_data = event.mon.tel[self.tel_id].flatfield status_data = event.mon.tel[self.tel_id].pixel_status calib_data = event.mon.tel[self.tel_id].calibration # mask from pedestal and flat-field data monitoring_unusable_pixels = np.logical_or( status_data.pedestal_failing_pixels, status_data.flatfield_failing_pixels) # calibration unusable pixels are an OR of all masks calib_data.unusable_pixels = np.logical_or( monitoring_unusable_pixels, status_data.hardware_failing_pixels) # Extract calibration coefficients with F-factor method # Assume fixed excess noise factor must be known from elsewhere # calculate photon-electrons numerator = self.squared_excess_noise_factor * ( ff_data.charge_median - ped_data.charge_median)**2 denominator = ff_data.charge_std**2 - ped_data.charge_std**2 n_pe = np.divide(numerator, denominator, out=np.zeros_like(numerator), where=denominator != 0) # fill WaveformCalibrationContainer calib_data.time = ff_data.sample_time calib_data.time_range = ff_data.sample_time_range calib_data.n_pe = n_pe # find signal median of good pixels masked_npe = np.ma.array(n_pe, mask=calib_data.unusable_pixels) npe_signal_median = np.ma.median(masked_npe, axis=1) # Flat field factor numerator = npe_signal_median[:, np.newaxis] denominator = n_pe ff = np.divide(numerator, denominator, out=np.zeros_like(denominator), where=denominator != 0) # calibration coefficients numerator = n_pe * ff denominator = (ff_data.charge_median - ped_data.charge_median) calib_data.dc_to_pe = np.divide(numerator, denominator, out=np.zeros_like(numerator), where=denominator != 0) # put the time around zero camera_time_median = np.median(ff_data.time_median, axis=1) calib_data.time_correction = -ff_data.relative_time_median - camera_time_median[:, np . newaxis] calib_data.pedestal_per_sample = ped_data.charge_median / self.pedestal.extractor.window_width # put to zero unusable pixels calib_data.dc_to_pe[calib_data.unusable_pixels] = 0 calib_data.pedestal_per_sample[calib_data.unusable_pixels] = 0 # eliminate inf values id any (still necessary?) calib_data.dc_to_pe[np.isinf(calib_data.dc_to_pe)] = 0 def process_interleaved(self, event): """ Process interleaved calibration events (pedestals and FF) Parameters ---------- """ new_ped = False new_ff = False # if pedestal event if LSTEventType.is_pedestal(event.r1.tel[self.tel_id].trigger_type): new_ped = self.pedestal.calculate_pedestals(event) # if flat-field event: no calibration TIB for the moment, # use a cut on the charge for ff events and on std for rejecting Magic Lidar events elif LSTEventType.is_calibration( event.r1.tel[self.tel_id].trigger_type ) or (np.median(np.sum(event.r1.tel[self.tel_id].waveform[0], axis=1)) > self.minimum_hg_charge_median and np.std(np.sum(event.r1.tel[self.tel_id].waveform[1], axis=1)) < self.maximum_lg_charge_std): new_ff = self.flatfield.calculate_relative_gain(event) # if new ff, calculate new calibration coefficients if new_ff: self.calculate_calibration_coefficients(event) return new_ped, new_ff def output_interleaved_results(self, event): """ Output interleaved results on request """ new_ped = False new_ff = False # store results if self.pedestal.num_events_seen > 0: self.pedestal.store_results(event) new_ped = True if self.flatfield.num_events_seen > 0: self.flatfield.store_results(event) # calculates calibration values self.calculate_calibration_coefficients(event) new_ff = True return new_ped, new_ff
class LSTEventSource(EventSource): """ EventSource for LST R0 data. """ multi_streams = Bool(True, help='Read in parallel all streams ').tag(config=True) min_flatfield_adc = Float( default_value=3000.0, help= ('Events with that have more than ``min_flatfield_pixel_fraction``' ' of the pixels inside [``min_flatfield_adc``, ``max_flatfield_adc``]' ' get tagged as EventType.FLATFIELD'), ).tag(config=True) max_flatfield_adc = Float( default_value=12000.0, help= ('Events with that have more than ``min_flatfield_pixel_fraction``' ' of the pixels inside [``min_flatfield_adc``, ``max_flatfield_adc``]' ' get tagged as EventType.FLATFIELD'), ).tag(config=True) min_flatfield_pixel_fraction = Float( default_value=0.8, help=( 'Events with that have more than ``min_flatfield_pixel_fraction``' ' of the pixels inside [``min_flatfield_pe``, ``max_flatfield_pe``]' ' get tagged as EventType.FLATFIELD'), ).tag(config=True) default_trigger_type = Enum( ['ucts', 'tib'], default_value='ucts', help= ('Default source for trigger type information.' ' For older data, tib might be the better choice but for data newer' ' than 2020-06-25, ucts is the preferred option. The source will still' ' fallback to the other device if the chosen default device is not ' ' available')).tag(config=True) use_flatfield_heuristic = Bool( default_value=True, help=('If true, try to identify flat field events independent of the' ' trigger type in the event. See option ``min_flatfield_adc``'), ).tag(config=True) calibrate_flatfields_and_pedestals = Bool( default_value=True, help='If True, flat field and pedestal events are also calibrated.' ).tag(config=True) apply_drs4_corrections = Bool( default_value=True, help=( 'Apply DRS4 corrections.' ' If True, this will fill R1 waveforms with the corrections applied' ' Use the options for the LSTR0Corrections to configure which' ' corrections are applied'), ).tag(config=True) trigger_information = Bool( default_value=True, help='Fill trigger information.').tag(config=True) pointing_information = Bool( default_value=True, help=('Fill pointing information.' ' Requires specifying `PointingSource.drive_report_path`'), ).tag(config=True) classes = [PointingSource, EventTimeCalculator, LSTR0Corrections] def __init__(self, input_url=None, **kwargs): ''' Create a new LSTEventSource. Parameters ---------- input_url: Path Path to or url understood by ``ctapipe.core.traits.Path``. If ``multi_streams`` is ``True``, the source will try to read all streams matching the given ``input_url`` **kwargs: Any of the traitlets. See ``LSTEventSource.class_print_help`` ''' super().__init__(input_url=input_url, **kwargs) if self.multi_streams: # test how many streams are there: # file name must be [stream name]Run[all the rest] # All the files with the same [all the rest] are opened path, name = os.path.split(os.path.abspath(self.input_url)) if 'Run' in name: _, run = name.split('Run', 1) else: run = name ls = listdir(path) self.file_list = [] for file_name in ls: if run in file_name: full_name = os.path.join(path, file_name) self.file_list.append(full_name) else: self.file_list = [self.input_url] self.multi_file = MultiFiles(self.file_list) self.geometry_version = 4 self.camera_config = self.multi_file.camera_config self.log.info("Read {} input files".format( self.multi_file.num_inputs())) self.tel_id = self.camera_config.telescope_id self._subarray = self.create_subarray(self.geometry_version, self.tel_id) self.r0_r1_calibrator = LSTR0Corrections(subarray=self._subarray, parent=self) self.time_calculator = EventTimeCalculator( subarray=self.subarray, run_id=self.camera_config.configuration_id, expected_modules_id=self.camera_config.lstcam.expected_modules_id, parent=self, ) self.pointing_source = PointingSource(subarray=self.subarray, parent=self) self.lst_service = self.fill_lst_service_container( self.tel_id, self.camera_config) @property def subarray(self): return self._subarray @property def is_simulation(self): return False @property def obs_ids(self): # currently no obs id is available from the input files return [ self.camera_config.configuration_id, ] @property def datalevels(self): if self.r0_r1_calibrator.calibration_path is not None: return (DataLevel.R0, DataLevel.R1) return (DataLevel.R0, ) def rewind(self): self.multi_file.rewind() @staticmethod def create_subarray(geometry_version, tel_id=1): """ Obtain the subarray from the EventSource Returns ------- ctapipe.instrument.SubarrayDescription """ # camera info from LSTCam-[geometry_version].camgeom.fits.gz file camera_geom = load_camera_geometry(version=geometry_version) # get info on the camera readout: daq_time_per_sample, pulse_shape_time_step, pulse_shapes = read_pulse_shapes( ) camera_readout = CameraReadout( 'LSTCam', 1 / daq_time_per_sample, pulse_shapes, pulse_shape_time_step, ) camera = CameraDescription('LSTCam', camera_geom, camera_readout) lst_tel_descr = TelescopeDescription(name='LST', tel_type='LST', optics=OPTICS, camera=camera) tel_descriptions = {tel_id: lst_tel_descr} # LSTs telescope position taken from MC from the moment tel_positions = {tel_id: [50., 50., 16] * u.m} subarray = SubarrayDescription( name=f"LST-{tel_id} subarray", tel_descriptions=tel_descriptions, tel_positions=tel_positions, ) return subarray def _generator(self): # container for LST data array_event = LSTArrayEventContainer() array_event.meta['input_url'] = self.input_url array_event.meta['max_events'] = self.max_events array_event.meta['origin'] = 'LSTCAM' # also add service container to the event section array_event.lst.tel[self.tel_id].svc = self.lst_service # initialize general monitoring container self.initialize_mon_container(array_event) # loop on events for count, zfits_event in enumerate(self.multi_file): array_event.count = count array_event.index.event_id = zfits_event.event_id array_event.index.obs_id = self.obs_ids[0] # Skip "empty" events that occur at the end of some runs if zfits_event.event_id == 0: self.log.warning('Event with event_id=0 found, skipping') continue self.fill_r0r1_container(array_event, zfits_event) self.fill_lst_event_container(array_event, zfits_event) if self.trigger_information: self.fill_trigger_info(array_event) self.fill_mon_container(array_event, zfits_event) if self.pointing_information: self.fill_pointing_info(array_event) # apply low level corrections if self.apply_drs4_corrections: self.r0_r1_calibrator.apply_drs4_corrections(array_event) # flat field tagging is performed on r1 data, so can only # be done after the drs4 corrections are applied if self.use_flatfield_heuristic: self.tag_flatfield_events(array_event) # gain select and calibrate to pe if self.r0_r1_calibrator.calibration_path is not None: # skip flatfield and pedestal events if asked if (self.calibrate_flatfields_and_pedestals or array_event.trigger.event_type not in {EventType.FLATFIELD, EventType.SKY_PEDESTAL}): self.r0_r1_calibrator.calibrate(array_event) yield array_event @staticmethod def is_compatible(file_path): from astropy.io import fits try: # The file contains two tables: # 1: CameraConfig # 2: Events h = fits.open(file_path)[2].header ttypes = [h[x] for x in h.keys() if 'TTYPE' in x] except OSError: # not even a fits file return False except IndexError: # A fits file of a different format return False is_protobuf_zfits_file = ((h['XTENSION'] == 'BINTABLE') and (h['EXTNAME'] == 'Events') and (h['ZTABLE'] is True) and (h['ORIGIN'] == 'CTA') and (h['PBFHEAD'] == 'R1.CameraEvent')) is_lst_file = 'lstcam_counters' in ttypes return is_protobuf_zfits_file & is_lst_file @staticmethod def fill_lst_service_container(tel_id, camera_config): """ Fill LSTServiceContainer with specific LST service data data (from the CameraConfig table of zfit file) """ return LSTServiceContainer( telescope_id=tel_id, cs_serial=camera_config.cs_serial, configuration_id=camera_config.configuration_id, date=camera_config.date, num_pixels=camera_config.num_pixels, num_samples=camera_config.num_samples, pixel_ids=camera_config.expected_pixels_id, data_model_version=camera_config.data_model_version, num_modules=camera_config.lstcam.num_modules, module_ids=camera_config.lstcam.expected_modules_id, idaq_version=camera_config.lstcam.idaq_version, cdhs_version=camera_config.lstcam.cdhs_version, algorithms=camera_config.lstcam.algorithms, pre_proc_algorithms=camera_config.lstcam.pre_proc_algorithms, ) def fill_lst_event_container(self, array_event, zfits_event): """ Fill LSTEventContainer with specific LST service data (from the Event table of zfit file) """ tel_id = self.tel_id lst_evt = array_event.lst.tel[tel_id].evt lst_evt.configuration_id = zfits_event.configuration_id lst_evt.event_id = zfits_event.event_id lst_evt.tel_event_id = zfits_event.tel_event_id lst_evt.pixel_status = zfits_event.pixel_status lst_evt.ped_id = zfits_event.ped_id lst_evt.module_status = zfits_event.lstcam.module_status lst_evt.extdevices_presence = zfits_event.lstcam.extdevices_presence # if TIB data are there if lst_evt.extdevices_presence & 1: tib = zfits_event.lstcam.tib_data.view(TIB_DTYPE)[0] lst_evt.tib_event_counter = tib['event_counter'] lst_evt.tib_pps_counter = tib['pps_counter'] lst_evt.tib_tenMHz_counter = tib['tenMHz_counter'] lst_evt.tib_stereo_pattern = tib['stereo_pattern'] lst_evt.tib_masked_trigger = tib['masked_trigger'] # if UCTS data are there if lst_evt.extdevices_presence & 2: if int(array_event.lst.tel[tel_id].svc.idaq_version) > 37201: cdts = zfits_event.lstcam.cdts_data.view( CDTS_AFTER_37201_DTYPE)[0] lst_evt.ucts_timestamp = cdts[0] lst_evt.ucts_address = cdts[1] # new lst_evt.ucts_event_counter = cdts[2] lst_evt.ucts_busy_counter = cdts[3] # new lst_evt.ucts_pps_counter = cdts[4] lst_evt.ucts_clock_counter = cdts[5] lst_evt.ucts_trigger_type = cdts[6] lst_evt.ucts_white_rabbit_status = cdts[7] lst_evt.ucts_stereo_pattern = cdts[8] # new lst_evt.ucts_num_in_bunch = cdts[9] # new lst_evt.ucts_cdts_version = cdts[10] # new else: # unpack UCTS-CDTS data (old version) cdts = zfits_event.lstcam.cdts_data.view( CDTS_BEFORE_37201_DTYPE)[0] lst_evt.ucts_event_counter = cdts[0] lst_evt.ucts_pps_counter = cdts[1] lst_evt.ucts_clock_counter = cdts[2] lst_evt.ucts_timestamp = cdts[3] lst_evt.ucts_camera_timestamp = cdts[4] lst_evt.ucts_trigger_type = cdts[5] lst_evt.ucts_white_rabbit_status = cdts[6] # if SWAT data are there if lst_evt.extdevices_presence & 4: # unpack SWAT data unpacked_swat = zfits_event.lstcam.swat_data.view(SWAT_DTYPE)[0] lst_evt.swat_timestamp = unpacked_swat[0] lst_evt.swat_counter1 = unpacked_swat[1] lst_evt.swat_counter2 = unpacked_swat[2] lst_evt.swat_event_type = unpacked_swat[3] lst_evt.swat_camera_flag = unpacked_swat[4] lst_evt.swat_camera_event_num = unpacked_swat[5] lst_evt.swat_array_flag = unpacked_swat[6] lst_evt.swat_array_event_num = unpacked_swat[7] # unpack Dragon counters counters = zfits_event.lstcam.counters.view(DRAGON_COUNTERS_DTYPE) lst_evt.pps_counter = counters['pps_counter'] lst_evt.tenMHz_counter = counters['tenMHz_counter'] lst_evt.event_counter = counters['event_counter'] lst_evt.trigger_counter = counters['trigger_counter'] lst_evt.local_clock_counter = counters['local_clock_counter'] lst_evt.chips_flags = zfits_event.lstcam.chips_flags lst_evt.first_capacitor_id = zfits_event.lstcam.first_capacitor_id lst_evt.drs_tag_status = zfits_event.lstcam.drs_tag_status lst_evt.drs_tag = zfits_event.lstcam.drs_tag lst_evt.ucts_jump = False def fill_trigger_info(self, array_event): tel_id = self.tel_id trigger = array_event.trigger trigger.time = self.time_calculator(tel_id, array_event) trigger.tels_with_trigger = [tel_id] trigger.tel[tel_id].time = trigger.time lst = array_event.lst.tel[tel_id] tib_available = lst.evt.extdevices_presence & 1 ucts_available = lst.evt.extdevices_presence & 2 # decide which source to use, if both are available, # the option decides, if not, fallback to the avilable source # if no source available, warn and do not fill trigger info if tib_available and ucts_available: if self.default_trigger_type == 'ucts': trigger_bits = lst.evt.ucts_trigger_type else: trigger_bits = lst.evt.tib_masked_trigger elif tib_available: trigger_bits = lst.evt.tib_masked_trigger elif ucts_available: trigger_bits = lst.evt.ucts_trigger_type else: self.log.warning('No trigger info available.') trigger.event_type = EventType.UNKNOWN return if (ucts_available and lst.evt.ucts_trigger_type == 42 and self.default_trigger_type == "ucts"): self.log.warning( 'Event with UCTS trigger_type 42 found.' ' Probably means unreliable or shifted UCTS data.' ' Consider switching to TIB using `default_trigger_type="tib"`' ) # first bit mono trigger, second stereo. # If *only* those two are set, we assume it's a physics event # for all other we only check if the flag is present if (trigger_bits & TriggerBits.PHYSICS) and not (trigger_bits & TriggerBits.OTHER): trigger.event_type = EventType.SUBARRAY elif trigger_bits & TriggerBits.CALIBRATION: trigger.event_type = EventType.FLATFIELD elif trigger_bits & TriggerBits.PEDESTAL: trigger.event_type = EventType.SKY_PEDESTAL elif trigger_bits & TriggerBits.SINGLE_PE: trigger.event_type = EventType.SINGLE_PE else: self.log.warning( f'Event {array_event.index.event_id} has unknown event type, trigger: {trigger_bits:08b}' ) trigger.event_type = EventType.UNKNOWN def tag_flatfield_events(self, array_event): ''' Use a heuristic based on R1 waveforms to recognize flat field events Currently, tagging of flat field events does not work, they are reported as physics events, here a heuristic identifies those events. Since trigger types might be wrong due to ucts errors, we try to identify flat field events in all trigger types. DRS4 corrections but not the p.e. calibration must be applied ''' tel_id = self.tel_id waveform = array_event.r1.tel[tel_id].waveform # needs to work for gain already selected or not if waveform.ndim == 3: image = waveform[HIGH_GAIN].sum(axis=1) else: image = waveform.sum(axis=1) in_range = (image >= self.min_flatfield_adc) & (image <= self.max_flatfield_adc) n_in_range = np.count_nonzero(in_range) looks_like_ff = n_in_range >= self.min_flatfield_pixel_fraction * image.size if looks_like_ff: array_event.trigger.event_type = EventType.FLATFIELD self.log.debug('Setting event type of event' f' {array_event.index.event_id} to FLATFIELD') elif array_event.trigger.event_type == EventType.FLATFIELD: self.log.warning( 'Found FF event that does not fulfill FF criteria: %d', array_event.index.event_id, ) array_event.trigger.event_type = EventType.UNKNOWN def fill_pointing_info(self, array_event): tel_id = self.tel_id pointing = self.pointing_source.get_pointing_position_altaz( tel_id, array_event.trigger.time, ) array_event.pointing.tel[tel_id] = pointing array_event.pointing.array_altitude = pointing.altitude array_event.pointing.array_azimuth = pointing.azimuth ra, dec = self.pointing_source.get_pointing_position_icrs( tel_id, array_event.trigger.time) array_event.pointing.array_ra = ra array_event.pointing.array_dec = dec def fill_r0r1_camera_container(self, zfits_event): """ Fill the r0 or r1 container, depending on whether gain selection has already happened (r1) or not (r0) This will create waveforms of shape (N_GAINS, N_PIXELS, N_SAMPLES), or (N_PIXELS, N_SAMPLES) respectively regardless of the n_pixels, n_samples in the file. Missing or broken pixels are filled using maxval of the waveform dtype. """ n_pixels = self.camera_config.num_pixels n_samples = self.camera_config.num_samples expected_pixels = self.camera_config.expected_pixels_id has_low_gain = (zfits_event.pixel_status & PixelStatus.LOW_GAIN_STORED).astype(bool) has_high_gain = (zfits_event.pixel_status & PixelStatus.HIGH_GAIN_STORED).astype(bool) not_broken = (has_low_gain | has_high_gain).astype(bool) # broken pixels have both false, so gain selected means checking # if there are any pixels where exactly one of high or low gain is stored gain_selected = np.any(has_low_gain != has_high_gain) # fill value for broken pixels dtype = zfits_event.waveform.dtype fill = np.iinfo(dtype).max # we assume that either all pixels are gain selected or none # only broken pixels are allowed to be missing completely if gain_selected: selected_gain = np.where(has_high_gain, 0, 1) waveform = np.full((n_pixels, n_samples), fill, dtype=dtype) waveform[not_broken] = zfits_event.waveform.reshape( (-1, n_samples)) reordered_waveform = np.full((N_PIXELS, N_SAMPLES), fill, dtype=dtype) reordered_waveform[expected_pixels] = waveform reordered_selected_gain = np.full(N_PIXELS, -1, dtype=np.int8) reordered_selected_gain[expected_pixels] = selected_gain r0 = R0CameraContainer() r1 = R1CameraContainer( waveform=reordered_waveform, selected_gain_channel=reordered_selected_gain, ) else: reshaped_waveform = zfits_event.waveform.reshape( N_GAINS, n_pixels, n_samples) # re-order the waveform following the expected_pixels_id values # could also just do waveform = reshaped_waveform[np.argsort(expected_ids)] reordered_waveform = np.full((N_GAINS, N_PIXELS, N_SAMPLES), fill, dtype=dtype) reordered_waveform[:, expected_pixels, :] = reshaped_waveform r0 = R0CameraContainer(waveform=reordered_waveform) r1 = R1CameraContainer() return r0, r1 def fill_r0r1_container(self, array_event, zfits_event): """ Fill with R0Container """ r0, r1 = self.fill_r0r1_camera_container(zfits_event) array_event.r0.tel[self.tel_id] = r0 array_event.r1.tel[self.tel_id] = r1 def initialize_mon_container(self, array_event): """ Fill with MonitoringContainer. For the moment, initialize only the PixelStatusContainer """ container = array_event.mon mon_camera_container = container.tel[self.tel_id] shape = (N_GAINS, N_PIXELS) # all pixels broken by default status_container = PixelStatusContainer( hardware_failing_pixels=np.ones(shape, dtype=bool), pedestal_failing_pixels=np.zeros(shape, dtype=bool), flatfield_failing_pixels=np.zeros(shape, dtype=bool), ) mon_camera_container.pixel_status = status_container def fill_mon_container(self, array_event, zfits_event): """ Fill with MonitoringContainer. For the moment, initialize only the PixelStatusContainer """ status_container = array_event.mon.tel[self.tel_id].pixel_status # reorder the array expected_pixels_id = self.camera_config.expected_pixels_id reordered_pixel_status = np.zeros(N_PIXELS, dtype=zfits_event.pixel_status.dtype) reordered_pixel_status[expected_pixels_id] = zfits_event.pixel_status channel_info = get_channel_info(reordered_pixel_status) status_container.hardware_failing_pixels[:] = channel_info == 0 def __exit__(self, exc_type, exc_value, traceback): self.close() def __len__(self): if self.max_events is not None: return min(self.max_events, len(self.multi_file)) return len(self.multi_file) def close(self): self.multi_file.close()
class TimeCorrectionCalculate(Component): """ The TimeCorrectionCalculate class to create h5py file with coefficients for time correction curve of chip DRS4. Description of this method: "Analysis techniques and performance of the Domino Ring Sampler version 4 based readout for the MAGIC telescopes [arxiv:1305.1007] """ minimum_charge = Float( 200, help='Cut on charge. Default 200 ADC').tag(config=True) tel_id = Int(1, help='Id of the telescope to calibrate').tag(config=True) n_combine = Int( 8, help='How many capacitors are combines in a single bin. Default 8' ).tag(config=True) n_harmonics = Int( 16, help='Number of harmonic for Fourier series expansion. Default 16' ).tag(config=True) n_capacitors = Int( 1024, help='Number of capacitors (1024 or 4096). Default 1024.').tag( config=True) charge_product = Unicode( 'LocalPeakWindowSum', help='Name of the charge extractor to be used').tag(config=True) calib_file_path = Unicode( '', allow_none=True, help='Path to the time calibration file').tag(config=True) def __init__(self, subarray, **kwargs): """ The TimeCorrectionCalculate class to create h5py file with coefficients for time correction curve of chip DRS4. Description of this method: "Analysis techniques and performance of the Domino Ring Sampler version 4 based readout for the MAGIC telescopes [arxiv:1305.1007] Parameters ---------- subarray: ctapipe.instrument.SubarrayDescription Description of the subarray. Provides information about the camera which are useful in charge extraction, such as reference pulse shape, sampling rate, neighboring pixels. Also required for configuring the TelescopeParameter traitlets. kwargs """ super().__init__(**kwargs) self.n_bins = int(self.n_capacitors / self.n_combine) self.mean_values_per_bin = np.zeros((n_gain, n_pixels, self.n_bins)) self.entries_per_bin = np.zeros((n_gain, n_pixels, self.n_bins)) self.first_cap_array = np.zeros((n_modules, n_gain, n_channel)) # load the waveform charge extractor self.extractor = ImageExtractor.from_name(self.charge_product, config=self.config, subarray=subarray) self.log.info(f"extractor {self.extractor}") self.sum_events = 0 def calibrate_peak_time(self, event): """ Fill bins using time pulse from LocalPeakWindowSum. Parameters ---------- event : `ctapipe` event-container """ if event.trigger.event_type == EventType.FLATFIELD: for nr_module in prange(0, n_modules): self.first_cap_array[ nr_module, :, :] = self.get_first_capacitor( event, nr_module) pixel_ids = event.lst.tel[self.tel_id].svc.pixel_ids waveforms = event.r1.tel[self.tel_id].waveform no_gain_selection = np.zeros( (waveforms.shape[0], waveforms.shape[1]), dtype=np.int64) # select both gain charge, peak_time = self.extractor( event.r1.tel[self.tel_id].waveform[:, :, :], self.tel_id, no_gain_selection) self.calib_peak_time_jit(charge, peak_time, pixel_ids, self.first_cap_array, self.mean_values_per_bin, self.entries_per_bin, n_cap=self.n_capacitors, n_combine=self.n_combine, min_charge=self.minimum_charge) self.sum_events += 1 @staticmethod @njit(parallel=True) def calib_peak_time_jit(charge, peak_time, pixel_ids, first_cap_array, mean_values_per_bin, entries_per_bin, n_cap, n_combine, min_charge): """ Numba function for calibration pulse time. Parameters ---------- pulse : ndarray Pulse time stored in a numpy array of shape (n_gain, n_pixels). charge : ndarray Charge in each pixel. (n_gain, n_pixels). pixel_ids: ndarray Array stored expected pixel id (n_pixels). first_cap_array : ndarray Value of first capacitor stored in a numpy array of shape (n_clus, n_gain, n_pix). mean_values_per_bin : ndarray Array to fill using pulse time stored in a numpy array of shape (n_gain, n_pixels, n_bins). entries_per_bin : ndarray Array to store number of entries per bin stored in a numpy array of shape (n_gain, n_pixels, n_bins). n_cap : int Number of capacitors n_combine : int Number of combine capacitors in a single bin """ for nr_module in prange(n_modules): for gain in prange(n_gain): for pix in prange(n_channel): pixel = pixel_ids[nr_module * 7 + pix] if charge[gain, pixel] > min_charge: # cut change fc = first_cap_array[nr_module, :, :] first_cap = (fc[gain, pix]) % n_cap bin = int(first_cap / n_combine) mean_values_per_bin[gain, pixel, bin] += peak_time[gain, pixel] entries_per_bin[gain, pixel, bin] += 1 def finalize(self): n_total = self.entries_per_bin.size n_available = np.count_nonzero(self.entries_per_bin) if n_available < n_total: raise RuntimeError( "No data available for some capacitors. " "It might help to use more events to create the calibration file. " f"Available: {n_available / n_total:.3%}, Missing: {n_total - n_available}" ) else: self.mean_values_per_bin = self.mean_values_per_bin / self.entries_per_bin self.save_to_hdf5_file() def fit(self, pixel_id, gain): """ Fit data bins using Fourier series expansion Parameters ---------- pixel_id : ndarray Array stored expected pixel id of shape (n_pixels). gain: int 0 for high gain, 1 for low gain """ self.pos = np.zeros(self.n_bins) for i in range(0, self.n_bins): self.pos[i] = (i + 0.5) * self.n_combine self.fan = np.zeros(self.n_harmonics) # cos coeff self.fbn = np.zeros(self.n_harmonics) # sin coeff for n in range(0, self.n_harmonics): self.integrate_with_trig(self.pos, self.mean_values_per_bin[gain, pixel_id], n, self.fan, self.fbn) def integrate_with_trig(self, x, y, n, an, bn): """ Function to expanding into Fourier series Parameters ---------- x : ndarray Array stored position in DRS4 ring of shape (n_bins). y: ndarray Array stored mean pulse time per bin of shape (n_bins) n : int n harmonic an: ndarray Array to fill with cos coeff of shape (n_harmonics) bn: ndarray Array to fill with sin coeff of shape (n_harmonics) """ suma = 0 sumb = 0 for i in range(0, self.n_bins): suma += y[i] * self.n_combine * np.cos( 2 * np.pi * n * (x[i] / float(self.n_capacitors))) sumb += y[i] * self.n_combine * np.sin( 2 * np.pi * n * (x[i] / float(self.n_capacitors))) an[n] = suma * (2. / (self.n_bins * self.n_combine)) bn[n] = sumb * (2. / (self.n_bins * self.n_combine)) def get_first_capacitor(self, event, nr): fc = np.zeros((n_gain, n_channel)) first_cap = event.lst.tel[ self.tel_id].evt.first_capacitor_id[nr * 8:(nr + 1) * 8] # First capacitor order according Dragon v5 board data format for i, j in zip([0, 1, 2, 3, 4, 5, 6], [0, 0, 1, 1, 2, 2, 3]): fc[high_gain, i] = first_cap[j] for i, j in zip([0, 1, 2, 3, 4, 5, 6], [4, 4, 5, 5, 6, 6, 7]): fc[low_gain, i] = first_cap[j] return fc def save_to_hdf5_file(self): """ Function to save Fourier series expansion coeff into hdf5 file """ fan_array = np.zeros((n_gain, n_pixels, self.n_harmonics)) fbn_array = np.zeros((n_gain, n_pixels, self.n_harmonics)) for pix_id in range(n_pixels): self.fit(pix_id, gain=high_gain) fan_array[high_gain, pix_id, :] = self.fan fbn_array[high_gain, pix_id, :] = self.fbn self.fit(pix_id, gain=low_gain) fan_array[low_gain, pix_id, :] = self.fan fbn_array[low_gain, pix_id, :] = self.fbn try: with h5py.File(self.calib_file_path, 'w') as hf: hf.create_dataset('fan', data=fan_array) hf.create_dataset('fbn', data=fbn_array) hf.attrs['n_events'] = self.sum_events hf.attrs['n_harm'] = self.n_harmonics # need pytables and time calib container # to use lstchain.io.add_config_metadata hf.attrs['config'] = str(self.config) metadata = global_metadata() write_metadata(metadata, self.calib_file_path) except Exception: raise IOError(f"Failed to create the file {self.calib_file_path}")
class SomeComponent(TelescopeComponent): path = TelescopeParameter(Path(allow_none=True, default_value=None), default_value=None).tag(config=True) val = TelescopeParameter(Float(), default_value=1.0).tag(config=True) flag = TelescopeParameter(Bool(), default_value=True).tag(config=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)