From 5c09d6700cf4045c406114115e2bb5b3c8a8dd78 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 18 Apr 2023 17:28:39 +0200 Subject: [PATCH 001/136] First kludge of protopipe irf code as a ctapipe tool --- ctapipe/irf/__init__.py | 3 + ctapipe/irf/irf_classes.py | 404 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 + src/ctapipe/tools/make-irf.py | 342 ++++++++++++++++++++++++++++ 4 files changed, 751 insertions(+) create mode 100644 ctapipe/irf/__init__.py create mode 100644 ctapipe/irf/irf_classes.py create mode 100644 src/ctapipe/tools/make-irf.py diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py new file mode 100644 index 00000000000..362baef0e80 --- /dev/null +++ b/ctapipe/irf/__init__.py @@ -0,0 +1,3 @@ +from .irf_classes import DataBinning, IrfToolBase + +__all__ = ["IrfToolBase", "DataBinning"] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py new file mode 100644 index 00000000000..ce572f7c6f1 --- /dev/null +++ b/ctapipe/irf/irf_classes.py @@ -0,0 +1,404 @@ +""" +Define a parent IrfTool class to hold all the options +""" +import astropy.units as u +import numpy as np +from astropy.table import QTable +from pyirf.binning import create_bins_per_decade + +from ..core import Component, QualityQuery, Tool, traits +from ..core.traits import Bool, Float, Integer, List, Unicode + + +class IrfToolBase(Tool): + + gamma_file = traits.Path( + default_value=None, directory_ok=False, help="Gamma input filename and path" + ).tag(config=True) + gamma_sim_spectrum = traits.Unicode( + default_value="CRAB_HEGRA", + help="Name of the pyrif spectra used for the simulated gamma spectrum", + ).tag(config=True) + proton_file = traits.Path( + default_value=None, directory_ok=False, help="Gamma input filename and path" + ).tag(config=True) + proton_sim_spectrum = traits.Unicode( + default_value="IRFDOC_PROTON_SPECTRUM", + help="Name of the pyrif spectra used for the simulated proton spectrum", + ).tag(config=True) + electron_file = traits.Path( + default_value=None, directory_ok=False, help="Gamma input filename and path" + ).tag(config=True) + electron_sim_spectrum = traits.Unicode( + default_value="IRFDOC_ELECTRON_SPECTRUM", + help="Name of the pyrif spectra used for the simulated electron spectrum", + ).tag(config=True) + + chunk_size = Integer( + default_value=100000, + allow_none=True, + help="How many subarray events to load at once for making predictions.", + ).tag(config=True) + + output_path = traits.Path( + default_value=None, + allow_none=False, + directory_ok=False, + help="Output file", + ).tag(config=True) + output_file = Unicode( + default_value=None, allow_none=False, help="Name for the output file" + ).tag(config=True) + overwrite = Bool( + False, + help="Overwrite the output file if it exists", + ).tag(config=True) + + obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) + obs_time_unit = Unicode( + default_value="hour", + help="Unit used to specify observation time as an astropy unit string.", + ).tag(config=True) + + alpha = Float( + default_value=5.0, help="Ratio between size of on and off regions" + ).tag(config=True) + + max_bg_radius = Float( + default_value=5.0, help="Radius used to calculate background rate in degrees" + ).tag(config=True) + + max_gh_cut_efficiency = Float( + default_value=0.8, help="Maximum gamma purity requested" + ).tag(config=True) + gh_cut_efficiency_step = Float( + default_value=0.01, + help="Stepsize used for scanning after optimal gammaness cut", + ).tag(config=True) + initial_gh_cut_efficency = Float( + default_value=0.4, help="Start value of gamma purity before optimisatoin" + ).tag(config=True) + + energy_reconstructor = Unicode( + default_value="RandomForestRegressor", + help="Prefix of the reco `_energy` column", + ).tag(config=True) + geometry_reconstructor = Unicode( + default_value="HillasReconstructor", + help="Prefix of the `_alt` and `_az` reco geometry columns", + ).tag(config=True) + gammaness_classifier = Unicode( + default_value="RandomForestClassifier", + help="Prefix of the classifier `_prediction` column", + ).tag(config=True) + + preselect_criteria = List( + default_value=[ + ("multiplicity 4", "np.count_nonzero(tels,axis=1) >= 4"), + ("valid classifier", "valid_classer"), + ("valid geom reco", "valid_geom"), + ("valid energy reco", "valid_energy"), + ], + help=QualityQuery.quality_criteria.help, + ).tag(config=True) + + rename_columns = List( + help="List containing translation pairs of quality columns" + "used for quality filters and their names as given in the input file used." + "Ex: [('valid_geom','HillasReconstructor_is_valid')]", + default_value=[ + ("valid_geom", "HillasReconstructor_is_valid"), + ("valid_energy", "RandomForestRegressor_is_valid"), + ("valid_classer", "RandomForestClassifier_is_valid"), + ], + ) + + def _preselect_events(self, events): + keep_columns = [ + "obs_id", + "event_id", + "true_energy", + "true_az", + "true_alt", + ] + rename_from = [ + f"{self.energy_reconstructor}_energy", + f"{self.geometry_reconstructor}_az", + f"{self.geometry_reconstructor}_alt", + f"{self.gammaness_classifier}_prediction", + ] + rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] + + for new, old in self.rename_columns: + rename_from.append(old) + rename_to.append(new) + + keep_columns.append(rename_from) + events = QTable(events[keep_columns], copy=False) + events.rename_columns(rename_from, rename_to) + keep = QualityQuery(quality_criteria=self.preselect_criteria).get_table_mask( + events + ) + + return events[keep] + + def _make_empty_table(self): + columns = [ + "obs_id", + "event_id", + "true_energy", + "true_az", + "true_alt", + "reco_energy", + "reco_az", + "reco_alt", + "gh_score", + "pointing_az", + "pointing_alt", + "true_source_fov_offset", + "reco_source_fov_offset", + "weights", + ] + units = [ + None, + None, + u.TeV, + u.deg, + u.deg, + u.TeV, + u.deg, + u.deg, + None, + u.deg, + u.deg, + u.deg, + u.deg, + None, + ] + + return QTable(names=columns, units=units) + + +class ThetaSettings(Component): + + min_angle = Float( + default_value=0.05, help="Smallest angular cut value allowed" + ).tag(config=True) + max_angle = Float(default_value=0.32, help="Largest angular cut value allowed").tag( + config=True + ) + min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + fill_value = Float( + default_value=0.32, help="Angular cut value used for bins with too few events" + ).tag(config=True) + + +class DataBinning(Component): + """ + Collects information on generating energy and angular bins for + generating IRFs as per pyIRF requirements. + + Stolen from LSTChain + """ + + true_energy_min = Float( + help="Minimum value for True Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + true_energy_max = Float( + help="Maximum value for True Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + true_energy_n_bins_per_decade = Float( + help="Number of edges per decade for True Energy bins", + default_value=5, + ).tag(config=True) + + reco_energy_min = Float( + help="Minimum value for Reco Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + reco_energy_max = Float( + help="Maximum value for Reco Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + reco_energy_n_bins_per_decade = Float( + help="Number of edges per decade for Reco Energy bins", + default_value=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 = Integer( + help="Number of bins in log scale for Energy Migration matrix", + default_value=31, + ).tag(config=True) + + theta_min_angle = Float( + default_value=0.05, help="Smallest angular cut value allowed" + ).tag(config=True) + + theta_max_angle = Float( + default_value=0.32, help="Largest angular cut value allowed" + ).tag(config=True) + + theta_min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + + theta_fill_value = Float( + default_value=0.32, help="Angular cut value used for bins with too few events" + ).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 = Integer( + 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 = Integer( + 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, + ).tag(config=True) + + source_offset_max = Float( + help="Maximum value for Source offset for PSF IRF", + default_value=1, + ).tag(config=True) + + source_offset_n_edges = Integer( + help="Number of edges for Source offset for PSF IRF", + default_value=101, + ).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. + + Examples + -------- + 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. + + Examples + -------- + 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 diff --git a/pyproject.toml b/pyproject.toml index 7a663d17c39..dc791c01b30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "numba >=0.56", "numpy ~=1.16", "psutil", + "pyirf", "pyyaml >=5.1", "requests", "scikit-learn !=1.4.0", # 1.4.0 breaks with astropy tables, before and after works @@ -96,6 +97,7 @@ ctapipe-dump-instrument = "ctapipe.tools.dump_instrument:main" ctapipe-display-dl1 = "ctapipe.tools.display_dl1:main" ctapipe-process = "ctapipe.tools.process:main" ctapipe-merge = "ctapipe.tools.merge:main" +ctapipe-make-irfs = "ctapipe.tools.make-irf:main" ctapipe-fileinfo = "ctapipe.tools.fileinfo:main" ctapipe-quickstart = "ctapipe.tools.quickstart:main" ctapipe-train-energy-regressor = "ctapipe.tools.train_energy_regressor:main" diff --git a/src/ctapipe/tools/make-irf.py b/src/ctapipe/tools/make-irf.py new file mode 100644 index 00000000000..f5608f09c24 --- /dev/null +++ b/src/ctapipe/tools/make-irf.py @@ -0,0 +1,342 @@ +"""Tool to generate IRFs""" +import operator +from pathlib import Path + +import astropy.units as u +import numpy as np +from astropy.io import fits +from astropy.table import vstack +from pyirf.benchmarks import angular_resolution, energy_bias_resolution +from pyirf.binning import ( + add_overflow_bins, + create_bins_per_decade, + create_histogram_table, +) +from pyirf.cut_optimization import optimize_gh_cut +from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut +from pyirf.io import ( + create_aeff2d_hdu, + create_background_2d_hdu, + create_energy_dispersion_hdu, + create_psf_table_hdu, + create_rad_max_hdu, +) +from pyirf.irf import ( + background_2d, + effective_area_per_energy, + energy_dispersion, + psf_table, +) +from pyirf.sensitivity import calculate_sensitivity, estimate_background +from pyirf.simulations import SimulatedEventsInfo +from pyirf.spectral import ( + CRAB_HEGRA, + IRFDOC_ELECTRON_SPECTRUM, + IRFDOC_PROTON_SPECTRUM, + PowerLaw, + calculate_event_weights, +) +from pyirf.utils import calculate_source_fov_offset, calculate_theta + +from ..core import Provenance +from ..io import TableLoader +from ..irf import DataBinning, IrfToolBase + +PYIRF_SPECTRA = { + "CRAB_HEGRA": CRAB_HEGRA, + "IRFDOC_ELECTRON_SPECTRUM": IRFDOC_ELECTRON_SPECTRUM, + "IRFDOC_PROTON_SPECTRUM": IRFDOC_PROTON_SPECTRUM, +} + + +class IrfTool(IrfToolBase, DataBinning): + name = "ctapipe-make-irfs" + description = "Tool to create IRF files in GAD format" + + def make_derived_columns(self, events, spectrum, target_spectrum): + events["pointing_az"] = 0 * u.deg + events["pointing_alt"] = 70 * u.deg + + events["theta"] = calculate_theta( + events, + assumed_source_az=events["true_az"], + assumed_source_alt=events["true_alt"], + ) + + events["true_source_fov_offset"] = calculate_source_fov_offset( + events, prefix="true" + ) + events["reco_source_fov_offset"] = calculate_source_fov_offset( + events, prefix="reco" + ) + events["weights"] = calculate_event_weights( + events["true_energy"], + target_spectrum=target_spectrum, + simulated_spectrum=spectrum, + ) + + return events + + def get_sim_info_and_spectrum(self, loader): + sim = loader.read_simulation_configuration() + + sim_info = SimulatedEventsInfo( + n_showers=sum(sim["n_showers"] * sim["shower_reuse"]), + energy_min=sim["energy_range_min"].quantity[0], + energy_max=sim["energy_range_max"].quantity[0], + max_impact=sim["max_scatter_range"].quantity[0], + spectral_index=sim["spectral_index"][0], + viewcone=sim["max_viewcone_radius"].quantity[0], + ) + + return sim_info, PowerLaw.from_simulation( + sim_info, obstime=self.obs_time * u.Unit(self.obs_time_unit) + ) + + def setup(self): + opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) + reduced_events = dict() + for kind, file, target_spectrum in [ + ("gamma", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum]), + ("proton", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum]), + ("electron", self.electron_file, PYIRF_SPECTRA[self.electron_sim_spectrum]), + ]: + with TableLoader(file, **opts) as load: + Provenance().add_input_file(file) + table = self._make_empty_table() + sim_info, spectrum = self.get_sim_info_and_spectrum(load) + if kind == "gamma": + self.sim_info = sim_info + self.spectrum = spectrum + for start, stop, events in load.read_subarray_events_chunked( + self.chunk_size + ): + selected = self._preselect_events(events) + selected = self.make_derived_columns( + selected, spectrum, target_spectrum + ) + table = vstack(table, selected) + + reduced_events[kind] = table + + self.signal = reduced_events["gamma"] + self.background = vstack(reduced_events["proton"], reduced_events["electron"]) + + self.theta_bins = add_overflow_bins( + create_bins_per_decade( + self.sim_info.energy_min, self.sim_info.energy_max, 50 + ) + ) + + self.energy_reco_bins = self.reco_energy_bins() + self.energy_true_bins = self.true_energy_bins() + self.source_offset_bins = self.source_offset_bins() + self.fov_offset_bins = self.fov_offset_bins() + self.energy_migration_bins = self.energy_migration_bins() + + def start(self): + + INITIAL_GH_CUT = np.quantile( + self.signal["gh_score"], (1 - self.initial_gh_cut_efficency) + ) + self.log.info( + f"Using fixed G/H cut of {INITIAL_GH_CUT} to calculate theta cuts" + ) + + mask_theta_cuts = self.signal["gh_score"] >= INITIAL_GH_CUT + + theta_cuts = calculate_percentile_cut( + self.signal["theta"][mask_theta_cuts], + self.signal["reco_energy"][mask_theta_cuts], + bins=self.theta_bins, + min_value=self.theta_min_angle * u.deg, + max_value=self.theta_max_angle * u.deg, + fill_value=self.theta_fill_value * u.deg, + min_events=self.theta_min_counts, + percentile=68, + ) + + self.log.info("Optimizing G/H separation cut for best sensitivity") + gh_cut_efficiencies = np.arange( + self.gh_cut_efficiency_step, + self.max_gh_cut_efficiency + self.gh_cut_efficiency_step / 2, + self.gh_cut_efficiency_step, + ) + + sens2, self.gh_cuts = optimize_gh_cut( + self.signal, + self.background, + reco_energy_bins=self.energy_reco_bins, + gh_cut_efficiencies=gh_cut_efficiencies, + op=operator.ge, + theta_cuts=theta_cuts, + alpha=self.alpha, + background_radius=self.max_bg_radius * u.deg, + ) + + # now that we have the optimized gh cuts, we recalculate the theta + # cut as 68 percent containment on the events surviving these cuts. + self.log.info("Recalculating theta cut for optimized GH Cuts") + for tab in (self.signal, self.background): + tab["selected_gh"] = evaluate_binned_cut( + tab["gh_score"], tab["reco_energy"], self.gh_cuts, operator.ge + ) + + self.theta_cuts_opt = calculate_percentile_cut( + self.signal[self.signal["selected_gh"]]["theta"], + self.signal[self.signal["selected_gh"]]["reco_energy"], + self.theta_bins, + percentile=68, + min_value=self.theta_min_angle * u.deg, + max_value=self.theta_max_angle * u.deg, + fill_value=self.theta_fill_value * u.deg, + min_events=self.theta_min_counts, + ) + self.signal["selected_theta"] = evaluate_binned_cut( + self.signal["theta"], + self.signal["reco_energy"], + self.theta_cuts_opt, + operator.le, + ) + self.signal["selected"] = ( + self.signal["selected_theta"] & self.signal["selected_gh"] + ) + + # calculate sensitivity + signal_hist = create_histogram_table( + self.signal[self.signal["selected"]], bins=self.energy_reco_bins + ) + background_hist = estimate_background( + self.background[self.background["selected_gh"]], + reco_energy_bins=self.energy_reco_bins, + theta_cuts=self.theta_cuts_opt, + alpha=self.alpha, + fov_offset_min=self.fov_offset_min, + fov_offset_max=self.fov_offset_max, + ) + self.sensitivity = calculate_sensitivity( + signal_hist, background_hist, alpha=self.alpha + ) + + # scale relative sensitivity by Crab flux to get the flux sensitivity + for s in (sens2, self.sensitivity): + s["flux_sensitivity"] = s["relative_sensitivity"] * self.spectrum( + s["reco_energy_center"] + ) + + def finalise(self): + + masks = { + "": self.signal["selected"], + "_NO_CUTS": slice(None), + "_ONLY_GH": self.signal["selected_gh"], + "_ONLY_THETA": self.signal["selected_theta"], + } + hdus = [ + fits.PrimaryHDU(), + fits.BinTableHDU(self.sensitivity, name="SENSITIVITY"), + # fits.BinTableHDU(sensitivity_step_2, name="SENSITIVITY_STEP_2"), + # fits.BinTableHDU(self.theta_cuts, name="THETA_CUTS"), + fits.BinTableHDU(self.theta_cuts_opt, name="THETA_CUTS_OPT"), + fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), + ] + + for label, mask in masks.items(): + effective_area = effective_area_per_energy( + self.signal[mask], + self.sim_info, + true_energy_bins=self.true_energy_bins, + ) + hdus.append( + create_aeff2d_hdu( + effective_area[..., np.newaxis], # +1 dimension for FOV offset + self.true_energy_bins, + self.fov_offset_bins, + extname="EFFECTIVE AREA" + label, + ) + ) + edisp = energy_dispersion( + self.signal[mask], + true_energy_bins=self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, + migration_bins=self.energy_migration_bins, + ) + hdus.append( + create_energy_dispersion_hdu( + edisp, + true_energy_bins=self.true_energy_bins, + migration_bins=self.energy_migration_bins, + fov_offset_bins=self.fov_offset_bins, + extname="ENERGY_DISPERSION" + label, + ) + ) + # Here we use reconstructed energy instead of true energy for the sake of + # current pipelines comparisons + bias_resolution = energy_bias_resolution( + self.signal[self.signal["selected"]], + self.reco_energy_bins, + energy_type="reco", + ) + + # Here we use reconstructed energy instead of true energy for the sake of + # current pipelines comparisons + ang_res = angular_resolution( + self.signal[self.signal["selected_gh"]], + self.reco_energy_bins, + energy_type="reco", + ) + + psf = psf_table( + self.signal[self.signal["selected_gh"]], + self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, + source_offset_bins=self.source_offset_bins, + ) + + background_rate = background_2d( + self.background[self.background["selected_gh"]], + self.reco_energy_bins, + fov_offset_bins=np.arange(0, 11) * u.deg, + t_obs=self.obs_time * u.Unit(self.obs_time_unit), + ) + + hdus.append( + create_background_2d_hdu( + background_rate, + self.reco_energy_bins, + fov_offset_bins=np.arange(0, 11) * u.deg, + ) + ) + + hdus.append( + create_psf_table_hdu( + psf, + self.true_energy_bins, + self.source_offset_bins, + self.fov_offset_bins, + ) + ) + hdus.append( + create_rad_max_hdu( + self.theta_cuts_opt["cut"][:, np.newaxis], + self.theta_bins, + self.fov_offset_bins, + ) + ) + hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) + hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) + + self.log.info("Writing outputfile") + fits.HDUList(hdus).writeto( + self.output_path / Path(self.output_file + ".fits.gz"), + overwrite=self.overwrite, + ) + + +def main(): + tool = IrfTool() + tool.run() + + +if __name__ == "main": + main() From 218c9dee99587103105ec75d53a2d35724f9b487 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 19 Apr 2023 15:27:04 +0200 Subject: [PATCH 002/136] Fixed names so the tool can install properly --- pyproject.toml | 2 +- src/ctapipe/tools/{make-irf.py => make_irf.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/ctapipe/tools/{make-irf.py => make_irf.py} (100%) diff --git a/pyproject.toml b/pyproject.toml index dc791c01b30..af525ce1add 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ ctapipe-dump-instrument = "ctapipe.tools.dump_instrument:main" ctapipe-display-dl1 = "ctapipe.tools.display_dl1:main" ctapipe-process = "ctapipe.tools.process:main" ctapipe-merge = "ctapipe.tools.merge:main" -ctapipe-make-irfs = "ctapipe.tools.make-irf:main" +ctapipe-make-irfs = "ctapipe.tools.make_irf:main" ctapipe-fileinfo = "ctapipe.tools.fileinfo:main" ctapipe-quickstart = "ctapipe.tools.quickstart:main" ctapipe-train-energy-regressor = "ctapipe.tools.train_energy_regressor:main" diff --git a/src/ctapipe/tools/make-irf.py b/src/ctapipe/tools/make_irf.py similarity index 100% rename from src/ctapipe/tools/make-irf.py rename to src/ctapipe/tools/make_irf.py From 582ae3390c6aee15ccf9687524f6bf945d614670 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 25 Apr 2023 12:41:07 +0200 Subject: [PATCH 003/136] Fixing two small comments from Karl --- ctapipe/irf/irf_classes.py | 2 +- src/ctapipe/tools/make_irf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index ce572f7c6f1..3dcb527d646 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -76,7 +76,7 @@ class IrfToolBase(Tool): help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) initial_gh_cut_efficency = Float( - default_value=0.4, help="Start value of gamma purity before optimisatoin" + default_value=0.4, help="Start value of gamma purity before optimisation" ).tag(config=True) energy_reconstructor = Unicode( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index f5608f09c24..46f3a202e8f 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -224,7 +224,7 @@ def start(self): s["reco_energy_center"] ) - def finalise(self): + def finish(self): masks = { "": self.signal["selected"], From c18f811618ad320b4a4052e8de4ff105c635c804 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 3 May 2023 11:03:06 +0200 Subject: [PATCH 004/136] Various small fixes --- ctapipe/irf/irf_classes.py | 8 +++----- environment.yml | 1 + src/ctapipe/tools/make_irf.py | 5 ++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 3dcb527d646..82785a5b329 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -41,14 +41,12 @@ class IrfToolBase(Tool): ).tag(config=True) output_path = traits.Path( - default_value=None, + default_value="./IRF.fits.gz", allow_none=False, directory_ok=False, help="Output file", ).tag(config=True) - output_file = Unicode( - default_value=None, allow_none=False, help="Name for the output file" - ).tag(config=True) + overwrite = Bool( False, help="Overwrite the output file if it exists", @@ -133,7 +131,7 @@ def _preselect_events(self, events): rename_from.append(old) rename_to.append(new) - keep_columns.append(rename_from) + keep_columns.extend(rename_from) events = QTable(events[keep_columns], copy=False) events.rename_columns(rename_from, rename_to) keep = QualityQuery(quality_criteria=self.preselect_criteria).get_table_mask( diff --git a/environment.yml b/environment.yml index f92fcfa6e72..d4b4a79eaad 100644 --- a/environment.yml +++ b/environment.yml @@ -25,6 +25,7 @@ dependencies: - pypandoc - pre-commit - psutil + - pyirf - pytables - pytest - pytest-cov diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 46f3a202e8f..593466622cc 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,6 +1,5 @@ """Tool to generate IRFs""" import operator -from pathlib import Path import astropy.units as u import numpy as np @@ -326,9 +325,9 @@ def finish(self): hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) - self.log.info("Writing outputfile") + self.log.info("Writing outputfile '%s'" % self.output_path) fits.HDUList(hdus).writeto( - self.output_path / Path(self.output_file + ".fits.gz"), + self.output_path, overwrite=self.overwrite, ) From bd5efb08b56a243beafa5409f83110620142c13b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 3 May 2023 18:35:16 +0200 Subject: [PATCH 005/136] Big refactor to use components as intended --- ctapipe/irf/__init__.py | 5 +- ctapipe/irf/irf_classes.py | 19 ++++--- src/ctapipe/tools/make_irf.py | 99 ++++++++++++++++++++--------------- 3 files changed, 70 insertions(+), 53 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 362baef0e80..79bb3bb0c33 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,3 +1,4 @@ -from .irf_classes import DataBinning, IrfToolBase +from .irf_classes import DataBinning, ToolConfig,EventPreSelector -__all__ = ["IrfToolBase", "DataBinning"] + +__all__ = ["DataBinning","ToolConfig","EventPreSelector"] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 82785a5b329..c87c3c188d2 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -6,11 +6,10 @@ from astropy.table import QTable from pyirf.binning import create_bins_per_decade -from ..core import Component, QualityQuery, Tool, traits +from ..core import Component, QualityQuery, traits from ..core.traits import Bool, Float, Integer, List, Unicode - -class IrfToolBase(Tool): +class ToolConfig(Component): gamma_file = traits.Path( default_value=None, directory_ok=False, help="Gamma input filename and path" @@ -61,6 +60,9 @@ class IrfToolBase(Tool): alpha = Float( default_value=5.0, help="Ratio between size of on and off regions" ).tag(config=True) + ON_radius = Float( + default_value=1.0, help="Radius of ON region in degrees" + ).tag(config=True) max_bg_radius = Float( default_value=5.0, help="Radius used to calculate background rate in degrees" @@ -90,9 +92,10 @@ class IrfToolBase(Tool): help="Prefix of the classifier `_prediction` column", ).tag(config=True) +class EventPreSelector(Component): preselect_criteria = List( default_value=[ - ("multiplicity 4", "np.count_nonzero(tels,axis=1) >= 4"), +# ("multiplicity 4", "np.count_nonzero(tels,axis=1) >= 4"), ("valid classifier", "valid_classer"), ("valid geom reco", "valid_geom"), ("valid energy reco", "valid_energy"), @@ -120,10 +123,10 @@ def _preselect_events(self, events): "true_alt", ] rename_from = [ - f"{self.energy_reconstructor}_energy", - f"{self.geometry_reconstructor}_az", - f"{self.geometry_reconstructor}_alt", - f"{self.gammaness_classifier}_prediction", + f"{self.tc.energy_reconstructor}_energy", + f"{self.tc.geometry_reconstructor}_az", + f"{self.tc.geometry_reconstructor}_alt", + f"{self.tc.gammaness_classifier}_prediction", ] rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 593466622cc..7a27ea7fe6f 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -37,9 +37,9 @@ ) from pyirf.utils import calculate_source_fov_offset, calculate_theta -from ..core import Provenance +from ..core import Provenance, Tool from ..io import TableLoader -from ..irf import DataBinning, IrfToolBase +from ..irf import DataBinning, ToolConfig,EventPreSelector PYIRF_SPECTRA = { "CRAB_HEGRA": CRAB_HEGRA, @@ -48,11 +48,13 @@ } -class IrfTool(IrfToolBase, DataBinning): +class IrfTool(Tool): name = "ctapipe-make-irfs" description = "Tool to create IRF files in GAD format" - def make_derived_columns(self, events, spectrum, target_spectrum): + classes = [ DataBinning, ToolConfig,EventPreSelector] + + def make_derived_columns(self,kind, events, spectrum, target_spectrum): events["pointing_az"] = 0 * u.deg events["pointing_alt"] = 70 * u.deg @@ -68,6 +70,9 @@ def make_derived_columns(self, events, spectrum, target_spectrum): events["reco_source_fov_offset"] = calculate_source_fov_offset( events, prefix="reco" ) + # Gamma source is assumed to be pointlike + if kind == "gamma": + spectrum = spectrum.integrate_cone(0*u.deg,self.tc.ON_radius*u.deg) events["weights"] = calculate_event_weights( events["true_energy"], target_spectrum=target_spectrum, @@ -79,6 +84,8 @@ def make_derived_columns(self, events, spectrum, target_spectrum): def get_sim_info_and_spectrum(self, loader): sim = loader.read_simulation_configuration() + # These sims better have the same viewcone! + assert( sim["max_viewcone_radius"].std() == 0) sim_info = SimulatedEventsInfo( n_showers=sum(sim["n_showers"] * sim["shower_reuse"]), energy_min=sim["energy_range_min"].quantity[0], @@ -89,37 +96,43 @@ def get_sim_info_and_spectrum(self, loader): ) return sim_info, PowerLaw.from_simulation( - sim_info, obstime=self.obs_time * u.Unit(self.obs_time_unit) + sim_info, obstime=self.tc.obs_time * u.Unit(self.tc.obs_time_unit) ) def setup(self): + self.tc = ToolConfig(parent=self) + self.bins = DataBinning(parent=self) + self.eps = EventPreSelector(parent=self) + opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) reduced_events = dict() for kind, file, target_spectrum in [ - ("gamma", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum]), - ("proton", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum]), - ("electron", self.electron_file, PYIRF_SPECTRA[self.electron_sim_spectrum]), + ("gamma", self.tc.gamma_file, PYIRF_SPECTRA[self.tc.gamma_sim_spectrum]), + ("proton", self.tc.proton_file, PYIRF_SPECTRA[self.tc.proton_sim_spectrum]), + ("electron", self.tc.electron_file, PYIRF_SPECTRA[self.tc.electron_sim_spectrum]), ]: with TableLoader(file, **opts) as load: Provenance().add_input_file(file) - table = self._make_empty_table() + table = self.eps._make_empty_table() sim_info, spectrum = self.get_sim_info_and_spectrum(load) if kind == "gamma": self.sim_info = sim_info self.spectrum = spectrum for start, stop, events in load.read_subarray_events_chunked( - self.chunk_size + self.tc.chunk_size ): - selected = self._preselect_events(events) - selected = self.make_derived_columns( + selected = self.eps._preselect_events(events) + selected = self.eps.make_derived_columns( + kind, selected, spectrum, target_spectrum ) - table = vstack(table, selected) + table = vstack([table, selected]) reduced_events[kind] = table - self.signal = reduced_events["gamma"] - self.background = vstack(reduced_events["proton"], reduced_events["electron"]) + select_ON = reduced_events["gamma"]["theta"] <= self.ON_radius*u.deg + self.signal = reduced_events["gamma"][select_ON] + self.background = vstack([reduced_events["proton"], reduced_events["electron"]]) self.theta_bins = add_overflow_bins( create_bins_per_decade( @@ -127,16 +140,16 @@ def setup(self): ) ) - self.energy_reco_bins = self.reco_energy_bins() - self.energy_true_bins = self.true_energy_bins() - self.source_offset_bins = self.source_offset_bins() - self.fov_offset_bins = self.fov_offset_bins() - self.energy_migration_bins = self.energy_migration_bins() + self.reco_energy_bins = self.bins.reco_energy_bins() + self.true_energy_bins = self.bins.true_energy_bins() + self.source_offset_bins = self.bins.source_offset_bins() + self.fov_offset_bins = self.bins.fov_offset_bins() + self.energy_migration_bins = self.bins.energy_migration_bins() def start(self): - + breakpoint() INITIAL_GH_CUT = np.quantile( - self.signal["gh_score"], (1 - self.initial_gh_cut_efficency) + self.signal["gh_score"], (1 - self.tc.initial_gh_cut_efficency) ) self.log.info( f"Using fixed G/H cut of {INITIAL_GH_CUT} to calculate theta cuts" @@ -148,29 +161,29 @@ def start(self): self.signal["theta"][mask_theta_cuts], self.signal["reco_energy"][mask_theta_cuts], bins=self.theta_bins, - min_value=self.theta_min_angle * u.deg, - max_value=self.theta_max_angle * u.deg, - fill_value=self.theta_fill_value * u.deg, - min_events=self.theta_min_counts, + min_value=self.bins.theta_min_angle * u.deg, + max_value=self.bins.theta_max_angle * u.deg, + fill_value=self.bins.theta_fill_value * u.deg, + min_events=self.bins.theta_min_counts, percentile=68, ) self.log.info("Optimizing G/H separation cut for best sensitivity") gh_cut_efficiencies = np.arange( - self.gh_cut_efficiency_step, - self.max_gh_cut_efficiency + self.gh_cut_efficiency_step / 2, - self.gh_cut_efficiency_step, + self.tc.gh_cut_efficiency_step, + self.tc.max_gh_cut_efficiency + self.tc.gh_cut_efficiency_step / 2, + self.tc.gh_cut_efficiency_step, ) sens2, self.gh_cuts = optimize_gh_cut( self.signal, self.background, - reco_energy_bins=self.energy_reco_bins, + reco_energy_bins=self.bins.reco_energy_bins, gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, theta_cuts=theta_cuts, - alpha=self.alpha, - background_radius=self.max_bg_radius * u.deg, + alpha=self.tc.alpha, + background_radius=self.tc.max_bg_radius * u.deg, ) # now that we have the optimized gh cuts, we recalculate the theta @@ -186,10 +199,10 @@ def start(self): self.signal[self.signal["selected_gh"]]["reco_energy"], self.theta_bins, percentile=68, - min_value=self.theta_min_angle * u.deg, - max_value=self.theta_max_angle * u.deg, - fill_value=self.theta_fill_value * u.deg, - min_events=self.theta_min_counts, + min_value=self.bins.theta_min_angle * u.deg, + max_value=self.bins.theta_max_angle * u.deg, + fill_value=self.bins.theta_fill_value * u.deg, + min_events=self.bins.theta_min_counts, ) self.signal["selected_theta"] = evaluate_binned_cut( self.signal["theta"], @@ -201,20 +214,21 @@ def start(self): self.signal["selected_theta"] & self.signal["selected_gh"] ) + breakpoint() # calculate sensitivity signal_hist = create_histogram_table( - self.signal[self.signal["selected"]], bins=self.energy_reco_bins + self.signal[self.signal["selected"]], bins=self.reco_energy_bins ) background_hist = estimate_background( self.background[self.background["selected_gh"]], - reco_energy_bins=self.energy_reco_bins, + reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, - alpha=self.alpha, - fov_offset_min=self.fov_offset_min, - fov_offset_max=self.fov_offset_max, + alpha=self.tc.alpha, + fov_offset_min=self.bins.fov_offset_min, + fov_offset_max=self.bins.fov_offset_max, ) self.sensitivity = calculate_sensitivity( - signal_hist, background_hist, alpha=self.alpha + signal_hist, background_hist, alpha=self.tc.alpha ) # scale relative sensitivity by Crab flux to get the flux sensitivity @@ -224,7 +238,6 @@ def start(self): ) def finish(self): - masks = { "": self.signal["selected"], "_NO_CUTS": slice(None), From 95c6fe1db5cb1acc5fc72b648d91aac664348468 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 4 May 2023 14:54:58 +0200 Subject: [PATCH 006/136] Got it all to run --- ctapipe/irf/__init__.py | 4 ++-- ctapipe/irf/irf_classes.py | 18 ++++++++++-------- src/ctapipe/tools/make_irf.py | 34 +++++++++++++++++----------------- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 79bb3bb0c33..08839dfd523 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,4 +1,4 @@ -from .irf_classes import DataBinning, ToolConfig,EventPreSelector +from .irf_classes import DataBinning, ToolConfig, EventPreProcessor -__all__ = ["DataBinning","ToolConfig","EventPreSelector"] +__all__ = ["DataBinning","ToolConfig","EventPreProcessor"] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index c87c3c188d2..346fd0ae5f2 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -72,13 +72,15 @@ class ToolConfig(Component): default_value=0.8, help="Maximum gamma purity requested" ).tag(config=True) gh_cut_efficiency_step = Float( - default_value=0.01, + default_value=0.1, help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) initial_gh_cut_efficency = Float( default_value=0.4, help="Start value of gamma purity before optimisation" ).tag(config=True) + +class EventPreProcessor(Component): energy_reconstructor = Unicode( default_value="RandomForestRegressor", help="Prefix of the reco `_energy` column", @@ -92,7 +94,6 @@ class ToolConfig(Component): help="Prefix of the classifier `_prediction` column", ).tag(config=True) -class EventPreSelector(Component): preselect_criteria = List( default_value=[ # ("multiplicity 4", "np.count_nonzero(tels,axis=1) >= 4"), @@ -115,6 +116,7 @@ class EventPreSelector(Component): ) def _preselect_events(self, events): + tc = self.parent.tc keep_columns = [ "obs_id", "event_id", @@ -123,10 +125,10 @@ def _preselect_events(self, events): "true_alt", ] rename_from = [ - f"{self.tc.energy_reconstructor}_energy", - f"{self.tc.geometry_reconstructor}_az", - f"{self.tc.geometry_reconstructor}_alt", - f"{self.tc.gammaness_classifier}_prediction", + f"{self.energy_reconstructor}_energy", + f"{self.geometry_reconstructor}_az", + f"{self.geometry_reconstructor}_alt", + f"{self.gammaness_classifier}_prediction", ] rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] @@ -268,12 +270,12 @@ class DataBinning(Component): ).tag(config=True) fov_offset_min = Float( - help="Minimum value for FoV Offset bins", + help="Minimum value for FoV Offset bins in degrees", default_value=0.1, ).tag(config=True) fov_offset_max = Float( - help="Maximum value for FoV offset bins", + help="Maximum value for FoV offset bins in degrees", default_value=1.1, ).tag(config=True) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 7a27ea7fe6f..b820317b4fe 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -39,7 +39,7 @@ from ..core import Provenance, Tool from ..io import TableLoader -from ..irf import DataBinning, ToolConfig,EventPreSelector +from ..irf import DataBinning, ToolConfig, EventPreProcessor PYIRF_SPECTRA = { "CRAB_HEGRA": CRAB_HEGRA, @@ -52,7 +52,7 @@ class IrfTool(Tool): name = "ctapipe-make-irfs" description = "Tool to create IRF files in GAD format" - classes = [ DataBinning, ToolConfig,EventPreSelector] + classes = [ DataBinning, ToolConfig,EventPreProcessor] def make_derived_columns(self,kind, events, spectrum, target_spectrum): events["pointing_az"] = 0 * u.deg @@ -73,7 +73,7 @@ def make_derived_columns(self,kind, events, spectrum, target_spectrum): # Gamma source is assumed to be pointlike if kind == "gamma": spectrum = spectrum.integrate_cone(0*u.deg,self.tc.ON_radius*u.deg) - events["weights"] = calculate_event_weights( + events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=target_spectrum, simulated_spectrum=spectrum, @@ -102,7 +102,7 @@ def get_sim_info_and_spectrum(self, loader): def setup(self): self.tc = ToolConfig(parent=self) self.bins = DataBinning(parent=self) - self.eps = EventPreSelector(parent=self) + self.eps = EventPreProcessor(parent=self) opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) reduced_events = dict() @@ -122,7 +122,7 @@ def setup(self): self.tc.chunk_size ): selected = self.eps._preselect_events(events) - selected = self.eps.make_derived_columns( + selected = self.make_derived_columns( kind, selected, spectrum, target_spectrum ) @@ -130,7 +130,7 @@ def setup(self): reduced_events[kind] = table - select_ON = reduced_events["gamma"]["theta"] <= self.ON_radius*u.deg + select_ON = reduced_events["gamma"]["theta"] <= self.tc.ON_radius*u.deg self.signal = reduced_events["gamma"][select_ON] self.background = vstack([reduced_events["proton"], reduced_events["electron"]]) @@ -144,10 +144,10 @@ def setup(self): self.true_energy_bins = self.bins.true_energy_bins() self.source_offset_bins = self.bins.source_offset_bins() self.fov_offset_bins = self.bins.fov_offset_bins() + self.bkg_fov_offset_bins = self.bins.bkg_fov_offset_bins() self.energy_migration_bins = self.bins.energy_migration_bins() def start(self): - breakpoint() INITIAL_GH_CUT = np.quantile( self.signal["gh_score"], (1 - self.tc.initial_gh_cut_efficency) ) @@ -178,12 +178,12 @@ def start(self): sens2, self.gh_cuts = optimize_gh_cut( self.signal, self.background, - reco_energy_bins=self.bins.reco_energy_bins, + reco_energy_bins=self.reco_energy_bins, gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, theta_cuts=theta_cuts, alpha=self.tc.alpha, - background_radius=self.tc.max_bg_radius * u.deg, + fov_offset_max=self.tc.max_bg_radius * u.deg, ) # now that we have the optimized gh cuts, we recalculate the theta @@ -214,18 +214,18 @@ def start(self): self.signal["selected_theta"] & self.signal["selected_gh"] ) - breakpoint() # calculate sensitivity signal_hist = create_histogram_table( self.signal[self.signal["selected"]], bins=self.reco_energy_bins ) + background_hist = estimate_background( self.background[self.background["selected_gh"]], reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.tc.alpha, - fov_offset_min=self.bins.fov_offset_min, - fov_offset_max=self.bins.fov_offset_max, + fov_offset_min=self.bins.fov_offset_min*u.deg, + fov_offset_max=self.bins.fov_offset_max*u.deg, ) self.sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.tc.alpha @@ -308,15 +308,15 @@ def finish(self): background_rate = background_2d( self.background[self.background["selected_gh"]], self.reco_energy_bins, - fov_offset_bins=np.arange(0, 11) * u.deg, - t_obs=self.obs_time * u.Unit(self.obs_time_unit), + fov_offset_bins=self.bkg_fov_offset_bins, + t_obs=self.tc.obs_time * u.Unit(self.tc.obs_time_unit), ) hdus.append( create_background_2d_hdu( background_rate, self.reco_energy_bins, - fov_offset_bins=np.arange(0, 11) * u.deg, + fov_offset_bins=self.bkg_fov_offset_bins, ) ) @@ -338,9 +338,9 @@ def finish(self): hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) - self.log.info("Writing outputfile '%s'" % self.output_path) + self.log.info("Writing outputfile '%s'" % self.tc.output_path) fits.HDUList(hdus).writeto( - self.output_path, + self.tc.output_path, overwrite=self.overwrite, ) From 11f360658c9c4dd759b4e6a3deb3e9e26f2dba0f Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 4 May 2023 14:57:07 +0200 Subject: [PATCH 007/136] Formatting fixes --- ctapipe/irf/__init__.py | 5 ++--- ctapipe/irf/irf_classes.py | 10 +++++----- src/ctapipe/tools/make_irf.py | 27 +++++++++++++++------------ 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 08839dfd523..19b3ac99230 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,4 +1,3 @@ -from .irf_classes import DataBinning, ToolConfig, EventPreProcessor +from .irf_classes import DataBinning, EventPreProcessor, ToolConfig - -__all__ = ["DataBinning","ToolConfig","EventPreProcessor"] +__all__ = ["DataBinning", "ToolConfig", "EventPreProcessor"] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 346fd0ae5f2..7f95c4b9a59 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -9,6 +9,7 @@ from ..core import Component, QualityQuery, traits from ..core.traits import Bool, Float, Integer, List, Unicode + class ToolConfig(Component): gamma_file = traits.Path( @@ -60,9 +61,9 @@ class ToolConfig(Component): alpha = Float( default_value=5.0, help="Ratio between size of on and off regions" ).tag(config=True) - ON_radius = Float( - default_value=1.0, help="Radius of ON region in degrees" - ).tag(config=True) + ON_radius = Float(default_value=1.0, help="Radius of ON region in degrees").tag( + config=True + ) max_bg_radius = Float( default_value=5.0, help="Radius used to calculate background rate in degrees" @@ -96,7 +97,7 @@ class EventPreProcessor(Component): preselect_criteria = List( default_value=[ -# ("multiplicity 4", "np.count_nonzero(tels,axis=1) >= 4"), + # ("multiplicity 4", "np.count_nonzero(tels,axis=1) >= 4"), ("valid classifier", "valid_classer"), ("valid geom reco", "valid_geom"), ("valid energy reco", "valid_energy"), @@ -116,7 +117,6 @@ class EventPreProcessor(Component): ) def _preselect_events(self, events): - tc = self.parent.tc keep_columns = [ "obs_id", "event_id", diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index b820317b4fe..010d2e748e6 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -39,7 +39,7 @@ from ..core import Provenance, Tool from ..io import TableLoader -from ..irf import DataBinning, ToolConfig, EventPreProcessor +from ..irf import DataBinning, EventPreProcessor, ToolConfig PYIRF_SPECTRA = { "CRAB_HEGRA": CRAB_HEGRA, @@ -52,9 +52,9 @@ class IrfTool(Tool): name = "ctapipe-make-irfs" description = "Tool to create IRF files in GAD format" - classes = [ DataBinning, ToolConfig,EventPreProcessor] + classes = [DataBinning, ToolConfig, EventPreProcessor] - def make_derived_columns(self,kind, events, spectrum, target_spectrum): + def make_derived_columns(self, kind, events, spectrum, target_spectrum): events["pointing_az"] = 0 * u.deg events["pointing_alt"] = 70 * u.deg @@ -70,9 +70,9 @@ def make_derived_columns(self,kind, events, spectrum, target_spectrum): events["reco_source_fov_offset"] = calculate_source_fov_offset( events, prefix="reco" ) - # Gamma source is assumed to be pointlike + # Gamma source is assumed to be pointlike if kind == "gamma": - spectrum = spectrum.integrate_cone(0*u.deg,self.tc.ON_radius*u.deg) + spectrum = spectrum.integrate_cone(0 * u.deg, self.tc.ON_radius * u.deg) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=target_spectrum, @@ -85,7 +85,7 @@ def get_sim_info_and_spectrum(self, loader): sim = loader.read_simulation_configuration() # These sims better have the same viewcone! - assert( sim["max_viewcone_radius"].std() == 0) + assert sim["max_viewcone_radius"].std() == 0 sim_info = SimulatedEventsInfo( n_showers=sum(sim["n_showers"] * sim["shower_reuse"]), energy_min=sim["energy_range_min"].quantity[0], @@ -109,7 +109,11 @@ def setup(self): for kind, file, target_spectrum in [ ("gamma", self.tc.gamma_file, PYIRF_SPECTRA[self.tc.gamma_sim_spectrum]), ("proton", self.tc.proton_file, PYIRF_SPECTRA[self.tc.proton_sim_spectrum]), - ("electron", self.tc.electron_file, PYIRF_SPECTRA[self.tc.electron_sim_spectrum]), + ( + "electron", + self.tc.electron_file, + PYIRF_SPECTRA[self.tc.electron_sim_spectrum], + ), ]: with TableLoader(file, **opts) as load: Provenance().add_input_file(file) @@ -123,14 +127,13 @@ def setup(self): ): selected = self.eps._preselect_events(events) selected = self.make_derived_columns( - kind, - selected, spectrum, target_spectrum + kind, selected, spectrum, target_spectrum ) table = vstack([table, selected]) reduced_events[kind] = table - select_ON = reduced_events["gamma"]["theta"] <= self.tc.ON_radius*u.deg + select_ON = reduced_events["gamma"]["theta"] <= self.tc.ON_radius * u.deg self.signal = reduced_events["gamma"][select_ON] self.background = vstack([reduced_events["proton"], reduced_events["electron"]]) @@ -224,8 +227,8 @@ def start(self): reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.tc.alpha, - fov_offset_min=self.bins.fov_offset_min*u.deg, - fov_offset_max=self.bins.fov_offset_max*u.deg, + fov_offset_min=self.bins.fov_offset_min * u.deg, + fov_offset_max=self.bins.fov_offset_max * u.deg, ) self.sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.tc.alpha From da86aa12ca082d30a789180e5d2b57b87f4267e3 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 4 May 2023 16:39:15 +0200 Subject: [PATCH 008/136] Adjusting defaults to make only one bin in offset? --- ctapipe/irf/irf_classes.py | 2 +- src/ctapipe/tools/make_irf.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 7f95c4b9a59..84d778bb66c 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -281,7 +281,7 @@ class DataBinning(Component): fov_offset_n_edges = Integer( help="Number of edges for FoV offset bins", - default_value=9, + default_value=2, ).tag(config=True) bkg_fov_offset_min = Float( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 010d2e748e6..023e2d3d34c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -262,6 +262,8 @@ def finish(self): self.sim_info, true_energy_bins=self.true_energy_bins, ) + self.log.debug(self.true_energy_bins) + self.log.debug(self.fov_offset_bins) hdus.append( create_aeff2d_hdu( effective_area[..., np.newaxis], # +1 dimension for FOV offset From e961a21526d5ee17fe69d711a6adc564529b1ce2 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 14:03:58 +0200 Subject: [PATCH 009/136] Moved preselection to start() --- src/ctapipe/tools/make_irf.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 023e2d3d34c..b600984450a 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -99,11 +99,7 @@ def get_sim_info_and_spectrum(self, loader): sim_info, obstime=self.tc.obs_time * u.Unit(self.tc.obs_time_unit) ) - def setup(self): - self.tc = ToolConfig(parent=self) - self.bins = DataBinning(parent=self) - self.eps = EventPreProcessor(parent=self) - + def load_preselected_events(self): opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) reduced_events = dict() for kind, file, target_spectrum in [ @@ -137,6 +133,11 @@ def setup(self): self.signal = reduced_events["gamma"][select_ON] self.background = vstack([reduced_events["proton"], reduced_events["electron"]]) + def setup(self): + self.tc = ToolConfig(parent=self) + self.bins = DataBinning(parent=self) + self.eps = EventPreProcessor(parent=self) + self.theta_bins = add_overflow_bins( create_bins_per_decade( self.sim_info.energy_min, self.sim_info.energy_max, 50 @@ -151,6 +152,8 @@ def setup(self): self.energy_migration_bins = self.bins.energy_migration_bins() def start(self): + self.load_preselected_events() + INITIAL_GH_CUT = np.quantile( self.signal["gh_score"], (1 - self.tc.initial_gh_cut_efficency) ) From c69337b70ba7a0b92497d875b45e30a147779667 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 14:57:35 +0200 Subject: [PATCH 010/136] Use only one true energy axis --- src/ctapipe/tools/make_irf.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index b600984450a..5611a23d8e6 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -6,11 +6,7 @@ from astropy.io import fits from astropy.table import vstack from pyirf.benchmarks import angular_resolution, energy_bias_resolution -from pyirf.binning import ( - add_overflow_bins, - create_bins_per_decade, - create_histogram_table, -) +from pyirf.binning import create_histogram_table from pyirf.cut_optimization import optimize_gh_cut from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut from pyirf.io import ( @@ -138,12 +134,6 @@ def setup(self): self.bins = DataBinning(parent=self) self.eps = EventPreProcessor(parent=self) - self.theta_bins = add_overflow_bins( - create_bins_per_decade( - self.sim_info.energy_min, self.sim_info.energy_max, 50 - ) - ) - self.reco_energy_bins = self.bins.reco_energy_bins() self.true_energy_bins = self.bins.true_energy_bins() self.source_offset_bins = self.bins.source_offset_bins() @@ -166,7 +156,7 @@ def start(self): theta_cuts = calculate_percentile_cut( self.signal["theta"][mask_theta_cuts], self.signal["reco_energy"][mask_theta_cuts], - bins=self.theta_bins, + bins=self.true_energy_bins, min_value=self.bins.theta_min_angle * u.deg, max_value=self.bins.theta_max_angle * u.deg, fill_value=self.bins.theta_fill_value * u.deg, From c33746c531ef9f13e7a64d17bac6a2bf4ce89336 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 15:09:18 +0200 Subject: [PATCH 011/136] Use only one true energy axis --- src/ctapipe/tools/make_irf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 5611a23d8e6..ad38dfec940 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -193,7 +193,7 @@ def start(self): self.theta_cuts_opt = calculate_percentile_cut( self.signal[self.signal["selected_gh"]]["theta"], self.signal[self.signal["selected_gh"]]["reco_energy"], - self.theta_bins, + self.true_energy_bins, percentile=68, min_value=self.bins.theta_min_angle * u.deg, max_value=self.bins.theta_max_angle * u.deg, From d8b3795816427343a8977b3d768cb411eb73a153 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 15:38:02 +0200 Subject: [PATCH 012/136] Use only one true energy axis --- src/ctapipe/tools/make_irf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index ad38dfec940..cb30a54bba6 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -326,10 +326,11 @@ def finish(self): self.fov_offset_bins, ) ) + hdus.append( create_rad_max_hdu( self.theta_cuts_opt["cut"][:, np.newaxis], - self.theta_bins, + self.true_energy_bins, self.fov_offset_bins, ) ) From beabdf032b2e255120de08f498f76b82db43c643 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 15:38:45 +0200 Subject: [PATCH 013/136] Refactoring to group things in the same manner --- src/ctapipe/tools/make_irf.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index cb30a54bba6..c9c59c78dd8 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -287,6 +287,7 @@ def finish(self): self.reco_energy_bins, energy_type="reco", ) + hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) # Here we use reconstructed energy instead of true energy for the sake of # current pipelines comparisons @@ -295,13 +296,7 @@ def finish(self): self.reco_energy_bins, energy_type="reco", ) - - psf = psf_table( - self.signal[self.signal["selected_gh"]], - self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins, - source_offset_bins=self.source_offset_bins, - ) + hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) background_rate = background_2d( self.background[self.background["selected_gh"]], @@ -309,7 +304,6 @@ def finish(self): fov_offset_bins=self.bkg_fov_offset_bins, t_obs=self.tc.obs_time * u.Unit(self.tc.obs_time_unit), ) - hdus.append( create_background_2d_hdu( background_rate, @@ -318,6 +312,12 @@ def finish(self): ) ) + psf = psf_table( + self.signal[self.signal["selected_gh"]], + self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, + source_offset_bins=self.source_offset_bins, + ) hdus.append( create_psf_table_hdu( psf, @@ -334,8 +334,6 @@ def finish(self): self.fov_offset_bins, ) ) - hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) - hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) self.log.info("Writing outputfile '%s'" % self.tc.output_path) fits.HDUList(hdus).writeto( From 4e53109d2f5ab7c2b99100ee3d5f17169533d897 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 12 May 2023 16:51:13 +0200 Subject: [PATCH 014/136] Split preselct into remaning and quality selecting, made EventPreProcessor a quality query --- ctapipe/irf/irf_classes.py | 32 +++++++++++++------------------- src/ctapipe/tools/make_irf.py | 5 +++-- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 84d778bb66c..d6dc6fac134 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -81,7 +81,7 @@ class ToolConfig(Component): ).tag(config=True) -class EventPreProcessor(Component): +class EventPreProcessor(QualityQuery): energy_reconstructor = Unicode( default_value="RandomForestRegressor", help="Prefix of the reco `_energy` column", @@ -97,26 +97,22 @@ class EventPreProcessor(Component): preselect_criteria = List( default_value=[ - # ("multiplicity 4", "np.count_nonzero(tels,axis=1) >= 4"), - ("valid classifier", "valid_classer"), - ("valid geom reco", "valid_geom"), - ("valid energy reco", "valid_energy"), + ("multiplicity 4", "subarray.multiplicity(tels_with_trigger) >= 4"), + ("valid classifier", "RandomForestClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "RandomForestRegressor_is_valid"), ], help=QualityQuery.quality_criteria.help, ).tag(config=True) rename_columns = List( - help="List containing translation pairs of quality columns" - "used for quality filters and their names as given in the input file used." + help="List containing translation pairs new and old column names" + "used when processing input with names differing from the CTA prod5b format" "Ex: [('valid_geom','HillasReconstructor_is_valid')]", - default_value=[ - ("valid_geom", "HillasReconstructor_is_valid"), - ("valid_energy", "RandomForestRegressor_is_valid"), - ("valid_classer", "RandomForestClassifier_is_valid"), - ], + default_value=[], ) - def _preselect_events(self, events): + def normalise_column_names(self, events): keep_columns = [ "obs_id", "event_id", @@ -132,6 +128,7 @@ def _preselect_events(self, events): ] rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] + # We never enter the loop if rename_columns is empty for new, old in self.rename_columns: rename_from.append(old) rename_to.append(new) @@ -139,13 +136,10 @@ def _preselect_events(self, events): keep_columns.extend(rename_from) events = QTable(events[keep_columns], copy=False) events.rename_columns(rename_from, rename_to) - keep = QualityQuery(quality_criteria=self.preselect_criteria).get_table_mask( - events - ) - - return events[keep] + return events - def _make_empty_table(self): + def make_empty_table(self): + """This function defines the columns later functions expect to be present in the event table""" columns = [ "obs_id", "event_id", diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index c9c59c78dd8..1a57f323524 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -109,7 +109,7 @@ def load_preselected_events(self): ]: with TableLoader(file, **opts) as load: Provenance().add_input_file(file) - table = self.eps._make_empty_table() + table = self.eps.make_empty_table() sim_info, spectrum = self.get_sim_info_and_spectrum(load) if kind == "gamma": self.sim_info = sim_info @@ -117,7 +117,8 @@ def load_preselected_events(self): for start, stop, events in load.read_subarray_events_chunked( self.tc.chunk_size ): - selected = self.eps._preselect_events(events) + selected = self.eps.normalise_column_names(events) + selected = selected[self.eps.get_table_mask(selected)] selected = self.make_derived_columns( kind, selected, spectrum, target_spectrum ) From 723fb265443ac29d4f793a6ed929003d64edf164 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 23 Aug 2023 18:39:37 +0200 Subject: [PATCH 015/136] Fixed serveral problems --- ctapipe/irf/irf_classes.py | 6 ++++-- src/ctapipe/tools/make_irf.py | 28 ++++++++++++++++++---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index d6dc6fac134..19d5ab100a4 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -152,9 +152,10 @@ def make_empty_table(self): "gh_score", "pointing_az", "pointing_alt", + "theta", "true_source_fov_offset", "reco_source_fov_offset", - "weights", + "weight", ] units = [ None, @@ -170,6 +171,7 @@ def make_empty_table(self): u.deg, u.deg, u.deg, + u.deg, None, ] @@ -265,7 +267,7 @@ class DataBinning(Component): fov_offset_min = Float( help="Minimum value for FoV Offset bins in degrees", - default_value=0.1, + default_value=0.0, ).tag(config=True) fov_offset_max = Float( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 1a57f323524..ee7449dc97c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -50,16 +50,21 @@ class IrfTool(Tool): classes = [DataBinning, ToolConfig, EventPreProcessor] - def make_derived_columns(self, kind, events, spectrum, target_spectrum): - events["pointing_az"] = 0 * u.deg - events["pointing_alt"] = 70 * u.deg + def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): + + if obs_conf["subarray_pointing_lat"].std() < 1e-3: + assert obs_conf["subarray_pointing_frame"] == 0 + # Lets suppose 0 means ALTAZ + events["pointing_alt"] = obs["subarray_pointing_lat"][0] + events["pointing_az"] = obs["subarray_pointing_lon"][0] + else: + raise NotImplemented("No support for making irfs from varying pointings yet") events["theta"] = calculate_theta( events, assumed_source_az=events["true_az"], assumed_source_alt=events["true_alt"], ) - events["true_source_fov_offset"] = calculate_source_fov_offset( events, prefix="true" ) @@ -77,7 +82,8 @@ def make_derived_columns(self, kind, events, spectrum, target_spectrum): return events - def get_sim_info_and_spectrum(self, loader): + def get_metadata(self, loader): + obs = loader.read_observation_information() sim = loader.read_simulation_configuration() # These sims better have the same viewcone! @@ -93,7 +99,7 @@ def get_sim_info_and_spectrum(self, loader): return sim_info, PowerLaw.from_simulation( sim_info, obstime=self.tc.obs_time * u.Unit(self.tc.obs_time_unit) - ) + ), obs def load_preselected_events(self): opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) @@ -109,21 +115,23 @@ def load_preselected_events(self): ]: with TableLoader(file, **opts) as load: Provenance().add_input_file(file) - table = self.eps.make_empty_table() - sim_info, spectrum = self.get_sim_info_and_spectrum(load) + header = self.eps.make_empty_table() + sim_info, spectrum, obs_conf = self.get_metadata(load) if kind == "gamma": self.sim_info = sim_info self.spectrum = spectrum + bits = [header] for start, stop, events in load.read_subarray_events_chunked( self.tc.chunk_size ): selected = self.eps.normalise_column_names(events) selected = selected[self.eps.get_table_mask(selected)] selected = self.make_derived_columns( - kind, selected, spectrum, target_spectrum + kind, selected, spectrum, target_spectrum, obs_conf ) - table = vstack([table, selected]) + bits.append(selected) + table = vstack(bits,join_type="exact") reduced_events[kind] = table select_ON = reduced_events["gamma"]["theta"] <= self.tc.ON_radius * u.deg From fef74688372c0f16d3acbdf2a35ff318b9934948 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 23 Aug 2023 22:07:58 +0200 Subject: [PATCH 016/136] Fixed unit issue --- src/ctapipe/tools/make_irf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index ee7449dc97c..25905904193 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -53,10 +53,10 @@ class IrfTool(Tool): def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): if obs_conf["subarray_pointing_lat"].std() < 1e-3: - assert obs_conf["subarray_pointing_frame"] == 0 + assert all( obs_conf["subarray_pointing_frame"] == 0) # Lets suppose 0 means ALTAZ - events["pointing_alt"] = obs["subarray_pointing_lat"][0] - events["pointing_az"] = obs["subarray_pointing_lon"][0] + events["pointing_alt"] = obs_conf["subarray_pointing_lat"][0]*u.deg + events["pointing_az"] = obs_conf["subarray_pointing_lon"][0]*u.deg else: raise NotImplemented("No support for making irfs from varying pointings yet") From 8ab46bd811183eddd730e00055adafdb41224949 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 23 Aug 2023 22:35:54 +0200 Subject: [PATCH 017/136] Updated some defaults --- ctapipe/irf/irf_classes.py | 8 ++++---- src/ctapipe/tools/make_irf.py | 25 ++++++++++++++++--------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 19d5ab100a4..ba4ac9f5a03 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -20,14 +20,14 @@ class ToolConfig(Component): help="Name of the pyrif spectra used for the simulated gamma spectrum", ).tag(config=True) proton_file = traits.Path( - default_value=None, directory_ok=False, help="Gamma input filename and path" + default_value=None, directory_ok=False, help="Proton input filename and path" ).tag(config=True) proton_sim_spectrum = traits.Unicode( default_value="IRFDOC_PROTON_SPECTRUM", help="Name of the pyrif spectra used for the simulated proton spectrum", ).tag(config=True) electron_file = traits.Path( - default_value=None, directory_ok=False, help="Gamma input filename and path" + default_value=None, directory_ok=False, help="Electron input filename and path" ).tag(config=True) electron_sim_spectrum = traits.Unicode( default_value="IRFDOC_ELECTRON_SPECTRUM", @@ -59,7 +59,7 @@ class ToolConfig(Component): ).tag(config=True) alpha = Float( - default_value=5.0, help="Ratio between size of on and off regions" + default_value=0.2, help="Ratio between size of on and off regions" ).tag(config=True) ON_radius = Float(default_value=1.0, help="Radius of ON region in degrees").tag( config=True @@ -215,7 +215,7 @@ class DataBinning(Component): true_energy_n_bins_per_decade = Float( help="Number of edges per decade for True Energy bins", - default_value=5, + default_value=10, ).tag(config=True) reco_energy_min = Float( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 25905904193..067d61c16b7 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -53,12 +53,14 @@ class IrfTool(Tool): def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): if obs_conf["subarray_pointing_lat"].std() < 1e-3: - assert all( obs_conf["subarray_pointing_frame"] == 0) + assert all(obs_conf["subarray_pointing_frame"] == 0) # Lets suppose 0 means ALTAZ - events["pointing_alt"] = obs_conf["subarray_pointing_lat"][0]*u.deg - events["pointing_az"] = obs_conf["subarray_pointing_lon"][0]*u.deg + events["pointing_alt"] = obs_conf["subarray_pointing_lat"][0] * u.deg + events["pointing_az"] = obs_conf["subarray_pointing_lon"][0] * u.deg else: - raise NotImplemented("No support for making irfs from varying pointings yet") + raise NotImplementedError( + "No support for making irfs from varying pointings yet" + ) events["theta"] = calculate_theta( events, @@ -97,9 +99,13 @@ def get_metadata(self, loader): viewcone=sim["max_viewcone_radius"].quantity[0], ) - return sim_info, PowerLaw.from_simulation( - sim_info, obstime=self.tc.obs_time * u.Unit(self.tc.obs_time_unit) - ), obs + return ( + sim_info, + PowerLaw.from_simulation( + sim_info, obstime=self.tc.obs_time * u.Unit(self.tc.obs_time_unit) + ), + obs, + ) def load_preselected_events(self): opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) @@ -131,7 +137,7 @@ def load_preselected_events(self): ) bits.append(selected) - table = vstack(bits,join_type="exact") + table = vstack(bits, join_type="exact") reduced_events[kind] = table select_ON = reduced_events["gamma"]["theta"] <= self.tc.ON_radius * u.deg @@ -280,6 +286,7 @@ def finish(self): fov_offset_bins=self.fov_offset_bins, migration_bins=self.energy_migration_bins, ) + breakpoint() hdus.append( create_energy_dispersion_hdu( edisp, @@ -338,7 +345,7 @@ def finish(self): hdus.append( create_rad_max_hdu( - self.theta_cuts_opt["cut"][:, np.newaxis], + self.theta_cuts_opt["cut"].reshape(-1, 1), self.true_energy_bins, self.fov_offset_bins, ) From f794b3c4aa004a61cdc3810a051e6a06f942de09 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 28 Aug 2023 15:20:03 +0200 Subject: [PATCH 018/136] Updated some comments to avoid problems with the doctest --- ctapipe/irf/irf_classes.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index ba4ac9f5a03..2122ad79d12 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -313,13 +313,6 @@ class DataBinning(Component): 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. - - Examples - -------- - It can be used as: - - >>> add_overflow_bins(***)[1:-1] """ true_energy = create_bins_per_decade( self.true_energy_min * u.TeV, @@ -331,13 +324,6 @@ def true_energy_bins(self): 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. - - Examples - -------- - It can be used as: - - >>> add_overflow_bins(***)[1:-1] """ reco_energy = create_bins_per_decade( self.reco_energy_min * u.TeV, From b4b65a1b5710431fd4b34e2d74a833207cbfba1b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 28 Aug 2023 15:30:02 +0200 Subject: [PATCH 019/136] Add changelog --- docs/changes/2315.irf-maker.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changes/2315.irf-maker.rst diff --git a/docs/changes/2315.irf-maker.rst b/docs/changes/2315.irf-maker.rst new file mode 100644 index 00000000000..37a898ba437 --- /dev/null +++ b/docs/changes/2315.irf-maker.rst @@ -0,0 +1 @@ +Add a `make-irf tool` able to produce irfs given a gamma, proton and electron DL2 input files. From 8edd9dca32232356c1f66c94868c05704d5556e8 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 28 Aug 2023 15:41:39 +0200 Subject: [PATCH 020/136] Fixed small bug, trying to pass doctests --- ctapipe/irf/irf_classes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 2122ad79d12..432d83be716 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -110,7 +110,7 @@ class EventPreProcessor(QualityQuery): "used when processing input with names differing from the CTA prod5b format" "Ex: [('valid_geom','HillasReconstructor_is_valid')]", default_value=[], - ) + ).tag(config=True) def normalise_column_names(self, events): keep_columns = [ From 723433b2290bac0a9793f15a614b5a8622cfb34b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 12:01:32 +0200 Subject: [PATCH 021/136] Refactored after feedback --- ctapipe/irf/__init__.py | 4 +- ctapipe/irf/irf_classes.py | 184 ++++++++++++---------------------- src/ctapipe/tools/make_irf.py | 119 ++++++++++++++++------ 3 files changed, 156 insertions(+), 151 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 19b3ac99230..b1056dbb807 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,3 +1,3 @@ -from .irf_classes import DataBinning, EventPreProcessor, ToolConfig +from .irf_classes import CutOptimising, DataBinning, EnergyBinning, EventPreProcessor -__all__ = ["DataBinning", "ToolConfig", "EventPreProcessor"] +__all__ = ["CutOptimising", "DataBinning", "EnergyBinning", "EventPreProcessor"] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 432d83be716..a524093c75b 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -6,82 +6,28 @@ from astropy.table import QTable from pyirf.binning import create_bins_per_decade -from ..core import Component, QualityQuery, traits -from ..core.traits import Bool, Float, Integer, List, Unicode +from ..core import Component, QualityQuery +from ..core.traits import Float, Integer, List, Unicode -class ToolConfig(Component): - - gamma_file = traits.Path( - default_value=None, directory_ok=False, help="Gamma input filename and path" - ).tag(config=True) - gamma_sim_spectrum = traits.Unicode( - default_value="CRAB_HEGRA", - help="Name of the pyrif spectra used for the simulated gamma spectrum", - ).tag(config=True) - proton_file = traits.Path( - default_value=None, directory_ok=False, help="Proton input filename and path" - ).tag(config=True) - proton_sim_spectrum = traits.Unicode( - default_value="IRFDOC_PROTON_SPECTRUM", - help="Name of the pyrif spectra used for the simulated proton spectrum", - ).tag(config=True) - electron_file = traits.Path( - default_value=None, directory_ok=False, help="Electron input filename and path" - ).tag(config=True) - electron_sim_spectrum = traits.Unicode( - default_value="IRFDOC_ELECTRON_SPECTRUM", - help="Name of the pyrif spectra used for the simulated electron spectrum", - ).tag(config=True) - - chunk_size = Integer( - default_value=100000, - allow_none=True, - help="How many subarray events to load at once for making predictions.", - ).tag(config=True) - - output_path = traits.Path( - default_value="./IRF.fits.gz", - allow_none=False, - directory_ok=False, - help="Output file", - ).tag(config=True) - - overwrite = Bool( - False, - help="Overwrite the output file if it exists", - ).tag(config=True) - - obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) - obs_time_unit = Unicode( - default_value="hour", - help="Unit used to specify observation time as an astropy unit string.", - ).tag(config=True) - - alpha = Float( - default_value=0.2, help="Ratio between size of on and off regions" - ).tag(config=True) - ON_radius = Float(default_value=1.0, help="Radius of ON region in degrees").tag( - config=True - ) - - max_bg_radius = Float( - default_value=5.0, help="Radius used to calculate background rate in degrees" - ).tag(config=True) +class CutOptimising(Component): + """Collects settings related to the cut configuration""" max_gh_cut_efficiency = Float( - default_value=0.8, help="Maximum gamma purity requested" + default_value=0.8, help="Maximum gamma efficiency requested" ).tag(config=True) gh_cut_efficiency_step = Float( default_value=0.1, help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) initial_gh_cut_efficency = Float( - default_value=0.4, help="Start value of gamma purity before optimisation" + default_value=0.4, help="Start value of gamma efficiency before optimisation" ).tag(config=True) class EventPreProcessor(QualityQuery): + """Defines preselection cuts and the necessary renaming of columns""" + energy_reconstructor = Unicode( default_value="RandomForestRegressor", help="Prefix of the reco `_energy` column", @@ -157,23 +103,19 @@ def make_empty_table(self): "reco_source_fov_offset", "weight", ] - units = [ - None, - None, - u.TeV, - u.deg, - u.deg, - u.TeV, - u.deg, - u.deg, - None, - u.deg, - u.deg, - u.deg, - u.deg, - u.deg, - None, - ] + units = { + "true_energy": u.TeV, + "true_az": u.deg, + "true_alt": u.deg, + "reco_energy": u.TeV, + "reco_az": u.deg, + "reco_alt": u.deg, + "pointing_az": u.deg, + "pointing_alt": u.deg, + "theta": u.deg, + "true_source_fov_offset": u.deg, + "reco_source_fov_offset": u.deg, + } return QTable(names=columns, units=units) @@ -195,13 +137,8 @@ class ThetaSettings(Component): ).tag(config=True) -class DataBinning(Component): - """ - Collects information on generating energy and angular bins for - generating IRFs as per pyIRF requirements. - - Stolen from LSTChain - """ +class EnergyBinning(Component): + """Collects energy binning settings""" true_energy_min = Float( help="Minimum value for True Energy bins in TeV units", @@ -248,6 +185,48 @@ class DataBinning(Component): default_value=31, ).tag(config=True) + def true_energy_bins(self): + """ + Creates bins per decade for true MC energy using pyirf function. + """ + 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. + """ + 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 + + +class DataBinning(Component): + """ + Collects information on generating energy and angular bins for + generating IRFs as per pyIRF requirements. + + Stolen from LSTChain + """ + theta_min_angle = Float( default_value=0.05, help="Smallest angular cut value allowed" ).tag(config=True) @@ -310,39 +289,6 @@ class DataBinning(Component): default_value=101, ).tag(config=True) - def true_energy_bins(self): - """ - Creates bins per decade for true MC energy using pyirf function. - """ - 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. - """ - 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. diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 067d61c16b7..2fbc50969d8 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -33,9 +33,10 @@ ) from pyirf.utils import calculate_source_fov_offset, calculate_theta -from ..core import Provenance, Tool +from ..core import Provenance, Tool, traits +from ..core.traits import Bool, Float, Integer, Unicode from ..io import TableLoader -from ..irf import DataBinning, EventPreProcessor, ToolConfig +from ..irf import CutOptimising, DataBinning, EnergyBinning, EventPreProcessor PYIRF_SPECTRA = { "CRAB_HEGRA": CRAB_HEGRA, @@ -48,7 +49,63 @@ class IrfTool(Tool): name = "ctapipe-make-irfs" description = "Tool to create IRF files in GAD format" - classes = [DataBinning, ToolConfig, EventPreProcessor] + gamma_file = traits.Path( + default_value=None, directory_ok=False, help="Gamma input filename and path" + ).tag(config=True) + gamma_sim_spectrum = traits.Unicode( + default_value="CRAB_HEGRA", + help="Name of the pyrif spectra used for the simulated gamma spectrum", + ).tag(config=True) + proton_file = traits.Path( + default_value=None, directory_ok=False, help="Proton input filename and path" + ).tag(config=True) + proton_sim_spectrum = traits.Unicode( + default_value="IRFDOC_PROTON_SPECTRUM", + help="Name of the pyrif spectra used for the simulated proton spectrum", + ).tag(config=True) + electron_file = traits.Path( + default_value=None, directory_ok=False, help="Electron input filename and path" + ).tag(config=True) + electron_sim_spectrum = traits.Unicode( + default_value="IRFDOC_ELECTRON_SPECTRUM", + help="Name of the pyrif spectra used for the simulated electron spectrum", + ).tag(config=True) + + chunk_size = Integer( + default_value=100000, + allow_none=True, + help="How many subarray events to load at once for making predictions.", + ).tag(config=True) + + output_path = traits.Path( + default_value="./IRF.fits.gz", + allow_none=False, + directory_ok=False, + help="Output file", + ).tag(config=True) + + overwrite = Bool( + False, + help="Overwrite the output file if it exists", + ).tag(config=True) + + obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) + obs_time_unit = Unicode( + default_value="hour", + help="Unit used to specify observation time as an astropy unit string.", + ).tag(config=True) + + alpha = Float( + default_value=0.2, help="Ratio between size of on and off regions" + ).tag(config=True) + ON_radius = Float(default_value=1.0, help="Radius of ON region in degrees").tag( + config=True + ) + max_bg_radius = Float( + default_value=3.0, help="Radius used to calculate background rate in degrees" + ).tag(config=True) + + classes = [CutOptimising, DataBinning, EnergyBinning, EventPreProcessor] def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): @@ -75,7 +132,7 @@ def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf ) # Gamma source is assumed to be pointlike if kind == "gamma": - spectrum = spectrum.integrate_cone(0 * u.deg, self.tc.ON_radius * u.deg) + spectrum = spectrum.integrate_cone(0 * u.deg, self.ON_radius * u.deg) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=target_spectrum, @@ -102,7 +159,7 @@ def get_metadata(self, loader): return ( sim_info, PowerLaw.from_simulation( - sim_info, obstime=self.tc.obs_time * u.Unit(self.tc.obs_time_unit) + sim_info, obstime=self.obs_time * u.Unit(self.obs_time_unit) ), obs, ) @@ -111,27 +168,27 @@ def load_preselected_events(self): opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) reduced_events = dict() for kind, file, target_spectrum in [ - ("gamma", self.tc.gamma_file, PYIRF_SPECTRA[self.tc.gamma_sim_spectrum]), - ("proton", self.tc.proton_file, PYIRF_SPECTRA[self.tc.proton_sim_spectrum]), + ("gamma", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum]), + ("proton", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum]), ( "electron", - self.tc.electron_file, - PYIRF_SPECTRA[self.tc.electron_sim_spectrum], + self.electron_file, + PYIRF_SPECTRA[self.electron_sim_spectrum], ), ]: with TableLoader(file, **opts) as load: Provenance().add_input_file(file) - header = self.eps.make_empty_table() + header = self.epp.make_empty_table() sim_info, spectrum, obs_conf = self.get_metadata(load) if kind == "gamma": self.sim_info = sim_info self.spectrum = spectrum bits = [header] for start, stop, events in load.read_subarray_events_chunked( - self.tc.chunk_size + self.chunk_size ): - selected = self.eps.normalise_column_names(events) - selected = selected[self.eps.get_table_mask(selected)] + selected = self.epp.normalise_column_names(events) + selected = selected[self.epp.get_table_mask(selected)] selected = self.make_derived_columns( kind, selected, spectrum, target_spectrum, obs_conf ) @@ -140,27 +197,29 @@ def load_preselected_events(self): table = vstack(bits, join_type="exact") reduced_events[kind] = table - select_ON = reduced_events["gamma"]["theta"] <= self.tc.ON_radius * u.deg + select_ON = reduced_events["gamma"]["theta"] <= self.ON_radius * u.deg self.signal = reduced_events["gamma"][select_ON] self.background = vstack([reduced_events["proton"], reduced_events["electron"]]) def setup(self): - self.tc = ToolConfig(parent=self) + self.co = CutOptimising(parent=self) + self.e_bins = EnergyBinning(parent=self) self.bins = DataBinning(parent=self) - self.eps = EventPreProcessor(parent=self) + self.epp = EventPreProcessor(parent=self) + + self.reco_energy_bins = self.e_bins.reco_energy_bins() + self.true_energy_bins = self.e_bins.true_energy_bins() + self.energy_migration_bins = self.e_bins.energy_migration_bins() - self.reco_energy_bins = self.bins.reco_energy_bins() - self.true_energy_bins = self.bins.true_energy_bins() self.source_offset_bins = self.bins.source_offset_bins() self.fov_offset_bins = self.bins.fov_offset_bins() self.bkg_fov_offset_bins = self.bins.bkg_fov_offset_bins() - self.energy_migration_bins = self.bins.energy_migration_bins() def start(self): self.load_preselected_events() INITIAL_GH_CUT = np.quantile( - self.signal["gh_score"], (1 - self.tc.initial_gh_cut_efficency) + self.signal["gh_score"], (1 - self.co.initial_gh_cut_efficency) ) self.log.info( f"Using fixed G/H cut of {INITIAL_GH_CUT} to calculate theta cuts" @@ -181,9 +240,9 @@ def start(self): self.log.info("Optimizing G/H separation cut for best sensitivity") gh_cut_efficiencies = np.arange( - self.tc.gh_cut_efficiency_step, - self.tc.max_gh_cut_efficiency + self.tc.gh_cut_efficiency_step / 2, - self.tc.gh_cut_efficiency_step, + self.co.gh_cut_efficiency_step, + self.co.max_gh_cut_efficiency + self.co.gh_cut_efficiency_step / 2, + self.co.gh_cut_efficiency_step, ) sens2, self.gh_cuts = optimize_gh_cut( @@ -193,8 +252,8 @@ def start(self): gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, theta_cuts=theta_cuts, - alpha=self.tc.alpha, - fov_offset_max=self.tc.max_bg_radius * u.deg, + alpha=self.alpha, + fov_offset_max=self.max_bg_radius * u.deg, ) # now that we have the optimized gh cuts, we recalculate the theta @@ -234,12 +293,12 @@ def start(self): self.background[self.background["selected_gh"]], reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, - alpha=self.tc.alpha, + alpha=self.alpha, fov_offset_min=self.bins.fov_offset_min * u.deg, fov_offset_max=self.bins.fov_offset_max * u.deg, ) self.sensitivity = calculate_sensitivity( - signal_hist, background_hist, alpha=self.tc.alpha + signal_hist, background_hist, alpha=self.alpha ) # scale relative sensitivity by Crab flux to get the flux sensitivity @@ -318,7 +377,7 @@ def finish(self): self.background[self.background["selected_gh"]], self.reco_energy_bins, fov_offset_bins=self.bkg_fov_offset_bins, - t_obs=self.tc.obs_time * u.Unit(self.tc.obs_time_unit), + t_obs=self.obs_time * u.Unit(self.obs_time_unit), ) hdus.append( create_background_2d_hdu( @@ -351,9 +410,9 @@ def finish(self): ) ) - self.log.info("Writing outputfile '%s'" % self.tc.output_path) + self.log.info("Writing outputfile '%s'" % self.output_path) fits.HDUList(hdus).writeto( - self.tc.output_path, + self.output_path, overwrite=self.overwrite, ) From e3349f3c7dcb9c632c69bdaa9d491dab8df5627d Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 13:42:42 +0200 Subject: [PATCH 022/136] Refactored after feedback --- ctapipe/irf/irf_classes.py | 46 +++++++++++++++++++++++++++++++++++ src/ctapipe/tools/make_irf.py | 42 +++++--------------------------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index a524093c75b..54afe51e2cd 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -1,10 +1,14 @@ """ Define a parent IrfTool class to hold all the options """ +import operator + import astropy.units as u import numpy as np from astropy.table import QTable from pyirf.binning import create_bins_per_decade +from pyirf.cut_optimization import optimize_gh_cut +from pyirf.cuts import calculate_percentile_cut from ..core import Component, QualityQuery from ..core.traits import Float, Integer, List, Unicode @@ -24,6 +28,48 @@ class CutOptimising(Component): default_value=0.4, help="Start value of gamma efficiency before optimisation" ).tag(config=True) + def optimise_gh_cut( + self, signal, background, Et_bins, Er_bins, bins, alpha, max_bg_radius + ): + INITIAL_GH_CUT = np.quantile( + signal["gh_score"], (1 - self.initial_gh_cut_efficency) + ) + self.log.info( + f"Using fixed G/H cut of {INITIAL_GH_CUT} to calculate theta cuts" + ) + + mask_theta_cuts = signal["gh_score"] >= INITIAL_GH_CUT + + theta_cuts = calculate_percentile_cut( + signal["theta"][mask_theta_cuts], + signal["reco_energy"][mask_theta_cuts], + bins=Et_bins, + min_value=bins.theta_min_angle * u.deg, + max_value=bins.theta_max_angle * u.deg, + fill_value=bins.theta_fill_value * u.deg, + min_events=bins.theta_min_counts, + percentile=68, + ) + + self.log.info("Optimizing G/H separation cut for best sensitivity") + gh_cut_efficiencies = np.arange( + self.gh_cut_efficiency_step, + self.max_gh_cut_efficiency + self.gh_cut_efficiency_step / 2, + self.gh_cut_efficiency_step, + ) + + sens2, gh_cuts = optimize_gh_cut( + signal, + background, + reco_energy_bins=Er_bins, + gh_cut_efficiencies=gh_cut_efficiencies, + op=operator.ge, + theta_cuts=theta_cuts, + alpha=alpha, + fov_offset_max=max_bg_radius * u.deg, + ) + return gh_cuts, sens2 + class EventPreProcessor(QualityQuery): """Defines preselection cuts and the necessary renaming of columns""" diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 2fbc50969d8..f055041c8d4 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -7,7 +7,6 @@ from astropy.table import vstack from pyirf.benchmarks import angular_resolution, energy_bias_resolution from pyirf.binning import create_histogram_table -from pyirf.cut_optimization import optimize_gh_cut from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut from pyirf.io import ( create_aeff2d_hdu, @@ -217,43 +216,14 @@ def setup(self): def start(self): self.load_preselected_events() - - INITIAL_GH_CUT = np.quantile( - self.signal["gh_score"], (1 - self.co.initial_gh_cut_efficency) - ) - self.log.info( - f"Using fixed G/H cut of {INITIAL_GH_CUT} to calculate theta cuts" - ) - - mask_theta_cuts = self.signal["gh_score"] >= INITIAL_GH_CUT - - theta_cuts = calculate_percentile_cut( - self.signal["theta"][mask_theta_cuts], - self.signal["reco_energy"][mask_theta_cuts], - bins=self.true_energy_bins, - min_value=self.bins.theta_min_angle * u.deg, - max_value=self.bins.theta_max_angle * u.deg, - fill_value=self.bins.theta_fill_value * u.deg, - min_events=self.bins.theta_min_counts, - percentile=68, - ) - - self.log.info("Optimizing G/H separation cut for best sensitivity") - gh_cut_efficiencies = np.arange( - self.co.gh_cut_efficiency_step, - self.co.max_gh_cut_efficiency + self.co.gh_cut_efficiency_step / 2, - self.co.gh_cut_efficiency_step, - ) - - sens2, self.gh_cuts = optimize_gh_cut( + self.gh_cuts, sens2 = self.co.optimise_gh_cut( self.signal, self.background, - reco_energy_bins=self.reco_energy_bins, - gh_cut_efficiencies=gh_cut_efficiencies, - op=operator.ge, - theta_cuts=theta_cuts, - alpha=self.alpha, - fov_offset_max=self.max_bg_radius * u.deg, + self.true_energy_bins, + self.reco_energy_bins, + self.bins, + self.alpha, + self.max_bg_radius, ) # now that we have the optimized gh cuts, we recalculate the theta From b075e73351b0b4adfea7d6c7cf5a27451ef6c8b3 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 13:45:01 +0200 Subject: [PATCH 023/136] Refactored after feedback --- ctapipe/irf/irf_classes.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 54afe51e2cd..6c4658d0658 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -166,23 +166,6 @@ def make_empty_table(self): return QTable(names=columns, units=units) -class ThetaSettings(Component): - - min_angle = Float( - default_value=0.05, help="Smallest angular cut value allowed" - ).tag(config=True) - max_angle = Float(default_value=0.32, help="Largest angular cut value allowed").tag( - config=True - ) - min_counts = Integer( - default_value=10, - help="Minimum number of events in a bin to attempt to find a cut value", - ).tag(config=True) - fill_value = Float( - default_value=0.32, help="Angular cut value used for bins with too few events" - ).tag(config=True) - - class EnergyBinning(Component): """Collects energy binning settings""" From a4e36ea58a97a96b443a2947d745951ec69fcb90 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 14:07:08 +0200 Subject: [PATCH 024/136] Made spectra into enums --- src/ctapipe/tools/make_irf.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index f055041c8d4..cbf4ae84ce4 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,5 +1,6 @@ """Tool to generate IRFs""" import operator +from enum import Enum import astropy.units as u import numpy as np @@ -37,10 +38,17 @@ from ..io import TableLoader from ..irf import CutOptimising, DataBinning, EnergyBinning, EventPreProcessor + +class Spectra(Enum): + CRAB_HEGRA = 1 + IRFDOC_ELECTRON_SPECTRUM = 2 + IRFDOC_PROTON_SPECTRUM = 3 + + PYIRF_SPECTRA = { - "CRAB_HEGRA": CRAB_HEGRA, - "IRFDOC_ELECTRON_SPECTRUM": IRFDOC_ELECTRON_SPECTRUM, - "IRFDOC_PROTON_SPECTRUM": IRFDOC_PROTON_SPECTRUM, + Spectra.CRAB_HEGRA: CRAB_HEGRA, + Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, + Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, } @@ -51,22 +59,25 @@ class IrfTool(Tool): gamma_file = traits.Path( default_value=None, directory_ok=False, help="Gamma input filename and path" ).tag(config=True) - gamma_sim_spectrum = traits.Unicode( - default_value="CRAB_HEGRA", + gamma_sim_spectrum = traits.UseEnum( + Spectra, + default_value=Spectra.CRAB_HEGRA, help="Name of the pyrif spectra used for the simulated gamma spectrum", ).tag(config=True) proton_file = traits.Path( default_value=None, directory_ok=False, help="Proton input filename and path" ).tag(config=True) - proton_sim_spectrum = traits.Unicode( - default_value="IRFDOC_PROTON_SPECTRUM", + proton_sim_spectrum = traits.UseEnum( + Spectra, + default_value=Spectra.IRFDOC_PROTON_SPECTRUM, help="Name of the pyrif spectra used for the simulated proton spectrum", ).tag(config=True) electron_file = traits.Path( default_value=None, directory_ok=False, help="Electron input filename and path" ).tag(config=True) - electron_sim_spectrum = traits.Unicode( - default_value="IRFDOC_ELECTRON_SPECTRUM", + electron_sim_spectrum = traits.UseEnum( + Spectra, + default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, help="Name of the pyrif spectra used for the simulated electron spectrum", ).tag(config=True) @@ -315,7 +326,6 @@ def finish(self): fov_offset_bins=self.fov_offset_bins, migration_bins=self.energy_migration_bins, ) - breakpoint() hdus.append( create_energy_dispersion_hdu( edisp, From 2a892659bcc76a84c1dbc398bb501f4542dcb970 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 7 Sep 2023 14:54:48 +0200 Subject: [PATCH 025/136] Added specific configuration of the optimisation grid to the cut optimiser component --- ctapipe/irf/__init__.py | 9 ++- ctapipe/irf/irf_classes.py | 109 ++++++++++++++++++++++++---------- src/ctapipe/tools/info.py | 1 + src/ctapipe/tools/make_irf.py | 31 ++-------- 4 files changed, 90 insertions(+), 60 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index b1056dbb807..6cd70eb6a8c 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,3 +1,8 @@ -from .irf_classes import CutOptimising, DataBinning, EnergyBinning, EventPreProcessor +from .irf_classes import ( + CutOptimising, + DataBinning, + EventPreProcessor, + OutputEnergyBinning, +) -__all__ = ["CutOptimising", "DataBinning", "EnergyBinning", "EventPreProcessor"] +__all__ = ["CutOptimising", "DataBinning", "OutputEnergyBinning", "EventPreProcessor"] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 6c4658d0658..88a1e470e61 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -8,29 +8,72 @@ from astropy.table import QTable from pyirf.binning import create_bins_per_decade from pyirf.cut_optimization import optimize_gh_cut -from pyirf.cuts import calculate_percentile_cut +from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut from ..core import Component, QualityQuery from ..core.traits import Float, Integer, List, Unicode class CutOptimising(Component): - """Collects settings related to the cut configuration""" + """Performs cut optimisation""" max_gh_cut_efficiency = Float( default_value=0.8, help="Maximum gamma efficiency requested" ).tag(config=True) + gh_cut_efficiency_step = Float( default_value=0.1, help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) + initial_gh_cut_efficency = Float( default_value=0.4, help="Start value of gamma efficiency before optimisation" ).tag(config=True) - def optimise_gh_cut( - self, signal, background, Et_bins, Er_bins, bins, alpha, max_bg_radius - ): + reco_energy_min = Float( + help="Minimum value for Reco Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + reco_energy_max = Float( + help="Maximum value for Reco Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + reco_energy_n_bins_per_decade = Float( + help="Number of edges per decade for Reco Energy bins", + default_value=5, + ).tag(config=True) + + theta_min_angle = Float( + default_value=0.05, help="Smallest angular cut value allowed" + ).tag(config=True) + + theta_max_angle = Float( + default_value=0.32, help="Largest angular cut value allowed" + ).tag(config=True) + + theta_min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + + theta_fill_value = Float( + default_value=0.32, help="Angular cut value used for bins with too few events" + ).tag(config=True) + + def reco_energy_bins(self): + """ + Creates bins per decade for reconstructed MC energy using pyirf function. + """ + 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 optimise_gh_cut(self, signal, background, alpha, max_bg_radius): INITIAL_GH_CUT = np.quantile( signal["gh_score"], (1 - self.initial_gh_cut_efficency) ) @@ -43,11 +86,11 @@ def optimise_gh_cut( theta_cuts = calculate_percentile_cut( signal["theta"][mask_theta_cuts], signal["reco_energy"][mask_theta_cuts], - bins=Et_bins, - min_value=bins.theta_min_angle * u.deg, - max_value=bins.theta_max_angle * u.deg, - fill_value=bins.theta_fill_value * u.deg, - min_events=bins.theta_min_counts, + bins=self.reco_energy_bins(), + min_value=self.theta_min_angle * u.deg, + max_value=self.theta_max_angle * u.deg, + fill_value=self.theta_fill_value * u.deg, + min_events=self.theta_min_counts, percentile=68, ) @@ -61,14 +104,33 @@ def optimise_gh_cut( sens2, gh_cuts = optimize_gh_cut( signal, background, - reco_energy_bins=Er_bins, + reco_energy_bins=self.reco_energy_bins(), gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, theta_cuts=theta_cuts, alpha=alpha, fov_offset_max=max_bg_radius * u.deg, ) - return gh_cuts, sens2 + + # now that we have the optimized gh cuts, we recalculate the theta + # cut as 68 percent containment on the events surviving these cuts. + self.log.info("Recalculating theta cut for optimized GH Cuts") + for tab in (signal, background): + tab["selected_gh"] = evaluate_binned_cut( + tab["gh_score"], tab["reco_energy"], gh_cuts, operator.ge + ) + + theta_cuts = calculate_percentile_cut( + signal[signal["selected_gh"]]["theta"], + signal[signal["selected_gh"]]["reco_energy"], + self.reco_energy_bins(), + percentile=68, + min_value=self.theta_min_angle * u.deg, + max_value=self.theta_max_angle * u.deg, + fill_value=self.theta_fill_value * u.deg, + min_events=self.theta_min_counts, + ) + return gh_cuts, theta_cuts, sens2 class EventPreProcessor(QualityQuery): @@ -166,7 +228,7 @@ def make_empty_table(self): return QTable(names=columns, units=units) -class EnergyBinning(Component): +class OutputEnergyBinning(Component): """Collects energy binning settings""" true_energy_min = Float( @@ -186,12 +248,12 @@ class EnergyBinning(Component): reco_energy_min = Float( help="Minimum value for Reco Energy bins in TeV units", - default_value=0.005, + default_value=0.006, ).tag(config=True) reco_energy_max = Float( help="Maximum value for Reco Energy bins in TeV units", - default_value=200, + default_value=190, ).tag(config=True) reco_energy_n_bins_per_decade = Float( @@ -256,23 +318,6 @@ class DataBinning(Component): Stolen from LSTChain """ - theta_min_angle = Float( - default_value=0.05, help="Smallest angular cut value allowed" - ).tag(config=True) - - theta_max_angle = Float( - default_value=0.32, help="Largest angular cut value allowed" - ).tag(config=True) - - theta_min_counts = Integer( - default_value=10, - help="Minimum number of events in a bin to attempt to find a cut value", - ).tag(config=True) - - theta_fill_value = Float( - default_value=0.32, help="Angular cut value used for bins with too few events" - ).tag(config=True) - fov_offset_min = Float( help="Minimum value for FoV Offset bins in degrees", default_value=0.0, diff --git a/src/ctapipe/tools/info.py b/src/ctapipe/tools/info.py index f100b24e80e..347661cf349 100644 --- a/src/ctapipe/tools/info.py +++ b/src/ctapipe/tools/info.py @@ -26,6 +26,7 @@ "iminuit", "tables", "eventio", + "pyirf", ] ) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index cbf4ae84ce4..f9ad3871ca6 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -8,7 +8,7 @@ from astropy.table import vstack from pyirf.benchmarks import angular_resolution, energy_bias_resolution from pyirf.binning import create_histogram_table -from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut +from pyirf.cuts import evaluate_binned_cut from pyirf.io import ( create_aeff2d_hdu, create_background_2d_hdu, @@ -36,7 +36,7 @@ from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode from ..io import TableLoader -from ..irf import CutOptimising, DataBinning, EnergyBinning, EventPreProcessor +from ..irf import CutOptimising, DataBinning, EventPreProcessor, OutputEnergyBinning class Spectra(Enum): @@ -115,7 +115,7 @@ class IrfTool(Tool): default_value=3.0, help="Radius used to calculate background rate in degrees" ).tag(config=True) - classes = [CutOptimising, DataBinning, EnergyBinning, EventPreProcessor] + classes = [CutOptimising, DataBinning, OutputEnergyBinning, EventPreProcessor] def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): @@ -213,7 +213,7 @@ def load_preselected_events(self): def setup(self): self.co = CutOptimising(parent=self) - self.e_bins = EnergyBinning(parent=self) + self.e_bins = OutputEnergyBinning(parent=self) self.bins = DataBinning(parent=self) self.epp = EventPreProcessor(parent=self) @@ -227,34 +227,13 @@ def setup(self): def start(self): self.load_preselected_events() - self.gh_cuts, sens2 = self.co.optimise_gh_cut( + self.gh_cuts, self.theta_cuts_opt, sens2 = self.co.optimise_gh_cut( self.signal, self.background, - self.true_energy_bins, - self.reco_energy_bins, - self.bins, self.alpha, self.max_bg_radius, ) - # now that we have the optimized gh cuts, we recalculate the theta - # cut as 68 percent containment on the events surviving these cuts. - self.log.info("Recalculating theta cut for optimized GH Cuts") - for tab in (self.signal, self.background): - tab["selected_gh"] = evaluate_binned_cut( - tab["gh_score"], tab["reco_energy"], self.gh_cuts, operator.ge - ) - - self.theta_cuts_opt = calculate_percentile_cut( - self.signal[self.signal["selected_gh"]]["theta"], - self.signal[self.signal["selected_gh"]]["reco_energy"], - self.true_energy_bins, - percentile=68, - min_value=self.bins.theta_min_angle * u.deg, - max_value=self.bins.theta_max_angle * u.deg, - fill_value=self.bins.theta_fill_value * u.deg, - min_events=self.bins.theta_min_counts, - ) self.signal["selected_theta"] = evaluate_binned_cut( self.signal["theta"], self.signal["reco_energy"], From c02eeed6005b967599e0be22210f72c262d0e0d8 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 8 Sep 2023 15:39:49 +0200 Subject: [PATCH 026/136] Refactored based on feedback: new tests that simulations stay consistent and some renaming --- ctapipe/irf/__init__.py | 4 +- ctapipe/irf/irf_classes.py | 2 +- src/ctapipe/tools/make_irf.py | 74 ++++++++++++++++++++++------------- 3 files changed, 49 insertions(+), 31 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 6cd70eb6a8c..825fcf698cd 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,8 +1,8 @@ from .irf_classes import ( - CutOptimising, + CutOptimizer, DataBinning, EventPreProcessor, OutputEnergyBinning, ) -__all__ = ["CutOptimising", "DataBinning", "OutputEnergyBinning", "EventPreProcessor"] +__all__ = ["CutOptimizer", "DataBinning", "OutputEnergyBinning", "EventPreProcessor"] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 88a1e470e61..f4a8b90d5af 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -14,7 +14,7 @@ from ..core.traits import Float, Integer, List, Unicode -class CutOptimising(Component): +class CutOptimizer(Component): """Performs cut optimisation""" max_gh_cut_efficiency = Float( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index f9ad3871ca6..d20f8ccafae 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -36,7 +36,7 @@ from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode from ..io import TableLoader -from ..irf import CutOptimising, DataBinning, EventPreProcessor, OutputEnergyBinning +from ..irf import CutOptimizer, DataBinning, EventPreProcessor, OutputEnergyBinning class Spectra(Enum): @@ -115,7 +115,7 @@ class IrfTool(Tool): default_value=3.0, help="Radius used to calculate background rate in degrees" ).tag(config=True) - classes = [CutOptimising, DataBinning, OutputEnergyBinning, EventPreProcessor] + classes = [CutOptimizer, DataBinning, OutputEnergyBinning, EventPreProcessor] def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): @@ -154,11 +154,25 @@ def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf def get_metadata(self, loader): obs = loader.read_observation_information() sim = loader.read_simulation_configuration() + show = loader.read_shower_distribution() # These sims better have the same viewcone! + if not np.diff(sim["energy_range_max"]).sum() == 0: + raise NotImplementedError( + "Unsupported: 'energy_range_max' differs across simulation runs" + ) + if not np.diff(sim["energy_range_min"]).sum() == 0: + raise NotImplementedError( + "Unsupported: 'energy_range_min' differs across simulation runs" + ) + if not np.diff(sim["spectral_index"]).sum() == 0: + raise NotImplementedError( + "Unsupported: 'spectral_index' differs across simulation runs" + ) + assert sim["max_viewcone_radius"].std() == 0 sim_info = SimulatedEventsInfo( - n_showers=sum(sim["n_showers"] * sim["shower_reuse"]), + n_showers=show["n_entries"].sum(), energy_min=sim["energy_range_min"].quantity[0], energy_max=sim["energy_range_max"].quantity[0], max_impact=sim["max_scatter_range"].quantity[0], @@ -208,11 +222,13 @@ def load_preselected_events(self): reduced_events[kind] = table select_ON = reduced_events["gamma"]["theta"] <= self.ON_radius * u.deg - self.signal = reduced_events["gamma"][select_ON] - self.background = vstack([reduced_events["proton"], reduced_events["electron"]]) + self.signal_events = reduced_events["gamma"][select_ON] + self.background_events = vstack( + [reduced_events["proton"], reduced_events["electron"]] + ) def setup(self): - self.co = CutOptimising(parent=self) + self.co = CutOptimizer(parent=self) self.e_bins = OutputEnergyBinning(parent=self) self.bins = DataBinning(parent=self) self.epp = EventPreProcessor(parent=self) @@ -227,30 +243,31 @@ def setup(self): def start(self): self.load_preselected_events() - self.gh_cuts, self.theta_cuts_opt, sens2 = self.co.optimise_gh_cut( - self.signal, - self.background, + self.gh_cuts, self.theta_cuts_opt, self.sens2 = self.co.optimise_gh_cut( + self.signal_events, + self.background_events, self.alpha, self.max_bg_radius, ) - self.signal["selected_theta"] = evaluate_binned_cut( - self.signal["theta"], - self.signal["reco_energy"], + self.signal_events["selected_theta"] = evaluate_binned_cut( + self.signal_events["theta"], + self.signal_events["reco_energy"], self.theta_cuts_opt, operator.le, ) - self.signal["selected"] = ( - self.signal["selected_theta"] & self.signal["selected_gh"] + self.signal_events["selected"] = ( + self.signal_events["selected_theta"] & self.signal_events["selected_gh"] ) # calculate sensitivity signal_hist = create_histogram_table( - self.signal[self.signal["selected"]], bins=self.reco_energy_bins + self.signal_events[self.signal_events["selected"]], + bins=self.reco_energy_bins, ) background_hist = estimate_background( - self.background[self.background["selected_gh"]], + self.background_events[self.background_events["selected_gh"]], reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.alpha, @@ -262,30 +279,30 @@ def start(self): ) # scale relative sensitivity by Crab flux to get the flux sensitivity - for s in (sens2, self.sensitivity): + for s in (self.sens2, self.sensitivity): s["flux_sensitivity"] = s["relative_sensitivity"] * self.spectrum( s["reco_energy_center"] ) def finish(self): masks = { - "": self.signal["selected"], + "": self.signal_events["selected"], "_NO_CUTS": slice(None), - "_ONLY_GH": self.signal["selected_gh"], - "_ONLY_THETA": self.signal["selected_theta"], + "_ONLY_GH": self.signal_events["selected_gh"], + "_ONLY_THETA": self.signal_events["selected_theta"], } hdus = [ fits.PrimaryHDU(), fits.BinTableHDU(self.sensitivity, name="SENSITIVITY"), - # fits.BinTableHDU(sensitivity_step_2, name="SENSITIVITY_STEP_2"), - # fits.BinTableHDU(self.theta_cuts, name="THETA_CUTS"), + fits.BinTableHDU(self.sens2, name="SENSITIVITY_STEP_2"), + fits.BinTableHDU(self.theta_cuts, name="THETA_CUTS"), fits.BinTableHDU(self.theta_cuts_opt, name="THETA_CUTS_OPT"), fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), ] for label, mask in masks.items(): effective_area = effective_area_per_energy( - self.signal[mask], + self.signal_events[mask], self.sim_info, true_energy_bins=self.true_energy_bins, ) @@ -300,7 +317,7 @@ def finish(self): ) ) edisp = energy_dispersion( - self.signal[mask], + self.signal_events[mask], true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, migration_bins=self.energy_migration_bins, @@ -317,7 +334,7 @@ def finish(self): # Here we use reconstructed energy instead of true energy for the sake of # current pipelines comparisons bias_resolution = energy_bias_resolution( - self.signal[self.signal["selected"]], + self.signal_events[self.signal_events["selected"]], self.reco_energy_bins, energy_type="reco", ) @@ -326,14 +343,14 @@ def finish(self): # Here we use reconstructed energy instead of true energy for the sake of # current pipelines comparisons ang_res = angular_resolution( - self.signal[self.signal["selected_gh"]], + self.signal_events[self.signal_events["selected_gh"]], self.reco_energy_bins, energy_type="reco", ) hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) background_rate = background_2d( - self.background[self.background["selected_gh"]], + self.background_events[self.background_events["selected_gh"]], self.reco_energy_bins, fov_offset_bins=self.bkg_fov_offset_bins, t_obs=self.obs_time * u.Unit(self.obs_time_unit), @@ -347,7 +364,7 @@ def finish(self): ) psf = psf_table( - self.signal[self.signal["selected_gh"]], + self.signal_events[self.signal_events["selected_gh"]], self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, source_offset_bins=self.source_offset_bins, @@ -374,6 +391,7 @@ def finish(self): self.output_path, overwrite=self.overwrite, ) + Provenance().add_output_file(self.output_path, role="IRF") def main(): From c338dc0c702eee9c4fe791c5019f0a190da6da3d Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 8 Sep 2023 15:47:48 +0200 Subject: [PATCH 027/136] Refactored based on feedback: cleaner test --- src/ctapipe/tools/make_irf.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index d20f8ccafae..ac1e9519199 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -118,7 +118,6 @@ class IrfTool(Tool): classes = [CutOptimizer, DataBinning, OutputEnergyBinning, EventPreProcessor] def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): - if obs_conf["subarray_pointing_lat"].std() < 1e-3: assert all(obs_conf["subarray_pointing_frame"] == 0) # Lets suppose 0 means ALTAZ @@ -156,19 +155,11 @@ def get_metadata(self, loader): sim = loader.read_simulation_configuration() show = loader.read_shower_distribution() - # These sims better have the same viewcone! - if not np.diff(sim["energy_range_max"]).sum() == 0: - raise NotImplementedError( - "Unsupported: 'energy_range_max' differs across simulation runs" - ) - if not np.diff(sim["energy_range_min"]).sum() == 0: - raise NotImplementedError( - "Unsupported: 'energy_range_min' differs across simulation runs" - ) - if not np.diff(sim["spectral_index"]).sum() == 0: - raise NotImplementedError( - "Unsupported: 'spectral_index' differs across simulation runs" - ) + for itm in ["spectral_index", "energy_range_min", "energy_range_max"]: + if len(np.unique(sim[itm])) > 1: + raise NotImplementedError( + f"Unsupported: '{itm}' differs across simulation runs" + ) assert sim["max_viewcone_radius"].std() == 0 sim_info = SimulatedEventsInfo( From e78e20661cfb13d5f5ab062703aa9f1570c0701e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 11 Sep 2023 18:42:06 +0200 Subject: [PATCH 028/136] Various changes to match reference script. --- ctapipe/irf/irf_classes.py | 6 ++-- src/ctapipe/tools/make_irf.py | 57 ++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index f4a8b90d5af..bf2409135ce 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -149,9 +149,9 @@ class EventPreProcessor(QualityQuery): help="Prefix of the classifier `_prediction` column", ).tag(config=True) - preselect_criteria = List( + quality_criteria = List( default_value=[ - ("multiplicity 4", "subarray.multiplicity(tels_with_trigger) >= 4"), + ("multiplicity 4", "np.count_nonzero(tels_with_trigger,axis=1) >= 4"), ("valid classifier", "RandomForestClassifier_is_valid"), ("valid geom reco", "HillasReconstructor_is_valid"), ("valid energy reco", "RandomForestRegressor_is_valid"), @@ -325,7 +325,7 @@ class DataBinning(Component): fov_offset_max = Float( help="Maximum value for FoV offset bins in degrees", - default_value=1.1, + default_value=2.0, ).tag(config=True) fov_offset_n_edges = Integer( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index ac1e9519199..b02d0601349 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -18,7 +18,7 @@ ) from pyirf.irf import ( background_2d, - effective_area_per_energy, + effective_area_per_energy_and_fov, energy_dispersion, psf_table, ) @@ -108,9 +108,9 @@ class IrfTool(Tool): alpha = Float( default_value=0.2, help="Ratio between size of on and off regions" ).tag(config=True) - ON_radius = Float(default_value=1.0, help="Radius of ON region in degrees").tag( - config=True - ) + # ON_radius = Float(default_value=1.0, help="Radius of ON region in degrees").tag( + # config=True + # ) max_bg_radius = Float( default_value=3.0, help="Radius used to calculate background rate in degrees" ).tag(config=True) @@ -139,9 +139,12 @@ def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf events["reco_source_fov_offset"] = calculate_source_fov_offset( events, prefix="reco" ) - # Gamma source is assumed to be pointlike + # TODO: Honestly not sure why this integral is needed, nor what + # are correct bounds if kind == "gamma": - spectrum = spectrum.integrate_cone(0 * u.deg, self.ON_radius * u.deg) + spectrum = spectrum.integrate_cone( + self.bins.fov_offset_min * u.deg, self.bins.fov_offset_max * u.deg + ) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=target_spectrum, @@ -161,7 +164,6 @@ def get_metadata(self, loader): f"Unsupported: '{itm}' differs across simulation runs" ) - assert sim["max_viewcone_radius"].std() == 0 sim_info = SimulatedEventsInfo( n_showers=show["n_entries"].sum(), energy_min=sim["energy_range_min"].quantity[0], @@ -199,21 +201,44 @@ def load_preselected_events(self): self.sim_info = sim_info self.spectrum = spectrum bits = [header] + n_raw_events = 0 for start, stop, events in load.read_subarray_events_chunked( self.chunk_size ): - selected = self.epp.normalise_column_names(events) - selected = selected[self.epp.get_table_mask(selected)] + selected = events[self.epp.get_table_mask(events)] + selected = self.epp.normalise_column_names(selected) selected = self.make_derived_columns( kind, selected, spectrum, target_spectrum, obs_conf ) bits.append(selected) + n_raw_events += len(events) table = vstack(bits, join_type="exact") reduced_events[kind] = table - - select_ON = reduced_events["gamma"]["theta"] <= self.ON_radius * u.deg - self.signal_events = reduced_events["gamma"][select_ON] + reduced_events[f"{kind}_count"] = n_raw_events + + self.log.debug( + "Loaded %d gammas, %d protons, %d electrons" + % ( + reduced_events["gamma_count"], + reduced_events["proton_count"], + reduced_events["electron_count"], + ) + ) + self.log.debug( + "Keeping %d gammas, %d protons, %d electrons" + % ( + len(reduced_events["gamma"]), + len(reduced_events["proton"]), + len(reduced_events["electron"]), + ) + ) + self.log.debug(self.epp.to_table()) + select_fov = ( + reduced_events["gamma"]["true_source_fov_offset"] + <= self.bins.fov_offset_max * u.deg + ) + self.signal_events = reduced_events["gamma"][select_fov] self.background_events = vstack( [reduced_events["proton"], reduced_events["electron"]] ) @@ -234,6 +259,10 @@ def setup(self): def start(self): self.load_preselected_events() + self.log.info( + "Optimising cuts using %d signal and %d background events" + % (len(self.signal_events), len(self.background_events)), + ) self.gh_cuts, self.theta_cuts_opt, self.sens2 = self.co.optimise_gh_cut( self.signal_events, self.background_events, @@ -286,16 +315,16 @@ def finish(self): fits.PrimaryHDU(), fits.BinTableHDU(self.sensitivity, name="SENSITIVITY"), fits.BinTableHDU(self.sens2, name="SENSITIVITY_STEP_2"), - fits.BinTableHDU(self.theta_cuts, name="THETA_CUTS"), fits.BinTableHDU(self.theta_cuts_opt, name="THETA_CUTS_OPT"), fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), ] for label, mask in masks.items(): - effective_area = effective_area_per_energy( + effective_area = effective_area_per_energy_and_fov( self.signal_events[mask], self.sim_info, true_energy_bins=self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, ) self.log.debug(self.true_energy_bins) self.log.debug(self.fov_offset_bins) From d850b2e0e9dda01a66a89fd0ee23c41c2833f1f2 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 19 Sep 2023 16:33:19 +0200 Subject: [PATCH 029/136] Reworked how initial theta cuts are calculated, changed some logging printouts --- ctapipe/irf/irf_classes.py | 26 +++++++++++++++++--------- src/ctapipe/tools/make_irf.py | 6 +++--- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index bf2409135ce..893dfea9e0d 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -74,18 +74,26 @@ def reco_energy_bins(self): return reco_energy def optimise_gh_cut(self, signal, background, alpha, max_bg_radius): - INITIAL_GH_CUT = np.quantile( - signal["gh_score"], (1 - self.initial_gh_cut_efficency) - ) - self.log.info( - f"Using fixed G/H cut of {INITIAL_GH_CUT} to calculate theta cuts" + initial_gh_cuts = calculate_percentile_cut( + signal["gh_score"], + signal["reco_energy"], + bins=self.reco_energy_bins(), + fill_value=0.0, + percentile=100 * (1 - self.initial_gh_cut_efficency), + min_events=25, + smoothing=1, ) - mask_theta_cuts = signal["gh_score"] >= INITIAL_GH_CUT + initial_gh_mask = evaluate_binned_cut( + signal["gh_score"], + signal["reco_energy"], + initial_gh_cuts, + op=operator.gt, + ) theta_cuts = calculate_percentile_cut( - signal["theta"][mask_theta_cuts], - signal["reco_energy"][mask_theta_cuts], + signal["theta"][initial_gh_mask], + signal["reco_energy"][initial_gh_mask], bins=self.reco_energy_bins(), min_value=self.theta_min_angle * u.deg, max_value=self.theta_max_angle * u.deg, @@ -325,7 +333,7 @@ class DataBinning(Component): fov_offset_max = Float( help="Maximum value for FoV offset bins in degrees", - default_value=2.0, + default_value=5.0, ).tag(config=True) fov_offset_n_edges = Integer( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index b02d0601349..f88f177c235 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -233,11 +233,11 @@ def load_preselected_events(self): len(reduced_events["electron"]), ) ) - self.log.debug(self.epp.to_table()) select_fov = ( reduced_events["gamma"]["true_source_fov_offset"] <= self.bins.fov_offset_max * u.deg ) + # TODO: verify that this fov cut on only gamma is ok self.signal_events = reduced_events["gamma"][select_fov] self.background_events = vstack( [reduced_events["proton"], reduced_events["electron"]] @@ -319,6 +319,8 @@ def finish(self): fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), ] + self.log.debug("True Energy bins", self.true_energy_bins) + self.log.debug("FoV offset bins", self.fov_offset_bins) for label, mask in masks.items(): effective_area = effective_area_per_energy_and_fov( self.signal_events[mask], @@ -326,8 +328,6 @@ def finish(self): true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, ) - self.log.debug(self.true_energy_bins) - self.log.debug(self.fov_offset_bins) hdus.append( create_aeff2d_hdu( effective_area[..., np.newaxis], # +1 dimension for FOV offset From 1dd9738903c7f9b2b843290edbf0bc26c43b3928 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 20 Sep 2023 13:39:57 +0200 Subject: [PATCH 030/136] Update conf.py copy Max's fix to the doc config to ignore traitlets things. --- docs/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 8941db319e4..ec81ed8b61e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -131,6 +131,10 @@ def setup(app): ("py:class", "traitlets.traitlets.Int"), ("py:class", "traitlets.config.application.Application"), ("py:class", "traitlets.utils.sentinel.Sentinel"), + ("py:class", "traitlets.traitlets.T"), + ("py:class", "re.Pattern[t.Any]"), + ("py:class", "Sentinel"), + ("py:class", "ObserveHandler"), ("py:class", "traitlets.traitlets.ObserveHandler"), ("py:class", "dict[K, V]"), ("py:class", "G"), From de62326c7066dd333314bbb72cbf625fd822f67e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 26 Sep 2023 15:28:44 +0200 Subject: [PATCH 031/136] Moved PSF related quantities into its own component --- ctapipe/irf/__init__.py | 9 +++- ctapipe/irf/irf_classes.py | 91 +++++++++++++++++++++++------------ src/ctapipe/tools/make_irf.py | 11 ++++- 3 files changed, 76 insertions(+), 35 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 825fcf698cd..e637f2e47e6 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -3,6 +3,13 @@ DataBinning, EventPreProcessor, OutputEnergyBinning, + PointSpreadFunction, ) -__all__ = ["CutOptimizer", "DataBinning", "OutputEnergyBinning", "EventPreProcessor"] +__all__ = [ + "CutOptimizer", + "DataBinning", + "OutputEnergyBinning", + "EventPreProcessor", + "PointSpreadFunction", +] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 893dfea9e0d..b15482b3660 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -45,23 +45,6 @@ class CutOptimizer(Component): default_value=5, ).tag(config=True) - theta_min_angle = Float( - default_value=0.05, help="Smallest angular cut value allowed" - ).tag(config=True) - - theta_max_angle = Float( - default_value=0.32, help="Largest angular cut value allowed" - ).tag(config=True) - - theta_min_counts = Integer( - default_value=10, - help="Minimum number of events in a bin to attempt to find a cut value", - ).tag(config=True) - - theta_fill_value = Float( - default_value=0.32, help="Angular cut value used for bins with too few events" - ).tag(config=True) - def reco_energy_bins(self): """ Creates bins per decade for reconstructed MC energy using pyirf function. @@ -73,7 +56,9 @@ def reco_energy_bins(self): ) return reco_energy - def optimise_gh_cut(self, signal, background, alpha, max_bg_radius): + def optimise_gh_cut( + self, signal, background, alpha, min_fov_radius, max_fov_radius, psf + ): initial_gh_cuts = calculate_percentile_cut( signal["gh_score"], signal["reco_energy"], @@ -91,15 +76,10 @@ def optimise_gh_cut(self, signal, background, alpha, max_bg_radius): op=operator.gt, ) - theta_cuts = calculate_percentile_cut( + theta_cuts = psf.calculate_theta_cuts( signal["theta"][initial_gh_mask], signal["reco_energy"][initial_gh_mask], - bins=self.reco_energy_bins(), - min_value=self.theta_min_angle * u.deg, - max_value=self.theta_max_angle * u.deg, - fill_value=self.theta_fill_value * u.deg, - min_events=self.theta_min_counts, - percentile=68, + self.reco_energy_bins(), ) self.log.info("Optimizing G/H separation cut for best sensitivity") @@ -117,28 +97,75 @@ def optimise_gh_cut(self, signal, background, alpha, max_bg_radius): op=operator.ge, theta_cuts=theta_cuts, alpha=alpha, - fov_offset_max=max_bg_radius * u.deg, + fov_offset_max=max_fov_radius * u.deg, + fov_offset_min=min_fov_radius * u.deg, ) # now that we have the optimized gh cuts, we recalculate the theta # cut as 68 percent containment on the events surviving these cuts. - self.log.info("Recalculating theta cut for optimized GH Cuts") for tab in (signal, background): tab["selected_gh"] = evaluate_binned_cut( tab["gh_score"], tab["reco_energy"], gh_cuts, operator.ge ) + self.log.info("Recalculating theta cut for optimized GH Cuts") - theta_cuts = calculate_percentile_cut( + theta_cuts = psf.calculate_theta_cuts( signal[signal["selected_gh"]]["theta"], signal[signal["selected_gh"]]["reco_energy"], self.reco_energy_bins(), - percentile=68, - min_value=self.theta_min_angle * u.deg, - max_value=self.theta_max_angle * u.deg, + ) + + return gh_cuts, theta_cuts, sens2 + + +class PointSpreadFunction(Component): + theta_min_angle = Float( + default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" + ).tag(config=True) + + theta_max_angle = Float( + default_value=0.32, help="Largest angular cut value allowed" + ).tag(config=True) + + theta_min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + + theta_fill_value = Float( + default_value=0.32, help="Angular cut value used for bins with too few events" + ).tag(config=True) + + theta_smoothing = Float( + default_value=-1, + help="When given, the width (in units of bins) of gaussian smoothing applied (-1)", + ).tag(config=True) + + target_percentile = Float( + default_value=68, + help="Percent of events in each energy bin keep after the theta cut", + ).tag(config=True) + + def calculate_theta_cuts(self, theta, reco_energy, energy_bins): + theta_min_angle = ( + None if self.theta_min_angle < 0 else self.theta_min_angle * u.deg + ) + theta_max_angle = ( + None if self.theta_max_angle < 0 else self.theta_max_angle * u.deg + ) + theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing + + return calculate_percentile_cut( + theta, + reco_energy, + energy_bins, + min_value=theta_min_angle, + max_value=theta_max_angle, + smoothing=theta_smoothing, + percentile=self.target_percentile, fill_value=self.theta_fill_value * u.deg, min_events=self.theta_min_counts, ) - return gh_cuts, theta_cuts, sens2 class EventPreProcessor(QualityQuery): diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index f88f177c235..d9008fb22fd 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -36,7 +36,13 @@ from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode from ..io import TableLoader -from ..irf import CutOptimizer, DataBinning, EventPreProcessor, OutputEnergyBinning +from ..irf import ( + CutOptimizer, + DataBinning, + EventPreProcessor, + OutputEnergyBinning, + PointSpreadFunction, +) class Spectra(Enum): @@ -244,10 +250,11 @@ def load_preselected_events(self): ) def setup(self): + self.epp = EventPreProcessor(parent=self) self.co = CutOptimizer(parent=self) + self.psf = PointSpreadFunction(parent=self) self.e_bins = OutputEnergyBinning(parent=self) self.bins = DataBinning(parent=self) - self.epp = EventPreProcessor(parent=self) self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() From 19fddfafd0500f9fb9a6c119f5a26efef13a9a9c Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 26 Sep 2023 16:58:25 +0200 Subject: [PATCH 032/136] Renamed PSF component to ThetaCutsCalculator, other small refactors --- ctapipe/irf/__init__.py | 4 ++-- ctapipe/irf/irf_classes.py | 16 ++++++++-------- src/ctapipe/tools/make_irf.py | 8 +++++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index e637f2e47e6..e6d04e2cff0 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -3,7 +3,7 @@ DataBinning, EventPreProcessor, OutputEnergyBinning, - PointSpreadFunction, + ThetaCutsCalculator, ) __all__ = [ @@ -11,5 +11,5 @@ "DataBinning", "OutputEnergyBinning", "EventPreProcessor", - "PointSpreadFunction", + "ThetaCutsCalculator", ] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index b15482b3660..0981fe625bf 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -17,6 +17,10 @@ class CutOptimizer(Component): """Performs cut optimisation""" + initial_gh_cut_efficency = Float( + default_value=0.4, help="Start value of gamma efficiency before optimisation" + ).tag(config=True) + max_gh_cut_efficiency = Float( default_value=0.8, help="Maximum gamma efficiency requested" ).tag(config=True) @@ -26,10 +30,6 @@ class CutOptimizer(Component): help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) - initial_gh_cut_efficency = Float( - default_value=0.4, help="Start value of gamma efficiency before optimisation" - ).tag(config=True) - reco_energy_min = Float( help="Minimum value for Reco Energy bins in TeV units", default_value=0.005, @@ -57,7 +57,7 @@ def reco_energy_bins(self): return reco_energy def optimise_gh_cut( - self, signal, background, alpha, min_fov_radius, max_fov_radius, psf + self, signal, background, alpha, min_fov_radius, max_fov_radius, theta ): initial_gh_cuts = calculate_percentile_cut( signal["gh_score"], @@ -76,7 +76,7 @@ def optimise_gh_cut( op=operator.gt, ) - theta_cuts = psf.calculate_theta_cuts( + theta_cuts = theta.calculate_theta_cuts( signal["theta"][initial_gh_mask], signal["reco_energy"][initial_gh_mask], self.reco_energy_bins(), @@ -109,7 +109,7 @@ def optimise_gh_cut( ) self.log.info("Recalculating theta cut for optimized GH Cuts") - theta_cuts = psf.calculate_theta_cuts( + theta_cuts = theta.calculate_theta_cuts( signal[signal["selected_gh"]]["theta"], signal[signal["selected_gh"]]["reco_energy"], self.reco_energy_bins(), @@ -118,7 +118,7 @@ def optimise_gh_cut( return gh_cuts, theta_cuts, sens2 -class PointSpreadFunction(Component): +class ThetaCutsCalculator(Component): theta_min_angle = Float( default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" ).tag(config=True) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index d9008fb22fd..cd34ee5cf51 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -41,7 +41,7 @@ DataBinning, EventPreProcessor, OutputEnergyBinning, - PointSpreadFunction, + ThetaCutsCalculator, ) @@ -252,7 +252,7 @@ def load_preselected_events(self): def setup(self): self.epp = EventPreProcessor(parent=self) self.co = CutOptimizer(parent=self) - self.psf = PointSpreadFunction(parent=self) + self.theta = ThetaCutsCalculator(parent=self) self.e_bins = OutputEnergyBinning(parent=self) self.bins = DataBinning(parent=self) @@ -274,7 +274,9 @@ def start(self): self.signal_events, self.background_events, self.alpha, - self.max_bg_radius, + self.bins.fov_offset_min, + self.bins.fov_offset_max, + self.theta, ) self.signal_events["selected_theta"] = evaluate_binned_cut( From 393327e153d7798c0c1f122ce2cb5d2fe873e7c7 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 5 Oct 2023 11:05:50 +0200 Subject: [PATCH 033/136] Update to support newest pyirf version --- src/ctapipe/tools/make_irf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index cd34ee5cf51..5ddae092c6c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -176,7 +176,8 @@ def get_metadata(self, loader): energy_max=sim["energy_range_max"].quantity[0], max_impact=sim["max_scatter_range"].quantity[0], spectral_index=sim["spectral_index"][0], - viewcone=sim["max_viewcone_radius"].quantity[0], + viewcone_max=sim["max_viewcone_radius"].quantity[0], + viewcone_min=sim["min_viewcone_radius"].quantity[0], ) return ( From 7218b977913a2b9571f18624a3ac4e962054b254 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 5 Oct 2023 11:06:33 +0200 Subject: [PATCH 034/136] Fix logging error --- src/ctapipe/tools/make_irf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 5ddae092c6c..fedde8032dd 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -329,8 +329,8 @@ def finish(self): fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), ] - self.log.debug("True Energy bins", self.true_energy_bins) - self.log.debug("FoV offset bins", self.fov_offset_bins) + self.log.debug(f"True Energy bins: {str(self.true_energy_bins.value)}") + self.log.debug(f"FoV offset bins: {str(self.fov_offset_bins.value)}") for label, mask in masks.items(): effective_area = effective_area_per_energy_and_fov( self.signal_events[mask], From a96abf7abe0983410ccb167a101ba375ffb7b2ef Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 18 Oct 2023 18:50:42 +0200 Subject: [PATCH 035/136] Use consistent offset binning --- ctapipe/irf/irf_classes.py | 30 ------------------------------ src/ctapipe/tools/make_irf.py | 5 ++--- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 0981fe625bf..bd7d99ff4f5 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -368,21 +368,6 @@ class DataBinning(Component): default_value=2, ).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 = Integer( - 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, @@ -412,21 +397,6 @@ def fov_offset_bins(self): ) 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. diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index fedde8032dd..e5cb02bfe9b 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -263,7 +263,6 @@ def setup(self): self.source_offset_bins = self.bins.source_offset_bins() self.fov_offset_bins = self.bins.fov_offset_bins() - self.bkg_fov_offset_bins = self.bins.bkg_fov_offset_bins() def start(self): self.load_preselected_events() @@ -382,14 +381,14 @@ def finish(self): background_rate = background_2d( self.background_events[self.background_events["selected_gh"]], self.reco_energy_bins, - fov_offset_bins=self.bkg_fov_offset_bins, + fov_offset_bins=self.fov_offset_bins, t_obs=self.obs_time * u.Unit(self.obs_time_unit), ) hdus.append( create_background_2d_hdu( background_rate, self.reco_energy_bins, - fov_offset_bins=self.bkg_fov_offset_bins, + fov_offset_bins=self.fov_offset_bins, ) ) From 410de22da9e63cb4dfec1c6052ae7c9668bd4853 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 23 Oct 2023 17:04:48 +0200 Subject: [PATCH 036/136] Add theta cut to the background, change logging statments --- src/ctapipe/tools/make_irf.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index e5cb02bfe9b..1c168c4ac4b 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -288,7 +288,12 @@ def start(self): self.signal_events["selected"] = ( self.signal_events["selected_theta"] & self.signal_events["selected_gh"] ) - + self.background_events["selected_theta"] = evaluate_binned_cut( + self.background_events["theta"], + self.background_events["reco_energy"], + self.theta_cuts_opt, + operator.le, + ) # calculate sensitivity signal_hist = create_histogram_table( self.signal_events[self.signal_events["selected"]], @@ -328,8 +333,8 @@ def finish(self): fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), ] - self.log.debug(f"True Energy bins: {str(self.true_energy_bins.value)}") - self.log.debug(f"FoV offset bins: {str(self.fov_offset_bins.value)}") + self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) + self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins.value)) for label, mask in masks.items(): effective_area = effective_area_per_energy_and_fov( self.signal_events[mask], @@ -364,8 +369,9 @@ def finish(self): # current pipelines comparisons bias_resolution = energy_bias_resolution( self.signal_events[self.signal_events["selected"]], - self.reco_energy_bins, - energy_type="reco", + self.true_energy_bins, + bias_function=np.mean, + energy_type="true", ) hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) @@ -378,8 +384,11 @@ def finish(self): ) hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) + sel = self.background_events["selected_gh"] + self.log.debug("%d background events selected" % sel.sum()) + self.log.debug("%f obs time" % self.obs_time) background_rate = background_2d( - self.background_events[self.background_events["selected_gh"]], + self.background_events[sel], self.reco_energy_bins, fov_offset_bins=self.fov_offset_bins, t_obs=self.obs_time * u.Unit(self.obs_time_unit), From 7e717753e50f82bc3520ac159adfebcff4cb736e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 16 Nov 2023 18:21:06 +0100 Subject: [PATCH 037/136] Partial refactoring into optimising and calculating parts --- ctapipe/irf/__init__.py | 11 +- ctapipe/irf/irf_classes.py | 215 +++------------------------------- ctapipe/irf/optimise.py | 114 ++++++++++++++++++ ctapipe/irf/select.py | 202 ++++++++++++++++++++++++++++++++ src/ctapipe/tools/make_irf.py | 183 +++++++---------------------- 5 files changed, 381 insertions(+), 344 deletions(-) create mode 100644 ctapipe/irf/optimise.py create mode 100644 ctapipe/irf/select.py diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index e6d04e2cff0..e9c813144a9 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,15 +1,20 @@ from .irf_classes import ( - CutOptimizer, + PYIRF_SPECTRA, DataBinning, - EventPreProcessor, OutputEnergyBinning, + Spectra, ThetaCutsCalculator, ) +from .optimise import GridOptimizer +from .select import EventPreProcessor, EventSelector __all__ = [ - "CutOptimizer", + "GridOptimizer", "DataBinning", "OutputEnergyBinning", + "EventSelector", "EventPreProcessor", + "Spectra", "ThetaCutsCalculator", + "PYIRF_SPECTRA", ] diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index bd7d99ff4f5..1e4e03af03b 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -1,121 +1,29 @@ """ Define a parent IrfTool class to hold all the options """ -import operator +from enum import Enum import astropy.units as u import numpy as np -from astropy.table import QTable from pyirf.binning import create_bins_per_decade -from pyirf.cut_optimization import optimize_gh_cut -from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut +from pyirf.cuts import calculate_percentile_cut +from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM -from ..core import Component, QualityQuery -from ..core.traits import Float, Integer, List, Unicode +from ..core import Component +from ..core.traits import Float, Integer -class CutOptimizer(Component): - """Performs cut optimisation""" +class Spectra(Enum): + CRAB_HEGRA = 1 + IRFDOC_ELECTRON_SPECTRUM = 2 + IRFDOC_PROTON_SPECTRUM = 3 - initial_gh_cut_efficency = Float( - default_value=0.4, help="Start value of gamma efficiency before optimisation" - ).tag(config=True) - - max_gh_cut_efficiency = Float( - default_value=0.8, help="Maximum gamma efficiency requested" - ).tag(config=True) - - gh_cut_efficiency_step = Float( - default_value=0.1, - help="Stepsize used for scanning after optimal gammaness cut", - ).tag(config=True) - - reco_energy_min = Float( - help="Minimum value for Reco Energy bins in TeV units", - default_value=0.005, - ).tag(config=True) - - reco_energy_max = Float( - help="Maximum value for Reco Energy bins in TeV units", - default_value=200, - ).tag(config=True) - - reco_energy_n_bins_per_decade = Float( - help="Number of edges per decade for Reco Energy bins", - default_value=5, - ).tag(config=True) - - def reco_energy_bins(self): - """ - Creates bins per decade for reconstructed MC energy using pyirf function. - """ - 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 optimise_gh_cut( - self, signal, background, alpha, min_fov_radius, max_fov_radius, theta - ): - initial_gh_cuts = calculate_percentile_cut( - signal["gh_score"], - signal["reco_energy"], - bins=self.reco_energy_bins(), - fill_value=0.0, - percentile=100 * (1 - self.initial_gh_cut_efficency), - min_events=25, - smoothing=1, - ) - - initial_gh_mask = evaluate_binned_cut( - signal["gh_score"], - signal["reco_energy"], - initial_gh_cuts, - op=operator.gt, - ) - - theta_cuts = theta.calculate_theta_cuts( - signal["theta"][initial_gh_mask], - signal["reco_energy"][initial_gh_mask], - self.reco_energy_bins(), - ) - - self.log.info("Optimizing G/H separation cut for best sensitivity") - gh_cut_efficiencies = np.arange( - self.gh_cut_efficiency_step, - self.max_gh_cut_efficiency + self.gh_cut_efficiency_step / 2, - self.gh_cut_efficiency_step, - ) - - sens2, gh_cuts = optimize_gh_cut( - signal, - background, - reco_energy_bins=self.reco_energy_bins(), - gh_cut_efficiencies=gh_cut_efficiencies, - op=operator.ge, - theta_cuts=theta_cuts, - alpha=alpha, - fov_offset_max=max_fov_radius * u.deg, - fov_offset_min=min_fov_radius * u.deg, - ) - - # now that we have the optimized gh cuts, we recalculate the theta - # cut as 68 percent containment on the events surviving these cuts. - for tab in (signal, background): - tab["selected_gh"] = evaluate_binned_cut( - tab["gh_score"], tab["reco_energy"], gh_cuts, operator.ge - ) - self.log.info("Recalculating theta cut for optimized GH Cuts") - - theta_cuts = theta.calculate_theta_cuts( - signal[signal["selected_gh"]]["theta"], - signal[signal["selected_gh"]]["reco_energy"], - self.reco_energy_bins(), - ) - return gh_cuts, theta_cuts, sens2 +PYIRF_SPECTRA = { + Spectra.CRAB_HEGRA: CRAB_HEGRA, + Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, + Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, +} class ThetaCutsCalculator(Component): @@ -168,101 +76,6 @@ def calculate_theta_cuts(self, theta, reco_energy, energy_bins): ) -class EventPreProcessor(QualityQuery): - """Defines preselection cuts and the necessary renaming of columns""" - - energy_reconstructor = Unicode( - default_value="RandomForestRegressor", - help="Prefix of the reco `_energy` column", - ).tag(config=True) - geometry_reconstructor = Unicode( - default_value="HillasReconstructor", - help="Prefix of the `_alt` and `_az` reco geometry columns", - ).tag(config=True) - gammaness_classifier = Unicode( - default_value="RandomForestClassifier", - help="Prefix of the classifier `_prediction` column", - ).tag(config=True) - - quality_criteria = List( - default_value=[ - ("multiplicity 4", "np.count_nonzero(tels_with_trigger,axis=1) >= 4"), - ("valid classifier", "RandomForestClassifier_is_valid"), - ("valid geom reco", "HillasReconstructor_is_valid"), - ("valid energy reco", "RandomForestRegressor_is_valid"), - ], - help=QualityQuery.quality_criteria.help, - ).tag(config=True) - - rename_columns = List( - help="List containing translation pairs new and old column names" - "used when processing input with names differing from the CTA prod5b format" - "Ex: [('valid_geom','HillasReconstructor_is_valid')]", - default_value=[], - ).tag(config=True) - - def normalise_column_names(self, events): - keep_columns = [ - "obs_id", - "event_id", - "true_energy", - "true_az", - "true_alt", - ] - rename_from = [ - f"{self.energy_reconstructor}_energy", - f"{self.geometry_reconstructor}_az", - f"{self.geometry_reconstructor}_alt", - f"{self.gammaness_classifier}_prediction", - ] - rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] - - # We never enter the loop if rename_columns is empty - for new, old in self.rename_columns: - rename_from.append(old) - rename_to.append(new) - - keep_columns.extend(rename_from) - events = QTable(events[keep_columns], copy=False) - events.rename_columns(rename_from, rename_to) - return events - - def make_empty_table(self): - """This function defines the columns later functions expect to be present in the event table""" - columns = [ - "obs_id", - "event_id", - "true_energy", - "true_az", - "true_alt", - "reco_energy", - "reco_az", - "reco_alt", - "gh_score", - "pointing_az", - "pointing_alt", - "theta", - "true_source_fov_offset", - "reco_source_fov_offset", - "weight", - ] - units = { - "true_energy": u.TeV, - "true_az": u.deg, - "true_alt": u.deg, - "reco_energy": u.TeV, - "reco_az": u.deg, - "reco_alt": u.deg, - "pointing_az": u.deg, - "pointing_alt": u.deg, - "theta": u.deg, - "true_source_fov_offset": u.deg, - "reco_source_fov_offset": u.deg, - } - - return QTable(names=columns, units=units) - - class OutputEnergyBinning(Component): """Collects energy binning settings""" diff --git a/ctapipe/irf/optimise.py b/ctapipe/irf/optimise.py new file mode 100644 index 00000000000..98a29f79e61 --- /dev/null +++ b/ctapipe/irf/optimise.py @@ -0,0 +1,114 @@ +import operator + +import astropy.units as u +import numpy as np +from pyirf.binning import create_bins_per_decade +from pyirf.cut_optimization import optimize_gh_cut +from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut + +from ..core import Component +from ..core.traits import Float + + +class GridOptimizer(Component): + """Performs cut optimisation""" + + initial_gh_cut_efficency = Float( + default_value=0.4, help="Start value of gamma efficiency before optimisation" + ).tag(config=True) + + max_gh_cut_efficiency = Float( + default_value=0.8, help="Maximum gamma efficiency requested" + ).tag(config=True) + + gh_cut_efficiency_step = Float( + default_value=0.1, + help="Stepsize used for scanning after optimal gammaness cut", + ).tag(config=True) + + reco_energy_min = Float( + help="Minimum value for Reco Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + reco_energy_max = Float( + help="Maximum value for Reco Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + reco_energy_n_bins_per_decade = Float( + help="Number of edges per decade for Reco Energy bins", + default_value=5, + ).tag(config=True) + + def reco_energy_bins(self): + """ + Creates bins per decade for reconstructed MC energy using pyirf function. + """ + 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 optimise_gh_cut( + self, signal, background, alpha, min_fov_radius, max_fov_radius, theta + ): + initial_gh_cuts = calculate_percentile_cut( + signal["gh_score"], + signal["reco_energy"], + bins=self.reco_energy_bins(), + fill_value=0.0, + percentile=100 * (1 - self.initial_gh_cut_efficency), + min_events=25, + smoothing=1, + ) + + initial_gh_mask = evaluate_binned_cut( + signal["gh_score"], + signal["reco_energy"], + initial_gh_cuts, + op=operator.gt, + ) + + theta_cuts = theta.calculate_theta_cuts( + signal["theta"][initial_gh_mask], + signal["reco_energy"][initial_gh_mask], + self.reco_energy_bins(), + ) + + self.log.info("Optimizing G/H separation cut for best sensitivity") + gh_cut_efficiencies = np.arange( + self.gh_cut_efficiency_step, + self.max_gh_cut_efficiency + self.gh_cut_efficiency_step / 2, + self.gh_cut_efficiency_step, + ) + + sens2, gh_cuts = optimize_gh_cut( + signal, + background, + reco_energy_bins=self.reco_energy_bins(), + gh_cut_efficiencies=gh_cut_efficiencies, + op=operator.ge, + theta_cuts=theta_cuts, + alpha=alpha, + fov_offset_max=max_fov_radius * u.deg, + fov_offset_min=min_fov_radius * u.deg, + ) + + # now that we have the optimized gh cuts, we recalculate the theta + # cut as 68 percent containment on the events surviving these cuts. + for tab in (signal, background): + tab["selected_gh"] = evaluate_binned_cut( + tab["gh_score"], tab["reco_energy"], gh_cuts, operator.ge + ) + self.log.info("Recalculating theta cut for optimized GH Cuts") + + theta_cuts = theta.calculate_theta_cuts( + signal[signal["selected_gh"]]["theta"], + signal[signal["selected_gh"]]["reco_energy"], + self.reco_energy_bins(), + ) + + return gh_cuts, theta_cuts, sens2 diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py new file mode 100644 index 00000000000..c659e0ffc8d --- /dev/null +++ b/ctapipe/irf/select.py @@ -0,0 +1,202 @@ +import astropy.units as u +import numpy as np +from astropy.table import QTable, vstack +from pyirf.simulations import SimulatedEventsInfo +from pyirf.spectral import PowerLaw, calculate_event_weights +from pyirf.utils import calculate_source_fov_offset, calculate_theta + +from ..core import Component, Provenance, QualityQuery +from ..core.traits import List, Unicode +from ..io import TableLoader + + +class EventSelector(Component): + def __init__(self, event_pre_processor, kind, file, target_spectrum, **kwargs): + super().__init__(**kwargs) + + self.epp = event_pre_processor + self.target_spectrum = target_spectrum + self.kind = kind + self.file = file + + def load_preselected_events(self, chunk_size): + opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) + with TableLoader(self.file, **opts) as load: + Provenance().add_input_file(self.file) + header = self.epp.make_empty_table() + sim_info, spectrum, obs_conf = self.get_metadata(load) + if self.kind == "gamma": + self.sim_info = sim_info + self.spectrum = spectrum + bits = [header] + n_raw_events = 0 + for start, stop, events in load.read_subarray_events_chunked(chunk_size): + selected = events[self.epp.get_table_mask(events)] + selected = self.epp.normalise_column_names(selected) + selected = self.make_derived_columns(selected, spectrum, obs_conf) + bits.append(selected) + n_raw_events += len(events) + + table = vstack(bits, join_type="exact") + # TODO: Fix reduced events stuff + return table, n_raw_events + + def get_metadata(self, loader): + obs = loader.read_observation_information() + sim = loader.read_simulation_configuration() + show = loader.read_shower_distribution() + + for itm in ["spectral_index", "energy_range_min", "energy_range_max"]: + if len(np.unique(sim[itm])) > 1: + raise NotImplementedError( + f"Unsupported: '{itm}' differs across simulation runs" + ) + + sim_info = SimulatedEventsInfo( + n_showers=show["n_entries"].sum(), + energy_min=sim["energy_range_min"].quantity[0], + energy_max=sim["energy_range_max"].quantity[0], + max_impact=sim["max_scatter_range"].quantity[0], + spectral_index=sim["spectral_index"][0], + viewcone_max=sim["max_viewcone_radius"].quantity[0], + viewcone_min=sim["min_viewcone_radius"].quantity[0], + ) + + return ( + sim_info, + PowerLaw.from_simulation( + sim_info, obstime=self.obs_time * u.Unit(self.obs_time_unit) + ), + obs, + ) + + def make_derived_columns(self, events, spectrum, obs_conf): + if obs_conf["subarray_pointing_lat"].std() < 1e-3: + assert all(obs_conf["subarray_pointing_frame"] == 0) + # Lets suppose 0 means ALTAZ + events["pointing_alt"] = obs_conf["subarray_pointing_lat"][0] * u.deg + events["pointing_az"] = obs_conf["subarray_pointing_lon"][0] * u.deg + else: + raise NotImplementedError( + "No support for making irfs from varying pointings yet" + ) + + events["theta"] = calculate_theta( + events, + assumed_source_az=events["true_az"], + assumed_source_alt=events["true_alt"], + ) + events["true_source_fov_offset"] = calculate_source_fov_offset( + events, prefix="true" + ) + events["reco_source_fov_offset"] = calculate_source_fov_offset( + events, prefix="reco" + ) + # TODO: Honestly not sure why this integral is needed, nor what + # are correct bounds + if self.kind == "gamma": + spectrum = spectrum.integrate_cone( + self.bins.fov_offset_min * u.deg, self.bins.fov_offset_max * u.deg + ) + events["weight"] = calculate_event_weights( + events["true_energy"], + target_spectrum=self.target_spectrum, + simulated_spectrum=spectrum, + ) + + return events + + +class EventPreProcessor(QualityQuery): + """Defines preselection cuts and the necessary renaming of columns""" + + energy_reconstructor = Unicode( + default_value="RandomForestRegressor", + help="Prefix of the reco `_energy` column", + ).tag(config=True) + geometry_reconstructor = Unicode( + default_value="HillasReconstructor", + help="Prefix of the `_alt` and `_az` reco geometry columns", + ).tag(config=True) + gammaness_classifier = Unicode( + default_value="RandomForestClassifier", + help="Prefix of the classifier `_prediction` column", + ).tag(config=True) + + quality_criteria = List( + default_value=[ + ("multiplicity 4", "np.count_nonzero(tels_with_trigger,axis=1) >= 4"), + ("valid classifier", "RandomForestClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "RandomForestRegressor_is_valid"), + ], + help=QualityQuery.quality_criteria.help, + ).tag(config=True) + + rename_columns = List( + help="List containing translation pairs new and old column names" + "used when processing input with names differing from the CTA prod5b format" + "Ex: [('valid_geom','HillasReconstructor_is_valid')]", + default_value=[], + ).tag(config=True) + + def normalise_column_names(self, events): + keep_columns = [ + "obs_id", + "event_id", + "true_energy", + "true_az", + "true_alt", + ] + rename_from = [ + f"{self.energy_reconstructor}_energy", + f"{self.geometry_reconstructor}_az", + f"{self.geometry_reconstructor}_alt", + f"{self.gammaness_classifier}_prediction", + ] + rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] + + # We never enter the loop if rename_columns is empty + for new, old in self.rename_columns: + rename_from.append(old) + rename_to.append(new) + + keep_columns.extend(rename_from) + events = QTable(events[keep_columns], copy=False) + events.rename_columns(rename_from, rename_to) + return events + + def make_empty_table(self): + """This function defines the columns later functions expect to be present in the event table""" + columns = [ + "obs_id", + "event_id", + "true_energy", + "true_az", + "true_alt", + "reco_energy", + "reco_az", + "reco_alt", + "gh_score", + "pointing_az", + "pointing_alt", + "theta", + "true_source_fov_offset", + "reco_source_fov_offset", + "weight", + ] + units = { + "true_energy": u.TeV, + "true_az": u.deg, + "true_alt": u.deg, + "reco_energy": u.TeV, + "reco_az": u.deg, + "reco_alt": u.deg, + "pointing_az": u.deg, + "pointing_alt": u.deg, + "theta": u.deg, + "true_source_fov_offset": u.deg, + "reco_source_fov_offset": u.deg, + } + + return QTable(names=columns, units=units) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 1c168c4ac4b..d4807c98192 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,6 +1,5 @@ """Tool to generate IRFs""" import operator -from enum import Enum import astropy.units as u import numpy as np @@ -23,41 +22,21 @@ psf_table, ) from pyirf.sensitivity import calculate_sensitivity, estimate_background -from pyirf.simulations import SimulatedEventsInfo -from pyirf.spectral import ( - CRAB_HEGRA, - IRFDOC_ELECTRON_SPECTRUM, - IRFDOC_PROTON_SPECTRUM, - PowerLaw, - calculate_event_weights, -) -from pyirf.utils import calculate_source_fov_offset, calculate_theta from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode -from ..io import TableLoader from ..irf import ( - CutOptimizer, + PYIRF_SPECTRA, DataBinning, EventPreProcessor, + EventSelector, + GridOptimizer, OutputEnergyBinning, + Spectra, ThetaCutsCalculator, ) -class Spectra(Enum): - CRAB_HEGRA = 1 - IRFDOC_ELECTRON_SPECTRUM = 2 - IRFDOC_PROTON_SPECTRUM = 3 - - -PYIRF_SPECTRA = { - Spectra.CRAB_HEGRA: CRAB_HEGRA, - Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, - Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, -} - - class IrfTool(Tool): name = "ctapipe-make-irfs" description = "Tool to create IRF files in GAD format" @@ -121,108 +100,47 @@ class IrfTool(Tool): default_value=3.0, help="Radius used to calculate background rate in degrees" ).tag(config=True) - classes = [CutOptimizer, DataBinning, OutputEnergyBinning, EventPreProcessor] - - def make_derived_columns(self, kind, events, spectrum, target_spectrum, obs_conf): - if obs_conf["subarray_pointing_lat"].std() < 1e-3: - assert all(obs_conf["subarray_pointing_frame"] == 0) - # Lets suppose 0 means ALTAZ - events["pointing_alt"] = obs_conf["subarray_pointing_lat"][0] * u.deg - events["pointing_az"] = obs_conf["subarray_pointing_lon"][0] * u.deg - else: - raise NotImplementedError( - "No support for making irfs from varying pointings yet" - ) - - events["theta"] = calculate_theta( - events, - assumed_source_az=events["true_az"], - assumed_source_alt=events["true_alt"], - ) - events["true_source_fov_offset"] = calculate_source_fov_offset( - events, prefix="true" - ) - events["reco_source_fov_offset"] = calculate_source_fov_offset( - events, prefix="reco" - ) - # TODO: Honestly not sure why this integral is needed, nor what - # are correct bounds - if kind == "gamma": - spectrum = spectrum.integrate_cone( - self.bins.fov_offset_min * u.deg, self.bins.fov_offset_max * u.deg - ) - events["weight"] = calculate_event_weights( - events["true_energy"], - target_spectrum=target_spectrum, - simulated_spectrum=spectrum, - ) + classes = [GridOptimizer, DataBinning, OutputEnergyBinning, EventPreProcessor] - return events - - def get_metadata(self, loader): - obs = loader.read_observation_information() - sim = loader.read_simulation_configuration() - show = loader.read_shower_distribution() + def setup(self): + self.go = GridOptimizer(parent=self) + self.theta = ThetaCutsCalculator(parent=self) + self.e_bins = OutputEnergyBinning(parent=self) + self.bins = DataBinning(parent=self) + epp = EventPreProcessor(parent=self) - for itm in ["spectral_index", "energy_range_min", "energy_range_max"]: - if len(np.unique(sim[itm])) > 1: - raise NotImplementedError( - f"Unsupported: '{itm}' differs across simulation runs" - ) + self.reco_energy_bins = self.e_bins.reco_energy_bins() + self.true_energy_bins = self.e_bins.true_energy_bins() + self.energy_migration_bins = self.e_bins.energy_migration_bins() - sim_info = SimulatedEventsInfo( - n_showers=show["n_entries"].sum(), - energy_min=sim["energy_range_min"].quantity[0], - energy_max=sim["energy_range_max"].quantity[0], - max_impact=sim["max_scatter_range"].quantity[0], - spectral_index=sim["spectral_index"][0], - viewcone_max=sim["max_viewcone_radius"].quantity[0], - viewcone_min=sim["min_viewcone_radius"].quantity[0], - ) + self.source_offset_bins = self.bins.source_offset_bins() + self.fov_offset_bins = self.bins.fov_offset_bins() - return ( - sim_info, - PowerLaw.from_simulation( - sim_info, obstime=self.obs_time * u.Unit(self.obs_time_unit) + self.particles = [ + EventSelector( + epp, "gammas", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum] ), - obs, - ) - - def load_preselected_events(self): - opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) - reduced_events = dict() - for kind, file, target_spectrum in [ - ("gamma", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum]), - ("proton", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum]), - ( - "electron", + EventSelector( + epp, + "protons", + self.proton_file, + PYIRF_SPECTRA[self.proton_sim_spectrum], + epp, + ), + EventSelector( + epp, + "electroms", self.electron_file, PYIRF_SPECTRA[self.electron_sim_spectrum], ), - ]: - with TableLoader(file, **opts) as load: - Provenance().add_input_file(file) - header = self.epp.make_empty_table() - sim_info, spectrum, obs_conf = self.get_metadata(load) - if kind == "gamma": - self.sim_info = sim_info - self.spectrum = spectrum - bits = [header] - n_raw_events = 0 - for start, stop, events in load.read_subarray_events_chunked( - self.chunk_size - ): - selected = events[self.epp.get_table_mask(events)] - selected = self.epp.normalise_column_names(selected) - selected = self.make_derived_columns( - kind, selected, spectrum, target_spectrum, obs_conf - ) - bits.append(selected) - n_raw_events += len(events) - - table = vstack(bits, join_type="exact") - reduced_events[kind] = table - reduced_events[f"{kind}_count"] = n_raw_events + ] + + def start(self): + reduced_events = dict() + for sel in self.particles: + evs, cnt = sel.load_preselected_events(self.chunk_size) + reduced_events[sel.kind] = evs + reduced_events[f"{sel.kind}_count"] = cnt self.log.debug( "Loaded %d gammas, %d protons, %d electrons" @@ -240,37 +158,22 @@ def load_preselected_events(self): len(reduced_events["electron"]), ) ) - select_fov = ( - reduced_events["gamma"]["true_source_fov_offset"] - <= self.bins.fov_offset_max * u.deg - ) + # select_fov = ( + # reduced_events["gamma"]["true_source_fov_offset"] + # <= self.bins.fov_offset_max * u.deg + # ) # TODO: verify that this fov cut on only gamma is ok - self.signal_events = reduced_events["gamma"][select_fov] + self.signal_events = reduced_events["gamma"] # [select_fov] self.background_events = vstack( [reduced_events["proton"], reduced_events["electron"]] ) - def setup(self): - self.epp = EventPreProcessor(parent=self) - self.co = CutOptimizer(parent=self) - self.theta = ThetaCutsCalculator(parent=self) - self.e_bins = OutputEnergyBinning(parent=self) - self.bins = DataBinning(parent=self) - - self.reco_energy_bins = self.e_bins.reco_energy_bins() - self.true_energy_bins = self.e_bins.true_energy_bins() - self.energy_migration_bins = self.e_bins.energy_migration_bins() - - self.source_offset_bins = self.bins.source_offset_bins() - self.fov_offset_bins = self.bins.fov_offset_bins() - - def start(self): self.load_preselected_events() self.log.info( "Optimising cuts using %d signal and %d background events" % (len(self.signal_events), len(self.background_events)), ) - self.gh_cuts, self.theta_cuts_opt, self.sens2 = self.co.optimise_gh_cut( + self.gh_cuts, self.theta_cuts_opt, self.sens2 = self.go.optimise_gh_cut( self.signal_events, self.background_events, self.alpha, From 8729e1f6f1725235ec0e0a29007b1fe81c367276 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 23 Nov 2023 19:28:22 +0100 Subject: [PATCH 038/136] Mayor refactor complete, still not running properly --- ctapipe/irf/__init__.py | 14 +- ctapipe/irf/binning.py | 176 ++++++++++++++++++ ctapipe/irf/irf_classes.py | 153 --------------- ctapipe/irf/optimise.py | 63 +++++-- ctapipe/irf/select.py | 21 ++- pyproject.toml | 1 + src/ctapipe/tools/make_irf.py | 111 ++++++----- src/ctapipe/tools/optimise_event_selection.py | 171 +++++++++++++++++ 8 files changed, 475 insertions(+), 235 deletions(-) create mode 100644 ctapipe/irf/binning.py create mode 100644 src/ctapipe/tools/optimise_event_selection.py diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index e9c813144a9..d08c5289dee 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,17 +1,15 @@ -from .irf_classes import ( - PYIRF_SPECTRA, - DataBinning, - OutputEnergyBinning, - Spectra, - ThetaCutsCalculator, -) -from .optimise import GridOptimizer +from .binning import FovOffsetBinning, OutputEnergyBinning, SourceOffsetBinning +from .irf_classes import PYIRF_SPECTRA, Spectra, ThetaCutsCalculator +from .optimise import GridOptimizer, OptimisationResult from .select import EventPreProcessor, EventSelector __all__ = [ + "OptimisationResult", "GridOptimizer", "DataBinning", "OutputEnergyBinning", + "SourceOffsetBinning", + "FovOffsetBinning", "EventSelector", "EventPreProcessor", "Spectra", diff --git a/ctapipe/irf/binning.py b/ctapipe/irf/binning.py new file mode 100644 index 00000000000..b83920f670b --- /dev/null +++ b/ctapipe/irf/binning.py @@ -0,0 +1,176 @@ +import astropy.units as u +import numpy as np +from pyirf.binning import create_bins_per_decade + +from ..core import Component +from ..core.traits import Float, Integer + + +class OutputEnergyBinning(Component): + """Collects energy binning settings""" + + true_energy_min = Float( + help="Minimum value for True Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + true_energy_max = Float( + help="Maximum value for True Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + true_energy_n_bins_per_decade = Float( + help="Number of edges per decade for True Energy bins", + default_value=10, + ).tag(config=True) + + reco_energy_min = Float( + help="Minimum value for Reco Energy bins in TeV units", + default_value=0.006, + ).tag(config=True) + + reco_energy_max = Float( + help="Maximum value for Reco Energy bins in TeV units", + default_value=190, + ).tag(config=True) + + reco_energy_n_bins_per_decade = Float( + help="Number of edges per decade for Reco Energy bins", + default_value=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 = Integer( + help="Number of bins in log scale for Energy Migration matrix", + default_value=31, + ).tag(config=True) + + def true_energy_bins(self): + """ + Creates bins per decade for true MC energy using pyirf function. + """ + 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. + """ + 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 + + +class FovOffsetBinning(Component): + """ + Collects information on generating energy and angular bins for + generating IRFs as per pyIRF requirements. + """ + + fov_offset_min = Float( + help="Minimum value for FoV Offset bins in degrees", + default_value=0.0, + ).tag(config=True) + + fov_offset_max = Float( + help="Maximum value for FoV offset bins in degrees", + default_value=5.0, + ).tag(config=True) + + fov_offset_n_edges = Integer( + help="Number of edges for FoV offset bins", + default_value=2, + ).tag(config=True) + + 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 + + +class SourceOffsetBinning(Component): + """ + Collects information on generating energy and angular bins for + generating IRFs as per pyIRF requirements. + """ + + source_offset_min = Float( + help="Minimum value for Source offset for PSF IRF", + default_value=0, + ).tag(config=True) + + source_offset_max = Float( + help="Maximum value for Source offset for PSF IRF", + default_value=1, + ).tag(config=True) + + source_offset_n_edges = Integer( + help="Number of edges for Source offset for PSF IRF", + default_value=101, + ).tag(config=True) + + 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 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 diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 1e4e03af03b..fe61759b3ae 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -4,8 +4,6 @@ from enum import Enum import astropy.units as u -import numpy as np -from pyirf.binning import create_bins_per_decade from pyirf.cuts import calculate_percentile_cut from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM @@ -74,154 +72,3 @@ def calculate_theta_cuts(self, theta, reco_energy, energy_bins): fill_value=self.theta_fill_value * u.deg, min_events=self.theta_min_counts, ) - - -class OutputEnergyBinning(Component): - """Collects energy binning settings""" - - true_energy_min = Float( - help="Minimum value for True Energy bins in TeV units", - default_value=0.005, - ).tag(config=True) - - true_energy_max = Float( - help="Maximum value for True Energy bins in TeV units", - default_value=200, - ).tag(config=True) - - true_energy_n_bins_per_decade = Float( - help="Number of edges per decade for True Energy bins", - default_value=10, - ).tag(config=True) - - reco_energy_min = Float( - help="Minimum value for Reco Energy bins in TeV units", - default_value=0.006, - ).tag(config=True) - - reco_energy_max = Float( - help="Maximum value for Reco Energy bins in TeV units", - default_value=190, - ).tag(config=True) - - reco_energy_n_bins_per_decade = Float( - help="Number of edges per decade for Reco Energy bins", - default_value=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 = Integer( - help="Number of bins in log scale for Energy Migration matrix", - default_value=31, - ).tag(config=True) - - def true_energy_bins(self): - """ - Creates bins per decade for true MC energy using pyirf function. - """ - 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. - """ - 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 - - -class DataBinning(Component): - """ - Collects information on generating energy and angular bins for - generating IRFs as per pyIRF requirements. - - Stolen from LSTChain - """ - - fov_offset_min = Float( - help="Minimum value for FoV Offset bins in degrees", - default_value=0.0, - ).tag(config=True) - - fov_offset_max = Float( - help="Maximum value for FoV offset bins in degrees", - default_value=5.0, - ).tag(config=True) - - fov_offset_n_edges = Integer( - help="Number of edges for FoV offset bins", - default_value=2, - ).tag(config=True) - - source_offset_min = Float( - help="Minimum value for Source offset for PSF IRF", - default_value=0, - ).tag(config=True) - - source_offset_max = Float( - help="Maximum value for Source offset for PSF IRF", - default_value=1, - ).tag(config=True) - - source_offset_n_edges = Integer( - help="Number of edges for Source offset for PSF IRF", - default_value=101, - ).tag(config=True) - - 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 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 diff --git a/ctapipe/irf/optimise.py b/ctapipe/irf/optimise.py index 98a29f79e61..555ede202dc 100644 --- a/ctapipe/irf/optimise.py +++ b/ctapipe/irf/optimise.py @@ -2,14 +2,59 @@ import astropy.units as u import numpy as np +from astropy.table import QTable, Table from pyirf.binning import create_bins_per_decade from pyirf.cut_optimization import optimize_gh_cut from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut -from ..core import Component +from ..core import Component, QualityQuery from ..core.traits import Float +class OptimisationResult: + def __init__(self, gh_cuts=None, offset_lim=None): + self.gh_cuts = gh_cuts + if gh_cuts: + self.gh_cuts.meta["extname"] = "GH_CUTS" + if offset_lim and isinstance(offset_lim[0], list): + self.offset_lim = offset_lim + else: + self.offset_lim = [offset_lim] + + def write(self, out_name, precuts, overwrite=False): + if isinstance(precuts, QualityQuery): + precuts = precuts.quality_criteria + if len(precuts) == 0: + precuts = [(" ", " ")] # Ensures table can be created + else: + precuts = QualityQuery() + cut_expr_tab = Table( + rows=precuts, + names=["name", "cut_expr"], + dtype=[np.unicode_, np.unicode_], + ) + cut_expr_tab.meta["extname"] = "QUALITY_CUTS_EXPR" + offset_lim_tab = QTable( + rows=self.offset_lim, names=["offset_min", "offset_max"] + ) + offset_lim_tab.meta["extname"] = "OFFSET_LIMITS" + self.gh_cuts.write(out_name, format="fits", overwrite=overwrite) + cut_expr_tab.write(out_name, format="fits", append=True) + offset_lim_tab.write(out_name, format="fits", append=True) + + def read(self, file_name): + self.gh_cuts = QTable.read(file_name, hdu=1) + cut_expr_tab = Table.read(file_name, hdu=2) + cut_expr_lst = [(name, expr) for name, expr in cut_expr_tab.iterrows()] + cut_expr_lst.remove((" ", " ")) + self.precuts.quality_criteria = cut_expr_lst + offset_lim_tab = QTable.read(file_name, hdu=3) + self.offset_lim = list(offset_lim_tab[0]) + + def __repr__(self): + return f"" + + class GridOptimizer(Component): """Performs cut optimisation""" @@ -97,18 +142,8 @@ def optimise_gh_cut( fov_offset_min=min_fov_radius * u.deg, ) - # now that we have the optimized gh cuts, we recalculate the theta - # cut as 68 percent containment on the events surviving these cuts. - for tab in (signal, background): - tab["selected_gh"] = evaluate_binned_cut( - tab["gh_score"], tab["reco_energy"], gh_cuts, operator.ge - ) - self.log.info("Recalculating theta cut for optimized GH Cuts") - - theta_cuts = theta.calculate_theta_cuts( - signal[signal["selected_gh"]]["theta"], - signal[signal["selected_gh"]]["reco_energy"], - self.reco_energy_bins(), + result = OptimisationResult( + gh_cuts, offset_lim=[min_fov_radius, max_fov_radius] ) - return gh_cuts, theta_cuts, sens2 + return result, sens2 diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index c659e0ffc8d..2465672d6c3 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -19,12 +19,12 @@ def __init__(self, event_pre_processor, kind, file, target_spectrum, **kwargs): self.kind = kind self.file = file - def load_preselected_events(self, chunk_size): + def load_preselected_events(self, chunk_size, obs_time, fov_bins): opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) with TableLoader(self.file, **opts) as load: Provenance().add_input_file(self.file) header = self.epp.make_empty_table() - sim_info, spectrum, obs_conf = self.get_metadata(load) + sim_info, spectrum, obs_conf = self.get_metadata(load, obs_time) if self.kind == "gamma": self.sim_info = sim_info self.spectrum = spectrum @@ -33,7 +33,9 @@ def load_preselected_events(self, chunk_size): for start, stop, events in load.read_subarray_events_chunked(chunk_size): selected = events[self.epp.get_table_mask(events)] selected = self.epp.normalise_column_names(selected) - selected = self.make_derived_columns(selected, spectrum, obs_conf) + selected = self.make_derived_columns( + selected, spectrum, obs_conf, fov_bins + ) bits.append(selected) n_raw_events += len(events) @@ -41,7 +43,7 @@ def load_preselected_events(self, chunk_size): # TODO: Fix reduced events stuff return table, n_raw_events - def get_metadata(self, loader): + def get_metadata(self, loader, obs_time): obs = loader.read_observation_information() sim = loader.read_simulation_configuration() show = loader.read_shower_distribution() @@ -64,13 +66,11 @@ def get_metadata(self, loader): return ( sim_info, - PowerLaw.from_simulation( - sim_info, obstime=self.obs_time * u.Unit(self.obs_time_unit) - ), + PowerLaw.from_simulation(sim_info, obstime=obs_time), obs, ) - def make_derived_columns(self, events, spectrum, obs_conf): + def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): if obs_conf["subarray_pointing_lat"].std() < 1e-3: assert all(obs_conf["subarray_pointing_frame"] == 0) # Lets suppose 0 means ALTAZ @@ -96,8 +96,11 @@ def make_derived_columns(self, events, spectrum, obs_conf): # are correct bounds if self.kind == "gamma": spectrum = spectrum.integrate_cone( - self.bins.fov_offset_min * u.deg, self.bins.fov_offset_max * u.deg + fov_bins.fov_offset_min * u.deg, fov_bins.fov_offset_max * u.deg ) + self.log.info("kind: %s" % self.kind) + self.log.info("target: %s" % self.target_spectrum) + self.log.info("spectrum: %s" % spectrum) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=self.target_spectrum, diff --git a/pyproject.toml b/pyproject.toml index af525ce1add..0261ad0a0ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ ctapipe-dump-instrument = "ctapipe.tools.dump_instrument:main" ctapipe-display-dl1 = "ctapipe.tools.display_dl1:main" ctapipe-process = "ctapipe.tools.process:main" ctapipe-merge = "ctapipe.tools.merge:main" +ctapipe-optimise-event-selection = "ctapipe.tools.optimise_event_selection:main" ctapipe-make-irfs = "ctapipe.tools.make_irf:main" ctapipe-fileinfo = "ctapipe.tools.fileinfo:main" ctapipe-quickstart = "ctapipe.tools.quickstart:main" diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index d4807c98192..a90c145750f 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -27,11 +27,12 @@ from ..core.traits import Bool, Float, Integer, Unicode from ..irf import ( PYIRF_SPECTRA, - DataBinning, EventPreProcessor, EventSelector, GridOptimizer, + OptimisationResult, OutputEnergyBinning, + SourceOffsetBinning, Spectra, ThetaCutsCalculator, ) @@ -41,6 +42,10 @@ class IrfTool(Tool): name = "ctapipe-make-irfs" description = "Tool to create IRF files in GAD format" + cuts_file = traits.Path( + default_value=None, directory_ok=False, help="Path to optimised cuts input file" + ).tag(config=True) + gamma_file = traits.Path( default_value=None, directory_ok=False, help="Gamma input filename and path" ).tag(config=True) @@ -69,7 +74,7 @@ class IrfTool(Tool): chunk_size = Integer( default_value=100000, allow_none=True, - help="How many subarray events to load at once for making predictions.", + help="How many subarray events to load at once while selecting.", ).tag(config=True) output_path = traits.Path( @@ -93,43 +98,46 @@ class IrfTool(Tool): alpha = Float( default_value=0.2, help="Ratio between size of on and off regions" ).tag(config=True) - # ON_radius = Float(default_value=1.0, help="Radius of ON region in degrees").tag( - # config=True - # ) - max_bg_radius = Float( - default_value=3.0, help="Radius used to calculate background rate in degrees" - ).tag(config=True) - classes = [GridOptimizer, DataBinning, OutputEnergyBinning, EventPreProcessor] + classes = [ + GridOptimizer, + SourceOffsetBinning, + OutputEnergyBinning, + EventPreProcessor, + ] def setup(self): - self.go = GridOptimizer(parent=self) + self.opt_result = OptimisationResult() self.theta = ThetaCutsCalculator(parent=self) self.e_bins = OutputEnergyBinning(parent=self) - self.bins = DataBinning(parent=self) - epp = EventPreProcessor(parent=self) + self.bins = SourceOffsetBinning(parent=self) + self.epp = EventPreProcessor(parent=self) + self.opt_result.read(self.cuts_file) + self.epp.quality_criteria = self.opt_result.precuts self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() self.energy_migration_bins = self.e_bins.energy_migration_bins() self.source_offset_bins = self.bins.source_offset_bins() - self.fov_offset_bins = self.bins.fov_offset_bins() + self.fov_offset_bins = self.opt_result.offset_lim self.particles = [ EventSelector( - epp, "gammas", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum] + self.epp, + "gammas", + self.gamma_file, + PYIRF_SPECTRA[self.gamma_sim_spectrum], ), EventSelector( - epp, + self.epp, "protons", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum], - epp, ), EventSelector( - epp, - "electroms", + self.epp, + "electrons", self.electron_file, PYIRF_SPECTRA[self.electron_sim_spectrum], ), @@ -150,36 +158,27 @@ def start(self): reduced_events["electron_count"], ) ) - self.log.debug( - "Keeping %d gammas, %d protons, %d electrons" - % ( - len(reduced_events["gamma"]), - len(reduced_events["proton"]), - len(reduced_events["electron"]), - ) - ) - # select_fov = ( - # reduced_events["gamma"]["true_source_fov_offset"] - # <= self.bins.fov_offset_max * u.deg - # ) - # TODO: verify that this fov cut on only gamma is ok - self.signal_events = reduced_events["gamma"] # [select_fov] + + self.signal_events = reduced_events["gamma"] self.background_events = vstack( [reduced_events["proton"], reduced_events["electron"]] ) - - self.load_preselected_events() - self.log.info( - "Optimising cuts using %d signal and %d background events" - % (len(self.signal_events), len(self.background_events)), + self.signal_events["selected_gh"] = evaluate_binned_cut( + self.signal_events["gh_score"], + self.signal_events["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, ) - self.gh_cuts, self.theta_cuts_opt, self.sens2 = self.go.optimise_gh_cut( - self.signal_events, - self.background_events, - self.alpha, - self.bins.fov_offset_min, - self.bins.fov_offset_max, - self.theta, + self.background_events["selected_gh"] = evaluate_binned_cut( + self.background_events["gh_score"], + self.background_events["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, + ) + self.theta_cuts_opt = self.theta.calculate_theta_cuts( + self.signal_events[self.signal_events["selected_gh"]]["theta"], + self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], + self.reco_energy_bins(), ) self.signal_events["selected_theta"] = evaluate_binned_cut( @@ -191,12 +190,24 @@ def start(self): self.signal_events["selected"] = ( self.signal_events["selected_theta"] & self.signal_events["selected_gh"] ) + self.background_events["selected_theta"] = evaluate_binned_cut( self.background_events["theta"], self.background_events["reco_energy"], self.theta_cuts_opt, operator.le, ) + + # TODO: rework the above so we can give the number per + # species + self.log.debug( + "Keeping %d signal, %d backgrond events" + % ( + sum(self.signal_events["selected"]), + sum(self.background_events["selected"]), + ) + ) + # calculate sensitivity signal_hist = create_histogram_table( self.signal_events[self.signal_events["selected"]], @@ -208,18 +219,17 @@ def start(self): reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.alpha, - fov_offset_min=self.bins.fov_offset_min * u.deg, - fov_offset_max=self.bins.fov_offset_max * u.deg, + fov_offset_min=self.bins.fov_offset_min, + fov_offset_max=self.bins.fov_offset_max, ) self.sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha ) # scale relative sensitivity by Crab flux to get the flux sensitivity - for s in (self.sens2, self.sensitivity): - s["flux_sensitivity"] = s["relative_sensitivity"] * self.spectrum( - s["reco_energy_center"] - ) + self.sensitivity["flux_sensitivity"] = self.sensitivity[ + "relative_sensitivity" + ] * self.spectrum(self.sensitivity["reco_energy_center"]) def finish(self): masks = { @@ -231,7 +241,6 @@ def finish(self): hdus = [ fits.PrimaryHDU(), fits.BinTableHDU(self.sensitivity, name="SENSITIVITY"), - fits.BinTableHDU(self.sens2, name="SENSITIVITY_STEP_2"), fits.BinTableHDU(self.theta_cuts_opt, name="THETA_CUTS_OPT"), fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), ] diff --git a/src/ctapipe/tools/optimise_event_selection.py b/src/ctapipe/tools/optimise_event_selection.py new file mode 100644 index 00000000000..5a6d4d2e95a --- /dev/null +++ b/src/ctapipe/tools/optimise_event_selection.py @@ -0,0 +1,171 @@ +"""Tool to generate selections for IRFs production""" +import astropy.units as u +from astropy.table import vstack + +from ..core import Provenance, Tool, traits +from ..core.traits import Bool, Float, Integer, Unicode +from ..irf import ( + PYIRF_SPECTRA, + EventPreProcessor, + EventSelector, + FovOffsetBinning, + GridOptimizer, + OptimisationResult, + OutputEnergyBinning, + Spectra, + ThetaCutsCalculator, +) + + +class IrfEventSelector(Tool): + name = "ctapipe-optimise-event-selection" + description = "Tool to create optimised cuts for IRF generation" + + gamma_file = traits.Path( + default_value=None, directory_ok=False, help="Gamma input filename and path" + ).tag(config=True) + gamma_sim_spectrum = traits.UseEnum( + Spectra, + default_value=Spectra.CRAB_HEGRA, + help="Name of the pyrif spectra used for the simulated gamma spectrum", + ).tag(config=True) + proton_file = traits.Path( + default_value=None, directory_ok=False, help="Proton input filename and path" + ).tag(config=True) + proton_sim_spectrum = traits.UseEnum( + Spectra, + default_value=Spectra.IRFDOC_PROTON_SPECTRUM, + help="Name of the pyrif spectra used for the simulated proton spectrum", + ).tag(config=True) + electron_file = traits.Path( + default_value=None, directory_ok=False, help="Electron input filename and path" + ).tag(config=True) + electron_sim_spectrum = traits.UseEnum( + Spectra, + default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, + help="Name of the pyrif spectra used for the simulated electron spectrum", + ).tag(config=True) + + chunk_size = Integer( + default_value=100000, + allow_none=True, + help="How many subarray events to load at once when preselecting events.", + ).tag(config=True) + + output_path = traits.Path( + default_value="./Selection_Cuts.fits.gz", + allow_none=False, + directory_ok=False, + help="Output file storing optimisation result", + ).tag(config=True) + + overwrite = Bool( + False, + help="Overwrite the output file if it exists", + ).tag(config=True) + + obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) + obs_time_unit = Unicode( + default_value="hour", + help="Unit used to specify observation time as an astropy unit string.", + ).tag(config=True) + + alpha = Float( + default_value=0.2, help="Ratio between size of on and off regions" + ).tag(config=True) + + classes = [GridOptimizer, FovOffsetBinning, OutputEnergyBinning, EventPreProcessor] + + def setup(self): + self.go = GridOptimizer(parent=self) + self.theta = ThetaCutsCalculator(parent=self) + self.e_bins = OutputEnergyBinning(parent=self) + self.bins = FovOffsetBinning(parent=self) + self.epp = EventPreProcessor(parent=self) + + self.reco_energy_bins = self.e_bins.reco_energy_bins() + self.true_energy_bins = self.e_bins.true_energy_bins() + self.energy_migration_bins = self.e_bins.energy_migration_bins() + + self.fov_offset_bins = self.bins.fov_offset_bins() + + self.particles = [ + EventSelector( + self.epp, + "gammas", + self.gamma_file, + PYIRF_SPECTRA[self.gamma_sim_spectrum], + ), + EventSelector( + self.epp, + "protons", + self.proton_file, + PYIRF_SPECTRA[self.proton_sim_spectrum], + ), + EventSelector( + self.epp, + "electrons", + self.electron_file, + PYIRF_SPECTRA[self.electron_sim_spectrum], + ), + ] + + def start(self): + reduced_events = dict() + for sel in self.particles: + evs, cnt = sel.load_preselected_events( + self.chunk_size, self.obs_time * u.Unit(self.obs_time_unit), self.bins + ) + reduced_events[sel.kind] = evs + reduced_events[f"{sel.kind}_count"] = cnt + + self.log.debug( + "Loaded %d gammas, %d protons, %d electrons" + % ( + reduced_events["gamma_count"], + reduced_events["proton_count"], + reduced_events["electron_count"], + ) + ) + self.log.debug( + "Keeping %d gammas, %d protons, %d electrons" + % ( + len(reduced_events["gamma"]), + len(reduced_events["proton"]), + len(reduced_events["electron"]), + ) + ) + self.signal_events = reduced_events["gamma"] + self.background_events = vstack( + [reduced_events["proton"], reduced_events["electron"]] + ) + + self.log.info( + "Optimising cuts using %d signal and %d background events" + % (len(self.signal_events), len(self.background_events)), + ) + self.gh_cuts, self.theta_cuts_opt, self.sens2 = self.go.optimise_gh_cut( + self.signal_events, + self.background_events, + self.alpha, + self.bins.fov_offset_min, + self.bins.fov_offset_max, + self.theta, + ) + + result = OptimisationResult( + self.gh_cuts, [self.bins.fov_offset_min, self.bins.fov_offset_max] + ) + + self.log.info("Writing results to %s" % self.output_path) + Provenance().add_output_file(self.output_path, role="Optimisation_Result") + result.write(self.output_path, self.epp.quality_criteria, self.overwrite) + + +def main(): + tool = IrfEventSelector() + tool.run() + + +if __name__ == "main": + main() From cab78e96eb67273becbe2101ce6d119cce2f7aa8 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Sat, 25 Nov 2023 15:44:16 +0100 Subject: [PATCH 039/136] Various changes to get the code to run end to end --- ctapipe/irf/irf_classes.py | 2 +- ctapipe/irf/optimise.py | 28 ++++++-- ctapipe/irf/select.py | 31 ++++---- src/ctapipe/tools/make_irf.py | 72 +++++++++++-------- src/ctapipe/tools/optimise_event_selection.py | 31 ++++---- 5 files changed, 93 insertions(+), 71 deletions(-) diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index fe61759b3ae..03a3afab6b1 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -1,5 +1,5 @@ """ -Define a parent IrfTool class to hold all the options +Defines classe with no better home """ from enum import Enum diff --git a/ctapipe/irf/optimise.py b/ctapipe/irf/optimise.py index 555ede202dc..114f70457c8 100644 --- a/ctapipe/irf/optimise.py +++ b/ctapipe/irf/optimise.py @@ -26,8 +26,6 @@ def write(self, out_name, precuts, overwrite=False): precuts = precuts.quality_criteria if len(precuts) == 0: precuts = [(" ", " ")] # Ensures table can be created - else: - precuts = QualityQuery() cut_expr_tab = Table( rows=precuts, names=["name", "cut_expr"], @@ -46,10 +44,22 @@ def read(self, file_name): self.gh_cuts = QTable.read(file_name, hdu=1) cut_expr_tab = Table.read(file_name, hdu=2) cut_expr_lst = [(name, expr) for name, expr in cut_expr_tab.iterrows()] - cut_expr_lst.remove((" ", " ")) - self.precuts.quality_criteria = cut_expr_lst + # TODO: this crudely fixes a problem when loading non empty tables, make it nicer + try: + cut_expr_lst.remove((" ", " ")) + except ValueError: + pass + precuts = QualityQuery() + precuts.quality_criteria = cut_expr_lst offset_lim_tab = QTable.read(file_name, hdu=3) - self.offset_lim = list(offset_lim_tab[0]) + # TODO: find some way to do this cleanly + offset_lim_tab["bins"] = np.array( + [offset_lim_tab["offset_min"], offset_lim_tab["offset_max"]] + ).T + self.offset_lim = ( + np.array(offset_lim_tab[0]) * offset_lim_tab["offset_max"].unit + ) + return precuts def __repr__(self): return f"" @@ -100,6 +110,10 @@ def reco_energy_bins(self): def optimise_gh_cut( self, signal, background, alpha, min_fov_radius, max_fov_radius, theta ): + if not isinstance(max_fov_radius, u.Quantity): + raise ValueError("max_fov_radius has to have a unit") + if not isinstance(min_fov_radius, u.Quantity): + raise ValueError("min_fov_radius has to have a unit") initial_gh_cuts = calculate_percentile_cut( signal["gh_score"], signal["reco_energy"], @@ -138,8 +152,8 @@ def optimise_gh_cut( op=operator.ge, theta_cuts=theta_cuts, alpha=alpha, - fov_offset_max=max_fov_radius * u.deg, - fov_offset_min=min_fov_radius * u.deg, + fov_offset_max=max_fov_radius, + fov_offset_min=min_fov_radius, ) result = OptimisationResult( diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index 2465672d6c3..e0b49a4eb32 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -5,9 +5,10 @@ from pyirf.spectral import PowerLaw, calculate_event_weights from pyirf.utils import calculate_source_fov_offset, calculate_theta -from ..core import Component, Provenance, QualityQuery +from ..core import Component, QualityQuery from ..core.traits import List, Unicode from ..io import TableLoader +from ..irf import FovOffsetBinning class EventSelector(Component): @@ -21,13 +22,13 @@ def __init__(self, event_pre_processor, kind, file, target_spectrum, **kwargs): def load_preselected_events(self, chunk_size, obs_time, fov_bins): opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) - with TableLoader(self.file, **opts) as load: - Provenance().add_input_file(self.file) + with TableLoader(self.file, parent=self, **opts) as load: header = self.epp.make_empty_table() sim_info, spectrum, obs_conf = self.get_metadata(load, obs_time) - if self.kind == "gamma": - self.sim_info = sim_info - self.spectrum = spectrum + if self.kind == "gammas": + meta = {"sim_info": sim_info, "spectrum": spectrum} + else: + meta = None bits = [header] n_raw_events = 0 for start, stop, events in load.read_subarray_events_chunked(chunk_size): @@ -41,7 +42,7 @@ def load_preselected_events(self, chunk_size, obs_time, fov_bins): table = vstack(bits, join_type="exact") # TODO: Fix reduced events stuff - return table, n_raw_events + return table, n_raw_events, meta def get_metadata(self, loader, obs_time): obs = loader.read_observation_information() @@ -94,13 +95,15 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): ) # TODO: Honestly not sure why this integral is needed, nor what # are correct bounds - if self.kind == "gamma": - spectrum = spectrum.integrate_cone( - fov_bins.fov_offset_min * u.deg, fov_bins.fov_offset_max * u.deg - ) - self.log.info("kind: %s" % self.kind) - self.log.info("target: %s" % self.target_spectrum) - self.log.info("spectrum: %s" % spectrum) + if self.kind == "gammas": + if isinstance(fov_bins, FovOffsetBinning): + spectrum = spectrum.integrate_cone( + fov_bins.fov_offset_min * u.deg, fov_bins.fov_offset_max * u.deg + ) + else: + spectrum = spectrum.integrate_cone( + fov_bins["offset_min"], fov_bins["offset_max"] + ) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=self.target_spectrum, diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index a90c145750f..2795f0cee33 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -113,15 +113,15 @@ def setup(self): self.bins = SourceOffsetBinning(parent=self) self.epp = EventPreProcessor(parent=self) - self.opt_result.read(self.cuts_file) - self.epp.quality_criteria = self.opt_result.precuts + # TODO: not very elegant, refactor later + precuts = self.opt_result.read(self.cuts_file) + self.epp.quality_criteria = precuts.quality_criteria self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() self.energy_migration_bins = self.e_bins.energy_migration_bins() self.source_offset_bins = self.bins.source_offset_bins() self.fov_offset_bins = self.opt_result.offset_lim - self.particles = [ EventSelector( self.epp, @@ -146,22 +146,29 @@ def setup(self): def start(self): reduced_events = dict() for sel in self.particles: - evs, cnt = sel.load_preselected_events(self.chunk_size) + evs, cnt, meta = sel.load_preselected_events( + self.chunk_size, + self.obs_time * u.Unit(self.obs_time_unit), + self.fov_offset_bins, + ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt + if sel.kind == "gammas": + self.sim_info = meta["sim_info"] + self.gamma_spectrum = meta["spectrum"] self.log.debug( "Loaded %d gammas, %d protons, %d electrons" % ( - reduced_events["gamma_count"], - reduced_events["proton_count"], - reduced_events["electron_count"], + reduced_events["gammas_count"], + reduced_events["protons_count"], + reduced_events["electrons_count"], ) ) - self.signal_events = reduced_events["gamma"] + self.signal_events = reduced_events["gammas"] self.background_events = vstack( - [reduced_events["proton"], reduced_events["electron"]] + [reduced_events["protons"], reduced_events["electrons"]] ) self.signal_events["selected_gh"] = evaluate_binned_cut( self.signal_events["gh_score"], @@ -178,7 +185,7 @@ def start(self): self.theta_cuts_opt = self.theta.calculate_theta_cuts( self.signal_events[self.signal_events["selected_gh"]]["theta"], self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], - self.reco_energy_bins(), + self.reco_energy_bins, ) self.signal_events["selected_theta"] = evaluate_binned_cut( @@ -187,19 +194,22 @@ def start(self): self.theta_cuts_opt, operator.le, ) - self.signal_events["selected"] = ( - self.signal_events["selected_theta"] & self.signal_events["selected_gh"] - ) - self.background_events["selected_theta"] = evaluate_binned_cut( self.background_events["theta"], self.background_events["reco_energy"], self.theta_cuts_opt, operator.le, ) + self.signal_events["selected"] = ( + self.signal_events["selected_theta"] & self.signal_events["selected_gh"] + ) + self.background_events["selected"] = ( + self.background_events["selected_theta"] + & self.background_events["selected_gh"] + ) - # TODO: rework the above so we can give the number per - # species + # TODO: maybe rework the above so we can give the number per + # species instead of the total background self.log.debug( "Keeping %d signal, %d backgrond events" % ( @@ -213,14 +223,13 @@ def start(self): self.signal_events[self.signal_events["selected"]], bins=self.reco_energy_bins, ) - background_hist = estimate_background( self.background_events[self.background_events["selected_gh"]], reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.alpha, - fov_offset_min=self.bins.fov_offset_min, - fov_offset_max=self.bins.fov_offset_max, + fov_offset_min=self.fov_offset_bins["offset_min"], + fov_offset_max=self.fov_offset_bins["offset_max"], ) self.sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha @@ -229,7 +238,7 @@ def start(self): # scale relative sensitivity by Crab flux to get the flux sensitivity self.sensitivity["flux_sensitivity"] = self.sensitivity[ "relative_sensitivity" - ] * self.spectrum(self.sensitivity["reco_energy_center"]) + ] * self.gamma_spectrum(self.sensitivity["reco_energy_center"]) def finish(self): masks = { @@ -242,30 +251,31 @@ def finish(self): fits.PrimaryHDU(), fits.BinTableHDU(self.sensitivity, name="SENSITIVITY"), fits.BinTableHDU(self.theta_cuts_opt, name="THETA_CUTS_OPT"), - fits.BinTableHDU(self.gh_cuts, name="GH_CUTS"), + fits.BinTableHDU(self.opt_result.gh_cuts, name="GH_CUTS"), ] self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) - self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins.value)) + self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins["bins"])) for label, mask in masks.items(): effective_area = effective_area_per_energy_and_fov( self.signal_events[mask], self.sim_info, true_energy_bins=self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins, + # TODO: the fucking units on these fov offset bits are not working out at all :( + fov_offset_bins=self.fov_offset_bins["bins"], ) hdus.append( create_aeff2d_hdu( effective_area[..., np.newaxis], # +1 dimension for FOV offset self.true_energy_bins, - self.fov_offset_bins, + self.fov_offset_bins["bins"], extname="EFFECTIVE AREA" + label, ) ) edisp = energy_dispersion( self.signal_events[mask], true_energy_bins=self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins, + fov_offset_bins=self.fov_offset_bins["bins"], migration_bins=self.energy_migration_bins, ) hdus.append( @@ -273,7 +283,7 @@ def finish(self): edisp, true_energy_bins=self.true_energy_bins, migration_bins=self.energy_migration_bins, - fov_offset_bins=self.fov_offset_bins, + fov_offset_bins=self.fov_offset_bins["bins"], extname="ENERGY_DISPERSION" + label, ) ) @@ -302,21 +312,21 @@ def finish(self): background_rate = background_2d( self.background_events[sel], self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, + fov_offset_bins=self.fov_offset_bins["bins"], t_obs=self.obs_time * u.Unit(self.obs_time_unit), ) hdus.append( create_background_2d_hdu( background_rate, self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, + fov_offset_bins=self.fov_offset_bins["bins"], ) ) psf = psf_table( self.signal_events[self.signal_events["selected_gh"]], self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins, + fov_offset_bins=self.fov_offset_bins["bins"], source_offset_bins=self.source_offset_bins, ) hdus.append( @@ -324,7 +334,7 @@ def finish(self): psf, self.true_energy_bins, self.source_offset_bins, - self.fov_offset_bins, + self.fov_offset_bins["bins"], ) ) @@ -332,7 +342,7 @@ def finish(self): create_rad_max_hdu( self.theta_cuts_opt["cut"].reshape(-1, 1), self.true_energy_bins, - self.fov_offset_bins, + self.fov_offset_bins["bins"], ) ) diff --git a/src/ctapipe/tools/optimise_event_selection.py b/src/ctapipe/tools/optimise_event_selection.py index 5a6d4d2e95a..2bcaaf563d5 100644 --- a/src/ctapipe/tools/optimise_event_selection.py +++ b/src/ctapipe/tools/optimise_event_selection.py @@ -10,7 +10,6 @@ EventSelector, FovOffsetBinning, GridOptimizer, - OptimisationResult, OutputEnergyBinning, Spectra, ThetaCutsCalculator, @@ -53,7 +52,7 @@ class IrfEventSelector(Tool): ).tag(config=True) output_path = traits.Path( - default_value="./Selection_Cuts.fits.gz", + default_value="./Selection_Cuts.fits", allow_none=False, directory_ok=False, help="Output file storing optimisation result", @@ -122,44 +121,40 @@ def start(self): self.log.debug( "Loaded %d gammas, %d protons, %d electrons" % ( - reduced_events["gamma_count"], - reduced_events["proton_count"], - reduced_events["electron_count"], + reduced_events["gammas_count"], + reduced_events["protons_count"], + reduced_events["electrons_count"], ) ) self.log.debug( "Keeping %d gammas, %d protons, %d electrons" % ( - len(reduced_events["gamma"]), - len(reduced_events["proton"]), - len(reduced_events["electron"]), + len(reduced_events["gammas"]), + len(reduced_events["protons"]), + len(reduced_events["electrons"]), ) ) - self.signal_events = reduced_events["gamma"] + self.signal_events = reduced_events["gammas"] self.background_events = vstack( - [reduced_events["proton"], reduced_events["electron"]] + [reduced_events["protons"], reduced_events["electrons"]] ) self.log.info( "Optimising cuts using %d signal and %d background events" % (len(self.signal_events), len(self.background_events)), ) - self.gh_cuts, self.theta_cuts_opt, self.sens2 = self.go.optimise_gh_cut( + result, sens2 = self.go.optimise_gh_cut( self.signal_events, self.background_events, self.alpha, - self.bins.fov_offset_min, - self.bins.fov_offset_max, + self.bins.fov_offset_min * u.deg, + self.bins.fov_offset_max * u.deg, self.theta, ) - result = OptimisationResult( - self.gh_cuts, [self.bins.fov_offset_min, self.bins.fov_offset_max] - ) - self.log.info("Writing results to %s" % self.output_path) Provenance().add_output_file(self.output_path, role="Optimisation_Result") - result.write(self.output_path, self.epp.quality_criteria, self.overwrite) + result.write(self.output_path, self.epp, self.overwrite) def main(): From 21cd5d1d93549b4d417f446791d4e4d39dc8ecf4 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 1 Dec 2023 01:44:52 +0100 Subject: [PATCH 040/136] Pretty large refactoring around how results are saved, cleaned out some not purely irf tables from the output --- ctapipe/irf/__init__.py | 11 +- ctapipe/irf/binning.py | 1 + ctapipe/irf/irf_classes.py | 55 ---- ctapipe/irf/optimise.py | 148 +++++++--- ctapipe/irf/select.py | 67 ++++- src/ctapipe/tools/make_irf.py | 270 +++++++----------- src/ctapipe/tools/optimise_event_selection.py | 22 +- 7 files changed, 293 insertions(+), 281 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index d08c5289dee..5aa650d2b8a 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,16 +1,17 @@ +"""Top level module for the irf functionality""" from .binning import FovOffsetBinning, OutputEnergyBinning, SourceOffsetBinning -from .irf_classes import PYIRF_SPECTRA, Spectra, ThetaCutsCalculator -from .optimise import GridOptimizer, OptimisationResult -from .select import EventPreProcessor, EventSelector +from .irf_classes import PYIRF_SPECTRA, Spectra +from .optimise import GridOptimizer, OptimisationResult, OptimisationResultSaver +from .select import EventPreProcessor, EventsLoader, ThetaCutsCalculator __all__ = [ "OptimisationResult", + "OptimisationResultSaver", "GridOptimizer", - "DataBinning", "OutputEnergyBinning", "SourceOffsetBinning", "FovOffsetBinning", - "EventSelector", + "EventsLoader", "EventPreProcessor", "Spectra", "ThetaCutsCalculator", diff --git a/ctapipe/irf/binning.py b/ctapipe/irf/binning.py index b83920f670b..6ba2856b600 100644 --- a/ctapipe/irf/binning.py +++ b/ctapipe/irf/binning.py @@ -1,3 +1,4 @@ +"""Collection of binning related functionality for the irf tools""" import astropy.units as u import numpy as np from pyirf.binning import create_bins_per_decade diff --git a/ctapipe/irf/irf_classes.py b/ctapipe/irf/irf_classes.py index 03a3afab6b1..570e8fdd869 100644 --- a/ctapipe/irf/irf_classes.py +++ b/ctapipe/irf/irf_classes.py @@ -3,13 +3,8 @@ """ from enum import Enum -import astropy.units as u -from pyirf.cuts import calculate_percentile_cut from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM -from ..core import Component -from ..core.traits import Float, Integer - class Spectra(Enum): CRAB_HEGRA = 1 @@ -22,53 +17,3 @@ class Spectra(Enum): Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, } - - -class ThetaCutsCalculator(Component): - theta_min_angle = Float( - default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" - ).tag(config=True) - - theta_max_angle = Float( - default_value=0.32, help="Largest angular cut value allowed" - ).tag(config=True) - - theta_min_counts = Integer( - default_value=10, - help="Minimum number of events in a bin to attempt to find a cut value", - ).tag(config=True) - - theta_fill_value = Float( - default_value=0.32, help="Angular cut value used for bins with too few events" - ).tag(config=True) - - theta_smoothing = Float( - default_value=-1, - help="When given, the width (in units of bins) of gaussian smoothing applied (-1)", - ).tag(config=True) - - target_percentile = Float( - default_value=68, - help="Percent of events in each energy bin keep after the theta cut", - ).tag(config=True) - - def calculate_theta_cuts(self, theta, reco_energy, energy_bins): - theta_min_angle = ( - None if self.theta_min_angle < 0 else self.theta_min_angle * u.deg - ) - theta_max_angle = ( - None if self.theta_max_angle < 0 else self.theta_max_angle * u.deg - ) - theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing - - return calculate_percentile_cut( - theta, - reco_energy, - energy_bins, - min_value=theta_min_angle, - max_value=theta_max_angle, - smoothing=theta_smoothing, - percentile=self.target_percentile, - fill_value=self.theta_fill_value * u.deg, - min_events=self.theta_min_counts, - ) diff --git a/ctapipe/irf/optimise.py b/ctapipe/irf/optimise.py index 114f70457c8..72e868bc7f8 100644 --- a/ctapipe/irf/optimise.py +++ b/ctapipe/irf/optimise.py @@ -1,3 +1,4 @@ +"""module containing optimisation related functions and classes""" import operator import astropy.units as u @@ -11,38 +12,87 @@ from ..core.traits import Float +class ResultValidRange: + def __init__(self, bounds_table, prefix): + self.min = bounds_table[f"{prefix}_min"] + self.max = bounds_table[f"{prefix}_max"] + self.bins = ( + np.array([self.min, self.max]).reshape(-1) + * bounds_table[f"{prefix}_max"].unit + ) + + class OptimisationResult: - def __init__(self, gh_cuts=None, offset_lim=None): - self.gh_cuts = gh_cuts - if gh_cuts: - self.gh_cuts.meta["extname"] = "GH_CUTS" - if offset_lim and isinstance(offset_lim[0], list): - self.offset_lim = offset_lim + def __init__(self, precuts, valid_energy, valid_offset, gh, theta): + self.precuts = precuts + self.valid_energy = ResultValidRange(valid_energy, "energy") + self.valid_offset = ResultValidRange(valid_offset, "offset") + self.gh_cuts = gh + self.theta_cuts = theta + + def __repr__(self): + return ( + f"" + ) + + +class OptimisationResultSaver: + def __init__(self, precuts=None): + if precuts: + if isinstance(precuts, QualityQuery): + self._precuts = precuts.quality_criteria + if len(self._precuts) == 0: + self._precuts = [(" ", " ")] # Ensures table serialises with units + elif isinstance(precuts, list): + self._precuts = precuts + else: + self._precuts = list(precuts) else: - self.offset_lim = [offset_lim] + self._precuts = None + + self._results = None + + def set_result(self, gh_cuts, theta_cuts, valid_energy, valid_offset): + if not self._precuts: + raise ValueError("Precuts must be defined before results can be saved") + + gh_cuts.meta["extname"] = "GH_CUTS" + theta_cuts.meta["extname"] = "RAD_MAX" + + energy_lim_tab = QTable(rows=[valid_energy], names=["energy_min", "energy_max"]) + energy_lim_tab.meta["extname"] = "VALID_ENERGY" + + offset_lim_tab = QTable(rows=[valid_offset], names=["offset_min", "offset_max"]) + offset_lim_tab.meta["extname"] = "VALID_OFFSET" + + self._results = [gh_cuts, theta_cuts, energy_lim_tab, offset_lim_tab] + + def write(self, output_name, overwrite=False): + if not isinstance(self._results, list): + raise ValueError( + "The results of this object" + "have not been properly initialised," + " call `set_results` before writing." + ) - def write(self, out_name, precuts, overwrite=False): - if isinstance(precuts, QualityQuery): - precuts = precuts.quality_criteria - if len(precuts) == 0: - precuts = [(" ", " ")] # Ensures table can be created cut_expr_tab = Table( - rows=precuts, + rows=self._precuts, names=["name", "cut_expr"], dtype=[np.unicode_, np.unicode_], ) cut_expr_tab.meta["extname"] = "QUALITY_CUTS_EXPR" - offset_lim_tab = QTable( - rows=self.offset_lim, names=["offset_min", "offset_max"] - ) - offset_lim_tab.meta["extname"] = "OFFSET_LIMITS" - self.gh_cuts.write(out_name, format="fits", overwrite=overwrite) - cut_expr_tab.write(out_name, format="fits", append=True) - offset_lim_tab.write(out_name, format="fits", append=True) + + cut_expr_tab.write(output_name, format="fits", overwrite=overwrite) + + for table in self._results: + table.write(output_name, format="fits", append=True) def read(self, file_name): - self.gh_cuts = QTable.read(file_name, hdu=1) - cut_expr_tab = Table.read(file_name, hdu=2) + cut_expr_tab = Table.read(file_name, hdu=1) cut_expr_lst = [(name, expr) for name, expr in cut_expr_tab.iterrows()] # TODO: this crudely fixes a problem when loading non empty tables, make it nicer try: @@ -51,18 +101,14 @@ def read(self, file_name): pass precuts = QualityQuery() precuts.quality_criteria = cut_expr_lst - offset_lim_tab = QTable.read(file_name, hdu=3) - # TODO: find some way to do this cleanly - offset_lim_tab["bins"] = np.array( - [offset_lim_tab["offset_min"], offset_lim_tab["offset_max"]] - ).T - self.offset_lim = ( - np.array(offset_lim_tab[0]) * offset_lim_tab["offset_max"].unit - ) - return precuts + gh_cuts = QTable.read(file_name, hdu=2) + theta_cuts = QTable.read(file_name, hdu=3) + valid_energy = QTable.read(file_name, hdu=4) + valid_offset = QTable.read(file_name, hdu=5) - def __repr__(self): - return f"" + return OptimisationResult( + precuts, valid_energy, valid_offset, gh_cuts, theta_cuts + ) class GridOptimizer(Component): @@ -108,7 +154,14 @@ def reco_energy_bins(self): return reco_energy def optimise_gh_cut( - self, signal, background, alpha, min_fov_radius, max_fov_radius, theta + self, + signal, + background, + alpha, + min_fov_radius, + max_fov_radius, + theta, + precuts, ): if not isinstance(max_fov_radius, u.Quantity): raise ValueError("max_fov_radius has to have a unit") @@ -144,7 +197,7 @@ def optimise_gh_cut( self.gh_cut_efficiency_step, ) - sens2, gh_cuts = optimize_gh_cut( + opt_sens, gh_cuts = optimize_gh_cut( signal, background, reco_energy_bins=self.reco_energy_bins(), @@ -155,9 +208,26 @@ def optimise_gh_cut( fov_offset_max=max_fov_radius, fov_offset_min=min_fov_radius, ) - - result = OptimisationResult( - gh_cuts, offset_lim=[min_fov_radius, max_fov_radius] + valid_energy = self._get_valid_energy_range(opt_sens) + + result_saver = OptimisationResultSaver(precuts) + result_saver.set_result( + gh_cuts, + theta_cuts, + valid_energy=valid_energy, + valid_offset=[min_fov_radius, max_fov_radius], ) - return result, sens2 + return result_saver, opt_sens + + def _get_valid_energy_range(self, opt_sens): + keep_mask = np.isfinite(opt_sens["significance"]) + + count = np.arange(start=0, stop=len(keep_mask), step=1) + if all(np.diff(count[keep_mask]) == 1): + return [ + opt_sens["reco_energy_low"][keep_mask][0], + opt_sens["reco_energy_high"][keep_mask][-1], + ] + else: + raise ValueError("Optimal significance curve has internal NaN bins") diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index e0b49a4eb32..9c0b929c666 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -1,17 +1,19 @@ +"""Module containing classes related to eveent preprocessing and selection""" import astropy.units as u import numpy as np from astropy.table import QTable, vstack +from pyirf.cuts import calculate_percentile_cut from pyirf.simulations import SimulatedEventsInfo from pyirf.spectral import PowerLaw, calculate_event_weights from pyirf.utils import calculate_source_fov_offset, calculate_theta from ..core import Component, QualityQuery -from ..core.traits import List, Unicode +from ..core.traits import Float, Integer, List, Unicode from ..io import TableLoader from ..irf import FovOffsetBinning -class EventSelector(Component): +class EventsLoader(Component): def __init__(self, event_pre_processor, kind, file, target_spectrum, **kwargs): super().__init__(**kwargs) @@ -31,7 +33,7 @@ def load_preselected_events(self, chunk_size, obs_time, fov_bins): meta = None bits = [header] n_raw_events = 0 - for start, stop, events in load.read_subarray_events_chunked(chunk_size): + for _, _, events in load.read_subarray_events_chunked(chunk_size): selected = events[self.epp.get_table_mask(events)] selected = self.epp.normalise_column_names(selected) selected = self.make_derived_columns( @@ -41,7 +43,6 @@ def load_preselected_events(self, chunk_size, obs_time, fov_bins): n_raw_events += len(events) table = vstack(bits, join_type="exact") - # TODO: Fix reduced events stuff return table, n_raw_events, meta def get_metadata(self, loader, obs_time): @@ -93,17 +94,14 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): events["reco_source_fov_offset"] = calculate_source_fov_offset( events, prefix="reco" ) - # TODO: Honestly not sure why this integral is needed, nor what - # are correct bounds + if self.kind == "gammas": if isinstance(fov_bins, FovOffsetBinning): spectrum = spectrum.integrate_cone( fov_bins.fov_offset_min * u.deg, fov_bins.fov_offset_max * u.deg ) else: - spectrum = spectrum.integrate_cone( - fov_bins["offset_min"], fov_bins["offset_max"] - ) + spectrum = spectrum.integrate_cone(fov_bins[0], fov_bins[0]) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=self.target_spectrum, @@ -206,3 +204,54 @@ def make_empty_table(self): } return QTable(names=columns, units=units) + + +class ThetaCutsCalculator(Component): + theta_min_angle = Float( + default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" + ).tag(config=True) + + theta_max_angle = Float( + default_value=0.32, help="Largest angular cut value allowed" + ).tag(config=True) + + theta_min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + + theta_fill_value = Float( + default_value=0.32, help="Angular cut value used for bins with too few events" + ).tag(config=True) + + theta_smoothing = Float( + default_value=None, + allow_none=True, + help="When given, the width (in units of bins) of gaussian smoothing applied (None)", + ).tag(config=True) + + target_percentile = Float( + default_value=68, + help="Percent of events in each energy bin keep after the theta cut", + ).tag(config=True) + + def calculate_theta_cuts(self, theta, reco_energy, energy_bins): + theta_min_angle = ( + None if self.theta_min_angle < 0 else self.theta_min_angle * u.deg + ) + theta_max_angle = ( + None if self.theta_max_angle < 0 else self.theta_max_angle * u.deg + ) + theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing + + return calculate_percentile_cut( + theta, + reco_energy, + energy_bins, + min_value=theta_min_angle, + max_value=theta_max_angle, + smoothing=theta_smoothing, + percentile=self.target_percentile, + fill_value=self.theta_fill_value * u.deg, + min_events=self.theta_min_counts, + ) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 2795f0cee33..32816b3ec37 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -5,32 +5,23 @@ import numpy as np from astropy.io import fits from astropy.table import vstack -from pyirf.benchmarks import angular_resolution, energy_bias_resolution -from pyirf.binning import create_histogram_table from pyirf.cuts import evaluate_binned_cut from pyirf.io import ( create_aeff2d_hdu, - create_background_2d_hdu, create_energy_dispersion_hdu, create_psf_table_hdu, create_rad_max_hdu, ) -from pyirf.irf import ( - background_2d, - effective_area_per_energy_and_fov, - energy_dispersion, - psf_table, -) -from pyirf.sensitivity import calculate_sensitivity, estimate_background +from pyirf.irf import effective_area_per_energy_and_fov, energy_dispersion, psf_table from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode from ..irf import ( PYIRF_SPECTRA, EventPreProcessor, - EventSelector, + EventsLoader, GridOptimizer, - OptimisationResult, + OptimisationResultSaver, OutputEnergyBinning, SourceOffsetBinning, Spectra, @@ -39,7 +30,7 @@ class IrfTool(Tool): - name = "ctapipe-make-irfs" + name = "ctapipe-make-irf" description = "Tool to create IRF files in GAD format" cuts_file = traits.Path( @@ -106,36 +97,93 @@ class IrfTool(Tool): EventPreProcessor, ] + def calculate_selections(self): + self.signal_events["selected_gh"] = evaluate_binned_cut( + self.signal_events["gh_score"], + self.signal_events["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, + ) + self.background_events["selected_gh"] = evaluate_binned_cut( + self.background_events["gh_score"], + self.background_events["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, + ) + self.theta_cuts_opt = self.theta.calculate_theta_cuts( + self.signal_events[self.signal_events["selected_gh"]]["theta"], + self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], + self.reco_energy_bins, + ) + + self.signal_events["selected_theta"] = evaluate_binned_cut( + self.signal_events["theta"], + self.signal_events["reco_energy"], + self.theta_cuts_opt, + operator.le, + ) + self.background_events["selected_theta"] = evaluate_binned_cut( + self.background_events["theta"], + self.background_events["reco_energy"], + self.theta_cuts_opt, + operator.le, + ) + self.signal_events["selected"] = ( + self.signal_events["selected_theta"] & self.signal_events["selected_gh"] + ) + self.background_events["selected"] = ( + self.background_events["selected_theta"] + & self.background_events["selected_gh"] + ) + + # TODO: maybe rework the above so we can give the number per + # species instead of the total background + self.log.debug( + "Keeping %d signal, %d backgrond events" + % ( + sum(self.signal_events["selected"]), + sum(self.background_events["selected"]), + ) + ) + + def _check_bins_in_range(self, bins, range): + low = bins >= range.min + hig = bins <= range.max + + if not all(low & hig): + raise ValueError(f"Valid range is {range.min} to {range.max}, got {bins}") + def setup(self): - self.opt_result = OptimisationResult() self.theta = ThetaCutsCalculator(parent=self) self.e_bins = OutputEnergyBinning(parent=self) self.bins = SourceOffsetBinning(parent=self) - self.epp = EventPreProcessor(parent=self) - # TODO: not very elegant, refactor later - precuts = self.opt_result.read(self.cuts_file) - self.epp.quality_criteria = precuts.quality_criteria + self.opt_result = OptimisationResultSaver().read(self.cuts_file) + # TODO: not very elegant to pass them this way, refactor later + self.epp = EventPreProcessor(parent=self) + self.epp.quality_criteria = self.opt_result.precuts.quality_criteria self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() self.energy_migration_bins = self.e_bins.energy_migration_bins() - self.source_offset_bins = self.bins.source_offset_bins() - self.fov_offset_bins = self.opt_result.offset_lim + + self._check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) + self._check_bins_in_range(self.source_offset_bins, self.opt_result.valid_offset) + self.fov_offset_bins = self.opt_result.valid_offset.bins self.particles = [ - EventSelector( + EventsLoader( self.epp, "gammas", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum], ), - EventSelector( + EventsLoader( self.epp, "protons", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum], ), - EventSelector( + EventsLoader( self.epp, "electrons", self.electron_file, @@ -144,6 +192,8 @@ def setup(self): ] def start(self): + # TODO: this event loading code seems to be largely repeated between all the tools, + # try to refactor to a common solution reduced_events = dict() for sel in self.particles: evs, cnt, meta = sel.load_preselected_events( @@ -170,163 +220,48 @@ def start(self): self.background_events = vstack( [reduced_events["protons"], reduced_events["electrons"]] ) - self.signal_events["selected_gh"] = evaluate_binned_cut( - self.signal_events["gh_score"], - self.signal_events["reco_energy"], - self.opt_result.gh_cuts, - operator.ge, - ) - self.background_events["selected_gh"] = evaluate_binned_cut( - self.background_events["gh_score"], - self.background_events["reco_energy"], - self.opt_result.gh_cuts, - operator.ge, - ) - self.theta_cuts_opt = self.theta.calculate_theta_cuts( - self.signal_events[self.signal_events["selected_gh"]]["theta"], - self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], - self.reco_energy_bins, - ) - - self.signal_events["selected_theta"] = evaluate_binned_cut( - self.signal_events["theta"], - self.signal_events["reco_energy"], - self.theta_cuts_opt, - operator.le, - ) - self.background_events["selected_theta"] = evaluate_binned_cut( - self.background_events["theta"], - self.background_events["reco_energy"], - self.theta_cuts_opt, - operator.le, - ) - self.signal_events["selected"] = ( - self.signal_events["selected_theta"] & self.signal_events["selected_gh"] - ) - self.background_events["selected"] = ( - self.background_events["selected_theta"] - & self.background_events["selected_gh"] - ) - - # TODO: maybe rework the above so we can give the number per - # species instead of the total background - self.log.debug( - "Keeping %d signal, %d backgrond events" - % ( - sum(self.signal_events["selected"]), - sum(self.background_events["selected"]), - ) - ) - # calculate sensitivity - signal_hist = create_histogram_table( - self.signal_events[self.signal_events["selected"]], - bins=self.reco_energy_bins, - ) - background_hist = estimate_background( - self.background_events[self.background_events["selected_gh"]], - reco_energy_bins=self.reco_energy_bins, - theta_cuts=self.theta_cuts_opt, - alpha=self.alpha, - fov_offset_min=self.fov_offset_bins["offset_min"], - fov_offset_max=self.fov_offset_bins["offset_max"], - ) - self.sensitivity = calculate_sensitivity( - signal_hist, background_hist, alpha=self.alpha - ) - - # scale relative sensitivity by Crab flux to get the flux sensitivity - self.sensitivity["flux_sensitivity"] = self.sensitivity[ - "relative_sensitivity" - ] * self.gamma_spectrum(self.sensitivity["reco_energy_center"]) + self.calculate_selections() - def finish(self): - masks = { - "": self.signal_events["selected"], - "_NO_CUTS": slice(None), - "_ONLY_GH": self.signal_events["selected_gh"], - "_ONLY_THETA": self.signal_events["selected_theta"], - } hdus = [ fits.PrimaryHDU(), - fits.BinTableHDU(self.sensitivity, name="SENSITIVITY"), - fits.BinTableHDU(self.theta_cuts_opt, name="THETA_CUTS_OPT"), - fits.BinTableHDU(self.opt_result.gh_cuts, name="GH_CUTS"), ] - self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) - self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins["bins"])) - for label, mask in masks.items(): - effective_area = effective_area_per_energy_and_fov( - self.signal_events[mask], - self.sim_info, - true_energy_bins=self.true_energy_bins, - # TODO: the fucking units on these fov offset bits are not working out at all :( - fov_offset_bins=self.fov_offset_bins["bins"], - ) - hdus.append( - create_aeff2d_hdu( - effective_area[..., np.newaxis], # +1 dimension for FOV offset - self.true_energy_bins, - self.fov_offset_bins["bins"], - extname="EFFECTIVE AREA" + label, - ) - ) - edisp = energy_dispersion( - self.signal_events[mask], - true_energy_bins=self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins["bins"], - migration_bins=self.energy_migration_bins, - ) - hdus.append( - create_energy_dispersion_hdu( - edisp, - true_energy_bins=self.true_energy_bins, - migration_bins=self.energy_migration_bins, - fov_offset_bins=self.fov_offset_bins["bins"], - extname="ENERGY_DISPERSION" + label, - ) - ) - # Here we use reconstructed energy instead of true energy for the sake of - # current pipelines comparisons - bias_resolution = energy_bias_resolution( + self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) + effective_area = effective_area_per_energy_and_fov( self.signal_events[self.signal_events["selected"]], - self.true_energy_bins, - bias_function=np.mean, - energy_type="true", + self.sim_info, + true_energy_bins=self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, ) - hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) - - # Here we use reconstructed energy instead of true energy for the sake of - # current pipelines comparisons - ang_res = angular_resolution( - self.signal_events[self.signal_events["selected_gh"]], - self.reco_energy_bins, - energy_type="reco", + hdus.append( + create_aeff2d_hdu( + effective_area[..., np.newaxis], # +1 dimension for FOV offset + self.true_energy_bins, + self.fov_offset_bins, + extname="EFFECTIVE AREA", + ) ) - hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) - - sel = self.background_events["selected_gh"] - self.log.debug("%d background events selected" % sel.sum()) - self.log.debug("%f obs time" % self.obs_time) - background_rate = background_2d( - self.background_events[sel], - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins["bins"], - t_obs=self.obs_time * u.Unit(self.obs_time_unit), + edisp = energy_dispersion( + self.signal_events[self.signal_events["selected"]], + true_energy_bins=self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, + migration_bins=self.energy_migration_bins, ) hdus.append( - create_background_2d_hdu( - background_rate, - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins["bins"], + create_energy_dispersion_hdu( + edisp, + true_energy_bins=self.true_energy_bins, + migration_bins=self.energy_migration_bins, + fov_offset_bins=self.fov_offset_bins, + extname="ENERGY_DISPERSION", ) ) psf = psf_table( self.signal_events[self.signal_events["selected_gh"]], self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins["bins"], + fov_offset_bins=self.fov_offset_bins, source_offset_bins=self.source_offset_bins, ) hdus.append( @@ -334,7 +269,7 @@ def finish(self): psf, self.true_energy_bins, self.source_offset_bins, - self.fov_offset_bins["bins"], + self.fov_offset_bins, ) ) @@ -342,12 +277,15 @@ def finish(self): create_rad_max_hdu( self.theta_cuts_opt["cut"].reshape(-1, 1), self.true_energy_bins, - self.fov_offset_bins["bins"], + self.fov_offset_bins, ) ) + self.hdus = hdus + + def finish(self): self.log.info("Writing outputfile '%s'" % self.output_path) - fits.HDUList(hdus).writeto( + fits.HDUList(self.hdus).writeto( self.output_path, overwrite=self.overwrite, ) diff --git a/src/ctapipe/tools/optimise_event_selection.py b/src/ctapipe/tools/optimise_event_selection.py index 2bcaaf563d5..57d7f8cdb16 100644 --- a/src/ctapipe/tools/optimise_event_selection.py +++ b/src/ctapipe/tools/optimise_event_selection.py @@ -7,7 +7,7 @@ from ..irf import ( PYIRF_SPECTRA, EventPreProcessor, - EventSelector, + EventsLoader, FovOffsetBinning, GridOptimizer, OutputEnergyBinning, @@ -89,19 +89,19 @@ def setup(self): self.fov_offset_bins = self.bins.fov_offset_bins() self.particles = [ - EventSelector( + EventsLoader( self.epp, "gammas", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum], ), - EventSelector( + EventsLoader( self.epp, "protons", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum], ), - EventSelector( + EventsLoader( self.epp, "electrons", self.electron_file, @@ -110,13 +110,20 @@ def setup(self): ] def start(self): + + # TODO: this event loading code seems to be largely repeated between all the tools, + # try to refactor to a common solution + reduced_events = dict() for sel in self.particles: - evs, cnt = sel.load_preselected_events( + evs, cnt, meta = sel.load_preselected_events( self.chunk_size, self.obs_time * u.Unit(self.obs_time_unit), self.bins ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt + if sel.kind == "gammas": + self.sim_info = meta["sim_info"] + self.gamma_spectrum = meta["spectrum"] self.log.debug( "Loaded %d gammas, %d protons, %d electrons" @@ -143,18 +150,19 @@ def start(self): "Optimising cuts using %d signal and %d background events" % (len(self.signal_events), len(self.background_events)), ) - result, sens2 = self.go.optimise_gh_cut( + result, ope_sens = self.go.optimise_gh_cut( self.signal_events, self.background_events, self.alpha, self.bins.fov_offset_min * u.deg, self.bins.fov_offset_max * u.deg, self.theta, + self.epp, ) self.log.info("Writing results to %s" % self.output_path) Provenance().add_output_file(self.output_path, role="Optimisation_Result") - result.write(self.output_path, self.epp, self.overwrite) + result.write(self.output_path, self.overwrite) def main(): From df5ba5140bb779669527b89cdcf70b92741c6c44 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Dec 2023 17:02:25 +0100 Subject: [PATCH 041/136] Further refactoring to decouple things now that there are two tools --- ctapipe/irf/__init__.py | 12 +- ctapipe/irf/binning.py | 86 ++----------- ctapipe/irf/irfs.py | 157 +++++++++++++++++++++++ ctapipe/irf/optimise.py | 4 +- src/ctapipe/tools/make_irf.py | 227 ++++++++++++++++++---------------- 5 files changed, 295 insertions(+), 191 deletions(-) create mode 100644 ctapipe/irf/irfs.py diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 5aa650d2b8a..9726037d1fc 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,19 +1,23 @@ """Top level module for the irf functionality""" -from .binning import FovOffsetBinning, OutputEnergyBinning, SourceOffsetBinning +from .binning import FovOffsetBinning, OutputEnergyBinning, check_bins_in_range from .irf_classes import PYIRF_SPECTRA, Spectra -from .optimise import GridOptimizer, OptimisationResult, OptimisationResultSaver +from .irfs import EffectiveAreaIrf, EnergyMigrationIrf, PsfIrf +from .optimise import GridOptimizer, OptimisationResult, OptimisationResultStore from .select import EventPreProcessor, EventsLoader, ThetaCutsCalculator __all__ = [ + "EnergyMigrationIrf", + "PsfIrf", + "EffectiveAreaIrf", "OptimisationResult", - "OptimisationResultSaver", + "OptimisationResultStore", "GridOptimizer", "OutputEnergyBinning", - "SourceOffsetBinning", "FovOffsetBinning", "EventsLoader", "EventPreProcessor", "Spectra", "ThetaCutsCalculator", "PYIRF_SPECTRA", + "check_bins_in_range", ] diff --git a/ctapipe/irf/binning.py b/ctapipe/irf/binning.py index 6ba2856b600..c422eb36c35 100644 --- a/ctapipe/irf/binning.py +++ b/ctapipe/irf/binning.py @@ -7,6 +7,14 @@ from ..core.traits import Float, Integer +def check_bins_in_range(bins, range): + low = bins >= range.min + hig = bins <= range.max + + if not all(low & hig): + raise ValueError(f"Valid range is {range.min} to {range.max}, got {bins}") + + class OutputEnergyBinning(Component): """Collects energy binning settings""" @@ -40,21 +48,6 @@ class OutputEnergyBinning(Component): default_value=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 = Integer( - help="Number of bins in log scale for Energy Migration matrix", - default_value=31, - ).tag(config=True) - def true_energy_bins(self): """ Creates bins per decade for true MC energy using pyirf function. @@ -77,17 +70,6 @@ def reco_energy_bins(self): ) 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 - class FovOffsetBinning(Component): """ @@ -123,55 +105,3 @@ def fov_offset_bins(self): * u.deg ) return fov_offset - - -class SourceOffsetBinning(Component): - """ - Collects information on generating energy and angular bins for - generating IRFs as per pyIRF requirements. - """ - - source_offset_min = Float( - help="Minimum value for Source offset for PSF IRF", - default_value=0, - ).tag(config=True) - - source_offset_max = Float( - help="Maximum value for Source offset for PSF IRF", - default_value=1, - ).tag(config=True) - - source_offset_n_edges = Integer( - help="Number of edges for Source offset for PSF IRF", - default_value=101, - ).tag(config=True) - - 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 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 diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py new file mode 100644 index 00000000000..c23355eef3d --- /dev/null +++ b/ctapipe/irf/irfs.py @@ -0,0 +1,157 @@ +"""components to generate irfs""" +import astropy.units as u +import numpy as np +from pyirf.binning import create_bins_per_decade +from pyirf.io import ( + create_aeff2d_hdu, + create_energy_dispersion_hdu, + create_psf_table_hdu, +) +from pyirf.irf import effective_area_per_energy_and_fov, energy_dispersion, psf_table + +from ..core import Component +from ..core.traits import Float, Integer +from .binning import check_bins_in_range + + +class PsfIrf(Component): + """ + Collects information on generating energy and angular bins for + generating IRFs as per pyIRF requirements. + """ + + source_offset_min = Float( + help="Minimum value for Source offset for PSF IRF", + default_value=0, + ).tag(config=True) + + source_offset_max = Float( + help="Maximum value for Source offset for PSF IRF", + default_value=1, + ).tag(config=True) + + source_offset_n_edges = Integer( + help="Number of edges for Source offset for PSF IRF", + default_value=101, + ).tag(config=True) + + def __init__(self, parent, energy_bins, valid_offset): + + super().__init__(parent=parent) + self.energy_bins = energy_bins + self.valid_offset = valid_offset + self.source_offset_bins = ( + np.linspace( + self.source_offset_min, + self.source_offset_max, + self.source_offset_n_edges, + ) + * u.deg + ) + + def make_psf_table_hdu(self, signal_events, fov_offset_bins): + check_bins_in_range(fov_offset_bins, self.valid_offset) + psf = psf_table( + events=signal_events, + true_energy_bins=self.energy_bins, + fov_offset_bins=fov_offset_bins, + source_offset_bins=self.source_offset_bins, + ) + return create_psf_table_hdu( + psf, + self.energy_bins, + self.source_offset_bins, + fov_offset_bins, + ) + + +class EnergyMigrationIrf(Component): + """Collects the functionality for generating Migration Matrix IRFs""" + + 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 = Integer( + help="Number of bins in log scale for Energy Migration matrix", + default_value=31, + ).tag(config=True) + + def __init__(self, parent, energy_bins): + """ + Creates bins per decade for true MC energy using pyirf function. + """ + super().__init__(parent=parent) + self.energy_bins = energy_bins + self.migration_bins = np.geomspace( + self.energy_migration_min, + self.energy_migration_max, + self.energy_migration_n_bins, + ) + + def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins): + edisp = energy_dispersion( + signal_events, + true_energy_bins=self.energy_bins, + fov_offset_bins=fov_offset_bins, + migration_bins=self.migration_bins, + ) + return create_energy_dispersion_hdu( + edisp, + true_energy_bins=self.energy_bins, + migration_bins=self.migration_bins, + fov_offset_bins=fov_offset_bins, + extname="ENERGY_DISPERSION", + ) + + +class EffectiveAreaIrf(Component): + """Collects the functionality for generating Effective Area IRFs""" + + true_energy_min = Float( + help="Minimum value for True Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + true_energy_max = Float( + help="Maximum value for True Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + true_energy_n_bins_per_decade = Float( + help="Number of edges per decade for True Energy bins", + default_value=10, + ).tag(config=True) + + def __init__(self, parent, sim_info): + """ + Creates bins per decade for true MC energy using pyirf function. + """ + super().__init__(parent=parent) + self.true_energy_bins = create_bins_per_decade( + self.true_energy_min * u.TeV, + self.true_energy_max * u.TeV, + self.true_energy_n_bins_per_decade, + ) + self.sim_info = sim_info + + def make_effective_area_hdu(self, signal_events, fov_offset_bins): + + effective_area = effective_area_per_energy_and_fov( + signal_events, + self.sim_info, + true_energy_bins=self.true_energy_bins, + fov_offset_bins=fov_offset_bins, + ) + return create_aeff2d_hdu( + effective_area[..., np.newaxis], # +1 dimension for FOV offset + self.true_energy_bins, + fov_offset_bins, + extname="EFFECTIVE AREA", + ) diff --git a/ctapipe/irf/optimise.py b/ctapipe/irf/optimise.py index 72e868bc7f8..bdce858aab0 100644 --- a/ctapipe/irf/optimise.py +++ b/ctapipe/irf/optimise.py @@ -40,7 +40,7 @@ def __repr__(self): ) -class OptimisationResultSaver: +class OptimisationResultStore: def __init__(self, precuts=None): if precuts: if isinstance(precuts, QualityQuery): @@ -210,7 +210,7 @@ def optimise_gh_cut( ) valid_energy = self._get_valid_energy_range(opt_sens) - result_saver = OptimisationResultSaver(precuts) + result_saver = OptimisationResultStore(precuts) result_saver.set_result( gh_cuts, theta_cuts, diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 32816b3ec37..8a578681c9e 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -2,36 +2,40 @@ import operator import astropy.units as u -import numpy as np from astropy.io import fits from astropy.table import vstack from pyirf.cuts import evaluate_binned_cut -from pyirf.io import ( - create_aeff2d_hdu, - create_energy_dispersion_hdu, - create_psf_table_hdu, - create_rad_max_hdu, -) -from pyirf.irf import effective_area_per_energy_and_fov, energy_dispersion, psf_table +from pyirf.io import create_rad_max_hdu from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode from ..irf import ( PYIRF_SPECTRA, + EffectiveAreaIrf, + EnergyMigrationIrf, EventPreProcessor, EventsLoader, - GridOptimizer, - OptimisationResultSaver, + FovOffsetBinning, + OptimisationResultStore, OutputEnergyBinning, - SourceOffsetBinning, + PsfIrf, Spectra, ThetaCutsCalculator, + check_bins_in_range, ) class IrfTool(Tool): name = "ctapipe-make-irf" description = "Tool to create IRF files in GAD format" + do_background = Bool( + True, + help="Compute background rate IRF using supplied files", + ).tag(config=True) + do_benchmarks = Bool( + False, + help="Produce IRF related benchmarks", + ).tag(config=True) cuts_file = traits.Path( default_value=None, directory_ok=False, help="Path to optimised cuts input file" @@ -46,7 +50,10 @@ class IrfTool(Tool): help="Name of the pyrif spectra used for the simulated gamma spectrum", ).tag(config=True) proton_file = traits.Path( - default_value=None, directory_ok=False, help="Proton input filename and path" + default_value=None, + allow_none=True, + directory_ok=False, + help="Proton input filename and path", ).tag(config=True) proton_sim_spectrum = traits.UseEnum( Spectra, @@ -54,7 +61,10 @@ class IrfTool(Tool): help="Name of the pyrif spectra used for the simulated proton spectrum", ).tag(config=True) electron_file = traits.Path( - default_value=None, directory_ok=False, help="Electron input filename and path" + default_value=None, + allow_none=True, + directory_ok=False, + help="Electron input filename and path", ).tag(config=True) electron_sim_spectrum = traits.UseEnum( Spectra, @@ -91,10 +101,12 @@ class IrfTool(Tool): ).tag(config=True) classes = [ - GridOptimizer, - SourceOffsetBinning, OutputEnergyBinning, + FovOffsetBinning, EventPreProcessor, + PsfIrf, + EnergyMigrationIrf, + EffectiveAreaIrf, ] def calculate_selections(self): @@ -146,30 +158,22 @@ def calculate_selections(self): ) ) - def _check_bins_in_range(self, bins, range): - low = bins >= range.min - hig = bins <= range.max - - if not all(low & hig): - raise ValueError(f"Valid range is {range.min} to {range.max}, got {bins}") - def setup(self): self.theta = ThetaCutsCalculator(parent=self) self.e_bins = OutputEnergyBinning(parent=self) - self.bins = SourceOffsetBinning(parent=self) + self.bins = FovOffsetBinning(parent=self) - self.opt_result = OptimisationResultSaver().read(self.cuts_file) - # TODO: not very elegant to pass them this way, refactor later + self.opt_result = OptimisationResultStore().read(self.cuts_file) self.epp = EventPreProcessor(parent=self) + # TODO: not very elegant to pass them this way, refactor later self.epp.quality_criteria = self.opt_result.precuts.quality_criteria self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() - self.energy_migration_bins = self.e_bins.energy_migration_bins() - self.source_offset_bins = self.bins.source_offset_bins() + self.fov_offset_bins = self.bins.fov_offset_bins() + + check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) + check_bins_in_range(self.fov_offset_bins, self.opt_result.valid_offset) - self._check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) - self._check_bins_in_range(self.source_offset_bins, self.opt_result.valid_offset) - self.fov_offset_bins = self.opt_result.valid_offset.bins self.particles = [ EventsLoader( self.epp, @@ -177,99 +181,74 @@ def setup(self): self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum], ), - EventsLoader( - self.epp, - "protons", - self.proton_file, - PYIRF_SPECTRA[self.proton_sim_spectrum], - ), - EventsLoader( - self.epp, - "electrons", - self.electron_file, - PYIRF_SPECTRA[self.electron_sim_spectrum], - ), ] - - def start(self): - # TODO: this event loading code seems to be largely repeated between all the tools, - # try to refactor to a common solution - reduced_events = dict() - for sel in self.particles: - evs, cnt, meta = sel.load_preselected_events( - self.chunk_size, - self.obs_time * u.Unit(self.obs_time_unit), - self.fov_offset_bins, + if self.do_background and self.proton_file: + self.particles.append( + EventsLoader( + self.epp, + "protons", + self.proton_file, + PYIRF_SPECTRA[self.proton_sim_spectrum], + ) ) - reduced_events[sel.kind] = evs - reduced_events[f"{sel.kind}_count"] = cnt - if sel.kind == "gammas": - self.sim_info = meta["sim_info"] - self.gamma_spectrum = meta["spectrum"] - - self.log.debug( - "Loaded %d gammas, %d protons, %d electrons" - % ( - reduced_events["gammas_count"], - reduced_events["protons_count"], - reduced_events["electrons_count"], + if self.do_background and self.electron_file: + self.particles.append( + EventsLoader( + self.epp, + "electrons", + self.electron_file, + PYIRF_SPECTRA[self.electron_sim_spectrum], + ) + ) + if self.do_background and len(self.particles) == 1: + raise RuntimeError( + "At least one electron or proton file required when speficying `do_background`." ) - ) - self.signal_events = reduced_events["gammas"] - self.background_events = vstack( - [reduced_events["protons"], reduced_events["electrons"]] + self.aeff = None + + self.psf = PsfIrf( + parent=self, + energy_bins=self.true_energy_bins, + valid_offset=self.opt_result.valid_offset, + ) + self.mig_matrix = EnergyMigrationIrf( + parent=self, + energy_bins=self.true_energy_bins, ) - self.calculate_selections() + def _stack_background(self, reduced_events): + bkgs = [] + if self.proton_file: + bkgs.append("protons") + if self.electron_file: + bkgs.append("electrons") + if len(bkgs) == 2: + background = vstack( + [reduced_events["protons"], reduced_events["electrons"]] + ) + else: + background = reduced_events[bkgs[0]] + return background - hdus = [ - fits.PrimaryHDU(), - ] - self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) - self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) - effective_area = effective_area_per_energy_and_fov( - self.signal_events[self.signal_events["selected"]], - self.sim_info, - true_energy_bins=self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins, - ) + def _make_signal_irf_hdus(self, hdus): hdus.append( - create_aeff2d_hdu( - effective_area[..., np.newaxis], # +1 dimension for FOV offset - self.true_energy_bins, - self.fov_offset_bins, - extname="EFFECTIVE AREA", + self.aeff.make_effective_area_hdu( + signal_events=self.signal_events[self.signal_events["selected"]], + fov_offset_bins=self.fov_offset_bins, ) ) - edisp = energy_dispersion( - self.signal_events[self.signal_events["selected"]], - true_energy_bins=self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins, - migration_bins=self.energy_migration_bins, - ) hdus.append( - create_energy_dispersion_hdu( - edisp, - true_energy_bins=self.true_energy_bins, - migration_bins=self.energy_migration_bins, + self.mig_matrix.make_energy_dispersion_hdu( + signal_events=self.signal_events[self.signal_events["selected"]], fov_offset_bins=self.fov_offset_bins, - extname="ENERGY_DISPERSION", ) ) - psf = psf_table( - self.signal_events[self.signal_events["selected_gh"]], - self.true_energy_bins, - fov_offset_bins=self.fov_offset_bins, - source_offset_bins=self.source_offset_bins, - ) hdus.append( - create_psf_table_hdu( - psf, - self.true_energy_bins, - self.source_offset_bins, - self.fov_offset_bins, + self.psf.make_psf_table_hdu( + signal_events=self.signal_events[self.signal_events["selected"]], + fov_offset_bins=self.fov_offset_bins, ) ) @@ -280,6 +259,40 @@ def start(self): self.fov_offset_bins, ) ) + return hdus + + def start(self): + # TODO: this event loading code seems to be largely repeated between all the tools, + # try to refactor to a common solution + reduced_events = dict() + for sel in self.particles: + evs, cnt, meta = sel.load_preselected_events( + self.chunk_size, + self.obs_time * u.Unit(self.obs_time_unit), + self.fov_offset_bins, + ) + reduced_events[sel.kind] = evs + reduced_events[f"{sel.kind}_count"] = cnt + self.log.debug( + "Loaded %d %s events" % (reduced_events[f"{sel.kind}_count"], sel.kind) + ) + if sel.kind == "gammas": + self.aeff = EffectiveAreaIrf(parent=self, sim_info=meta["sim_info"]) + self.gamma_spectrum = meta["spectrum"] + + self.signal_events = reduced_events["gammas"] + if self.do_background: + self.background_events = self._stack_background(reduced_events) + + self.calculate_selections() + + self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) + self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) + + hdus = [ + fits.PrimaryHDU(), + ] + hdus = self._make_signal_irf_hdus(hdus) self.hdus = hdus def finish(self): From 5f0e3715af5d459627f55846bd868f7808f0df56 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Dec 2023 18:13:08 +0100 Subject: [PATCH 042/136] Added background and benchmarking option to the make_irf tool --- ctapipe/irf/irfs.py | 12 +-- src/ctapipe/tools/make_irf.py | 134 ++++++++++++++++++++++++++++------ 2 files changed, 117 insertions(+), 29 deletions(-) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index c23355eef3d..aa66be40a4c 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -35,9 +35,9 @@ class PsfIrf(Component): default_value=101, ).tag(config=True) - def __init__(self, parent, energy_bins, valid_offset): + def __init__(self, parent, energy_bins, valid_offset, **kwargs): - super().__init__(parent=parent) + super().__init__(parent=parent, **kwargs) self.energy_bins = energy_bins self.valid_offset = valid_offset self.source_offset_bins = ( @@ -83,11 +83,11 @@ class EnergyMigrationIrf(Component): default_value=31, ).tag(config=True) - def __init__(self, parent, energy_bins): + def __init__(self, parent, energy_bins, **kwargs): """ Creates bins per decade for true MC energy using pyirf function. """ - super().__init__(parent=parent) + super().__init__(parent=parent, **kwargs) self.energy_bins = energy_bins self.migration_bins = np.geomspace( self.energy_migration_min, @@ -129,11 +129,11 @@ class EffectiveAreaIrf(Component): default_value=10, ).tag(config=True) - def __init__(self, parent, sim_info): + def __init__(self, parent, sim_info, **kwargs): """ Creates bins per decade for true MC energy using pyirf function. """ - super().__init__(parent=parent) + super().__init__(parent=parent, **kwargs) self.true_energy_bins = create_bins_per_decade( self.true_energy_min * u.TeV, self.true_energy_max * u.TeV, diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 8a578681c9e..e986e946f1e 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -2,10 +2,15 @@ import operator import astropy.units as u +import numpy as np from astropy.io import fits from astropy.table import vstack +from pyirf.benchmarks import angular_resolution, energy_bias_resolution +from pyirf.binning import create_histogram_table from pyirf.cuts import evaluate_binned_cut -from pyirf.io import create_rad_max_hdu +from pyirf.io import create_background_2d_hdu, create_rad_max_hdu +from pyirf.irf import background_2d +from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode @@ -28,6 +33,7 @@ class IrfTool(Tool): name = "ctapipe-make-irf" description = "Tool to create IRF files in GAD format" + do_background = Bool( True, help="Compute background rate IRF using supplied files", @@ -110,43 +116,45 @@ class IrfTool(Tool): ] def calculate_selections(self): + """Add the selection columns to the signal and optionally background tables""" self.signal_events["selected_gh"] = evaluate_binned_cut( self.signal_events["gh_score"], self.signal_events["reco_energy"], self.opt_result.gh_cuts, operator.ge, ) - self.background_events["selected_gh"] = evaluate_binned_cut( - self.background_events["gh_score"], - self.background_events["reco_energy"], - self.opt_result.gh_cuts, - operator.ge, - ) self.theta_cuts_opt = self.theta.calculate_theta_cuts( self.signal_events[self.signal_events["selected_gh"]]["theta"], self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], self.reco_energy_bins, ) - self.signal_events["selected_theta"] = evaluate_binned_cut( self.signal_events["theta"], self.signal_events["reco_energy"], self.theta_cuts_opt, operator.le, ) - self.background_events["selected_theta"] = evaluate_binned_cut( - self.background_events["theta"], - self.background_events["reco_energy"], - self.theta_cuts_opt, - operator.le, - ) self.signal_events["selected"] = ( self.signal_events["selected_theta"] & self.signal_events["selected_gh"] ) - self.background_events["selected"] = ( - self.background_events["selected_theta"] - & self.background_events["selected_gh"] - ) + + if self.do_background: + self.background_events["selected_gh"] = evaluate_binned_cut( + self.background_events["gh_score"], + self.background_events["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, + ) + self.background_events["selected_theta"] = evaluate_binned_cut( + self.background_events["theta"], + self.background_events["reco_energy"], + self.theta_cuts_opt, + operator.le, + ) + self.background_events["selected"] = ( + self.background_events["selected_theta"] + & self.background_events["selected_gh"] + ) # TODO: maybe rework the above so we can give the number per # species instead of the total background @@ -216,6 +224,11 @@ def setup(self): parent=self, energy_bins=self.true_energy_bins, ) + if self.do_benchmarks: + self.b_hdus = None + self.b_output = self.output_path.with_name( + self.output_path.name.replace(".fits", "-benchmark.fits") + ) def _stack_background(self, reduced_events): bkgs = [] @@ -261,9 +274,72 @@ def _make_signal_irf_hdus(self, hdus): ) return hdus + def _make_background_hdu(self): + sel = self.background_events["selected_gh"] + self.log.debug("%d background events selected" % sel.sum()) + self.log.debug("%f obs time" % self.obs_time) + + background_rate = background_2d( + self.background_events[sel], + self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + t_obs=self.obs_time * u.Unit(self.obs_time_unit), + ) + return create_background_2d_hdu( + background_rate, + self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + ) + + def _make_benchmark_hdus(self, hdus): + # Here we use reconstructed energy instead of true energy for the sake of + # current pipelines comparisons + bias_resolution = energy_bias_resolution( + self.signal_events[self.signal_events["selected"]], + self.true_energy_bins, + bias_function=np.mean, + energy_type="true", + ) + hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) + + # Here we use reconstructed energy instead of true energy for the sake of + # current pipelines comparisons + ang_res = angular_resolution( + self.signal_events[self.signal_events["selected_gh"]], + self.reco_energy_bins, + energy_type="reco", + ) + hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) + + if self.do_background: + signal_hist = create_histogram_table( + self.signal_events[self.signal_events["selected"]], + bins=self.reco_energy_bins, + ) + background_hist = estimate_background( + self.background_events[self.background_events["selected_gh"]], + reco_energy_bins=self.reco_energy_bins, + theta_cuts=self.theta_cuts_opt, + alpha=self.alpha, + fov_offset_min=self.fov_offset_bins["offset_min"], + fov_offset_max=self.fov_offset_bins["offset_max"], + ) + sensitivity = calculate_sensitivity( + signal_hist, background_hist, alpha=self.alpha + ) + + # scale relative sensitivity by Crab flux to get the flux sensitivity + sensitivity["flux_sensitivity"] = sensitivity[ + "relative_sensitivity" + ] * self.gamma_spectrum(sensitivity["reco_energy_center"]) + + hdus.append(fits.BinTableHDU(sensitivity, name="SENSITIVITY")) + + return hdus + def start(self): - # TODO: this event loading code seems to be largely repeated between all the tools, - # try to refactor to a common solution + # TODO: this event loading code seems to be largely repeated between both + # tools, try to refactor to a common solution reduced_events = dict() for sel in self.particles: evs, cnt, meta = sel.load_preselected_events( @@ -289,12 +365,17 @@ def start(self): self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) - hdus = [ - fits.PrimaryHDU(), - ] + hdus = [fits.PrimaryHDU()] hdus = self._make_signal_irf_hdus(hdus) + if self.do_background: + hdus.append(self._make_background_hdu()) self.hdus = hdus + if self.do_benchmarks: + b_hdus = [fits.PrimaryHDU()] + b_hdus = self._make_benchmark_hdus(b_hdus) + self.b_hdus = self.b_hdus + def finish(self): self.log.info("Writing outputfile '%s'" % self.output_path) @@ -303,6 +384,13 @@ def finish(self): overwrite=self.overwrite, ) Provenance().add_output_file(self.output_path, role="IRF") + if self.do_benchmarks: + self.log.info("Writing benchmark file to '%s'" % self.b_output) + fits.HDUList(self.b_hdus).writeto( + self.b_output, + overwrite=self.overwrite, + ) + Provenance().add_output_file(self.b_output, role="Benchmark") def main(): From 06e0e2d80965302ca4f0c1b4cce3ac9125a93289 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 7 Dec 2023 15:59:14 +0100 Subject: [PATCH 043/136] removed some dead code that had been left behind by mistake --- src/ctapipe/tools/optimise_event_selection.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ctapipe/tools/optimise_event_selection.py b/src/ctapipe/tools/optimise_event_selection.py index 57d7f8cdb16..a0c1b3d60fe 100644 --- a/src/ctapipe/tools/optimise_event_selection.py +++ b/src/ctapipe/tools/optimise_event_selection.py @@ -84,7 +84,6 @@ def setup(self): self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() - self.energy_migration_bins = self.e_bins.energy_migration_bins() self.fov_offset_bins = self.bins.fov_offset_bins() From c19a6be284b0c79242d7de25c383cf31658df0c6 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 7 Dec 2023 18:16:39 +0100 Subject: [PATCH 044/136] Minor fixes --- ctapipe/irf/binning.py | 11 ++++------- ctapipe/irf/irfs.py | 15 +++++---------- ctapipe/irf/optimise.py | 6 +----- ctapipe/irf/select.py | 9 ++++++--- src/ctapipe/tools/make_irf.py | 5 ++--- src/ctapipe/tools/optimise_event_selection.py | 1 - 6 files changed, 18 insertions(+), 29 deletions(-) diff --git a/ctapipe/irf/binning.py b/ctapipe/irf/binning.py index c422eb36c35..570195d8272 100644 --- a/ctapipe/irf/binning.py +++ b/ctapipe/irf/binning.py @@ -16,7 +16,7 @@ def check_bins_in_range(bins, range): class OutputEnergyBinning(Component): - """Collects energy binning settings""" + """Collects energy binning settings.""" true_energy_min = Float( help="Minimum value for True Energy bins in TeV units", @@ -35,12 +35,12 @@ class OutputEnergyBinning(Component): reco_energy_min = Float( help="Minimum value for Reco Energy bins in TeV units", - default_value=0.006, + default_value=0.015, ).tag(config=True) reco_energy_max = Float( help="Maximum value for Reco Energy bins in TeV units", - default_value=190, + default_value=200, ).tag(config=True) reco_energy_n_bins_per_decade = Float( @@ -72,10 +72,7 @@ def reco_energy_bins(self): class FovOffsetBinning(Component): - """ - Collects information on generating energy and angular bins for - generating IRFs as per pyIRF requirements. - """ + """Collects FoV binning settings.""" fov_offset_min = Float( help="Minimum value for FoV Offset bins in degrees", diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index aa66be40a4c..52aae585c0a 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -15,10 +15,7 @@ class PsfIrf(Component): - """ - Collects information on generating energy and angular bins for - generating IRFs as per pyIRF requirements. - """ + """Collects the functionality for generating PSF IRFs.""" source_offset_min = Float( help="Minimum value for Source offset for PSF IRF", @@ -36,7 +33,6 @@ class PsfIrf(Component): ).tag(config=True) def __init__(self, parent, energy_bins, valid_offset, **kwargs): - super().__init__(parent=parent, **kwargs) self.energy_bins = energy_bins self.valid_offset = valid_offset @@ -66,7 +62,7 @@ def make_psf_table_hdu(self, signal_events, fov_offset_bins): class EnergyMigrationIrf(Component): - """Collects the functionality for generating Migration Matrix IRFs""" + """Collects the functionality for generating Migration Matrix IRFs.""" energy_migration_min = Float( help="Minimum value of Energy Migration matrix", @@ -85,7 +81,7 @@ class EnergyMigrationIrf(Component): def __init__(self, parent, energy_bins, **kwargs): """ - Creates bins per decade for true MC energy using pyirf function. + Creates bins per decade for true MC energy. """ super().__init__(parent=parent, **kwargs) self.energy_bins = energy_bins @@ -112,7 +108,7 @@ def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins): class EffectiveAreaIrf(Component): - """Collects the functionality for generating Effective Area IRFs""" + """Collects the functionality for generating Effective Area IRFs.""" true_energy_min = Float( help="Minimum value for True Energy bins in TeV units", @@ -131,7 +127,7 @@ class EffectiveAreaIrf(Component): def __init__(self, parent, sim_info, **kwargs): """ - Creates bins per decade for true MC energy using pyirf function. + Creates bins per decade for true MC energy. """ super().__init__(parent=parent, **kwargs) self.true_energy_bins = create_bins_per_decade( @@ -142,7 +138,6 @@ def __init__(self, parent, sim_info, **kwargs): self.sim_info = sim_info def make_effective_area_hdu(self, signal_events, fov_offset_bins): - effective_area = effective_area_per_energy_and_fov( signal_events, self.sim_info, diff --git a/ctapipe/irf/optimise.py b/ctapipe/irf/optimise.py index bdce858aab0..5cddcc297d0 100644 --- a/ctapipe/irf/optimise.py +++ b/ctapipe/irf/optimise.py @@ -16,10 +16,6 @@ class ResultValidRange: def __init__(self, bounds_table, prefix): self.min = bounds_table[f"{prefix}_min"] self.max = bounds_table[f"{prefix}_max"] - self.bins = ( - np.array([self.min, self.max]).reshape(-1) - * bounds_table[f"{prefix}_max"].unit - ) class OptimisationResult: @@ -129,7 +125,7 @@ class GridOptimizer(Component): reco_energy_min = Float( help="Minimum value for Reco Energy bins in TeV units", - default_value=0.005, + default_value=0.015, ).tag(config=True) reco_energy_max = Float( diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index 9c0b929c666..4706159fa88 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -101,7 +101,7 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): fov_bins.fov_offset_min * u.deg, fov_bins.fov_offset_max * u.deg ) else: - spectrum = spectrum.integrate_cone(fov_bins[0], fov_bins[0]) + spectrum = spectrum.integrate_cone(fov_bins[0], fov_bins[-1]) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=self.target_spectrum, @@ -232,7 +232,7 @@ class ThetaCutsCalculator(Component): target_percentile = Float( default_value=68, - help="Percent of events in each energy bin keep after the theta cut", + help="Percent of events in each energy bin to keep after the theta cut", ).tag(config=True) def calculate_theta_cuts(self, theta, reco_energy, energy_bins): @@ -242,7 +242,10 @@ def calculate_theta_cuts(self, theta, reco_energy, energy_bins): theta_max_angle = ( None if self.theta_max_angle < 0 else self.theta_max_angle * u.deg ) - theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing + if self.theta_smoothing: + theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing + else: + theta_smoothing = self.theta_smoothing return calculate_percentile_cut( theta, diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index e986e946f1e..fc649928920 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -321,8 +321,8 @@ def _make_benchmark_hdus(self, hdus): reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.alpha, - fov_offset_min=self.fov_offset_bins["offset_min"], - fov_offset_max=self.fov_offset_bins["offset_max"], + fov_offset_min=self.opt_result.valid_offset.min, + fov_offset_max=self.opt_result.valid_offset.max, ) sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha @@ -377,7 +377,6 @@ def start(self): self.b_hdus = self.b_hdus def finish(self): - self.log.info("Writing outputfile '%s'" % self.output_path) fits.HDUList(self.hdus).writeto( self.output_path, diff --git a/src/ctapipe/tools/optimise_event_selection.py b/src/ctapipe/tools/optimise_event_selection.py index a0c1b3d60fe..04864db0db7 100644 --- a/src/ctapipe/tools/optimise_event_selection.py +++ b/src/ctapipe/tools/optimise_event_selection.py @@ -109,7 +109,6 @@ def setup(self): ] def start(self): - # TODO: this event loading code seems to be largely repeated between all the tools, # try to refactor to a common solution From cce0285b59d5f3d562d47cda355987931b688842 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 8 Dec 2023 11:51:19 +0100 Subject: [PATCH 045/136] Ensure users requested binning is honoured --- src/ctapipe/tools/make_irf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index fc649928920..7284bc1386b 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -321,8 +321,8 @@ def _make_benchmark_hdus(self, hdus): reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.alpha, - fov_offset_min=self.opt_result.valid_offset.min, - fov_offset_max=self.opt_result.valid_offset.max, + fov_offset_min=self.fov_offset_bins["offset_min"], + fov_offset_max=self.fov_offset_bins["offset_max"], ) sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha From 04f205469f92aede68ba9ea7211b2e330ad8e6fb Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 12 Dec 2023 01:58:24 +0100 Subject: [PATCH 046/136] Fixed typo in EXTNAME of energy dispersion --- ctapipe/irf/irfs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index 52aae585c0a..b666e5efd66 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -103,7 +103,7 @@ def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins): true_energy_bins=self.energy_bins, migration_bins=self.migration_bins, fov_offset_bins=fov_offset_bins, - extname="ENERGY_DISPERSION", + extname="ENERGY DISPERSION", ) From 9b53fe97df372c0bf0ffda591b17d8a60ce79feb Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 12 Dec 2023 02:00:07 +0100 Subject: [PATCH 047/136] Add several convenience functions for plotting irfs --- ctapipe/irf/visualisation.py | 201 +++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 ctapipe/irf/visualisation.py diff --git a/ctapipe/irf/visualisation.py b/ctapipe/irf/visualisation.py new file mode 100644 index 00000000000..9c804466c0e --- /dev/null +++ b/ctapipe/irf/visualisation.py @@ -0,0 +1,201 @@ +import matplotlib.pyplot as plt +import numpy as np +import scipy.stats as st +from astropy.visualization import quantity_support +from matplotlib.colors import LogNorm +from pyirf.binning import join_bin_lo_hi + +quantity_support() + + +def plot_2D_irf_table( + ax, table, column, x_prefix, y_prefix, x_label=None, y_label=None, **mpl_args +): + x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" + y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" + + xbins = np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])) + + ybins = np.hstack((table[y_lo_name][0], table[y_hi_name][0][-1])) + if not x_label: + x_label = x_prefix + if not y_label: + y_label = y_prefix + if isinstance(column, str): + mat_vals = np.squeeze(table[column].T) + else: + mat_vals = column.T + + plot = plot_hist2D( + ax, mat_vals, xbins, ybins, xlabel=x_label, ylabel=y_label, **mpl_args + ) + plt.colorbar(plot) + return ax + + +def rebin_x_2D_hist(hist, xbins, x_cent, num_bins_merge=3): + num_y, num_x = hist.shape + if (num_x) % num_bins_merge == 0: + rebin_x = xbins[::num_bins_merge] + rebin_xcent = x_cent.reshape(-1, num_bins_merge).mean(axis=1) + rebin_hist = hist.reshape(300, -1, num_bins_merge).sum(axis=2) + return rebin_x, rebin_xcent, rebin_hist + else: + raise ValueError( + f"Could not merge {num_bins_merge} along axis of dimension {num_x}" + ) + + +def find_columnwise_stats(table, col_bins, percentiles, density=False): + tab = np.squeeze(table) + out = np.ones((tab.shape[1], 4)) * -1 + for idx, col in enumerate(tab.T): + if (col > 0).sum() == 0: + continue + col_est = st.rv_histogram((col, col_bins), density=density) + out[idx, 0] = col_est.mean() + out[idx, 1] = col_est.median() + out[idx, 2] = col_est.std() + out[idx, 3] = col_est.ppf(percentiles[0]) + out[idx, 4] = col_est.ppf(percentiles[1]) + return out + + +def plot_2D_table_with_col_stats( + ax, + table, + column, + x_prefix, + y_prefix, + num_rebin=4, + stat_kind=2, + quantiles=[0.2, 0.8], + x_label=None, + y_label=None, + mpl_args={ + "histo": {"xscale": "log"}, + "stats": {"color": "firebrick"}, + }, +): + x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" + y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" + + xbins = np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])) + + ybins = np.hstack((table[y_lo_name][0], table[y_hi_name][0][-1])) + + xcent = np.convolve( + [0.5, 0.5], np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])), "valid" + ) + if not x_label: + x_label = x_prefix + if not y_label: + y_label = y_prefix + if isinstance(column, str): + mat_vals = np.squeeze(table[column].T) + else: + mat_vals = column.T + + rebin_x, rebin_xcent, rebin_hist = rebin_x_2D_hist( + mat_vals, xbins, xcent, num_bins_merge=num_rebin + ) + if not num_rebin == 1: + density = False + stats = find_columnwise_stats(rebin_hist, ybins, quantiles, density) + + plot = plot_hist2D( + ax, + rebin_hist.T, + rebin_x, + ybins, + xlabel=x_label, + ylabel=y_label, + **mpl_args["histo"], + ) + plt.colorbar(plot) + + sel = stats[:, 0] > 0 + if stat_kind == 1: + y_idx = 0 + y_err_idx = 2 + if stat_kind == 2: + y_idx = 1 + y_err_idx = 2 + if stat_kind == 3: + y_idx = 0 + ax.errorbar( + x=rebin_xcent[sel], + y=stats[sel, y_idx], + yerr=stats[sel, y_err_idx], + **mpl_args["stats"], + ) + + return ax + + +def plot_irf_table( + ax, table, column, prefix=None, lo_name=None, hi_name=None, label=None, **mpl_args +): + if isinstance(column, str): + vals = np.squeeze(table[column]) + else: + vals = column + + if prefix: + lo = table[f"{prefix}_LO"] + hi = table[f"{prefix}_HI"] + elif hi_name and lo_name: + lo = table[lo_name] + hi = table[hi_name] + else: + raise ValueError( + "Either prefix or both `lo_name` and `hi_name` has to be given" + ) + if not label: + label = column + + bins = np.squeeze(join_bin_lo_hi(lo, hi)) + ax.stairs(vals, bins, label=label, **mpl_args) + + +def plot_hist2D_as_contour( + ax, + hist, + xedges, + yedges, + xlabel, + ylabel, + levels=5, + xscale="linear", + yscale="linear", + norm="log", + cmap="Reds", +): + if norm == "log": + norm = LogNorm(vmax=hist.max()) + xg, yg = np.meshgrid(xedges[1:], yedges[1:]) + out = ax.contour(xg, yg, hist.T, norm=norm, cmap=cmap, levels=levels) + ax.set(xscale=xscale, xlabel=xlabel, yscale=yscale, ylabel=ylabel) + return out + + +def plot_hist2D( + ax, + hist, + xedges, + yedges, + xlabel, + ylabel, + xscale="linear", + yscale="linear", + norm="log", + cmap="viridis", +): + + if norm == "log": + norm = LogNorm(vmax=hist.max()) + + xg, yg = np.meshgrid(xedges, yedges) + out = ax.pcolormesh(xg, yg, hist.T, norm=norm, cmap=cmap) + ax.set(xscale=xscale, xlabel=xlabel, yscale=yscale, ylabel=ylabel) + return out From d7ae783c1b4ec30bf4370f5dc0b8b7f29836eccc Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 12 Dec 2023 16:17:41 +0100 Subject: [PATCH 048/136] Added function to draw 2d hist from a irf table --- ctapipe/irf/visualisation.py | 44 ++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/ctapipe/irf/visualisation.py b/ctapipe/irf/visualisation.py index 9c804466c0e..f6927aa278e 100644 --- a/ctapipe/irf/visualisation.py +++ b/ctapipe/irf/visualisation.py @@ -38,7 +38,7 @@ def rebin_x_2D_hist(hist, xbins, x_cent, num_bins_merge=3): if (num_x) % num_bins_merge == 0: rebin_x = xbins[::num_bins_merge] rebin_xcent = x_cent.reshape(-1, num_bins_merge).mean(axis=1) - rebin_hist = hist.reshape(300, -1, num_bins_merge).sum(axis=2) + rebin_hist = hist.reshape(num_y, -1, num_bins_merge).sum(axis=2) return rebin_x, rebin_xcent, rebin_hist else: raise ValueError( @@ -48,7 +48,7 @@ def rebin_x_2D_hist(hist, xbins, x_cent, num_bins_merge=3): def find_columnwise_stats(table, col_bins, percentiles, density=False): tab = np.squeeze(table) - out = np.ones((tab.shape[1], 4)) * -1 + out = np.ones((tab.shape[1], 5)) * -1 for idx, col in enumerate(tab.T): if (col > 0).sum() == 0: continue @@ -72,11 +72,18 @@ def plot_2D_table_with_col_stats( quantiles=[0.2, 0.8], x_label=None, y_label=None, + density=False, mpl_args={ "histo": {"xscale": "log"}, "stats": {"color": "firebrick"}, }, ): + """Function to draw 2d histogram along with columnwise statistics + the conten values shown depending on stat_kind: + 0 -> mean + standard deviation + 1 -> median + standard deviation + 2 -> median + user specified quantiles around median (default 0.1 to 0.9) + """ x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" @@ -92,15 +99,18 @@ def plot_2D_table_with_col_stats( if not y_label: y_label = y_prefix if isinstance(column, str): - mat_vals = np.squeeze(table[column].T) + mat_vals = np.squeeze(table[column]) else: - mat_vals = column.T + mat_vals = column - rebin_x, rebin_xcent, rebin_hist = rebin_x_2D_hist( - mat_vals, xbins, xcent, num_bins_merge=num_rebin - ) - if not num_rebin == 1: + if num_rebin > 1: + rebin_x, rebin_xcent, rebin_hist = rebin_x_2D_hist( + mat_vals, xbins, xcent, num_bins_merge=num_rebin + ) density = False + else: + rebin_x, rebin_xcent, rebin_hist = xbins, xcent, mat_vals + stats = find_columnwise_stats(rebin_hist, ybins, quantiles, density) plot = plot_hist2D( @@ -117,18 +127,28 @@ def plot_2D_table_with_col_stats( sel = stats[:, 0] > 0 if stat_kind == 1: y_idx = 0 - y_err_idx = 2 + err = stats[sel, 2] + label = "mean + std" if stat_kind == 2: y_idx = 1 - y_err_idx = 2 + err = stats[sel, 2] + label = "median + std" if stat_kind == 3: - y_idx = 0 + y_idx = 1 + err = np.zeros_like(stats[:, 3:]) + err[sel, 0] = stats[sel, 1] - stats[sel, 3] + err[sel, 1] = stats[sel, 4] - stats[sel, 1] + err = err[sel, :].T + label = f"median + IRQ[{quantiles[0]:.2f},{quantiles[1]:.2f}]" + ax.errorbar( x=rebin_xcent[sel], y=stats[sel, y_idx], - yerr=stats[sel, y_err_idx], + yerr=err, + label=label, **mpl_args["stats"], ) + ax.legend(loc="best") return ax From b1c840ec7500741586f388cc042351345206dfb1 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 09:44:03 +0100 Subject: [PATCH 049/136] Use integar indices for fov bounds; fix write out of benchmark hdus --- src/ctapipe/tools/make_irf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 7284bc1386b..fc4b5dd1fd8 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -321,8 +321,8 @@ def _make_benchmark_hdus(self, hdus): reco_energy_bins=self.reco_energy_bins, theta_cuts=self.theta_cuts_opt, alpha=self.alpha, - fov_offset_min=self.fov_offset_bins["offset_min"], - fov_offset_max=self.fov_offset_bins["offset_max"], + fov_offset_min=self.fov_offset_bins[0], + fov_offset_max=self.fov_offset_bins[-1], ) sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha @@ -374,7 +374,7 @@ def start(self): if self.do_benchmarks: b_hdus = [fits.PrimaryHDU()] b_hdus = self._make_benchmark_hdus(b_hdus) - self.b_hdus = self.b_hdus + self.b_hdus = b_hdus def finish(self): self.log.info("Writing outputfile '%s'" % self.output_path) From a65329d1b5453433570dd4af80f13e628877d563 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 10:45:43 +0100 Subject: [PATCH 050/136] Remove redundant overwrite flag --- src/ctapipe/tools/make_irf.py | 5 ----- src/ctapipe/tools/optimise_event_selection.py | 7 +------ 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index fc4b5dd1fd8..a1423ba70db 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -91,11 +91,6 @@ class IrfTool(Tool): help="Output file", ).tag(config=True) - overwrite = Bool( - False, - help="Overwrite the output file if it exists", - ).tag(config=True) - obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) obs_time_unit = Unicode( default_value="hour", diff --git a/src/ctapipe/tools/optimise_event_selection.py b/src/ctapipe/tools/optimise_event_selection.py index 04864db0db7..8a0deea1e66 100644 --- a/src/ctapipe/tools/optimise_event_selection.py +++ b/src/ctapipe/tools/optimise_event_selection.py @@ -3,7 +3,7 @@ from astropy.table import vstack from ..core import Provenance, Tool, traits -from ..core.traits import Bool, Float, Integer, Unicode +from ..core.traits import Float, Integer, Unicode from ..irf import ( PYIRF_SPECTRA, EventPreProcessor, @@ -58,11 +58,6 @@ class IrfEventSelector(Tool): help="Output file storing optimisation result", ).tag(config=True) - overwrite = Bool( - False, - help="Overwrite the output file if it exists", - ).tag(config=True) - obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) obs_time_unit = Unicode( default_value="hour", From 95cedce8f04fbba2c6ef4d860f4a35b8f4d7f531 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 11:13:51 +0100 Subject: [PATCH 051/136] Add some aliases and flags; fix logging bug --- src/ctapipe/tools/make_irf.py | 43 ++++++++++++++++--- src/ctapipe/tools/optimise_event_selection.py | 8 ++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index a1423ba70db..3b08830fb7c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -13,7 +13,7 @@ from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core import Provenance, Tool, traits -from ..core.traits import Bool, Float, Integer, Unicode +from ..core.traits import Bool, Float, Integer, Unicode, flag from ..irf import ( PYIRF_SPECTRA, EffectiveAreaIrf, @@ -101,6 +101,30 @@ class IrfTool(Tool): default_value=0.2, help="Ratio between size of on and off regions" ).tag(config=True) + aliases = { + "cuts": "IrfTool.cuts_file", + "gamma-file": "IrfTool.gamma_file", + "proton-file": "IrfTool.proton_file", + "electron-file": "IrfTool.electron_file", + "output": "IrfTool.output_path", + "chunk_size": "IrfTool.chunk_size", + } + + flags = { + **flag( + "do-background", + "IrfTool.do_background", + "Compute background rate.", + "Do not compute background rate.", + ), + **flag( + "do-benchmarks", + "IrfTool.do_benchmarks", + "Produce IRF related benchmarks.", + "Do not produce IRF related benchmarks.", + ), + } + classes = [ OutputEnergyBinning, FovOffsetBinning, @@ -153,13 +177,18 @@ def calculate_selections(self): # TODO: maybe rework the above so we can give the number per # species instead of the total background - self.log.debug( - "Keeping %d signal, %d backgrond events" - % ( - sum(self.signal_events["selected"]), - sum(self.background_events["selected"]), + if self.do_background: + self.log.debug( + "Keeping %d signal, %d background events" + % ( + sum(self.signal_events["selected"]), + sum(self.background_events["selected"]), + ) + ) + else: + self.log.debug( + "Keeping %d signal events" % (sum(self.signal_events["selected"])) ) - ) def setup(self): self.theta = ThetaCutsCalculator(parent=self) diff --git a/src/ctapipe/tools/optimise_event_selection.py b/src/ctapipe/tools/optimise_event_selection.py index 8a0deea1e66..88eea2d0df0 100644 --- a/src/ctapipe/tools/optimise_event_selection.py +++ b/src/ctapipe/tools/optimise_event_selection.py @@ -68,6 +68,14 @@ class IrfEventSelector(Tool): default_value=0.2, help="Ratio between size of on and off regions" ).tag(config=True) + aliases = { + "gamma-file": "IrfEventSelector.gamma_file", + "proton-file": "IrfEventSelector.proton_file", + "electron-file": "IrfEventSelector.electron_file", + "output": "IrfEventSelector.output_path", + "chunk_size": "IrfEventSelector.chunk_size", + } + classes = [GridOptimizer, FovOffsetBinning, OutputEnergyBinning, EventPreProcessor] def setup(self): From fd07242c1e75270008a8065c0a54ff3d8c84c349 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 16:18:24 +0100 Subject: [PATCH 052/136] Rename some files and classes --- ctapipe/irf/__init__.py | 6 +++--- ctapipe/irf/{optimise.py => optimize.py} | 18 +++++++++--------- pyproject.toml | 4 ++-- src/ctapipe/tools/make_irf.py | 6 +++--- ...election.py => optimize_event_selection.py} | 12 ++++++------ 5 files changed, 23 insertions(+), 23 deletions(-) rename ctapipe/irf/{optimise.py => optimize.py} (95%) rename src/ctapipe/tools/{optimise_event_selection.py => optimize_event_selection.py} (94%) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 9726037d1fc..6a6bac3dfa7 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -2,15 +2,15 @@ from .binning import FovOffsetBinning, OutputEnergyBinning, check_bins_in_range from .irf_classes import PYIRF_SPECTRA, Spectra from .irfs import EffectiveAreaIrf, EnergyMigrationIrf, PsfIrf -from .optimise import GridOptimizer, OptimisationResult, OptimisationResultStore +from .optimize import GridOptimizer, OptimizationResult, OptimizationResultStore from .select import EventPreProcessor, EventsLoader, ThetaCutsCalculator __all__ = [ "EnergyMigrationIrf", "PsfIrf", "EffectiveAreaIrf", - "OptimisationResult", - "OptimisationResultStore", + "OptimizationResult", + "OptimizationResultStore", "GridOptimizer", "OutputEnergyBinning", "FovOffsetBinning", diff --git a/ctapipe/irf/optimise.py b/ctapipe/irf/optimize.py similarity index 95% rename from ctapipe/irf/optimise.py rename to ctapipe/irf/optimize.py index 5cddcc297d0..017b16c2996 100644 --- a/ctapipe/irf/optimise.py +++ b/ctapipe/irf/optimize.py @@ -1,4 +1,4 @@ -"""module containing optimisation related functions and classes""" +"""module containing optimization related functions and classes""" import operator import astropy.units as u @@ -18,7 +18,7 @@ def __init__(self, bounds_table, prefix): self.max = bounds_table[f"{prefix}_max"] -class OptimisationResult: +class OptimizationResult: def __init__(self, precuts, valid_energy, valid_offset, gh, theta): self.precuts = precuts self.valid_energy = ResultValidRange(valid_energy, "energy") @@ -28,7 +28,7 @@ def __init__(self, precuts, valid_energy, valid_offset, gh, theta): def __repr__(self): return ( - f" Date: Tue, 19 Dec 2023 17:24:59 +0100 Subject: [PATCH 053/136] Make EventPreProcessor a subcomponent of EventsLoader --- ctapipe/irf/select.py | 196 +++++++++--------- src/ctapipe/tools/make_irf.py | 13 +- src/ctapipe/tools/optimize_event_selection.py | 9 +- 3 files changed, 106 insertions(+), 112 deletions(-) diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index 4706159fa88..9432ed6a375 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -13,11 +13,108 @@ from ..irf import FovOffsetBinning +class EventPreProcessor(QualityQuery): + """Defines preselection cuts and the necessary renaming of columns""" + + energy_reconstructor = Unicode( + default_value="RandomForestRegressor", + help="Prefix of the reco `_energy` column", + ).tag(config=True) + geometry_reconstructor = Unicode( + default_value="HillasReconstructor", + help="Prefix of the `_alt` and `_az` reco geometry columns", + ).tag(config=True) + gammaness_classifier = Unicode( + default_value="RandomForestClassifier", + help="Prefix of the classifier `_prediction` column", + ).tag(config=True) + + quality_criteria = List( + default_value=[ + ("multiplicity 4", "np.count_nonzero(tels_with_trigger,axis=1) >= 4"), + ("valid classifier", "RandomForestClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "RandomForestRegressor_is_valid"), + ], + help=QualityQuery.quality_criteria.help, + ).tag(config=True) + + rename_columns = List( + help="List containing translation pairs new and old column names" + "used when processing input with names differing from the CTA prod5b format" + "Ex: [('valid_geom','HillasReconstructor_is_valid')]", + default_value=[], + ).tag(config=True) + + def normalise_column_names(self, events): + keep_columns = [ + "obs_id", + "event_id", + "true_energy", + "true_az", + "true_alt", + ] + rename_from = [ + f"{self.energy_reconstructor}_energy", + f"{self.geometry_reconstructor}_az", + f"{self.geometry_reconstructor}_alt", + f"{self.gammaness_classifier}_prediction", + ] + rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] + + # We never enter the loop if rename_columns is empty + for new, old in self.rename_columns: + rename_from.append(old) + rename_to.append(new) + + keep_columns.extend(rename_from) + events = QTable(events[keep_columns], copy=False) + events.rename_columns(rename_from, rename_to) + return events + + def make_empty_table(self): + """This function defines the columns later functions expect to be present in the event table""" + columns = [ + "obs_id", + "event_id", + "true_energy", + "true_az", + "true_alt", + "reco_energy", + "reco_az", + "reco_alt", + "gh_score", + "pointing_az", + "pointing_alt", + "theta", + "true_source_fov_offset", + "reco_source_fov_offset", + "weight", + ] + units = { + "true_energy": u.TeV, + "true_az": u.deg, + "true_alt": u.deg, + "reco_energy": u.TeV, + "reco_az": u.deg, + "reco_alt": u.deg, + "pointing_az": u.deg, + "pointing_alt": u.deg, + "theta": u.deg, + "true_source_fov_offset": u.deg, + "reco_source_fov_offset": u.deg, + } + + return QTable(names=columns, units=units) + + class EventsLoader(Component): - def __init__(self, event_pre_processor, kind, file, target_spectrum, **kwargs): + classes = [EventPreProcessor] + + def __init__(self, kind, file, target_spectrum, **kwargs): super().__init__(**kwargs) - self.epp = event_pre_processor + self.epp = EventPreProcessor(parent=self) self.target_spectrum = target_spectrum self.kind = kind self.file = file @@ -111,101 +208,6 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): return events -class EventPreProcessor(QualityQuery): - """Defines preselection cuts and the necessary renaming of columns""" - - energy_reconstructor = Unicode( - default_value="RandomForestRegressor", - help="Prefix of the reco `_energy` column", - ).tag(config=True) - geometry_reconstructor = Unicode( - default_value="HillasReconstructor", - help="Prefix of the `_alt` and `_az` reco geometry columns", - ).tag(config=True) - gammaness_classifier = Unicode( - default_value="RandomForestClassifier", - help="Prefix of the classifier `_prediction` column", - ).tag(config=True) - - quality_criteria = List( - default_value=[ - ("multiplicity 4", "np.count_nonzero(tels_with_trigger,axis=1) >= 4"), - ("valid classifier", "RandomForestClassifier_is_valid"), - ("valid geom reco", "HillasReconstructor_is_valid"), - ("valid energy reco", "RandomForestRegressor_is_valid"), - ], - help=QualityQuery.quality_criteria.help, - ).tag(config=True) - - rename_columns = List( - help="List containing translation pairs new and old column names" - "used when processing input with names differing from the CTA prod5b format" - "Ex: [('valid_geom','HillasReconstructor_is_valid')]", - default_value=[], - ).tag(config=True) - - def normalise_column_names(self, events): - keep_columns = [ - "obs_id", - "event_id", - "true_energy", - "true_az", - "true_alt", - ] - rename_from = [ - f"{self.energy_reconstructor}_energy", - f"{self.geometry_reconstructor}_az", - f"{self.geometry_reconstructor}_alt", - f"{self.gammaness_classifier}_prediction", - ] - rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] - - # We never enter the loop if rename_columns is empty - for new, old in self.rename_columns: - rename_from.append(old) - rename_to.append(new) - - keep_columns.extend(rename_from) - events = QTable(events[keep_columns], copy=False) - events.rename_columns(rename_from, rename_to) - return events - - def make_empty_table(self): - """This function defines the columns later functions expect to be present in the event table""" - columns = [ - "obs_id", - "event_id", - "true_energy", - "true_az", - "true_alt", - "reco_energy", - "reco_az", - "reco_alt", - "gh_score", - "pointing_az", - "pointing_alt", - "theta", - "true_source_fov_offset", - "reco_source_fov_offset", - "weight", - ] - units = { - "true_energy": u.TeV, - "true_az": u.deg, - "true_alt": u.deg, - "reco_energy": u.TeV, - "reco_az": u.deg, - "reco_alt": u.deg, - "pointing_az": u.deg, - "pointing_alt": u.deg, - "theta": u.deg, - "true_source_fov_offset": u.deg, - "reco_source_fov_offset": u.deg, - } - - return QTable(names=columns, units=units) - - class ThetaCutsCalculator(Component): theta_min_angle = Float( default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index cedeaa6c83c..0379203f1eb 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -18,7 +18,6 @@ PYIRF_SPECTRA, EffectiveAreaIrf, EnergyMigrationIrf, - EventPreProcessor, EventsLoader, FovOffsetBinning, OptimizationResultStore, @@ -128,7 +127,7 @@ class IrfTool(Tool): classes = [ OutputEnergyBinning, FovOffsetBinning, - EventPreProcessor, + EventsLoader, PsfIrf, EnergyMigrationIrf, EffectiveAreaIrf, @@ -196,9 +195,7 @@ def setup(self): self.bins = FovOffsetBinning(parent=self) self.opt_result = OptimizationResultStore().read(self.cuts_file) - self.epp = EventPreProcessor(parent=self) - # TODO: not very elegant to pass them this way, refactor later - self.epp.quality_criteria = self.opt_result.precuts.quality_criteria + self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() self.fov_offset_bins = self.bins.fov_offset_bins() @@ -208,7 +205,6 @@ def setup(self): self.particles = [ EventsLoader( - self.epp, "gammas", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum], @@ -217,7 +213,6 @@ def setup(self): if self.do_background and self.proton_file: self.particles.append( EventsLoader( - self.epp, "protons", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum], @@ -226,7 +221,6 @@ def setup(self): if self.do_background and self.electron_file: self.particles.append( EventsLoader( - self.epp, "electrons", self.electron_file, PYIRF_SPECTRA[self.electron_sim_spectrum], @@ -236,6 +230,9 @@ def setup(self): raise RuntimeError( "At least one electron or proton file required when speficying `do_background`." ) + for loader in self.particles: + # TODO: not very elegant to pass them this way, refactor later + loader.epp.quality_criteria = self.opt_result.precuts.quality_criteria self.aeff = None diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 5cd3b7ea1f4..e476ef5da8e 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -6,7 +6,6 @@ from ..core.traits import Float, Integer, Unicode from ..irf import ( PYIRF_SPECTRA, - EventPreProcessor, EventsLoader, FovOffsetBinning, GridOptimizer, @@ -76,14 +75,13 @@ class IrfEventSelector(Tool): "chunk_size": "IrfEventSelector.chunk_size", } - classes = [GridOptimizer, FovOffsetBinning, OutputEnergyBinning, EventPreProcessor] + classes = [GridOptimizer, FovOffsetBinning, OutputEnergyBinning, EventsLoader] def setup(self): self.go = GridOptimizer(parent=self) self.theta = ThetaCutsCalculator(parent=self) self.e_bins = OutputEnergyBinning(parent=self) self.bins = FovOffsetBinning(parent=self) - self.epp = EventPreProcessor(parent=self) self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() @@ -92,19 +90,16 @@ def setup(self): self.particles = [ EventsLoader( - self.epp, "gammas", self.gamma_file, PYIRF_SPECTRA[self.gamma_sim_spectrum], ), EventsLoader( - self.epp, "protons", self.proton_file, PYIRF_SPECTRA[self.proton_sim_spectrum], ), EventsLoader( - self.epp, "electrons", self.electron_file, PYIRF_SPECTRA[self.electron_sim_spectrum], @@ -158,7 +153,7 @@ def start(self): self.bins.fov_offset_min * u.deg, self.bins.fov_offset_max * u.deg, self.theta, - self.epp, + self.particles[0].epp, # precuts are the same for all particle types ) self.log.info("Writing results to %s" % self.output_path) From 23d1750c30d999b2c93a66b95397940d31fb713e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 20 Dec 2023 17:48:05 +0100 Subject: [PATCH 054/136] Use reco energy for rad_max table --- src/ctapipe/tools/make_irf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 0379203f1eb..6ee6bf3794f 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -289,7 +289,7 @@ def _make_signal_irf_hdus(self, hdus): hdus.append( create_rad_max_hdu( self.theta_cuts_opt["cut"].reshape(-1, 1), - self.true_energy_bins, + self.reco_energy_bins, self.fov_offset_bins, ) ) From 09a67d6831443182474a0ec462a30648d69d262e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 12 Jan 2024 18:09:23 +0100 Subject: [PATCH 055/136] Use n_bins everywhere instead of n_edges --- ctapipe/irf/binning.py | 6 +++--- ctapipe/irf/irfs.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ctapipe/irf/binning.py b/ctapipe/irf/binning.py index 570195d8272..0a6e27c32c0 100644 --- a/ctapipe/irf/binning.py +++ b/ctapipe/irf/binning.py @@ -84,9 +84,9 @@ class FovOffsetBinning(Component): default_value=5.0, ).tag(config=True) - fov_offset_n_edges = Integer( + fov_offset_n_bins = Integer( help="Number of edges for FoV offset bins", - default_value=2, + default_value=1, ).tag(config=True) def fov_offset_bins(self): @@ -97,7 +97,7 @@ def fov_offset_bins(self): np.linspace( self.fov_offset_min, self.fov_offset_max, - self.fov_offset_n_edges, + self.fov_offset_n_bins + 1, ) * u.deg ) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index b666e5efd66..8865fbda7b5 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -27,9 +27,9 @@ class PsfIrf(Component): default_value=1, ).tag(config=True) - source_offset_n_edges = Integer( + source_offset_n_bins = Integer( help="Number of edges for Source offset for PSF IRF", - default_value=101, + default_value=100, ).tag(config=True) def __init__(self, parent, energy_bins, valid_offset, **kwargs): @@ -40,7 +40,7 @@ def __init__(self, parent, energy_bins, valid_offset, **kwargs): np.linspace( self.source_offset_min, self.source_offset_max, - self.source_offset_n_edges, + self.source_offset_n_bins + 1, ) * u.deg ) From e3a64b92737d34c35de8c845a82e3d882a58741a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 12 Jan 2024 18:36:43 +0100 Subject: [PATCH 056/136] Add reco bins to logging; remove unused bins --- src/ctapipe/tools/make_irf.py | 1 + src/ctapipe/tools/optimize_event_selection.py | 9 +-------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 6ee6bf3794f..401c4ad8883 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -384,6 +384,7 @@ def start(self): self.calculate_selections() self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) + self.log.debug("Reco Energy bins: %s" % str(self.reco_energy_bins.value)) self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) hdus = [fits.PrimaryHDU()] diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index e476ef5da8e..8438627e60a 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -9,7 +9,6 @@ EventsLoader, FovOffsetBinning, GridOptimizer, - OutputEnergyBinning, Spectra, ThetaCutsCalculator, ) @@ -75,19 +74,13 @@ class IrfEventSelector(Tool): "chunk_size": "IrfEventSelector.chunk_size", } - classes = [GridOptimizer, FovOffsetBinning, OutputEnergyBinning, EventsLoader] + classes = [GridOptimizer, FovOffsetBinning, EventsLoader] def setup(self): self.go = GridOptimizer(parent=self) self.theta = ThetaCutsCalculator(parent=self) - self.e_bins = OutputEnergyBinning(parent=self) self.bins = FovOffsetBinning(parent=self) - self.reco_energy_bins = self.e_bins.reco_energy_bins() - self.true_energy_bins = self.e_bins.true_energy_bins() - - self.fov_offset_bins = self.bins.fov_offset_bins() - self.particles = [ EventsLoader( "gammas", From 31f428d770acce00f0634ea849e3d00ba9dc71ec Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Sat, 13 Jan 2024 12:54:50 +0100 Subject: [PATCH 057/136] Fix EventPreProcessor configurability --- src/ctapipe/tools/make_irf.py | 21 +++++++++++-------- src/ctapipe/tools/optimize_event_selection.py | 21 +++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 401c4ad8883..09463ed2f34 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -205,25 +205,28 @@ def setup(self): self.particles = [ EventsLoader( - "gammas", - self.gamma_file, - PYIRF_SPECTRA[self.gamma_sim_spectrum], + parent=self, + kind="gammas", + file=self.gamma_file, + target_spectrum=PYIRF_SPECTRA[self.gamma_sim_spectrum], ), ] if self.do_background and self.proton_file: self.particles.append( EventsLoader( - "protons", - self.proton_file, - PYIRF_SPECTRA[self.proton_sim_spectrum], + parent=self, + kind="protons", + file=self.proton_file, + target_spectrum=PYIRF_SPECTRA[self.proton_sim_spectrum], ) ) if self.do_background and self.electron_file: self.particles.append( EventsLoader( - "electrons", - self.electron_file, - PYIRF_SPECTRA[self.electron_sim_spectrum], + parent=self, + kind="electrons", + file=self.electron_file, + target_spectrum=PYIRF_SPECTRA[self.electron_sim_spectrum], ) ) if self.do_background and len(self.particles) == 1: diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 8438627e60a..f2ea3e09799 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -83,19 +83,22 @@ def setup(self): self.particles = [ EventsLoader( - "gammas", - self.gamma_file, - PYIRF_SPECTRA[self.gamma_sim_spectrum], + parent=self, + kind="gammas", + file=self.gamma_file, + target_spectrum=PYIRF_SPECTRA[self.gamma_sim_spectrum], ), EventsLoader( - "protons", - self.proton_file, - PYIRF_SPECTRA[self.proton_sim_spectrum], + parent=self, + kind="protons", + file=self.proton_file, + target_spectrum=PYIRF_SPECTRA[self.proton_sim_spectrum], ), EventsLoader( - "electrons", - self.electron_file, - PYIRF_SPECTRA[self.electron_sim_spectrum], + parent=self, + kind="electrons", + file=self.electron_file, + target_spectrum=PYIRF_SPECTRA[self.electron_sim_spectrum], ), ] From 90b13d800c37766b590f01d72ffc7b53760193c4 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Sat, 13 Jan 2024 15:22:34 +0100 Subject: [PATCH 058/136] Make ThetaCutsCalculator configurable --- src/ctapipe/tools/make_irf.py | 1 + src/ctapipe/tools/optimize_event_selection.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 09463ed2f34..b709d8eccb3 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -125,6 +125,7 @@ class IrfTool(Tool): } classes = [ + ThetaCutsCalculator, OutputEnergyBinning, FovOffsetBinning, EventsLoader, diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index f2ea3e09799..c509257e00c 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -74,7 +74,7 @@ class IrfEventSelector(Tool): "chunk_size": "IrfEventSelector.chunk_size", } - classes = [GridOptimizer, FovOffsetBinning, EventsLoader] + classes = [GridOptimizer, ThetaCutsCalculator, FovOffsetBinning, EventsLoader] def setup(self): self.go = GridOptimizer(parent=self) From 80248d468934032d81f6f587125468639de74f3d Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 15 Jan 2024 10:51:32 +0100 Subject: [PATCH 059/136] Fix help for n_bins config options --- ctapipe/irf/binning.py | 6 +++--- ctapipe/irf/irfs.py | 4 ++-- ctapipe/irf/optimize.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ctapipe/irf/binning.py b/ctapipe/irf/binning.py index 0a6e27c32c0..557a148b147 100644 --- a/ctapipe/irf/binning.py +++ b/ctapipe/irf/binning.py @@ -29,7 +29,7 @@ class OutputEnergyBinning(Component): ).tag(config=True) true_energy_n_bins_per_decade = Float( - help="Number of edges per decade for True Energy bins", + help="Number of bins per decade for True Energy bins", default_value=10, ).tag(config=True) @@ -44,7 +44,7 @@ class OutputEnergyBinning(Component): ).tag(config=True) reco_energy_n_bins_per_decade = Float( - help="Number of edges per decade for Reco Energy bins", + help="Number of bins per decade for Reco Energy bins", default_value=5, ).tag(config=True) @@ -85,7 +85,7 @@ class FovOffsetBinning(Component): ).tag(config=True) fov_offset_n_bins = Integer( - help="Number of edges for FoV offset bins", + help="Number of bins for FoV offset bins", default_value=1, ).tag(config=True) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index 8865fbda7b5..fbf78c98b5e 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -28,7 +28,7 @@ class PsfIrf(Component): ).tag(config=True) source_offset_n_bins = Integer( - help="Number of edges for Source offset for PSF IRF", + help="Number of bins for Source offset for PSF IRF", default_value=100, ).tag(config=True) @@ -121,7 +121,7 @@ class EffectiveAreaIrf(Component): ).tag(config=True) true_energy_n_bins_per_decade = Float( - help="Number of edges per decade for True Energy bins", + help="Number of bins per decade for True Energy bins", default_value=10, ).tag(config=True) diff --git a/ctapipe/irf/optimize.py b/ctapipe/irf/optimize.py index 017b16c2996..43039141895 100644 --- a/ctapipe/irf/optimize.py +++ b/ctapipe/irf/optimize.py @@ -134,7 +134,7 @@ class GridOptimizer(Component): ).tag(config=True) reco_energy_n_bins_per_decade = Float( - help="Number of edges per decade for Reco Energy bins", + help="Number of bins per decade for Reco Energy bins", default_value=5, ).tag(config=True) From b74d0c503dac508468958aa17db0fa5b596ffc84 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 Jan 2024 17:16:41 +0100 Subject: [PATCH 060/136] Added helper function for plotting just bin-wise statistics of IRF tables --- ctapipe/irf/visualisation.py | 97 ++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/ctapipe/irf/visualisation.py b/ctapipe/irf/visualisation.py index f6927aa278e..95f98c4f739 100644 --- a/ctapipe/irf/visualisation.py +++ b/ctapipe/irf/visualisation.py @@ -1,3 +1,4 @@ +import astropy.units as u import matplotlib.pyplot as plt import numpy as np import scipy.stats as st @@ -22,9 +23,9 @@ def plot_2D_irf_table( if not y_label: y_label = y_prefix if isinstance(column, str): - mat_vals = np.squeeze(table[column].T) + mat_vals = np.squeeze(table[column]) else: - mat_vals = column.T + mat_vals = column plot = plot_hist2D( ax, mat_vals, xbins, ybins, xlabel=x_label, ylabel=y_label, **mpl_args @@ -49,6 +50,9 @@ def rebin_x_2D_hist(hist, xbins, x_cent, num_bins_merge=3): def find_columnwise_stats(table, col_bins, percentiles, density=False): tab = np.squeeze(table) out = np.ones((tab.shape[1], 5)) * -1 + # This loop over the columns seems unavoidable, + # so having a reasonable number of bins in that + # direction is good for idx, col in enumerate(tab.T): if (col > 0).sum() == 0: continue @@ -112,10 +116,9 @@ def plot_2D_table_with_col_stats( rebin_x, rebin_xcent, rebin_hist = xbins, xcent, mat_vals stats = find_columnwise_stats(rebin_hist, ybins, quantiles, density) - plot = plot_hist2D( ax, - rebin_hist.T, + rebin_hist, rebin_x, ybins, xlabel=x_label, @@ -153,6 +156,87 @@ def plot_2D_table_with_col_stats( return ax +def plot_2D_table_col_stats( + ax, + table, + column, + x_prefix, + y_prefix, + num_rebin=4, + stat_kind=2, + quantiles=[0.2, 0.8], + x_label=None, + y_label=None, + density=False, + lbl_prefix="", + mpl_args={"xscale": "log"}, +): + """Function to draw columnwise statistics of 2D hist + the content values shown depending on stat_kind: + 0 -> mean + standard deviation + 1 -> median + standard deviation + 2 -> median + user specified quantiles around median (default 0.1 to 0.9) + """ + x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" + y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" + + xbins = np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])) + + ybins = np.hstack((table[y_lo_name][0], table[y_hi_name][0][-1])) + + xcent = np.convolve( + [0.5, 0.5], np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])), "valid" + ) + if not x_label: + x_label = x_prefix + if not y_label: + y_label = y_prefix + if isinstance(column, str): + mat_vals = np.squeeze(table[column]) + else: + mat_vals = column + + if num_rebin > 1: + rebin_x, rebin_xcent, rebin_hist = rebin_x_2D_hist( + mat_vals, xbins, xcent, num_bins_merge=num_rebin + ) + density = False + else: + rebin_xcent, rebin_hist = xcent, mat_vals + + stats = find_columnwise_stats(rebin_hist, ybins, quantiles, density) + + sel = stats[:, 0] > 0 + if stat_kind == 1: + y_idx = 0 + err = stats[sel, 2] + label = "mean + std" + if stat_kind == 2: + y_idx = 1 + err = stats[sel, 2] + label = "median + std" + if stat_kind == 3: + y_idx = 1 + err = np.zeros_like(stats[:, 3:]) + err[sel, 0] = stats[sel, 1] - stats[sel, 3] + err[sel, 1] = stats[sel, 4] - stats[sel, 1] + err = err[sel, :].T + label = f"median + IRQ[{quantiles[0]:.2f},{quantiles[1]:.2f}]" + + ax.errorbar( + x=rebin_xcent[sel], + y=stats[sel, y_idx], + yerr=err, + label=f"{lbl_prefix} {label}", + ) + if "xscale" in mpl_args: + ax.set_xscale(mpl_args["xscale"]) + + ax.legend(loc="best") + + return ax + + def plot_irf_table( ax, table, column, prefix=None, lo_name=None, hi_name=None, label=None, **mpl_args ): @@ -212,10 +296,13 @@ def plot_hist2D( cmap="viridis", ): + if isinstance(hist, u.Quantity): + hist = hist.value + if norm == "log": norm = LogNorm(vmax=hist.max()) xg, yg = np.meshgrid(xedges, yedges) - out = ax.pcolormesh(xg, yg, hist.T, norm=norm, cmap=cmap) + out = ax.pcolormesh(xg, yg, hist, norm=norm, cmap=cmap) ax.set(xscale=xscale, xlabel=xlabel, yscale=yscale, ylabel=ylabel) return out From 8c416f15b7a246036efa4a601184fd2de3a11638 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 15 Jan 2024 10:41:19 +0100 Subject: [PATCH 061/136] Added energy binning directly to the PsfIrf class --- ctapipe/irf/irfs.py | 57 +++++++++++++++++++++++++++++------ src/ctapipe/tools/make_irf.py | 4 --- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index fbf78c98b5e..618c27cb17f 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -17,6 +17,21 @@ class PsfIrf(Component): """Collects the functionality for generating PSF IRFs.""" + true_energy_min = Float( + help="Minimum value for True Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + true_energy_max = Float( + help="Maximum value for True Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + true_energy_n_bins_per_decade = Float( + help="Number of edges per decade for True Energy bins", + default_value=10, + ).tag(config=True) + source_offset_min = Float( help="Minimum value for Source offset for PSF IRF", default_value=0, @@ -32,9 +47,13 @@ class PsfIrf(Component): default_value=100, ).tag(config=True) - def __init__(self, parent, energy_bins, valid_offset, **kwargs): + def __init__(self, parent, valid_offset, **kwargs): super().__init__(parent=parent, **kwargs) - self.energy_bins = energy_bins + self.true_energy_bins = create_bins_per_decade( + self.true_energy_min * u.TeV, + self.true_energy_max * u.TeV, + self.true_energy_n_bins_per_decade, + ) self.valid_offset = valid_offset self.source_offset_bins = ( np.linspace( @@ -49,15 +68,16 @@ def make_psf_table_hdu(self, signal_events, fov_offset_bins): check_bins_in_range(fov_offset_bins, self.valid_offset) psf = psf_table( events=signal_events, - true_energy_bins=self.energy_bins, + true_energy_bins=self.true_energy_bins, fov_offset_bins=fov_offset_bins, source_offset_bins=self.source_offset_bins, ) return create_psf_table_hdu( psf, - self.energy_bins, + self.true_energy_bins, self.source_offset_bins, fov_offset_bins, + extname="PSF", ) @@ -79,13 +99,32 @@ class EnergyMigrationIrf(Component): default_value=31, ).tag(config=True) - def __init__(self, parent, energy_bins, **kwargs): + true_energy_min = Float( + help="Minimum value for True Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + true_energy_max = Float( + help="Maximum value for True Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + true_energy_n_bins_per_decade = Float( + help="Number of edges per decade for True Energy bins", + default_value=10, + ).tag(config=True) + + def __init__(self, parent, **kwargs): """ Creates bins per decade for true MC energy. """ super().__init__(parent=parent, **kwargs) - self.energy_bins = energy_bins - self.migration_bins = np.geomspace( + self.true_energy_bins = create_bins_per_decade( + self.true_energy_min * u.TeV, + self.true_energy_max * u.TeV, + self.true_energy_n_bins_per_decade, + ) + self.migration_bins = np.linspace( self.energy_migration_min, self.energy_migration_max, self.energy_migration_n_bins, @@ -94,13 +133,13 @@ def __init__(self, parent, energy_bins, **kwargs): def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins): edisp = energy_dispersion( signal_events, - true_energy_bins=self.energy_bins, + true_energy_bins=self.true_energy_bins, fov_offset_bins=fov_offset_bins, migration_bins=self.migration_bins, ) return create_energy_dispersion_hdu( edisp, - true_energy_bins=self.energy_bins, + true_energy_bins=self.true_energy_bins, migration_bins=self.migration_bins, fov_offset_bins=fov_offset_bins, extname="ENERGY DISPERSION", diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index b709d8eccb3..8fa8091365e 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -242,12 +242,10 @@ def setup(self): self.psf = PsfIrf( parent=self, - energy_bins=self.true_energy_bins, valid_offset=self.opt_result.valid_offset, ) self.mig_matrix = EnergyMigrationIrf( parent=self, - energy_bins=self.true_energy_bins, ) if self.do_benchmarks: self.b_hdus = None @@ -317,8 +315,6 @@ def _make_background_hdu(self): ) def _make_benchmark_hdus(self, hdus): - # Here we use reconstructed energy instead of true energy for the sake of - # current pipelines comparisons bias_resolution = energy_bias_resolution( self.signal_events[self.signal_events["selected"]], self.true_energy_bins, From 4601f59bdb366a4f08275c5925d69bdf496fdab4 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 15 Jan 2024 12:12:06 +0100 Subject: [PATCH 062/136] Created a Background2D and Background3D IRF classes --- ctapipe/irf/__init__.py | 12 ++- ctapipe/irf/irfs.py | 149 +++++++++++++++++++++++++++++++++- src/ctapipe/tools/make_irf.py | 58 +++++++------ 3 files changed, 189 insertions(+), 30 deletions(-) diff --git a/ctapipe/irf/__init__.py b/ctapipe/irf/__init__.py index 6a6bac3dfa7..41618b91c1a 100644 --- a/ctapipe/irf/__init__.py +++ b/ctapipe/irf/__init__.py @@ -1,14 +1,22 @@ """Top level module for the irf functionality""" from .binning import FovOffsetBinning, OutputEnergyBinning, check_bins_in_range from .irf_classes import PYIRF_SPECTRA, Spectra -from .irfs import EffectiveAreaIrf, EnergyMigrationIrf, PsfIrf +from .irfs import ( + Background2dIrf, + Background3dIrf, + EffectiveAreaIrf, + EnergyMigrationIrf, + PsfIrf, +) from .optimize import GridOptimizer, OptimizationResult, OptimizationResultStore from .select import EventPreProcessor, EventsLoader, ThetaCutsCalculator __all__ = [ + "Background2dIrf", + "Background3dIrf", + "EffectiveAreaIrf", "EnergyMigrationIrf", "PsfIrf", - "EffectiveAreaIrf", "OptimizationResult", "OptimizationResultStore", "GridOptimizer", diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index 618c27cb17f..5349d97384b 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -4,10 +4,18 @@ from pyirf.binning import create_bins_per_decade from pyirf.io import ( create_aeff2d_hdu, + create_background_2d_hdu, + create_background_3d_hdu, create_energy_dispersion_hdu, create_psf_table_hdu, ) -from pyirf.irf import effective_area_per_energy_and_fov, energy_dispersion, psf_table +from pyirf.irf import ( + background_2d, + background_3d, + effective_area_per_energy_and_fov, + energy_dispersion, + psf_table, +) from ..core import Component from ..core.traits import Float, Integer @@ -81,6 +89,145 @@ def make_psf_table_hdu(self, signal_events, fov_offset_bins): ) +class Background3dIrf(Component): + """Collects the functionality for generating 3D Background IRFs using square bins.""" + + reco_energy_min = Float( + help="Minimum value for Reco Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + reco_energy_max = Float( + help="Maximum value for Reco Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + reco_energy_n_bins_per_decade = Float( + help="Number of edges per decade for Reco Energy bins", + default_value=10, + ).tag(config=True) + + fov_offset_min = Float( + help="Minimum value for Field of View offset for background IRF", + default_value=0, + ).tag(config=True) + + fov_offset_max = Float( + help="Maximum value for Field of View offset for background IRF", + default_value=1, + ).tag(config=True) + + fov_offset_n_edges = Integer( + help="Number of edges for Field of View offset for background IRF", + default_value=1, + ).tag(config=True) + + def __init__(self, parent, valid_offset, **kwargs): + super().__init__(parent=parent, **kwargs) + self.reco_energy_bins = create_bins_per_decade( + self.reco_energy_min * u.TeV, + self.reco_energy_max * u.TeV, + self.reco_energy_n_bins_per_decade, + ) + self.valid_offset = valid_offset + self.fov_offset_bins = ( + np.linspace( + self.fov_offset_min, + self.fov_offset_max, + self.fov_offset_n_edges, + ) + * u.deg + ) + # check_bins_in_range(self.fov_offset_bins, self.valid_offset) + + def make_bkg3d_table_hdu(self, bkg_events, obs_time): + sel = bkg_events["selected_gh"] + self.log.debug("%d background events selected" % sel.sum()) + self.log.debug("%f obs time" % obs_time.to_value(u.h)) + breakpoint() + background_rate = background_3d( + bkg_events[sel], + self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + t_obs=obs_time, + ) + return create_background_3d_hdu( + background_rate, + self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + extname="BACKGROUND3D", + ) + + +class Background2dIrf(Component): + """Collects the functionality for generating 2D Background IRFs.""" + + reco_energy_min = Float( + help="Minimum value for Reco Energy bins in TeV units", + default_value=0.005, + ).tag(config=True) + + reco_energy_max = Float( + help="Maximum value for Reco Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + reco_energy_n_bins_per_decade = Float( + help="Number of edges per decade for Reco Energy bins", + default_value=10, + ).tag(config=True) + + fov_offset_min = Float( + help="Minimum value for Field of View offset for background IRF", + default_value=0, + ).tag(config=True) + + fov_offset_max = Float( + help="Maximum value for Field of View offset for background IRF", + default_value=1, + ).tag(config=True) + + fov_offset_n_edges = Integer( + help="Number of edges for Field of View offset for background IRF", + default_value=1, + ).tag(config=True) + + def __init__(self, parent, valid_offset, **kwargs): + super().__init__(parent=parent, **kwargs) + self.reco_energy_bins = create_bins_per_decade( + self.reco_energy_min * u.TeV, + self.reco_energy_max * u.TeV, + self.reco_energy_n_bins_per_decade, + ) + self.valid_offset = valid_offset + self.fov_offset_bins = ( + np.linspace( + self.fov_offset_min, + self.fov_offset_max, + self.fov_offset_n_edges, + ) + * u.deg + ) + # check_bins_in_range(self.fov_offset_bins, self.valid_offset) + + def make_bkg2d_table_hdu(self, bkg_events, obs_time): + sel = bkg_events["selected_gh"] + self.log.debug("%d background events selected" % sel.sum()) + self.log.debug("%f obs time" % obs_time.to_value(u.h)) + + background_rate = background_2d( + bkg_events[sel], + self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + t_obs=obs_time, + ) + return create_background_2d_hdu( + background_rate, + self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + ) + + class EnergyMigrationIrf(Component): """Collects the functionality for generating Migration Matrix IRFs.""" diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 8fa8091365e..78fd74dbb03 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -8,14 +8,15 @@ from pyirf.benchmarks import angular_resolution, energy_bias_resolution from pyirf.binning import create_histogram_table from pyirf.cuts import evaluate_binned_cut -from pyirf.io import create_background_2d_hdu, create_rad_max_hdu -from pyirf.irf import background_2d +from pyirf.io import create_rad_max_hdu from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core import Provenance, Tool, traits from ..core.traits import Bool, Float, Integer, Unicode, flag from ..irf import ( PYIRF_SPECTRA, + Background2dIrf, + Background3dIrf, EffectiveAreaIrf, EnergyMigrationIrf, EventsLoader, @@ -126,12 +127,14 @@ class IrfTool(Tool): classes = [ ThetaCutsCalculator, - OutputEnergyBinning, - FovOffsetBinning, EventsLoader, - PsfIrf, - EnergyMigrationIrf, + Background2dIrf, + Background3dIrf, EffectiveAreaIrf, + EnergyMigrationIrf, + FovOffsetBinning, + OutputEnergyBinning, + PsfIrf, ] def calculate_selections(self): @@ -234,9 +237,16 @@ def setup(self): raise RuntimeError( "At least one electron or proton file required when speficying `do_background`." ) - for loader in self.particles: - # TODO: not very elegant to pass them this way, refactor later - loader.epp.quality_criteria = self.opt_result.precuts.quality_criteria + + if self.do_background: + self.bkg = Background2dIrf( + parent=self, + valid_offset=self.opt_result.valid_offset, + ) + self.bkg3 = Background3dIrf( + parent=self, + valid_offset=self.opt_result.valid_offset, + ) self.aeff = None @@ -297,23 +307,6 @@ def _make_signal_irf_hdus(self, hdus): ) return hdus - def _make_background_hdu(self): - sel = self.background_events["selected_gh"] - self.log.debug("%d background events selected" % sel.sum()) - self.log.debug("%f obs time" % self.obs_time) - - background_rate = background_2d( - self.background_events[sel], - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, - t_obs=self.obs_time * u.Unit(self.obs_time_unit), - ) - return create_background_2d_hdu( - background_rate, - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, - ) - def _make_benchmark_hdus(self, hdus): bias_resolution = energy_bias_resolution( self.signal_events[self.signal_events["selected"]], @@ -363,6 +356,8 @@ def start(self): # tools, try to refactor to a common solution reduced_events = dict() for sel in self.particles: + # TODO: not very elegant to pass them this way, refactor later + sel.epp.quality_criteria = self.opt_result.precuts.quality_criteria evs, cnt, meta = sel.load_preselected_events( self.chunk_size, self.obs_time * u.Unit(self.obs_time_unit), @@ -390,7 +385,16 @@ def start(self): hdus = [fits.PrimaryHDU()] hdus = self._make_signal_irf_hdus(hdus) if self.do_background: - hdus.append(self._make_background_hdu()) + hdus.append( + self.bkg.make_bkg2d_table_hdu( + self.background_events, self.obs_time * u.Unit(self.obs_time_unit) + ) + ) + hdus.append( + self.bkg3.make_bkg3d_table_hdu( + self.background_events, self.obs_time * u.Unit(self.obs_time_unit) + ) + ) self.hdus = hdus if self.do_benchmarks: From df6e10133c1b77169a531d40687a639c4815c56e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 15 Jan 2024 17:49:07 +0100 Subject: [PATCH 063/136] Add option for point-like IRF; support diffuse and point-like gammas --- ctapipe/irf/irfs.py | 39 +++- ctapipe/irf/optimize.py | 53 +++-- ctapipe/irf/select.py | 8 +- src/ctapipe/tools/make_irf.py | 208 +++++++++++------- src/ctapipe/tools/optimize_event_selection.py | 24 +- 5 files changed, 216 insertions(+), 116 deletions(-) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index 5349d97384b..1f6e6c78f36 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -12,6 +12,7 @@ from pyirf.irf import ( background_2d, background_3d, + effective_area_per_energy, effective_area_per_energy_and_fov, energy_dispersion, psf_table, @@ -277,18 +278,19 @@ def __init__(self, parent, **kwargs): self.energy_migration_n_bins, ) - def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins): + def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins, point_like): edisp = energy_dispersion( - signal_events, + selected_events=signal_events, true_energy_bins=self.true_energy_bins, fov_offset_bins=fov_offset_bins, migration_bins=self.migration_bins, ) return create_energy_dispersion_hdu( - edisp, + energy_dispersion=edisp, true_energy_bins=self.true_energy_bins, migration_bins=self.migration_bins, fov_offset_bins=fov_offset_bins, + point_like=point_like, extname="ENERGY DISPERSION", ) @@ -323,16 +325,29 @@ def __init__(self, parent, sim_info, **kwargs): ) self.sim_info = sim_info - def make_effective_area_hdu(self, signal_events, fov_offset_bins): - effective_area = effective_area_per_energy_and_fov( - signal_events, - self.sim_info, + def make_effective_area_hdu( + self, signal_events, fov_offset_bins, point_like, signal_is_point_like + ): + # For point-like gammas the effective area can only be calculated at one point in the FoV + if signal_is_point_like: + effective_area = effective_area_per_energy( + selected_events=signal_events, + simulation_info=self.sim_info, + true_energy_bins=self.true_energy_bins, + ) + else: + effective_area = effective_area_per_energy_and_fov( + selected_events=signal_events, + simulation_info=self.sim_info, + true_energy_bins=self.true_energy_bins, + fov_offset_bins=fov_offset_bins, + ) + return create_aeff2d_hdu( + effective_area=effective_area[ + ..., np.newaxis + ], # +1 dimension for FOV offset true_energy_bins=self.true_energy_bins, fov_offset_bins=fov_offset_bins, - ) - return create_aeff2d_hdu( - effective_area[..., np.newaxis], # +1 dimension for FOV offset - self.true_energy_bins, - fov_offset_bins, + point_like=point_like, extname="EFFECTIVE AREA", ) diff --git a/ctapipe/irf/optimize.py b/ctapipe/irf/optimize.py index 43039141895..fb52e7c1083 100644 --- a/ctapipe/irf/optimize.py +++ b/ctapipe/irf/optimize.py @@ -158,33 +158,44 @@ def optimize_gh_cut( max_fov_radius, theta, precuts, + point_like, ): if not isinstance(max_fov_radius, u.Quantity): raise ValueError("max_fov_radius has to have a unit") if not isinstance(min_fov_radius, u.Quantity): raise ValueError("min_fov_radius has to have a unit") - initial_gh_cuts = calculate_percentile_cut( - signal["gh_score"], - signal["reco_energy"], - bins=self.reco_energy_bins(), - fill_value=0.0, - percentile=100 * (1 - self.initial_gh_cut_efficency), - min_events=25, - smoothing=1, - ) - initial_gh_mask = evaluate_binned_cut( - signal["gh_score"], - signal["reco_energy"], - initial_gh_cuts, - op=operator.gt, - ) + reco_energy_bins = self.reco_energy_bins() + if point_like: + initial_gh_cuts = calculate_percentile_cut( + signal["gh_score"], + signal["reco_energy"], + bins=reco_energy_bins, + fill_value=0.0, + percentile=100 * (1 - self.initial_gh_cut_efficency), + min_events=25, + smoothing=1, + ) + initial_gh_mask = evaluate_binned_cut( + signal["gh_score"], + signal["reco_energy"], + initial_gh_cuts, + op=operator.gt, + ) - theta_cuts = theta.calculate_theta_cuts( - signal["theta"][initial_gh_mask], - signal["reco_energy"][initial_gh_mask], - self.reco_energy_bins(), - ) + theta_cuts = theta.calculate_theta_cuts( + signal["theta"][initial_gh_mask], + signal["reco_energy"][initial_gh_mask], + reco_energy_bins, + ) + else: + # TODO: Find a better solution for full enclosure than this dummy theta cut + self.log.info("Optimizing G/H separation cut without prior theta cut.") + theta_cuts = QTable() + theta_cuts["low"] = reco_energy_bins[:-1] + theta_cuts["center"] = 0.5 * (reco_energy_bins[:-1] + reco_energy_bins[1:]) + theta_cuts["high"] = reco_energy_bins[1:] + theta_cuts["cut"] = max_fov_radius self.log.info("Optimizing G/H separation cut for best sensitivity") gh_cut_efficiencies = np.arange( @@ -196,7 +207,7 @@ def optimize_gh_cut( opt_sens, gh_cuts = optimize_gh_cut( signal, background, - reco_energy_bins=self.reco_energy_bins(), + reco_energy_bins=reco_energy_bins, gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, theta_cuts=theta_cuts, diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index 9432ed6a375..40faabe56c3 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -192,13 +192,19 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): events, prefix="reco" ) - if self.kind == "gammas": + if ( + self.kind == "gammas" + and self.target_spectrum.normalization.unit.is_equivalent( + spectrum.normalization.unit * u.sr + ) + ): if isinstance(fov_bins, FovOffsetBinning): spectrum = spectrum.integrate_cone( fov_bins.fov_offset_min * u.deg, fov_bins.fov_offset_max * u.deg ) else: spectrum = spectrum.integrate_cone(fov_bins[0], fov_bins[-1]) + events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=self.target_spectrum, diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 78fd74dbb03..cf3376eceb8 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -4,14 +4,14 @@ import astropy.units as u import numpy as np from astropy.io import fits -from astropy.table import vstack +from astropy.table import QTable, vstack from pyirf.benchmarks import angular_resolution, energy_bias_resolution from pyirf.binning import create_histogram_table from pyirf.cuts import evaluate_binned_cut from pyirf.io import create_rad_max_hdu from pyirf.sensitivity import calculate_sensitivity, estimate_background -from ..core import Provenance, Tool, traits +from ..core import Provenance, Tool, ToolConfigurationError, traits from ..core.traits import Bool, Float, Integer, Unicode, flag from ..irf import ( PYIRF_SPECTRA, @@ -98,7 +98,15 @@ class IrfTool(Tool): ).tag(config=True) alpha = Float( - default_value=0.2, help="Ratio between size of on and off regions" + default_value=0.2, help="Ratio between size of on and off regions." + ).tag(config=True) + + point_like = Bool( + False, + help=( + "Compute a point-like IRF by applying a theta cut in additon" + " to the G/H separation cut." + ), ).tag(config=True) aliases = { @@ -123,6 +131,12 @@ class IrfTool(Tool): "Produce IRF related benchmarks.", "Do not produce IRF related benchmarks.", ), + **flag( + "point-like", + "IrfTool.point_like", + "Compute a point-like IRF.", + "Compute a full-enclosure IRF.", + ), } classes = [ @@ -137,62 +151,6 @@ class IrfTool(Tool): PsfIrf, ] - def calculate_selections(self): - """Add the selection columns to the signal and optionally background tables""" - self.signal_events["selected_gh"] = evaluate_binned_cut( - self.signal_events["gh_score"], - self.signal_events["reco_energy"], - self.opt_result.gh_cuts, - operator.ge, - ) - self.theta_cuts_opt = self.theta.calculate_theta_cuts( - self.signal_events[self.signal_events["selected_gh"]]["theta"], - self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], - self.reco_energy_bins, - ) - self.signal_events["selected_theta"] = evaluate_binned_cut( - self.signal_events["theta"], - self.signal_events["reco_energy"], - self.theta_cuts_opt, - operator.le, - ) - self.signal_events["selected"] = ( - self.signal_events["selected_theta"] & self.signal_events["selected_gh"] - ) - - if self.do_background: - self.background_events["selected_gh"] = evaluate_binned_cut( - self.background_events["gh_score"], - self.background_events["reco_energy"], - self.opt_result.gh_cuts, - operator.ge, - ) - self.background_events["selected_theta"] = evaluate_binned_cut( - self.background_events["theta"], - self.background_events["reco_energy"], - self.theta_cuts_opt, - operator.le, - ) - self.background_events["selected"] = ( - self.background_events["selected_theta"] - & self.background_events["selected_gh"] - ) - - # TODO: maybe rework the above so we can give the number per - # species instead of the total background - if self.do_background: - self.log.debug( - "Keeping %d signal, %d background events" - % ( - sum(self.signal_events["selected"]), - sum(self.background_events["selected"]), - ) - ) - else: - self.log.debug( - "Keeping %d signal events" % (sum(self.signal_events["selected"])) - ) - def setup(self): self.theta = ThetaCutsCalculator(parent=self) self.e_bins = OutputEnergyBinning(parent=self) @@ -207,6 +165,11 @@ def setup(self): check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.fov_offset_bins, self.opt_result.valid_offset) + if self.point_like and "n_events" not in self.opt_result.theta_cuts.colnames: + raise ToolConfigurationError( + "Computing a point-like IRF requires an (optimized) theta cut." + ) + self.particles = [ EventsLoader( parent=self, @@ -248,21 +211,87 @@ def setup(self): valid_offset=self.opt_result.valid_offset, ) - self.aeff = None - - self.psf = PsfIrf( - parent=self, - valid_offset=self.opt_result.valid_offset, - ) self.mig_matrix = EnergyMigrationIrf( parent=self, ) if self.do_benchmarks: - self.b_hdus = None self.b_output = self.output_path.with_name( self.output_path.name.replace(".fits", "-benchmark.fits") ) + def calculate_selections(self): + """Add the selection columns to the signal and optionally background tables""" + self.signal_events["selected_gh"] = evaluate_binned_cut( + self.signal_events["gh_score"], + self.signal_events["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, + ) + if self.point_like: + self.theta_cuts_opt = self.theta.calculate_theta_cuts( + self.signal_events[self.signal_events["selected_gh"]]["theta"], + self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], + self.reco_energy_bins, + ) + self.signal_events["selected_theta"] = evaluate_binned_cut( + self.signal_events["theta"], + self.signal_events["reco_energy"], + self.theta_cuts_opt, + operator.le, + ) + self.signal_events["selected"] = ( + self.signal_events["selected_theta"] & self.signal_events["selected_gh"] + ) + else: + # Re-"calculate" the dummy theta cut because of potentially different reco energy binning + self.theta_cuts_opt = QTable() + self.theta_cuts_opt["low"] = self.reco_energy_bins[:-1] + self.theta_cuts_opt["center"] = 0.5 * ( + self.reco_energy_bins[:-1] + self.reco_energy_bins[1:] + ) + self.theta_cuts_opt["high"] = self.reco_energy_bins[1:] + self.theta_cuts_opt["cut"] = self.opt_result.valid_offset.max + + self.signal_events["selected"] = self.signal_events["selected_gh"] + + if self.do_background: + self.background_events["selected_gh"] = evaluate_binned_cut( + self.background_events["gh_score"], + self.background_events["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, + ) + if self.point_like: + self.background_events["selected_theta"] = evaluate_binned_cut( + self.background_events["theta"], + self.background_events["reco_energy"], + self.theta_cuts_opt, + operator.le, + ) + self.background_events["selected"] = ( + self.background_events["selected_theta"] + & self.background_events["selected_gh"] + ) + else: + self.background_events["selected"] = self.background_events[ + "selected_gh" + ] + + # TODO: maybe rework the above so we can give the number per + # species instead of the total background + if self.do_background: + self.log.debug( + "Keeping %d signal, %d background events" + % ( + sum(self.signal_events["selected"]), + sum(self.background_events["selected"]), + ) + ) + else: + self.log.debug( + "Keeping %d signal events" % (sum(self.signal_events["selected"])) + ) + def _stack_background(self, reduced_events): bkgs = [] if self.proton_file: @@ -282,29 +311,32 @@ def _make_signal_irf_hdus(self, hdus): self.aeff.make_effective_area_hdu( signal_events=self.signal_events[self.signal_events["selected"]], fov_offset_bins=self.fov_offset_bins, + point_like=self.point_like, + signal_is_point_like=self.signal_is_point_like, ) ) hdus.append( self.mig_matrix.make_energy_dispersion_hdu( signal_events=self.signal_events[self.signal_events["selected"]], fov_offset_bins=self.fov_offset_bins, + point_like=self.point_like, ) ) - - hdus.append( - self.psf.make_psf_table_hdu( - signal_events=self.signal_events[self.signal_events["selected"]], - fov_offset_bins=self.fov_offset_bins, + if not self.point_like: + hdus.append( + self.psf.make_psf_table_hdu( + signal_events=self.signal_events[self.signal_events["selected"]], + fov_offset_bins=self.fov_offset_bins, + ) ) - ) - - hdus.append( - create_rad_max_hdu( - self.theta_cuts_opt["cut"].reshape(-1, 1), - self.reco_energy_bins, - self.fov_offset_bins, + else: + hdus.append( + create_rad_max_hdu( + self.theta_cuts_opt["cut"].reshape(-1, 1), + self.reco_energy_bins, + self.fov_offset_bins, + ) ) - ) return hdus def _make_benchmark_hdus(self, hdus): @@ -371,6 +403,17 @@ def start(self): if sel.kind == "gammas": self.aeff = EffectiveAreaIrf(parent=self, sim_info=meta["sim_info"]) self.gamma_spectrum = meta["spectrum"] + self.signal_is_point_like = ( + meta["sim_info"].viewcone_max - meta["sim_info"].viewcone_min + ).value == 0 + + if self.signal_is_point_like: + self.log.info( + "The gamma input file contains point-like simulations." + " Therefore, the IRF is only calculated at a single point in the FoV." + " Changing `fov_offset_n_bins` to 1." + ) + self.fov_offset_bins.fov_offset_n_bins = 1 self.signal_events = reduced_events["gammas"] if self.do_background: @@ -382,9 +425,14 @@ def start(self): self.log.debug("Reco Energy bins: %s" % str(self.reco_energy_bins.value)) self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) + if not self.point_like: + self.psf = PsfIrf( + parent=self, + valid_offset=self.opt_result.valid_offset, + ) hdus = [fits.PrimaryHDU()] hdus = self._make_signal_irf_hdus(hdus) - if self.do_background: + if self.do_background and not self.point_like: hdus.append( self.bkg.make_bkg2d_table_hdu( self.background_events, self.obs_time * u.Unit(self.obs_time_unit) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index c509257e00c..f63714e7ee6 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -3,7 +3,7 @@ from astropy.table import vstack from ..core import Provenance, Tool, traits -from ..core.traits import Float, Integer, Unicode +from ..core.traits import Bool, Float, Integer, Unicode, flag from ..irf import ( PYIRF_SPECTRA, EventsLoader, @@ -63,7 +63,15 @@ class IrfEventSelector(Tool): ).tag(config=True) alpha = Float( - default_value=0.2, help="Ratio between size of on and off regions" + default_value=0.2, help="Ratio between size of on and off regions." + ).tag(config=True) + + point_like = Bool( + False, + help=( + "Optimize both G/H separation cut and theta cut" + " for computing point-like IRFs" + ), ).tag(config=True) aliases = { @@ -74,6 +82,15 @@ class IrfEventSelector(Tool): "chunk_size": "IrfEventSelector.chunk_size", } + flags = { + **flag( + "point-like", + "IrfEventSelector.point_like", + "Optimize both G/H separation cut and theta cut.", + "Optimize G/H separation cut without prior theta cut.", + ) + } + classes = [GridOptimizer, ThetaCutsCalculator, FovOffsetBinning, EventsLoader] def setup(self): @@ -150,9 +167,12 @@ def start(self): self.bins.fov_offset_max * u.deg, self.theta, self.particles[0].epp, # precuts are the same for all particle types + self.point_like, ) self.log.info("Writing results to %s" % self.output_path) + if not self.point_like: + self.log.info("Writing dummy theta cut to %s" % self.output_path) Provenance().add_output_file(self.output_path, role="Optimization Result") result.write(self.output_path, self.overwrite) From f55b0c69c4e102fe70c44a040ee90e0aa16bd23c Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 11:31:10 +0100 Subject: [PATCH 064/136] Update to consistently use number of bins --- ctapipe/irf/irfs.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index 1f6e6c78f36..6161052da80 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -118,8 +118,8 @@ class Background3dIrf(Component): default_value=1, ).tag(config=True) - fov_offset_n_edges = Integer( - help="Number of edges for Field of View offset for background IRF", + fov_offset_n_bins = Integer( + help="Number of bins for Field of View offset for background IRF", default_value=1, ).tag(config=True) @@ -135,7 +135,7 @@ def __init__(self, parent, valid_offset, **kwargs): np.linspace( self.fov_offset_min, self.fov_offset_max, - self.fov_offset_n_edges, + self.fov_offset_n_bins + 1, ) * u.deg ) @@ -188,8 +188,8 @@ class Background2dIrf(Component): default_value=1, ).tag(config=True) - fov_offset_n_edges = Integer( - help="Number of edges for Field of View offset for background IRF", + fov_offset_n_bins = Integer( + help="Number of bins for Field of View offset for background IRF", default_value=1, ).tag(config=True) @@ -205,7 +205,7 @@ def __init__(self, parent, valid_offset, **kwargs): np.linspace( self.fov_offset_min, self.fov_offset_max, - self.fov_offset_n_edges, + self.fov_offset_n_bins + 1, ) * u.deg ) From 51fca72bb0e9c6f19f03aac0f057536e3783b0a3 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 14:47:44 +0100 Subject: [PATCH 065/136] Added calculation of fov_lat/lon and explicit column descriptions --- ctapipe/irf/select.py | 45 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index 40faabe56c3..dd9bae686fd 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -1,12 +1,14 @@ """Module containing classes related to eveent preprocessing and selection""" import astropy.units as u import numpy as np +from astropy.coordinates import AltAz, SkyCoord from astropy.table import QTable, vstack from pyirf.cuts import calculate_percentile_cut from pyirf.simulations import SimulatedEventsInfo from pyirf.spectral import PowerLaw, calculate_event_weights from pyirf.utils import calculate_source_fov_offset, calculate_theta +from ..coordinates import NominalFrame from ..core import Component, QualityQuery from ..core.traits import Float, Integer, List, Unicode from ..io import TableLoader @@ -83,6 +85,8 @@ def make_empty_table(self): "reco_energy", "reco_az", "reco_alt", + "reco_fov_lat", + "reco_fov_lon", "gh_score", "pointing_az", "pointing_alt", @@ -98,14 +102,34 @@ def make_empty_table(self): "reco_energy": u.TeV, "reco_az": u.deg, "reco_alt": u.deg, + "reco_fov_lat": u.deg, + "reco_fov_lon": u.deg, "pointing_az": u.deg, "pointing_alt": u.deg, "theta": u.deg, "true_source_fov_offset": u.deg, "reco_source_fov_offset": u.deg, } + descriptions = { + "obs_id": "Observation Block ID", + "event_id": "Array Event ID", + "true_energy": "Simulated Energy", + "true_az": "Simulated azimuth", + "true_alt": "Simulated altitude", + "reco_energy": "Reconstructed energy", + "reco_az": "Reconstructed azimuth", + "reco_alt": "Reconstructed altitude", + "reco_fov_lat": "Reconstructed field of view lat", + "reco_fov_lon": "Reconstructed field of view lon", + "pointing_az": "Pointing azimuth", + "pointing_alt": "Pointing altitude", + "theta": "Reconstructed angular offset from source position", + "true_source_fov_offset": "Simulated angular offset from pointing direction", + "reco_source_fov_offset": "Reconstructed angular offset from pointing direction", + "gh_score": "prediction of the classifier, defined between [0,1], where values close to 1 mean that the positive class (e.g. gamma in gamma-ray analysis) is more likely", + } - return QTable(names=columns, units=units) + return QTable(names=columns, units=units, descriptions=descriptions) class EventsLoader(Component): @@ -139,7 +163,8 @@ def load_preselected_events(self, chunk_size, obs_time, fov_bins): bits.append(selected) n_raw_events += len(events) - table = vstack(bits, join_type="exact") + bits.append(header) # Putting it last ensures the correct metadata is used + table = vstack(bits, join_type="exact", metadata_conflicts="silent") return table, n_raw_events, meta def get_metadata(self, loader, obs_time): @@ -192,6 +217,22 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): events, prefix="reco" ) + altaz = AltAz() + pointing = SkyCoord( + alt=events["pointing_alt"], az=events["pointing_az"], frame=altaz + ) + reco = SkyCoord( + alt=events["reco_alt"], + az=events["reco_az"], + frame=altaz, + ) + nominal = NominalFrame(origin=pointing) + reco_nominal = reco.transform_to(nominal) + events["reco_fov_lon"] = u.Quantity( + -reco_nominal.fov_lon, copy=False + ) # minus for GADF + events["reco_fov_lat"] = u.Quantity(reco_nominal.fov_lat, copy=False) + if ( self.kind == "gammas" and self.target_spectrum.normalization.unit.is_equivalent( From 61fcab07486a04e09f1d2d64cb4877d4c06cf1eb Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 14:48:06 +0100 Subject: [PATCH 066/136] Added a bit of logging --- src/ctapipe/tools/make_irf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index cf3376eceb8..a1938cbab95 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -390,6 +390,7 @@ def start(self): for sel in self.particles: # TODO: not very elegant to pass them this way, refactor later sel.epp.quality_criteria = self.opt_result.precuts.quality_criteria + self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) evs, cnt, meta = sel.load_preselected_events( self.chunk_size, self.obs_time * u.Unit(self.obs_time_unit), From 2d003416587ec70c038d334bec8cd20c4995ed2a Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 15:34:07 +0100 Subject: [PATCH 067/136] Remove unnecessary conversions --- ctapipe/irf/irfs.py | 1 - ctapipe/irf/select.py | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ctapipe/irf/irfs.py b/ctapipe/irf/irfs.py index 6161052da80..57561302645 100644 --- a/ctapipe/irf/irfs.py +++ b/ctapipe/irf/irfs.py @@ -145,7 +145,6 @@ def make_bkg3d_table_hdu(self, bkg_events, obs_time): sel = bkg_events["selected_gh"] self.log.debug("%d background events selected" % sel.sum()) self.log.debug("%f obs time" % obs_time.to_value(u.h)) - breakpoint() background_rate = background_3d( bkg_events[sel], self.reco_energy_bins, diff --git a/ctapipe/irf/select.py b/ctapipe/irf/select.py index dd9bae686fd..4468f3b8209 100644 --- a/ctapipe/irf/select.py +++ b/ctapipe/irf/select.py @@ -228,10 +228,8 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): ) nominal = NominalFrame(origin=pointing) reco_nominal = reco.transform_to(nominal) - events["reco_fov_lon"] = u.Quantity( - -reco_nominal.fov_lon, copy=False - ) # minus for GADF - events["reco_fov_lat"] = u.Quantity(reco_nominal.fov_lat, copy=False) + events["reco_fov_lon"] = -reco_nominal.fov_lon # minus for GADF + events["reco_fov_lat"] = reco_nominal.fov_lat if ( self.kind == "gammas" From e95b9b1aa5c8a4afa314703d60cf4fba68e33828 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 6 Feb 2024 14:08:38 +0100 Subject: [PATCH 068/136] Moved stuff to match src/ organisatoin --- {ctapipe => src/ctapipe}/irf/__init__.py | 0 {ctapipe => src/ctapipe}/irf/binning.py | 0 {ctapipe => src/ctapipe}/irf/irf_classes.py | 0 {ctapipe => src/ctapipe}/irf/irfs.py | 0 {ctapipe => src/ctapipe}/irf/optimize.py | 0 {ctapipe => src/ctapipe}/irf/select.py | 0 {ctapipe => src/ctapipe}/irf/visualisation.py | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename {ctapipe => src/ctapipe}/irf/__init__.py (100%) rename {ctapipe => src/ctapipe}/irf/binning.py (100%) rename {ctapipe => src/ctapipe}/irf/irf_classes.py (100%) rename {ctapipe => src/ctapipe}/irf/irfs.py (100%) rename {ctapipe => src/ctapipe}/irf/optimize.py (100%) rename {ctapipe => src/ctapipe}/irf/select.py (100%) rename {ctapipe => src/ctapipe}/irf/visualisation.py (100%) diff --git a/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py similarity index 100% rename from ctapipe/irf/__init__.py rename to src/ctapipe/irf/__init__.py diff --git a/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py similarity index 100% rename from ctapipe/irf/binning.py rename to src/ctapipe/irf/binning.py diff --git a/ctapipe/irf/irf_classes.py b/src/ctapipe/irf/irf_classes.py similarity index 100% rename from ctapipe/irf/irf_classes.py rename to src/ctapipe/irf/irf_classes.py diff --git a/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py similarity index 100% rename from ctapipe/irf/irfs.py rename to src/ctapipe/irf/irfs.py diff --git a/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py similarity index 100% rename from ctapipe/irf/optimize.py rename to src/ctapipe/irf/optimize.py diff --git a/ctapipe/irf/select.py b/src/ctapipe/irf/select.py similarity index 100% rename from ctapipe/irf/select.py rename to src/ctapipe/irf/select.py diff --git a/ctapipe/irf/visualisation.py b/src/ctapipe/irf/visualisation.py similarity index 100% rename from ctapipe/irf/visualisation.py rename to src/ctapipe/irf/visualisation.py From ad88ebef7de884b839e2ffcc6819b753aadd6a6a Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Sun, 25 Feb 2024 23:33:00 +0100 Subject: [PATCH 069/136] Fix extra dimension in effective area per offset, adapt to new loader syntax --- src/ctapipe/irf/irfs.py | 6 +++--- src/ctapipe/irf/select.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 57561302645..da9ebcda3cd 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -334,6 +334,8 @@ def make_effective_area_hdu( simulation_info=self.sim_info, true_energy_bins=self.true_energy_bins, ) + # +1 dimension for FOV offset + effective_area = effective_area[..., np.newaxis] else: effective_area = effective_area_per_energy_and_fov( selected_events=signal_events, @@ -342,9 +344,7 @@ def make_effective_area_hdu( fov_offset_bins=fov_offset_bins, ) return create_aeff2d_hdu( - effective_area=effective_area[ - ..., np.newaxis - ], # +1 dimension for FOV offset + effective_area, true_energy_bins=self.true_energy_bins, fov_offset_bins=fov_offset_bins, point_like=point_like, diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 4468f3b8209..bdafcbeae95 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -144,7 +144,7 @@ def __init__(self, kind, file, target_spectrum, **kwargs): self.file = file def load_preselected_events(self, chunk_size, obs_time, fov_bins): - opts = dict(load_dl2=True, load_simulated=True, load_dl1_parameters=False) + opts = dict(dl2=True, simulated=True) with TableLoader(self.file, parent=self, **opts) as load: header = self.epp.make_empty_table() sim_info, spectrum, obs_conf = self.get_metadata(load, obs_time) @@ -154,7 +154,7 @@ def load_preselected_events(self, chunk_size, obs_time, fov_bins): meta = None bits = [header] n_raw_events = 0 - for _, _, events in load.read_subarray_events_chunked(chunk_size): + for _, _, events in load.read_subarray_events_chunked(chunk_size, **opts): selected = events[self.epp.get_table_mask(events)] selected = self.epp.normalise_column_names(selected) selected = self.make_derived_columns( From e8997a9a24a23ee896cca6a6dd0c341b77291c6f Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 29 Feb 2024 14:38:08 +0100 Subject: [PATCH 070/136] Fixed bug where background only used gh-cuts --- src/ctapipe/irf/irfs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index da9ebcda3cd..cf9b84d8fc3 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -142,7 +142,7 @@ def __init__(self, parent, valid_offset, **kwargs): # check_bins_in_range(self.fov_offset_bins, self.valid_offset) def make_bkg3d_table_hdu(self, bkg_events, obs_time): - sel = bkg_events["selected_gh"] + sel = bkg_events["selected"] self.log.debug("%d background events selected" % sel.sum()) self.log.debug("%f obs time" % obs_time.to_value(u.h)) background_rate = background_3d( @@ -211,7 +211,7 @@ def __init__(self, parent, valid_offset, **kwargs): # check_bins_in_range(self.fov_offset_bins, self.valid_offset) def make_bkg2d_table_hdu(self, bkg_events, obs_time): - sel = bkg_events["selected_gh"] + sel = bkg_events["selected"] self.log.debug("%d background events selected" % sel.sum()) self.log.debug("%f obs time" % obs_time.to_value(u.h)) From e87d01629f1606fc9dbcf5997936d51fece6e727 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Mar 2024 13:39:31 +0100 Subject: [PATCH 071/136] Renamed some options for clarity --- src/ctapipe/tools/make_irf.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index a1938cbab95..11a4ac546a0 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -50,7 +50,7 @@ class IrfTool(Tool): gamma_file = traits.Path( default_value=None, directory_ok=False, help="Gamma input filename and path" ).tag(config=True) - gamma_sim_spectrum = traits.UseEnum( + gamma_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.CRAB_HEGRA, help="Name of the pyrif spectra used for the simulated gamma spectrum", @@ -61,7 +61,7 @@ class IrfTool(Tool): directory_ok=False, help="Proton input filename and path", ).tag(config=True) - proton_sim_spectrum = traits.UseEnum( + proton_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_PROTON_SPECTRUM, help="Name of the pyrif spectra used for the simulated proton spectrum", @@ -72,7 +72,7 @@ class IrfTool(Tool): directory_ok=False, help="Electron input filename and path", ).tag(config=True) - electron_sim_spectrum = traits.UseEnum( + electron_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, help="Name of the pyrif spectra used for the simulated electron spectrum", @@ -175,7 +175,7 @@ def setup(self): parent=self, kind="gammas", file=self.gamma_file, - target_spectrum=PYIRF_SPECTRA[self.gamma_sim_spectrum], + target_spectrum=PYIRF_SPECTRA[self.gamma_target_spectrum], ), ] if self.do_background and self.proton_file: @@ -184,7 +184,7 @@ def setup(self): parent=self, kind="protons", file=self.proton_file, - target_spectrum=PYIRF_SPECTRA[self.proton_sim_spectrum], + target_spectrum=PYIRF_SPECTRA[self.proton_target_spectrum], ) ) if self.do_background and self.electron_file: @@ -193,7 +193,7 @@ def setup(self): parent=self, kind="electrons", file=self.electron_file, - target_spectrum=PYIRF_SPECTRA[self.electron_sim_spectrum], + target_spectrum=PYIRF_SPECTRA[self.electron_target_spectrum], ) ) if self.do_background and len(self.particles) == 1: @@ -373,11 +373,11 @@ def _make_benchmark_hdus(self, hdus): sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha ) - + gamma_spectrum = PYIRF_SPECTRA[self.gamma_target_spectrum] # scale relative sensitivity by Crab flux to get the flux sensitivity sensitivity["flux_sensitivity"] = sensitivity[ "relative_sensitivity" - ] * self.gamma_spectrum(sensitivity["reco_energy_center"]) + ] * gamma_spectrum(sensitivity["reco_energy_center"]) hdus.append(fits.BinTableHDU(sensitivity, name="SENSITIVITY")) @@ -403,7 +403,6 @@ def start(self): ) if sel.kind == "gammas": self.aeff = EffectiveAreaIrf(parent=self, sim_info=meta["sim_info"]) - self.gamma_spectrum = meta["spectrum"] self.signal_is_point_like = ( meta["sim_info"].viewcone_max - meta["sim_info"].viewcone_min ).value == 0 From 9126b6ad3c1df1a66ac58057c8151f29061d6aee Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 Apr 2024 18:32:48 +0200 Subject: [PATCH 072/136] Clarified arguments for full-enclosure irfs --- src/ctapipe/tools/make_irf.py | 63 +++++++++++++++++------------------ 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 11a4ac546a0..4c0fc3c366c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -101,11 +101,11 @@ class IrfTool(Tool): default_value=0.2, help="Ratio between size of on and off regions." ).tag(config=True) - point_like = Bool( + full_enclosure = Bool( False, help=( - "Compute a point-like IRF by applying a theta cut in additon" - " to the G/H separation cut." + "Compute a full enclosure IRF by not applying a theta cut and only use" + " the G/H separation cut." ), ).tag(config=True) @@ -132,10 +132,10 @@ class IrfTool(Tool): "Do not produce IRF related benchmarks.", ), **flag( - "point-like", - "IrfTool.point_like", - "Compute a point-like IRF.", + "full-enclosure", + "IrfTool.full_enclosure", "Compute a full-enclosure IRF.", + "Compute a point-like IRF.", ), } @@ -165,7 +165,10 @@ def setup(self): check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.fov_offset_bins, self.opt_result.valid_offset) - if self.point_like and "n_events" not in self.opt_result.theta_cuts.colnames: + if ( + not self.full_enclosure + and "n_events" not in self.opt_result.theta_cuts.colnames + ): raise ToolConfigurationError( "Computing a point-like IRF requires an (optimized) theta cut." ) @@ -227,7 +230,7 @@ def calculate_selections(self): self.opt_result.gh_cuts, operator.ge, ) - if self.point_like: + if not self.full_enclosure: self.theta_cuts_opt = self.theta.calculate_theta_cuts( self.signal_events[self.signal_events["selected_gh"]]["theta"], self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], @@ -261,7 +264,7 @@ def calculate_selections(self): self.opt_result.gh_cuts, operator.ge, ) - if self.point_like: + if not self.full_enclosure: self.background_events["selected_theta"] = evaluate_binned_cut( self.background_events["theta"], self.background_events["reco_energy"], @@ -311,7 +314,7 @@ def _make_signal_irf_hdus(self, hdus): self.aeff.make_effective_area_hdu( signal_events=self.signal_events[self.signal_events["selected"]], fov_offset_bins=self.fov_offset_bins, - point_like=self.point_like, + point_like=not self.full_enclosure, signal_is_point_like=self.signal_is_point_like, ) ) @@ -319,24 +322,22 @@ def _make_signal_irf_hdus(self, hdus): self.mig_matrix.make_energy_dispersion_hdu( signal_events=self.signal_events[self.signal_events["selected"]], fov_offset_bins=self.fov_offset_bins, - point_like=self.point_like, + point_like=not self.full_enclosure, ) ) - if not self.point_like: - hdus.append( - self.psf.make_psf_table_hdu( - signal_events=self.signal_events[self.signal_events["selected"]], - fov_offset_bins=self.fov_offset_bins, - ) + hdus.append( + self.psf.make_psf_table_hdu( + signal_events=self.signal_events[self.signal_events["selected"]], + fov_offset_bins=self.fov_offset_bins, ) - else: - hdus.append( - create_rad_max_hdu( - self.theta_cuts_opt["cut"].reshape(-1, 1), - self.reco_energy_bins, - self.fov_offset_bins, - ) + ) + hdus.append( + create_rad_max_hdu( + self.theta_cuts_opt["cut"].reshape(-1, 1), + self.reco_energy_bins, + self.fov_offset_bins, ) + ) return hdus def _make_benchmark_hdus(self, hdus): @@ -384,8 +385,7 @@ def _make_benchmark_hdus(self, hdus): return hdus def start(self): - # TODO: this event loading code seems to be largely repeated between both - # tools, try to refactor to a common solution + reduced_events = dict() for sel in self.particles: # TODO: not very elegant to pass them this way, refactor later @@ -425,14 +425,13 @@ def start(self): self.log.debug("Reco Energy bins: %s" % str(self.reco_energy_bins.value)) self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) - if not self.point_like: - self.psf = PsfIrf( - parent=self, - valid_offset=self.opt_result.valid_offset, - ) + self.psf = PsfIrf( + parent=self, + valid_offset=self.opt_result.valid_offset, + ) hdus = [fits.PrimaryHDU()] hdus = self._make_signal_irf_hdus(hdus) - if self.do_background and not self.point_like: + if self.do_background: hdus.append( self.bkg.make_bkg2d_table_hdu( self.background_events, self.obs_time * u.Unit(self.obs_time_unit) From d66a91df04450f5425bb219c02969b9d7404a3fd Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 14:47:02 +0200 Subject: [PATCH 073/136] Sort and remove duplicates in sphinx nitpicks --- docs/conf.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index ec81ed8b61e..2c397c32c39 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -118,6 +118,7 @@ def setup(app): ("py:class", "t.Type"), ("py:class", "t.List"), ("py:class", "t.Tuple"), + ("py:class", "t.Sequence"), ("py:class", "Config"), ("py:class", "traitlets.config.configurable.Configurable"), ("py:class", "traitlets.traitlets.HasTraits"), @@ -131,40 +132,35 @@ def setup(app): ("py:class", "traitlets.traitlets.Int"), ("py:class", "traitlets.config.application.Application"), ("py:class", "traitlets.utils.sentinel.Sentinel"), + ("py:class", "traitlets.traitlets.ObserveHandler"), ("py:class", "traitlets.traitlets.T"), - ("py:class", "re.Pattern[t.Any]"), + ("py:class", "traitlets.traitlets.G"), ("py:class", "Sentinel"), ("py:class", "ObserveHandler"), - ("py:class", "traitlets.traitlets.ObserveHandler"), ("py:class", "dict[K, V]"), ("py:class", "G"), ("py:class", "K"), ("py:class", "V"), - ("py:class", "t.Sequence"), ("py:class", "StrDict"), ("py:class", "ClassesType"), - ("py:class", "traitlets.traitlets.G"), + ("py:class", "re.Pattern"), + ("py:class", "re.Pattern[t.Any]"), + ("py:class", "astropy.coordinates.baseframe.BaseCoordinateFrame"), + ("py:class", "astropy.table.table.Table"), + ("py:class", "eventio.simtel.simtelfile.SimTelFile"), + ("py:class", "ctapipe.compat.StrEnum"), + ("py:class", "ctapipe.compat.StrEnum"), + ("py:obj", "traitlets.traitlets.T"), ("py:obj", "traitlets.traitlets.G"), ("py:obj", "traitlets.traitlets.S"), - ("py:obj", "traitlets.traitlets.T"), - ("py:class", "traitlets.traitlets.T"), - ("py:class", "re.Pattern[t.Any]"), - ("py:class", "re.Pattern"), - ("py:class", "Sentinel"), - ("py:class", "ObserveHandler"), ("py:obj", "traitlets.config.boolean_flag"), ("py:obj", "traitlets.TraitError"), ("py:obj", "-v"), # fix for wrong syntax in a traitlets docstring + ("py:obj", "cls"), + ("py:obj", "name"), ("py:meth", "MetaHasDescriptors.__init__"), ("py:meth", "HasTraits.__new__"), ("py:meth", "BaseDescriptor.instance_init"), - ("py:obj", "cls"), - ("py:obj", "name"), - ("py:class", "astropy.coordinates.baseframe.BaseCoordinateFrame"), - ("py:class", "astropy.table.table.Table"), - ("py:class", "eventio.simtel.simtelfile.SimTelFile"), - ("py:class", "ctapipe.compat.StrEnum"), - ("py:class", "ctapipe.compat.StrEnum"), ] # Sphinx gallery config From 418d7208eb0a61998c0f9dc7084a54e1dca09183 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 15:16:27 +0200 Subject: [PATCH 074/136] Use AstroQuantity trait for observation time --- src/ctapipe/tools/make_irf.py | 23 ++++++++----------- src/ctapipe/tools/optimize_event_selection.py | 12 +++++----- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 4c0fc3c366c..8ea50825015 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -12,7 +12,7 @@ from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core import Provenance, Tool, ToolConfigurationError, traits -from ..core.traits import Bool, Float, Integer, Unicode, flag +from ..core.traits import AstroQuantity, Bool, Float, Integer, flag from ..irf import ( PYIRF_SPECTRA, Background2dIrf, @@ -91,10 +91,10 @@ class IrfTool(Tool): help="Output file", ).tag(config=True) - obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) - obs_time_unit = Unicode( - default_value="hour", - help="Unit used to specify observation time as an astropy unit string.", + obs_time = AstroQuantity( + default_value=50.0 * u.hour, + physical_type=u.physical.time, + help="Observation time in the form `` ``", ).tag(config=True) alpha = Float( @@ -201,7 +201,7 @@ def setup(self): ) if self.do_background and len(self.particles) == 1: raise RuntimeError( - "At least one electron or proton file required when speficying `do_background`." + "At least one electron or proton file required when specifying `do_background`." ) if self.do_background: @@ -385,7 +385,6 @@ def _make_benchmark_hdus(self, hdus): return hdus def start(self): - reduced_events = dict() for sel in self.particles: # TODO: not very elegant to pass them this way, refactor later @@ -393,7 +392,7 @@ def start(self): self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) evs, cnt, meta = sel.load_preselected_events( self.chunk_size, - self.obs_time * u.Unit(self.obs_time_unit), + self.obs_time, self.fov_offset_bins, ) reduced_events[sel.kind] = evs @@ -433,14 +432,10 @@ def start(self): hdus = self._make_signal_irf_hdus(hdus) if self.do_background: hdus.append( - self.bkg.make_bkg2d_table_hdu( - self.background_events, self.obs_time * u.Unit(self.obs_time_unit) - ) + self.bkg.make_bkg2d_table_hdu(self.background_events, self.obs_time) ) hdus.append( - self.bkg3.make_bkg3d_table_hdu( - self.background_events, self.obs_time * u.Unit(self.obs_time_unit) - ) + self.bkg3.make_bkg3d_table_hdu(self.background_events, self.obs_time) ) self.hdus = hdus diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index f63714e7ee6..5fe26fe03e8 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -3,7 +3,7 @@ from astropy.table import vstack from ..core import Provenance, Tool, traits -from ..core.traits import Bool, Float, Integer, Unicode, flag +from ..core.traits import AstroQuantity, Bool, Float, Integer, flag from ..irf import ( PYIRF_SPECTRA, EventsLoader, @@ -56,10 +56,10 @@ class IrfEventSelector(Tool): help="Output file storing optimization result", ).tag(config=True) - obs_time = Float(default_value=50.0, help="Observation time").tag(config=True) - obs_time_unit = Unicode( - default_value="hour", - help="Unit used to specify observation time as an astropy unit string.", + obs_time = AstroQuantity( + default_value=50.0 * u.hour, + physical_type=u.physical.time, + help="Observation time in the form `` ``", ).tag(config=True) alpha = Float( @@ -126,7 +126,7 @@ def start(self): reduced_events = dict() for sel in self.particles: evs, cnt, meta = sel.load_preselected_events( - self.chunk_size, self.obs_time * u.Unit(self.obs_time_unit), self.bins + self.chunk_size, self.obs_time, self.bins ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt From 65a46aba398b4894e7c921192bc8cd88bdb23c0e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 15:43:07 +0200 Subject: [PATCH 075/136] Add definition of selectable spectra to irf.select --- src/ctapipe/irf/__init__.py | 11 ++++++--- src/ctapipe/irf/irf_classes.py | 19 --------------- src/ctapipe/irf/select.py | 23 ++++++++++++++++++- src/ctapipe/tools/make_irf.py | 16 ++++++------- src/ctapipe/tools/optimize_event_selection.py | 14 +++++------ 5 files changed, 45 insertions(+), 38 deletions(-) delete mode 100644 src/ctapipe/irf/irf_classes.py diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 41618b91c1a..c0f313e73cb 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,6 +1,5 @@ """Top level module for the irf functionality""" from .binning import FovOffsetBinning, OutputEnergyBinning, check_bins_in_range -from .irf_classes import PYIRF_SPECTRA, Spectra from .irfs import ( Background2dIrf, Background3dIrf, @@ -9,7 +8,13 @@ PsfIrf, ) from .optimize import GridOptimizer, OptimizationResult, OptimizationResultStore -from .select import EventPreProcessor, EventsLoader, ThetaCutsCalculator +from .select import ( + SPECTRA, + EventPreProcessor, + EventsLoader, + Spectra, + ThetaCutsCalculator, +) __all__ = [ "Background2dIrf", @@ -26,6 +31,6 @@ "EventPreProcessor", "Spectra", "ThetaCutsCalculator", - "PYIRF_SPECTRA", + "SPECTRA", "check_bins_in_range", ] diff --git a/src/ctapipe/irf/irf_classes.py b/src/ctapipe/irf/irf_classes.py deleted file mode 100644 index 570e8fdd869..00000000000 --- a/src/ctapipe/irf/irf_classes.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Defines classe with no better home -""" -from enum import Enum - -from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM - - -class Spectra(Enum): - CRAB_HEGRA = 1 - IRFDOC_ELECTRON_SPECTRUM = 2 - IRFDOC_PROTON_SPECTRUM = 3 - - -PYIRF_SPECTRA = { - Spectra.CRAB_HEGRA: CRAB_HEGRA, - Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, - Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, -} diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index bdafcbeae95..74ed7e93a4a 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -1,11 +1,19 @@ """Module containing classes related to eveent preprocessing and selection""" +from enum import Enum + import astropy.units as u import numpy as np from astropy.coordinates import AltAz, SkyCoord from astropy.table import QTable, vstack from pyirf.cuts import calculate_percentile_cut from pyirf.simulations import SimulatedEventsInfo -from pyirf.spectral import PowerLaw, calculate_event_weights +from pyirf.spectral import ( + CRAB_HEGRA, + IRFDOC_ELECTRON_SPECTRUM, + IRFDOC_PROTON_SPECTRUM, + PowerLaw, + calculate_event_weights, +) from pyirf.utils import calculate_source_fov_offset, calculate_theta from ..coordinates import NominalFrame @@ -15,6 +23,19 @@ from ..irf import FovOffsetBinning +class Spectra(Enum): + CRAB_HEGRA = 1 + IRFDOC_ELECTRON_SPECTRUM = 2 + IRFDOC_PROTON_SPECTRUM = 3 + + +SPECTRA = { + Spectra.CRAB_HEGRA: CRAB_HEGRA, + Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, + Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, +} + + class EventPreProcessor(QualityQuery): """Defines preselection cuts and the necessary renaming of columns""" diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 8ea50825015..013ecd5903a 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -14,7 +14,7 @@ from ..core import Provenance, Tool, ToolConfigurationError, traits from ..core.traits import AstroQuantity, Bool, Float, Integer, flag from ..irf import ( - PYIRF_SPECTRA, + SPECTRA, Background2dIrf, Background3dIrf, EffectiveAreaIrf, @@ -53,7 +53,7 @@ class IrfTool(Tool): gamma_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.CRAB_HEGRA, - help="Name of the pyrif spectra used for the simulated gamma spectrum", + help="Name of the pyirf spectra used for the simulated gamma spectrum", ).tag(config=True) proton_file = traits.Path( default_value=None, @@ -64,7 +64,7 @@ class IrfTool(Tool): proton_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_PROTON_SPECTRUM, - help="Name of the pyrif spectra used for the simulated proton spectrum", + help="Name of the pyirf spectra used for the simulated proton spectrum", ).tag(config=True) electron_file = traits.Path( default_value=None, @@ -75,7 +75,7 @@ class IrfTool(Tool): electron_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, - help="Name of the pyrif spectra used for the simulated electron spectrum", + help="Name of the pyirf spectra used for the simulated electron spectrum", ).tag(config=True) chunk_size = Integer( @@ -178,7 +178,7 @@ def setup(self): parent=self, kind="gammas", file=self.gamma_file, - target_spectrum=PYIRF_SPECTRA[self.gamma_target_spectrum], + target_spectrum=SPECTRA[self.gamma_target_spectrum], ), ] if self.do_background and self.proton_file: @@ -187,7 +187,7 @@ def setup(self): parent=self, kind="protons", file=self.proton_file, - target_spectrum=PYIRF_SPECTRA[self.proton_target_spectrum], + target_spectrum=SPECTRA[self.proton_target_spectrum], ) ) if self.do_background and self.electron_file: @@ -196,7 +196,7 @@ def setup(self): parent=self, kind="electrons", file=self.electron_file, - target_spectrum=PYIRF_SPECTRA[self.electron_target_spectrum], + target_spectrum=SPECTRA[self.electron_target_spectrum], ) ) if self.do_background and len(self.particles) == 1: @@ -374,7 +374,7 @@ def _make_benchmark_hdus(self, hdus): sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha ) - gamma_spectrum = PYIRF_SPECTRA[self.gamma_target_spectrum] + gamma_spectrum = SPECTRA[self.gamma_target_spectrum] # scale relative sensitivity by Crab flux to get the flux sensitivity sensitivity["flux_sensitivity"] = sensitivity[ "relative_sensitivity" diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 5fe26fe03e8..b34c248b0a9 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -5,7 +5,7 @@ from ..core import Provenance, Tool, traits from ..core.traits import AstroQuantity, Bool, Float, Integer, flag from ..irf import ( - PYIRF_SPECTRA, + SPECTRA, EventsLoader, FovOffsetBinning, GridOptimizer, @@ -24,7 +24,7 @@ class IrfEventSelector(Tool): gamma_sim_spectrum = traits.UseEnum( Spectra, default_value=Spectra.CRAB_HEGRA, - help="Name of the pyrif spectra used for the simulated gamma spectrum", + help="Name of the pyirf spectra used for the simulated gamma spectrum", ).tag(config=True) proton_file = traits.Path( default_value=None, directory_ok=False, help="Proton input filename and path" @@ -32,7 +32,7 @@ class IrfEventSelector(Tool): proton_sim_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_PROTON_SPECTRUM, - help="Name of the pyrif spectra used for the simulated proton spectrum", + help="Name of the pyirf spectra used for the simulated proton spectrum", ).tag(config=True) electron_file = traits.Path( default_value=None, directory_ok=False, help="Electron input filename and path" @@ -40,7 +40,7 @@ class IrfEventSelector(Tool): electron_sim_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, - help="Name of the pyrif spectra used for the simulated electron spectrum", + help="Name of the pyirf spectra used for the simulated electron spectrum", ).tag(config=True) chunk_size = Integer( @@ -103,19 +103,19 @@ def setup(self): parent=self, kind="gammas", file=self.gamma_file, - target_spectrum=PYIRF_SPECTRA[self.gamma_sim_spectrum], + target_spectrum=SPECTRA[self.gamma_sim_spectrum], ), EventsLoader( parent=self, kind="protons", file=self.proton_file, - target_spectrum=PYIRF_SPECTRA[self.proton_sim_spectrum], + target_spectrum=SPECTRA[self.proton_sim_spectrum], ), EventsLoader( parent=self, kind="electrons", file=self.electron_file, - target_spectrum=PYIRF_SPECTRA[self.electron_sim_spectrum], + target_spectrum=SPECTRA[self.electron_sim_spectrum], ), ] From b29636db9fe0f0d3331c9edc6552361eb57be538 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 18:17:33 +0200 Subject: [PATCH 076/136] Force same clf for computation and application of g/h cuts; warn if overwriting precuts in irf-tool --- src/ctapipe/irf/optimize.py | 18 ++++++----- src/ctapipe/irf/select.py | 3 +- src/ctapipe/tools/make_irf.py | 31 ++++++++++++++++++- src/ctapipe/tools/optimize_event_selection.py | 17 +++++----- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index fb52e7c1083..bf823db6c0a 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -52,18 +52,19 @@ def __init__(self, precuts=None): self._results = None - def set_result(self, gh_cuts, theta_cuts, valid_energy, valid_offset): + def set_result(self, gh_cuts, theta_cuts, valid_energy, valid_offset, clf_prefix): if not self._precuts: raise ValueError("Precuts must be defined before results can be saved") - gh_cuts.meta["extname"] = "GH_CUTS" - theta_cuts.meta["extname"] = "RAD_MAX" + gh_cuts.meta["EXTNAME"] = "GH_CUTS" + gh_cuts.meta["CLFNAME"] = clf_prefix + theta_cuts.meta["EXTNAME"] = "RAD_MAX" energy_lim_tab = QTable(rows=[valid_energy], names=["energy_min", "energy_max"]) - energy_lim_tab.meta["extname"] = "VALID_ENERGY" + energy_lim_tab.meta["EXTNAME"] = "VALID_ENERGY" offset_lim_tab = QTable(rows=[valid_offset], names=["offset_min", "offset_max"]) - offset_lim_tab.meta["extname"] = "VALID_OFFSET" + offset_lim_tab.meta["EXTNAME"] = "VALID_OFFSET" self._results = [gh_cuts, theta_cuts, energy_lim_tab, offset_lim_tab] @@ -80,7 +81,7 @@ def write(self, output_name, overwrite=False): names=["name", "cut_expr"], dtype=[np.unicode_, np.unicode_], ) - cut_expr_tab.meta["extname"] = "QUALITY_CUTS_EXPR" + cut_expr_tab.meta["EXTNAME"] = "QUALITY_CUTS_EXPR" cut_expr_tab.write(output_name, format="fits", overwrite=overwrite) @@ -95,8 +96,7 @@ def read(self, file_name): cut_expr_lst.remove((" ", " ")) except ValueError: pass - precuts = QualityQuery() - precuts.quality_criteria = cut_expr_lst + precuts = QualityQuery(quality_criteria=cut_expr_lst) gh_cuts = QTable.read(file_name, hdu=2) theta_cuts = QTable.read(file_name, hdu=3) valid_energy = QTable.read(file_name, hdu=4) @@ -158,6 +158,7 @@ def optimize_gh_cut( max_fov_radius, theta, precuts, + clf_prefix, point_like, ): if not isinstance(max_fov_radius, u.Quantity): @@ -223,6 +224,7 @@ def optimize_gh_cut( theta_cuts, valid_energy=valid_energy, valid_offset=[min_fov_radius, max_fov_radius], + clf_prefix=clf_prefix, ) return result_saver, opt_sens diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 74ed7e93a4a..28477379564 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -18,7 +18,7 @@ from ..coordinates import NominalFrame from ..core import Component, QualityQuery -from ..core.traits import Float, Integer, List, Unicode +from ..core.traits import Float, Integer, List, Tuple, Unicode from ..io import TableLoader from ..irf import FovOffsetBinning @@ -53,6 +53,7 @@ class EventPreProcessor(QualityQuery): ).tag(config=True) quality_criteria = List( + Tuple(Unicode(), Unicode()), default_value=[ ("multiplicity 4", "np.count_nonzero(tels_with_trigger,axis=1) >= 4"), ("valid classifier", "RandomForestClassifier_is_valid"), diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 013ecd5903a..bcd49400255 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -19,6 +19,7 @@ Background3dIrf, EffectiveAreaIrf, EnergyMigrationIrf, + EventPreProcessor, EventsLoader, FovOffsetBinning, OptimizationResultStore, @@ -388,7 +389,35 @@ def start(self): reduced_events = dict() for sel in self.particles: # TODO: not very elegant to pass them this way, refactor later - sel.epp.quality_criteria = self.opt_result.precuts.quality_criteria + if sel.epp.quality_criteria != self.opt_result.precuts.quality_criteria: + self.log.warning( + "Precuts are different from precuts used for calculating " + "g/h / theta cuts. Provided precuts:\n%s. " + "\nUsing the same precuts as g/h / theta cuts:\n%s. " + % ( + sel.epp.to_table(functions=True)["criteria", "func"], + self.opt_result.precuts.to_table(functions=True)[ + "criteria", "func" + ], + ) + ) + sel.epp = EventPreProcessor( + parent=sel, + quality_criteria=self.opt_result.precuts.quality_criteria, + ) + + if sel.epp.gammaness_classifier != self.opt_result.gh_cuts.meta["CLFNAME"]: + self.log.warning( + "G/H cuts are only valid for gammaness scores predicted by " + "the same classifier model. Requested model: %s. " + "Model used, so that g/h cuts are valid: %s." + % ( + sel.epp.gammaness_classifier, + self.opt_result.gh_cuts.meta["CLFNAME"], + ) + ) + sel.epp.gammaness_classifier = self.opt_result.gh_cuts.meta["CLFNAME"] + self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) evs, cnt, meta = sel.load_preselected_events( self.chunk_size, diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index b34c248b0a9..1a02d032253 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -160,14 +160,15 @@ def start(self): % (len(self.signal_events), len(self.background_events)), ) result, ope_sens = self.go.optimize_gh_cut( - self.signal_events, - self.background_events, - self.alpha, - self.bins.fov_offset_min * u.deg, - self.bins.fov_offset_max * u.deg, - self.theta, - self.particles[0].epp, # precuts are the same for all particle types - self.point_like, + signal=self.signal_events, + background=self.background_events, + alpha=self.alpha, + min_fov_radius=self.bins.fov_offset_min * u.deg, + max_fov_radius=self.bins.fov_offset_max * u.deg, + theta=self.theta, + precuts=self.particles[0].epp, # identical precuts for all particle types + clf_prefix=self.particles[0].epp.gammaness_classifier, + point_like=self.point_like, ) self.log.info("Writing results to %s" % self.output_path) From 8d984ca6041d3b6a0b2a313f1746cc959e13e080 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 30 Apr 2024 14:20:58 +0200 Subject: [PATCH 077/136] Use full-enclosure flag for cut optimization tool --- src/ctapipe/tools/optimize_event_selection.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 1a02d032253..b8beca6a4d8 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -66,12 +66,9 @@ class IrfEventSelector(Tool): default_value=0.2, help="Ratio between size of on and off regions." ).tag(config=True) - point_like = Bool( + full_enclosure = Bool( False, - help=( - "Optimize both G/H separation cut and theta cut" - " for computing point-like IRFs" - ), + help="Compute only the G/H separation cut needed for full enclosure IRF.", ).tag(config=True) aliases = { @@ -84,10 +81,10 @@ class IrfEventSelector(Tool): flags = { **flag( - "point-like", - "IrfEventSelector.point_like", - "Optimize both G/H separation cut and theta cut.", - "Optimize G/H separation cut without prior theta cut.", + "full-enclosure", + "IrfEventSelector.full_enclosure", + "Compute only the G/H separation cut.", + "Compute the G/H separation cut and the theta cut.", ) } @@ -168,11 +165,11 @@ def start(self): theta=self.theta, precuts=self.particles[0].epp, # identical precuts for all particle types clf_prefix=self.particles[0].epp.gammaness_classifier, - point_like=self.point_like, + point_like=not self.full_enclosure, ) self.log.info("Writing results to %s" % self.output_path) - if not self.point_like: + if self.full_enclosure: self.log.info("Writing dummy theta cut to %s" % self.output_path) Provenance().add_output_file(self.output_path, role="Optimization Result") result.write(self.output_path, self.overwrite) From 927ff8cc45a428707feee3d075ce4ccc5136040e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 11:35:18 +0200 Subject: [PATCH 078/136] Remove theta cut re-calculation from irf-tool; move ThetaCutsCalculator to optimize.py; add individual binning to ThetaCutsCalculator --- src/ctapipe/irf/__init__.py | 8 ++- src/ctapipe/irf/optimize.py | 122 ++++++++++++++++++++++++++++++---- src/ctapipe/irf/select.py | 57 +--------------- src/ctapipe/tools/make_irf.py | 29 ++------ 4 files changed, 121 insertions(+), 95 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index c0f313e73cb..ce68fe1df00 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -7,13 +7,17 @@ EnergyMigrationIrf, PsfIrf, ) -from .optimize import GridOptimizer, OptimizationResult, OptimizationResultStore +from .optimize import ( + GridOptimizer, + OptimizationResult, + OptimizationResultStore, + ThetaCutsCalculator, +) from .select import ( SPECTRA, EventPreProcessor, EventsLoader, Spectra, - ThetaCutsCalculator, ) __all__ = [ diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index bf823db6c0a..c400a181318 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -9,7 +9,7 @@ from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut from ..core import Component, QualityQuery -from ..core.traits import Float +from ..core.traits import Float, Integer class ResultValidRange: @@ -138,17 +138,6 @@ class GridOptimizer(Component): default_value=5, ).tag(config=True) - def reco_energy_bins(self): - """ - Creates bins per decade for reconstructed MC energy using pyirf function. - """ - 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 optimize_gh_cut( self, signal, @@ -166,7 +155,12 @@ def optimize_gh_cut( if not isinstance(min_fov_radius, u.Quantity): raise ValueError("min_fov_radius has to have a unit") - reco_energy_bins = self.reco_energy_bins() + reco_energy_bins = create_bins_per_decade( + self.reco_energy_min * u.TeV, + self.reco_energy_max * u.TeV, + self.reco_energy_n_bins_per_decade, + ) + if point_like: initial_gh_cuts = calculate_percentile_cut( signal["gh_score"], @@ -218,10 +212,32 @@ def optimize_gh_cut( ) valid_energy = self._get_valid_energy_range(opt_sens) + # Re-calculate theta cut with optimized g/h cut + if point_like: + signal["selected_gh"] = evaluate_binned_cut( + signal["gh_score"], + signal["reco_energy"], + gh_cuts, + operator.ge, + ) + theta_cuts_opt = theta.calculate_theta_cuts( + signal[signal["selected_gh"]]["theta"], + signal[signal["selected_gh"]]["reco_energy"], + ) + else: + # TODO: Find a better solution for full enclosure than this dummy theta cut + theta_cuts_opt = QTable() + theta_cuts_opt["low"] = reco_energy_bins[:-1] + theta_cuts_opt["center"] = 0.5 * ( + reco_energy_bins[:-1] + reco_energy_bins[1:] + ) + theta_cuts_opt["high"] = reco_energy_bins[1:] + theta_cuts_opt["cut"] = max_fov_radius + result_saver = OptimizationResultStore(precuts) result_saver.set_result( gh_cuts, - theta_cuts, + theta_cuts_opt, valid_energy=valid_energy, valid_offset=[min_fov_radius, max_fov_radius], clf_prefix=clf_prefix, @@ -240,3 +256,81 @@ def _get_valid_energy_range(self, opt_sens): ] else: raise ValueError("Optimal significance curve has internal NaN bins") + + +class ThetaCutsCalculator(Component): + """Compute percentile cuts on theta""" + + theta_min_angle = Float( + default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" + ).tag(config=True) + + theta_max_angle = Float( + default_value=0.32, help="Largest angular cut value allowed" + ).tag(config=True) + + theta_min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + + theta_fill_value = Float( + default_value=0.32, help="Angular cut value used for bins with too few events" + ).tag(config=True) + + theta_smoothing = Float( + default_value=None, + allow_none=True, + help="When given, the width (in units of bins) of gaussian smoothing applied (None)", + ).tag(config=True) + + target_percentile = Float( + default_value=68, + help="Percent of events in each energy bin to keep after the theta cut", + ).tag(config=True) + + reco_energy_min = Float( + help="Minimum value for Reco Energy bins in TeV units", + default_value=0.015, + ).tag(config=True) + + reco_energy_max = Float( + help="Maximum value for Reco Energy bins in TeV units", + default_value=200, + ).tag(config=True) + + reco_energy_n_bins_per_decade = Float( + help="Number of bins per decade for Reco Energy bins", + default_value=5, + ).tag(config=True) + + def calculate_theta_cuts(self, theta, reco_energy, reco_energy_bins=None): + if reco_energy_bins is None: + reco_energy_bins = create_bins_per_decade( + self.reco_energy_min * u.TeV, + self.reco_energy_max * u.TeV, + self.reco_energy_n_bins_per_decade, + ) + + theta_min_angle = ( + None if self.theta_min_angle < 0 else self.theta_min_angle * u.deg + ) + theta_max_angle = ( + None if self.theta_max_angle < 0 else self.theta_max_angle * u.deg + ) + if self.theta_smoothing: + theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing + else: + theta_smoothing = self.theta_smoothing + + return calculate_percentile_cut( + theta, + reco_energy, + reco_energy_bins, + min_value=theta_min_angle, + max_value=theta_max_angle, + smoothing=theta_smoothing, + percentile=self.target_percentile, + fill_value=self.theta_fill_value * u.deg, + min_events=self.theta_min_counts, + ) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 28477379564..4bf3d4203e4 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -5,7 +5,6 @@ import numpy as np from astropy.coordinates import AltAz, SkyCoord from astropy.table import QTable, vstack -from pyirf.cuts import calculate_percentile_cut from pyirf.simulations import SimulatedEventsInfo from pyirf.spectral import ( CRAB_HEGRA, @@ -18,7 +17,7 @@ from ..coordinates import NominalFrame from ..core import Component, QualityQuery -from ..core.traits import Float, Integer, List, Tuple, Unicode +from ..core.traits import List, Tuple, Unicode from ..io import TableLoader from ..irf import FovOffsetBinning @@ -273,57 +272,3 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): ) return events - - -class ThetaCutsCalculator(Component): - theta_min_angle = Float( - default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" - ).tag(config=True) - - theta_max_angle = Float( - default_value=0.32, help="Largest angular cut value allowed" - ).tag(config=True) - - theta_min_counts = Integer( - default_value=10, - help="Minimum number of events in a bin to attempt to find a cut value", - ).tag(config=True) - - theta_fill_value = Float( - default_value=0.32, help="Angular cut value used for bins with too few events" - ).tag(config=True) - - theta_smoothing = Float( - default_value=None, - allow_none=True, - help="When given, the width (in units of bins) of gaussian smoothing applied (None)", - ).tag(config=True) - - target_percentile = Float( - default_value=68, - help="Percent of events in each energy bin to keep after the theta cut", - ).tag(config=True) - - def calculate_theta_cuts(self, theta, reco_energy, energy_bins): - theta_min_angle = ( - None if self.theta_min_angle < 0 else self.theta_min_angle * u.deg - ) - theta_max_angle = ( - None if self.theta_max_angle < 0 else self.theta_max_angle * u.deg - ) - if self.theta_smoothing: - theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing - else: - theta_smoothing = self.theta_smoothing - - return calculate_percentile_cut( - theta, - reco_energy, - energy_bins, - min_value=theta_min_angle, - max_value=theta_max_angle, - smoothing=theta_smoothing, - percentile=self.target_percentile, - fill_value=self.theta_fill_value * u.deg, - min_events=self.theta_min_counts, - ) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index bcd49400255..e67168017fe 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -4,7 +4,7 @@ import astropy.units as u import numpy as np from astropy.io import fits -from astropy.table import QTable, vstack +from astropy.table import vstack from pyirf.benchmarks import angular_resolution, energy_bias_resolution from pyirf.binning import create_histogram_table from pyirf.cuts import evaluate_binned_cut @@ -26,7 +26,6 @@ OutputEnergyBinning, PsfIrf, Spectra, - ThetaCutsCalculator, check_bins_in_range, ) @@ -141,7 +140,6 @@ class IrfTool(Tool): } classes = [ - ThetaCutsCalculator, EventsLoader, Background2dIrf, Background3dIrf, @@ -153,7 +151,6 @@ class IrfTool(Tool): ] def setup(self): - self.theta = ThetaCutsCalculator(parent=self) self.e_bins = OutputEnergyBinning(parent=self) self.bins = FovOffsetBinning(parent=self) @@ -224,7 +221,7 @@ def setup(self): ) def calculate_selections(self): - """Add the selection columns to the signal and optionally background tables""" + """Add the selection columns to the signal and optionally background tables.""" self.signal_events["selected_gh"] = evaluate_binned_cut( self.signal_events["gh_score"], self.signal_events["reco_energy"], @@ -232,30 +229,16 @@ def calculate_selections(self): operator.ge, ) if not self.full_enclosure: - self.theta_cuts_opt = self.theta.calculate_theta_cuts( - self.signal_events[self.signal_events["selected_gh"]]["theta"], - self.signal_events[self.signal_events["selected_gh"]]["reco_energy"], - self.reco_energy_bins, - ) self.signal_events["selected_theta"] = evaluate_binned_cut( self.signal_events["theta"], self.signal_events["reco_energy"], - self.theta_cuts_opt, + self.opt_result.theta_cuts, operator.le, ) self.signal_events["selected"] = ( self.signal_events["selected_theta"] & self.signal_events["selected_gh"] ) else: - # Re-"calculate" the dummy theta cut because of potentially different reco energy binning - self.theta_cuts_opt = QTable() - self.theta_cuts_opt["low"] = self.reco_energy_bins[:-1] - self.theta_cuts_opt["center"] = 0.5 * ( - self.reco_energy_bins[:-1] + self.reco_energy_bins[1:] - ) - self.theta_cuts_opt["high"] = self.reco_energy_bins[1:] - self.theta_cuts_opt["cut"] = self.opt_result.valid_offset.max - self.signal_events["selected"] = self.signal_events["selected_gh"] if self.do_background: @@ -269,7 +252,7 @@ def calculate_selections(self): self.background_events["selected_theta"] = evaluate_binned_cut( self.background_events["theta"], self.background_events["reco_energy"], - self.theta_cuts_opt, + self.opt_result.theta_cuts, operator.le, ) self.background_events["selected"] = ( @@ -334,7 +317,7 @@ def _make_signal_irf_hdus(self, hdus): ) hdus.append( create_rad_max_hdu( - self.theta_cuts_opt["cut"].reshape(-1, 1), + self.opt_result.theta_cuts["cut"].reshape(-1, 1), self.reco_energy_bins, self.fov_offset_bins, ) @@ -367,7 +350,7 @@ def _make_benchmark_hdus(self, hdus): background_hist = estimate_background( self.background_events[self.background_events["selected_gh"]], reco_energy_bins=self.reco_energy_bins, - theta_cuts=self.theta_cuts_opt, + theta_cuts=self.opt_result.theta_cuts, alpha=self.alpha, fov_offset_min=self.fov_offset_bins[0], fov_offset_max=self.fov_offset_bins[-1], From 727b72168b1e5e8ca9766094145c24418464fb20 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 16:13:01 +0200 Subject: [PATCH 079/136] Make saving a theta cut optional in cut-opt tool and if saved, also save it as RAD_MAX --- src/ctapipe/irf/optimize.py | 83 +++++++++++-------- src/ctapipe/tools/make_irf.py | 73 +++++++++++----- src/ctapipe/tools/optimize_event_selection.py | 2 - 3 files changed, 98 insertions(+), 60 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index c400a181318..f48d5fa0aee 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -3,6 +3,7 @@ import astropy.units as u import numpy as np +from astropy.io import fits from astropy.table import QTable, Table from pyirf.binning import create_bins_per_decade from pyirf.cut_optimization import optimize_gh_cut @@ -27,13 +28,21 @@ def __init__(self, precuts, valid_energy, valid_offset, gh, theta): self.theta_cuts = theta def __repr__(self): - return ( - f"" - ) + if self.theta_cuts is not None: + return ( + f"" + ) + else: + return ( + f"" + ) class OptimizationResultStore: @@ -52,13 +61,14 @@ def __init__(self, precuts=None): self._results = None - def set_result(self, gh_cuts, theta_cuts, valid_energy, valid_offset, clf_prefix): + def set_result( + self, gh_cuts, valid_energy, valid_offset, clf_prefix, theta_cuts=None + ): if not self._precuts: raise ValueError("Precuts must be defined before results can be saved") gh_cuts.meta["EXTNAME"] = "GH_CUTS" gh_cuts.meta["CLFNAME"] = clf_prefix - theta_cuts.meta["EXTNAME"] = "RAD_MAX" energy_lim_tab = QTable(rows=[valid_energy], names=["energy_min", "energy_max"]) energy_lim_tab.meta["EXTNAME"] = "VALID_ENERGY" @@ -66,7 +76,11 @@ def set_result(self, gh_cuts, theta_cuts, valid_energy, valid_offset, clf_prefix offset_lim_tab = QTable(rows=[valid_offset], names=["offset_min", "offset_max"]) offset_lim_tab.meta["EXTNAME"] = "VALID_OFFSET" - self._results = [gh_cuts, theta_cuts, energy_lim_tab, offset_lim_tab] + self._results = [gh_cuts, energy_lim_tab, offset_lim_tab] + + if theta_cuts is not None: + theta_cuts.meta["EXTNAME"] = "RAD_MAX" + self._results += [theta_cuts] def write(self, output_name, overwrite=False): if not isinstance(self._results, list): @@ -89,18 +103,20 @@ def write(self, output_name, overwrite=False): table.write(output_name, format="fits", append=True) def read(self, file_name): - cut_expr_tab = Table.read(file_name, hdu=1) - cut_expr_lst = [(name, expr) for name, expr in cut_expr_tab.iterrows()] - # TODO: this crudely fixes a problem when loading non empty tables, make it nicer - try: - cut_expr_lst.remove((" ", " ")) - except ValueError: - pass - precuts = QualityQuery(quality_criteria=cut_expr_lst) - gh_cuts = QTable.read(file_name, hdu=2) - theta_cuts = QTable.read(file_name, hdu=3) - valid_energy = QTable.read(file_name, hdu=4) - valid_offset = QTable.read(file_name, hdu=5) + with fits.open(file_name) as hdul: + cut_expr_tab = Table.read(hdul[1]) + cut_expr_lst = [(name, expr) for name, expr in cut_expr_tab.iterrows()] + # TODO: this crudely fixes a problem when loading non empty tables, make it nicer + try: + cut_expr_lst.remove((" ", " ")) + except ValueError: + pass + + precuts = QualityQuery(quality_criteria=cut_expr_lst) + gh_cuts = QTable.read(hdul[2]) + valid_energy = QTable.read(hdul[3]) + valid_offset = QTable.read(hdul[4]) + theta_cuts = QTable.read(hdul[5]) if len(hdul) > 5 else None return OptimizationResult( precuts, valid_energy, valid_offset, gh_cuts, theta_cuts @@ -183,16 +199,20 @@ def optimize_gh_cut( signal["reco_energy"][initial_gh_mask], reco_energy_bins, ) + self.log.info("Optimizing G/H separation cut for best sensitivity") else: - # TODO: Find a better solution for full enclosure than this dummy theta cut - self.log.info("Optimizing G/H separation cut without prior theta cut.") + # Create a dummy theta cut since `pyirf.cut_optimization.optimize_gh_cut` + # needs a theta cut atm. theta_cuts = QTable() theta_cuts["low"] = reco_energy_bins[:-1] theta_cuts["center"] = 0.5 * (reco_energy_bins[:-1] + reco_energy_bins[1:]) theta_cuts["high"] = reco_energy_bins[1:] theta_cuts["cut"] = max_fov_radius + self.log.info( + "Optimizing G/H separation cut for best sensitivity " + "with `max_fov_radius` as theta cut." + ) - self.log.info("Optimizing G/H separation cut for best sensitivity") gh_cut_efficiencies = np.arange( self.gh_cut_efficiency_step, self.max_gh_cut_efficiency + self.gh_cut_efficiency_step / 2, @@ -224,23 +244,14 @@ def optimize_gh_cut( signal[signal["selected_gh"]]["theta"], signal[signal["selected_gh"]]["reco_energy"], ) - else: - # TODO: Find a better solution for full enclosure than this dummy theta cut - theta_cuts_opt = QTable() - theta_cuts_opt["low"] = reco_energy_bins[:-1] - theta_cuts_opt["center"] = 0.5 * ( - reco_energy_bins[:-1] + reco_energy_bins[1:] - ) - theta_cuts_opt["high"] = reco_energy_bins[1:] - theta_cuts_opt["cut"] = max_fov_radius result_saver = OptimizationResultStore(precuts) result_saver.set_result( - gh_cuts, - theta_cuts_opt, + gh_cuts=gh_cuts, valid_energy=valid_energy, valid_offset=[min_fov_radius, max_fov_radius], clf_prefix=clf_prefix, + theta_cuts=theta_cuts_opt if point_like else None, ) return result_saver, opt_sens diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index e67168017fe..31a44d50162 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -4,7 +4,7 @@ import astropy.units as u import numpy as np from astropy.io import fits -from astropy.table import vstack +from astropy.table import QTable, vstack from pyirf.benchmarks import angular_resolution, energy_bias_resolution from pyirf.binning import create_histogram_table from pyirf.cuts import evaluate_binned_cut @@ -163,10 +163,7 @@ def setup(self): check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.fov_offset_bins, self.opt_result.valid_offset) - if ( - not self.full_enclosure - and "n_events" not in self.opt_result.theta_cuts.colnames - ): + if not self.full_enclosure and self.opt_result.theta_cuts is None: raise ToolConfigurationError( "Computing a point-like IRF requires an (optimized) theta cut." ) @@ -215,6 +212,12 @@ def setup(self): self.mig_matrix = EnergyMigrationIrf( parent=self, ) + if self.full_enclosure: + self.psf = PsfIrf( + parent=self, + valid_offset=self.opt_result.valid_offset, + ) + if self.do_benchmarks: self.b_output = self.output_path.with_name( self.output_path.name.replace(".fits", "-benchmark.fits") @@ -309,19 +312,33 @@ def _make_signal_irf_hdus(self, hdus): point_like=not self.full_enclosure, ) ) - hdus.append( - self.psf.make_psf_table_hdu( - signal_events=self.signal_events[self.signal_events["selected"]], - fov_offset_bins=self.fov_offset_bins, + if self.full_enclosure: + hdus.append( + self.psf.make_psf_table_hdu( + signal_events=self.signal_events[self.signal_events["selected"]], + fov_offset_bins=self.fov_offset_bins, + ) ) - ) - hdus.append( - create_rad_max_hdu( - self.opt_result.theta_cuts["cut"].reshape(-1, 1), - self.reco_energy_bins, - self.fov_offset_bins, + else: + # TODO: Support fov binning + if self.bins.fov_offset_n_bins > 1: + self.log.warning( + "Currently no fov binning is supported for RAD_MAX. " + "Using `fov_offset_bins = [fov_offset_min, fov_offset_max]`." + ) + + hdus.append( + create_rad_max_hdu( + rad_max=self.opt_result.theta_cuts["cut"].reshape(-1, 1), + reco_energy_bins=np.append( + self.opt_result.theta_cuts["low"], + self.opt_result.theta_cuts["high"][-1], + ), + fov_offset_bins=u.Quantity( + [self.fov_offset_bins[0], self.fov_offset_bins[-1]] + ), + ) ) - ) return hdus def _make_benchmark_hdus(self, hdus): @@ -343,6 +360,21 @@ def _make_benchmark_hdus(self, hdus): hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) if self.do_background: + if self.full_enclosure: + # Create a dummy theta cut since `pyirf.sensitivity.estimate_background` + # needs a theta cut atm. + self.log.info( + "Using all signal events with `theta < fov_offset_max` " + "to compute the sensitivity." + ) + theta_cuts = QTable() + theta_cuts["center"] = 0.5 * ( + self.reco_energy_bins[:-1] + self.reco_energy_bins[1:] + ) + theta_cuts["cut"] = self.fov_offset_bins[-1] + else: + theta_cuts = self.opt_result.theta_cuts + signal_hist = create_histogram_table( self.signal_events[self.signal_events["selected"]], bins=self.reco_energy_bins, @@ -350,7 +382,7 @@ def _make_benchmark_hdus(self, hdus): background_hist = estimate_background( self.background_events[self.background_events["selected_gh"]], reco_energy_bins=self.reco_energy_bins, - theta_cuts=self.opt_result.theta_cuts, + theta_cuts=theta_cuts, alpha=self.alpha, fov_offset_min=self.fov_offset_bins[0], fov_offset_max=self.fov_offset_bins[-1], @@ -424,7 +456,8 @@ def start(self): " Therefore, the IRF is only calculated at a single point in the FoV." " Changing `fov_offset_n_bins` to 1." ) - self.fov_offset_bins.fov_offset_n_bins = 1 + self.bins.fov_offset_n_bins = 1 + self.fov_offset_bins = self.bins.fov_offset_bins() self.signal_events = reduced_events["gammas"] if self.do_background: @@ -436,10 +469,6 @@ def start(self): self.log.debug("Reco Energy bins: %s" % str(self.reco_energy_bins.value)) self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) - self.psf = PsfIrf( - parent=self, - valid_offset=self.opt_result.valid_offset, - ) hdus = [fits.PrimaryHDU()] hdus = self._make_signal_irf_hdus(hdus) if self.do_background: diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index b8beca6a4d8..cc535060153 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -169,8 +169,6 @@ def start(self): ) self.log.info("Writing results to %s" % self.output_path) - if self.full_enclosure: - self.log.info("Writing dummy theta cut to %s" % self.output_path) Provenance().add_output_file(self.output_path, role="Optimization Result") result.write(self.output_path, self.overwrite) From c4ffdf807075c574f5d7904b117c3772ce225253 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 16:22:07 +0200 Subject: [PATCH 080/136] Remove redundant bin range check --- src/ctapipe/irf/irfs.py | 13 ++------ src/ctapipe/tools/make_irf.py | 61 ++++++++++++++--------------------- 2 files changed, 28 insertions(+), 46 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index cf9b84d8fc3..7af1fe9363c 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -20,7 +20,6 @@ from ..core import Component from ..core.traits import Float, Integer -from .binning import check_bins_in_range class PsfIrf(Component): @@ -56,14 +55,13 @@ class PsfIrf(Component): default_value=100, ).tag(config=True) - def __init__(self, parent, valid_offset, **kwargs): + def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) self.true_energy_bins = create_bins_per_decade( self.true_energy_min * u.TeV, self.true_energy_max * u.TeV, self.true_energy_n_bins_per_decade, ) - self.valid_offset = valid_offset self.source_offset_bins = ( np.linspace( self.source_offset_min, @@ -74,7 +72,6 @@ def __init__(self, parent, valid_offset, **kwargs): ) def make_psf_table_hdu(self, signal_events, fov_offset_bins): - check_bins_in_range(fov_offset_bins, self.valid_offset) psf = psf_table( events=signal_events, true_energy_bins=self.true_energy_bins, @@ -123,14 +120,13 @@ class Background3dIrf(Component): default_value=1, ).tag(config=True) - def __init__(self, parent, valid_offset, **kwargs): + def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) self.reco_energy_bins = create_bins_per_decade( self.reco_energy_min * u.TeV, self.reco_energy_max * u.TeV, self.reco_energy_n_bins_per_decade, ) - self.valid_offset = valid_offset self.fov_offset_bins = ( np.linspace( self.fov_offset_min, @@ -139,7 +135,6 @@ def __init__(self, parent, valid_offset, **kwargs): ) * u.deg ) - # check_bins_in_range(self.fov_offset_bins, self.valid_offset) def make_bkg3d_table_hdu(self, bkg_events, obs_time): sel = bkg_events["selected"] @@ -192,14 +187,13 @@ class Background2dIrf(Component): default_value=1, ).tag(config=True) - def __init__(self, parent, valid_offset, **kwargs): + def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) self.reco_energy_bins = create_bins_per_decade( self.reco_energy_min * u.TeV, self.reco_energy_max * u.TeV, self.reco_energy_n_bins_per_decade, ) - self.valid_offset = valid_offset self.fov_offset_bins = ( np.linspace( self.fov_offset_min, @@ -208,7 +202,6 @@ def __init__(self, parent, valid_offset, **kwargs): ) * u.deg ) - # check_bins_in_range(self.fov_offset_bins, self.valid_offset) def make_bkg2d_table_hdu(self, bkg_events, obs_time): sel = bkg_events["selected"] diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 31a44d50162..394b3ed0bf7 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -176,47 +176,36 @@ def setup(self): target_spectrum=SPECTRA[self.gamma_target_spectrum], ), ] - if self.do_background and self.proton_file: - self.particles.append( - EventsLoader( - parent=self, - kind="protons", - file=self.proton_file, - target_spectrum=SPECTRA[self.proton_target_spectrum], + if self.do_background: + if self.proton_file: + self.particles.append( + EventsLoader( + parent=self, + kind="protons", + file=self.proton_file, + target_spectrum=SPECTRA[self.proton_target_spectrum], + ) ) - ) - if self.do_background and self.electron_file: - self.particles.append( - EventsLoader( - parent=self, - kind="electrons", - file=self.electron_file, - target_spectrum=SPECTRA[self.electron_target_spectrum], + if self.electron_file: + self.particles.append( + EventsLoader( + parent=self, + kind="electrons", + file=self.electron_file, + target_spectrum=SPECTRA[self.electron_target_spectrum], + ) + ) + if len(self.particles) == 1: + raise RuntimeError( + "At least one electron or proton file required when specifying `do_background`." ) - ) - if self.do_background and len(self.particles) == 1: - raise RuntimeError( - "At least one electron or proton file required when specifying `do_background`." - ) - if self.do_background: - self.bkg = Background2dIrf( - parent=self, - valid_offset=self.opt_result.valid_offset, - ) - self.bkg3 = Background3dIrf( - parent=self, - valid_offset=self.opt_result.valid_offset, - ) + self.bkg = Background2dIrf(parent=self) + self.bkg3 = Background3dIrf(parent=self) - self.mig_matrix = EnergyMigrationIrf( - parent=self, - ) + self.mig_matrix = EnergyMigrationIrf(parent=self) if self.full_enclosure: - self.psf = PsfIrf( - parent=self, - valid_offset=self.opt_result.valid_offset, - ) + self.psf = PsfIrf(parent=self) if self.do_benchmarks: self.b_output = self.output_path.with_name( From 282070d5a3df8eba9aa80fa18cdfec5377da8d5e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 17:17:57 +0200 Subject: [PATCH 081/136] Apply event selection per particle type --- src/ctapipe/tools/make_irf.py | 95 +++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 394b3ed0bf7..d9e5faffb07 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -212,64 +212,81 @@ def setup(self): self.output_path.name.replace(".fits", "-benchmark.fits") ) - def calculate_selections(self): - """Add the selection columns to the signal and optionally background tables.""" - self.signal_events["selected_gh"] = evaluate_binned_cut( - self.signal_events["gh_score"], - self.signal_events["reco_energy"], + def calculate_selections(self, reduced_events: dict) -> dict: + """ + Add the selection columns to the signal and optionally background tables. + + Parameters + ---------- + reduced_events: dict + dict containing the signal (``"gammas"``) and optionally background + tables (``"protons"``, ``"electrons"``) + + Returns + ------- + dict + ``reduced_events`` with selection columns added. + """ + reduced_events["gammas"]["selected_gh"] = evaluate_binned_cut( + reduced_events["gammas"]["gh_score"], + reduced_events["gammas"]["reco_energy"], self.opt_result.gh_cuts, operator.ge, ) if not self.full_enclosure: - self.signal_events["selected_theta"] = evaluate_binned_cut( - self.signal_events["theta"], - self.signal_events["reco_energy"], + reduced_events["gammas"]["selected_theta"] = evaluate_binned_cut( + reduced_events["gammas"]["theta"], + reduced_events["gammas"]["reco_energy"], self.opt_result.theta_cuts, operator.le, ) - self.signal_events["selected"] = ( - self.signal_events["selected_theta"] & self.signal_events["selected_gh"] + reduced_events["gammas"]["selected"] = ( + reduced_events["gammas"]["selected_theta"] + & reduced_events["gammas"]["selected_gh"] ) else: - self.signal_events["selected"] = self.signal_events["selected_gh"] + reduced_events["gammas"]["selected"] = reduced_events["gammas"][ + "selected_gh" + ] if self.do_background: - self.background_events["selected_gh"] = evaluate_binned_cut( - self.background_events["gh_score"], - self.background_events["reco_energy"], - self.opt_result.gh_cuts, - operator.ge, - ) - if not self.full_enclosure: - self.background_events["selected_theta"] = evaluate_binned_cut( - self.background_events["theta"], - self.background_events["reco_energy"], - self.opt_result.theta_cuts, - operator.le, - ) - self.background_events["selected"] = ( - self.background_events["selected_theta"] - & self.background_events["selected_gh"] + for bg_type in ("protons", "electrons"): + reduced_events[bg_type]["selected_gh"] = evaluate_binned_cut( + reduced_events[bg_type]["gh_score"], + reduced_events[bg_type]["reco_energy"], + self.opt_result.gh_cuts, + operator.ge, ) - else: - self.background_events["selected"] = self.background_events[ - "selected_gh" - ] + if not self.full_enclosure: + reduced_events[bg_type]["selected_theta"] = evaluate_binned_cut( + reduced_events[bg_type]["theta"], + reduced_events[bg_type]["reco_energy"], + self.opt_result.theta_cuts, + operator.le, + ) + reduced_events[bg_type]["selected"] = ( + reduced_events[bg_type]["selected_theta"] + & reduced_events[bg_type]["selected_gh"] + ) + else: + reduced_events[bg_type]["selected"] = reduced_events[bg_type][ + "selected_gh" + ] - # TODO: maybe rework the above so we can give the number per - # species instead of the total background if self.do_background: self.log.debug( - "Keeping %d signal, %d background events" + "Keeping %d signal, %d proton events, and %d electron events" % ( - sum(self.signal_events["selected"]), - sum(self.background_events["selected"]), + sum(reduced_events["gammas"]["selected"]), + sum(reduced_events["protons"]["selected"]), + sum(reduced_events["electrons"]["selected"]), ) ) else: self.log.debug( - "Keeping %d signal events" % (sum(self.signal_events["selected"])) + "Keeping %d signal events" % (sum(reduced_events["gammas"]["selected"])) ) + return reduced_events def _stack_background(self, reduced_events): bkgs = [] @@ -448,12 +465,12 @@ def start(self): self.bins.fov_offset_n_bins = 1 self.fov_offset_bins = self.bins.fov_offset_bins() + reduced_events = self.calculate_selections(reduced_events) + self.signal_events = reduced_events["gammas"] if self.do_background: self.background_events = self._stack_background(reduced_events) - self.calculate_selections() - self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) self.log.debug("Reco Energy bins: %s" % str(self.reco_energy_bins.value)) self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) From 061424b5695356eef732ced1ea58d265455365e2 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 18:41:58 +0200 Subject: [PATCH 082/136] More AstroQuantity --- src/ctapipe/irf/binning.py | 60 ++++---- src/ctapipe/irf/irfs.py | 144 ++++++++++-------- src/ctapipe/irf/optimize.py | 66 ++++---- src/ctapipe/irf/select.py | 2 +- src/ctapipe/tools/make_irf.py | 14 +- src/ctapipe/tools/optimize_event_selection.py | 9 +- 6 files changed, 169 insertions(+), 126 deletions(-) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 557a148b147..3728529aebc 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -4,7 +4,7 @@ from pyirf.binning import create_bins_per_decade from ..core import Component -from ..core.traits import Float, Integer +from ..core.traits import AstroQuantity, Integer def check_bins_in_range(bins, range): @@ -18,32 +18,36 @@ def check_bins_in_range(bins, range): class OutputEnergyBinning(Component): """Collects energy binning settings.""" - true_energy_min = Float( - help="Minimum value for True Energy bins in TeV units", - default_value=0.005, + true_energy_min = AstroQuantity( + help="Minimum value for True Energy bins", + default_value=0.005 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_max = Float( - help="Maximum value for True Energy bins in TeV units", - default_value=200, + true_energy_max = AstroQuantity( + help="Maximum value for True Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_n_bins_per_decade = Float( + true_energy_n_bins_per_decade = Integer( help="Number of bins per decade for True Energy bins", default_value=10, ).tag(config=True) - reco_energy_min = Float( - help="Minimum value for Reco Energy bins in TeV units", - default_value=0.015, + reco_energy_min = AstroQuantity( + help="Minimum value for Reco Energy bins", + default_value=0.015 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_max = Float( - help="Maximum value for Reco Energy bins in TeV units", - default_value=200, + reco_energy_max = AstroQuantity( + help="Maximum value for Reco Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_n_bins_per_decade = Float( + reco_energy_n_bins_per_decade = Integer( help="Number of bins per decade for Reco Energy bins", default_value=5, ).tag(config=True) @@ -53,8 +57,8 @@ def true_energy_bins(self): Creates bins per decade for true MC energy using pyirf function. """ true_energy = create_bins_per_decade( - self.true_energy_min * u.TeV, - self.true_energy_max * u.TeV, + self.true_energy_min.to(u.TeV), + self.true_energy_max.to(u.TeV), self.true_energy_n_bins_per_decade, ) return true_energy @@ -64,8 +68,8 @@ def reco_energy_bins(self): Creates bins per decade for reconstructed MC energy using pyirf function. """ reco_energy = create_bins_per_decade( - self.reco_energy_min * u.TeV, - self.reco_energy_max * u.TeV, + self.reco_energy_min.to(u.TeV), + self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, ) return reco_energy @@ -74,14 +78,16 @@ def reco_energy_bins(self): class FovOffsetBinning(Component): """Collects FoV binning settings.""" - fov_offset_min = Float( - help="Minimum value for FoV Offset bins in degrees", - default_value=0.0, + fov_offset_min = AstroQuantity( + help="Minimum value for FoV Offset bins", + default_value=0.0 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) - fov_offset_max = Float( - help="Maximum value for FoV offset bins in degrees", - default_value=5.0, + fov_offset_max = AstroQuantity( + help="Maximum value for FoV offset bins", + default_value=5.0 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) fov_offset_n_bins = Integer( @@ -95,8 +101,8 @@ def fov_offset_bins(self): """ fov_offset = ( np.linspace( - self.fov_offset_min, - self.fov_offset_max, + self.fov_offset_min.to_value(u.deg), + self.fov_offset_max.to_value(u.deg), self.fov_offset_n_bins + 1, ) * u.deg diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 7af1fe9363c..431d35836fc 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -19,35 +19,39 @@ ) from ..core import Component -from ..core.traits import Float, Integer +from ..core.traits import AstroQuantity, Float, Integer class PsfIrf(Component): """Collects the functionality for generating PSF IRFs.""" - true_energy_min = Float( - help="Minimum value for True Energy bins in TeV units", - default_value=0.005, + true_energy_min = AstroQuantity( + help="Minimum value for True Energy bins", + default_value=0.005 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_max = Float( - help="Maximum value for True Energy bins in TeV units", - default_value=200, + true_energy_max = AstroQuantity( + help="Maximum value for True Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_n_bins_per_decade = Float( + true_energy_n_bins_per_decade = Integer( help="Number of edges per decade for True Energy bins", default_value=10, ).tag(config=True) - source_offset_min = Float( + source_offset_min = AstroQuantity( help="Minimum value for Source offset for PSF IRF", - default_value=0, + default_value=0 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) - source_offset_max = Float( + source_offset_max = AstroQuantity( help="Maximum value for Source offset for PSF IRF", - default_value=1, + default_value=1 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) source_offset_n_bins = Integer( @@ -58,14 +62,14 @@ class PsfIrf(Component): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) self.true_energy_bins = create_bins_per_decade( - self.true_energy_min * u.TeV, - self.true_energy_max * u.TeV, + self.true_energy_min.to(u.TeV), + self.true_energy_max.to(u.TeV), self.true_energy_n_bins_per_decade, ) self.source_offset_bins = ( np.linspace( - self.source_offset_min, - self.source_offset_max, + self.source_offset_min.to_value(u.deg), + self.source_offset_max.to_value(u.deg), self.source_offset_n_bins + 1, ) * u.deg @@ -90,29 +94,33 @@ def make_psf_table_hdu(self, signal_events, fov_offset_bins): class Background3dIrf(Component): """Collects the functionality for generating 3D Background IRFs using square bins.""" - reco_energy_min = Float( - help="Minimum value for Reco Energy bins in TeV units", - default_value=0.005, + reco_energy_min = AstroQuantity( + help="Minimum value for Reco Energy bins", + default_value=0.005 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_max = Float( - help="Maximum value for Reco Energy bins in TeV units", - default_value=200, + reco_energy_max = AstroQuantity( + help="Maximum value for Reco Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_n_bins_per_decade = Float( + reco_energy_n_bins_per_decade = Integer( help="Number of edges per decade for Reco Energy bins", default_value=10, ).tag(config=True) - fov_offset_min = Float( + fov_offset_min = AstroQuantity( help="Minimum value for Field of View offset for background IRF", - default_value=0, + default_value=0 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) - fov_offset_max = Float( + fov_offset_max = AstroQuantity( help="Maximum value for Field of View offset for background IRF", - default_value=1, + default_value=1 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) fov_offset_n_bins = Integer( @@ -123,14 +131,14 @@ class Background3dIrf(Component): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) self.reco_energy_bins = create_bins_per_decade( - self.reco_energy_min * u.TeV, - self.reco_energy_max * u.TeV, + self.reco_energy_min.to(u.TeV), + self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, ) self.fov_offset_bins = ( np.linspace( - self.fov_offset_min, - self.fov_offset_max, + self.fov_offset_min.to_value(u.deg), + self.fov_offset_max.to_value(u.deg), self.fov_offset_n_bins + 1, ) * u.deg @@ -157,29 +165,33 @@ def make_bkg3d_table_hdu(self, bkg_events, obs_time): class Background2dIrf(Component): """Collects the functionality for generating 2D Background IRFs.""" - reco_energy_min = Float( - help="Minimum value for Reco Energy bins in TeV units", - default_value=0.005, + reco_energy_min = AstroQuantity( + help="Minimum value for Reco Energy bins", + default_value=0.005 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_max = Float( - help="Maximum value for Reco Energy bins in TeV units", - default_value=200, + reco_energy_max = AstroQuantity( + help="Maximum value for Reco Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_n_bins_per_decade = Float( + reco_energy_n_bins_per_decade = Integer( help="Number of edges per decade for Reco Energy bins", default_value=10, ).tag(config=True) - fov_offset_min = Float( + fov_offset_min = AstroQuantity( help="Minimum value for Field of View offset for background IRF", - default_value=0, + default_value=0 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) - fov_offset_max = Float( + fov_offset_max = AstroQuantity( help="Maximum value for Field of View offset for background IRF", - default_value=1, + default_value=1 * u.deg, + physical_type=u.physical.angle, ).tag(config=True) fov_offset_n_bins = Integer( @@ -190,14 +202,14 @@ class Background2dIrf(Component): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) self.reco_energy_bins = create_bins_per_decade( - self.reco_energy_min * u.TeV, - self.reco_energy_max * u.TeV, + self.reco_energy_min.to(u.TeV), + self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, ) self.fov_offset_bins = ( np.linspace( - self.fov_offset_min, - self.fov_offset_max, + self.fov_offset_min.to_value(u.deg), + self.fov_offset_max.to_value(u.deg), self.fov_offset_n_bins + 1, ) * u.deg @@ -239,17 +251,19 @@ class EnergyMigrationIrf(Component): default_value=31, ).tag(config=True) - true_energy_min = Float( - help="Minimum value for True Energy bins in TeV units", - default_value=0.005, + true_energy_min = AstroQuantity( + help="Minimum value for True Energy bins", + default_value=0.005 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_max = Float( - help="Maximum value for True Energy bins in TeV units", - default_value=200, + true_energy_max = AstroQuantity( + help="Maximum value for True Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_n_bins_per_decade = Float( + true_energy_n_bins_per_decade = Integer( help="Number of edges per decade for True Energy bins", default_value=10, ).tag(config=True) @@ -260,8 +274,8 @@ def __init__(self, parent, **kwargs): """ super().__init__(parent=parent, **kwargs) self.true_energy_bins = create_bins_per_decade( - self.true_energy_min * u.TeV, - self.true_energy_max * u.TeV, + self.true_energy_min.to(u.TeV), + self.true_energy_max.to(u.TeV), self.true_energy_n_bins_per_decade, ) self.migration_bins = np.linspace( @@ -290,17 +304,19 @@ def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins, point_like) class EffectiveAreaIrf(Component): """Collects the functionality for generating Effective Area IRFs.""" - true_energy_min = Float( - help="Minimum value for True Energy bins in TeV units", - default_value=0.005, + true_energy_min = AstroQuantity( + help="Minimum value for True Energy bins", + default_value=0.005 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_max = Float( - help="Maximum value for True Energy bins in TeV units", - default_value=200, + true_energy_max = AstroQuantity( + help="Maximum value for True Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - true_energy_n_bins_per_decade = Float( + true_energy_n_bins_per_decade = Integer( help="Number of bins per decade for True Energy bins", default_value=10, ).tag(config=True) @@ -311,8 +327,8 @@ def __init__(self, parent, sim_info, **kwargs): """ super().__init__(parent=parent, **kwargs) self.true_energy_bins = create_bins_per_decade( - self.true_energy_min * u.TeV, - self.true_energy_max * u.TeV, + self.true_energy_min.to(u.TeV), + self.true_energy_max.to(u.TeV), self.true_energy_n_bins_per_decade, ) self.sim_info = sim_info diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index f48d5fa0aee..d4a3ef5c667 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -10,7 +10,7 @@ from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut from ..core import Component, QualityQuery -from ..core.traits import Float, Integer +from ..core.traits import AstroQuantity, Float, Integer class ResultValidRange: @@ -139,17 +139,19 @@ class GridOptimizer(Component): help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) - reco_energy_min = Float( - help="Minimum value for Reco Energy bins in TeV units", - default_value=0.015, + reco_energy_min = AstroQuantity( + help="Minimum value for Reco Energy bins", + default_value=0.015 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_max = Float( - help="Maximum value for Reco Energy bins in TeV units", - default_value=200, + reco_energy_max = AstroQuantity( + help="Maximum value for Reco Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_n_bins_per_decade = Float( + reco_energy_n_bins_per_decade = Integer( help="Number of bins per decade for Reco Energy bins", default_value=5, ).tag(config=True) @@ -172,8 +174,8 @@ def optimize_gh_cut( raise ValueError("min_fov_radius has to have a unit") reco_energy_bins = create_bins_per_decade( - self.reco_energy_min * u.TeV, - self.reco_energy_max * u.TeV, + self.reco_energy_min.to(u.TeV), + self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, ) @@ -272,12 +274,16 @@ def _get_valid_energy_range(self, opt_sens): class ThetaCutsCalculator(Component): """Compute percentile cuts on theta""" - theta_min_angle = Float( - default_value=-1, help="Smallest angular cut value allowed (-1 means no cut)" + theta_min_angle = AstroQuantity( + default_value=-1 * u.deg, + physical_type=u.physical.angle, + help="Smallest angular cut value allowed (-1 means no cut)", ).tag(config=True) - theta_max_angle = Float( - default_value=0.32, help="Largest angular cut value allowed" + theta_max_angle = AstroQuantity( + default_value=0.32 * u.deg, + physical_type=u.physical.angle, + help="Largest angular cut value allowed", ).tag(config=True) theta_min_counts = Integer( @@ -285,8 +291,10 @@ class ThetaCutsCalculator(Component): help="Minimum number of events in a bin to attempt to find a cut value", ).tag(config=True) - theta_fill_value = Float( - default_value=0.32, help="Angular cut value used for bins with too few events" + theta_fill_value = AstroQuantity( + default_value=0.32 * u.deg, + physical_type=u.physical.angle, + help="Angular cut value used for bins with too few events", ).tag(config=True) theta_smoothing = Float( @@ -300,17 +308,19 @@ class ThetaCutsCalculator(Component): help="Percent of events in each energy bin to keep after the theta cut", ).tag(config=True) - reco_energy_min = Float( - help="Minimum value for Reco Energy bins in TeV units", - default_value=0.015, + reco_energy_min = AstroQuantity( + help="Minimum value for Reco Energy bins", + default_value=0.015 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_max = Float( - help="Maximum value for Reco Energy bins in TeV units", - default_value=200, + reco_energy_max = AstroQuantity( + help="Maximum value for Reco Energy bins", + default_value=200 * u.TeV, + physical_type=u.physical.energy, ).tag(config=True) - reco_energy_n_bins_per_decade = Float( + reco_energy_n_bins_per_decade = Integer( help="Number of bins per decade for Reco Energy bins", default_value=5, ).tag(config=True) @@ -318,16 +328,16 @@ class ThetaCutsCalculator(Component): def calculate_theta_cuts(self, theta, reco_energy, reco_energy_bins=None): if reco_energy_bins is None: reco_energy_bins = create_bins_per_decade( - self.reco_energy_min * u.TeV, - self.reco_energy_max * u.TeV, + self.reco_energy_min.to(u.TeV), + self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, ) theta_min_angle = ( - None if self.theta_min_angle < 0 else self.theta_min_angle * u.deg + None if self.theta_min_angle < 0 * u.deg else self.theta_min_angle ) theta_max_angle = ( - None if self.theta_max_angle < 0 else self.theta_max_angle * u.deg + None if self.theta_max_angle < 0 * u.deg else self.theta_max_angle ) if self.theta_smoothing: theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing @@ -342,6 +352,6 @@ def calculate_theta_cuts(self, theta, reco_energy, reco_energy_bins=None): max_value=theta_max_angle, smoothing=theta_smoothing, percentile=self.target_percentile, - fill_value=self.theta_fill_value * u.deg, + fill_value=self.theta_fill_value, min_events=self.theta_min_counts, ) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 4bf3d4203e4..905d23425d8 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -260,7 +260,7 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): ): if isinstance(fov_bins, FovOffsetBinning): spectrum = spectrum.integrate_cone( - fov_bins.fov_offset_min * u.deg, fov_bins.fov_offset_max * u.deg + fov_bins.fov_offset_min, fov_bins.fov_offset_max ) else: spectrum = spectrum.integrate_cone(fov_bins[0], fov_bins[-1]) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index d9e5faffb07..107c77f5498 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -38,6 +38,7 @@ class IrfTool(Tool): True, help="Compute background rate IRF using supplied files", ).tag(config=True) + do_benchmarks = Bool( False, help="Produce IRF related benchmarks", @@ -50,28 +51,33 @@ class IrfTool(Tool): gamma_file = traits.Path( default_value=None, directory_ok=False, help="Gamma input filename and path" ).tag(config=True) + gamma_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.CRAB_HEGRA, help="Name of the pyirf spectra used for the simulated gamma spectrum", ).tag(config=True) + proton_file = traits.Path( default_value=None, allow_none=True, directory_ok=False, help="Proton input filename and path", ).tag(config=True) + proton_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_PROTON_SPECTRUM, help="Name of the pyirf spectra used for the simulated proton spectrum", ).tag(config=True) + electron_file = traits.Path( default_value=None, allow_none=True, directory_ok=False, help="Electron input filename and path", ).tag(config=True) + electron_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, @@ -341,7 +347,7 @@ def _make_signal_irf_hdus(self, hdus): self.opt_result.theta_cuts["high"][-1], ), fov_offset_bins=u.Quantity( - [self.fov_offset_bins[0], self.fov_offset_bins[-1]] + [self.bins.fov_offset_min, self.bins.fov_offset_max] ), ) ) @@ -377,7 +383,7 @@ def _make_benchmark_hdus(self, hdus): theta_cuts["center"] = 0.5 * ( self.reco_energy_bins[:-1] + self.reco_energy_bins[1:] ) - theta_cuts["cut"] = self.fov_offset_bins[-1] + theta_cuts["cut"] = self.bins.fov_offset_max else: theta_cuts = self.opt_result.theta_cuts @@ -390,8 +396,8 @@ def _make_benchmark_hdus(self, hdus): reco_energy_bins=self.reco_energy_bins, theta_cuts=theta_cuts, alpha=self.alpha, - fov_offset_min=self.fov_offset_bins[0], - fov_offset_max=self.fov_offset_bins[-1], + fov_offset_min=self.bins.fov_offset_min, + fov_offset_max=self.bins.fov_offset_max, ) sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index cc535060153..1cb2d264e0d 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -21,22 +21,27 @@ class IrfEventSelector(Tool): gamma_file = traits.Path( default_value=None, directory_ok=False, help="Gamma input filename and path" ).tag(config=True) + gamma_sim_spectrum = traits.UseEnum( Spectra, default_value=Spectra.CRAB_HEGRA, help="Name of the pyirf spectra used for the simulated gamma spectrum", ).tag(config=True) + proton_file = traits.Path( default_value=None, directory_ok=False, help="Proton input filename and path" ).tag(config=True) + proton_sim_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_PROTON_SPECTRUM, help="Name of the pyirf spectra used for the simulated proton spectrum", ).tag(config=True) + electron_file = traits.Path( default_value=None, directory_ok=False, help="Electron input filename and path" ).tag(config=True) + electron_sim_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, @@ -160,8 +165,8 @@ def start(self): signal=self.signal_events, background=self.background_events, alpha=self.alpha, - min_fov_radius=self.bins.fov_offset_min * u.deg, - max_fov_radius=self.bins.fov_offset_max * u.deg, + min_fov_radius=self.bins.fov_offset_min, + max_fov_radius=self.bins.fov_offset_max, theta=self.theta, precuts=self.particles[0].epp, # identical precuts for all particle types clf_prefix=self.particles[0].epp.gammaness_classifier, From 1b9c7672e82654e46b0fde966a1a0e1caef4a8e2 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 3 May 2024 19:14:05 +0200 Subject: [PATCH 083/136] Rework irf classes --- src/ctapipe/irf/__init__.py | 28 ++- src/ctapipe/irf/binning.py | 11 +- src/ctapipe/irf/irfs.py | 444 +++++++++++++++++----------------- src/ctapipe/irf/optimize.py | 8 +- src/ctapipe/irf/select.py | 2 +- src/ctapipe/tools/make_irf.py | 92 ++++--- 6 files changed, 313 insertions(+), 272 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index ce68fe1df00..57a3f214189 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -2,10 +2,16 @@ from .binning import FovOffsetBinning, OutputEnergyBinning, check_bins_in_range from .irfs import ( Background2dIrf, - Background3dIrf, - EffectiveAreaIrf, - EnergyMigrationIrf, - PsfIrf, + BackgroundIrfBase, + EffectiveArea2dIrf, + EffectiveAreaIrfBase, + EnergyMigration2dIrf, + EnergyMigrationIrfBase, + Irf2dBase, + IrfRecoEnergyBase, + IrfTrueEnergyBase, + Psf3dIrf, + PsfIrfBase, ) from .optimize import ( GridOptimizer, @@ -21,11 +27,17 @@ ) __all__ = [ + "Irf2dBase", + "IrfRecoEnergyBase", + "IrfTrueEnergyBase", + "PsfIrfBase", + "BackgroundIrfBase", + "EnergyMigrationIrfBase", + "EffectiveAreaIrfBase", + "Psf3dIrf", "Background2dIrf", - "Background3dIrf", - "EffectiveAreaIrf", - "EnergyMigrationIrf", - "PsfIrf", + "EnergyMigration2dIrf", + "EffectiveArea2dIrf", "OptimizationResult", "OptimizationResultStore", "GridOptimizer", diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 3728529aebc..edf4ea6ab7b 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -9,7 +9,10 @@ def check_bins_in_range(bins, range): low = bins >= range.min - hig = bins <= range.max + # `pyirf.binning.create_bins_per_decade` includes the endpoint, if reasonably close. + # So different choices of `n_bins_per_decade` can lead to mismatches, if the same + # `*_energy_max` is chosen. + hig = bins <= range.max * 1.0000001 if not all(low & hig): raise ValueError(f"Valid range is {range.min} to {range.max}, got {bins}") @@ -20,13 +23,13 @@ class OutputEnergyBinning(Component): true_energy_min = AstroQuantity( help="Minimum value for True Energy bins", - default_value=0.005 * u.TeV, + default_value=0.015 * u.TeV, physical_type=u.physical.energy, ).tag(config=True) true_energy_max = AstroQuantity( help="Maximum value for True Energy bins", - default_value=200 * u.TeV, + default_value=150 * u.TeV, physical_type=u.physical.energy, ).tag(config=True) @@ -43,7 +46,7 @@ class OutputEnergyBinning(Component): reco_energy_max = AstroQuantity( help="Maximum value for Reco Energy bins", - default_value=200 * u.TeV, + default_value=150 * u.TeV, physical_type=u.physical.energy, ).tag(config=True) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 431d35836fc..6058a770927 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -1,17 +1,19 @@ -"""components to generate irfs""" +"""Components to generate IRFs""" +from abc import abstractmethod + import astropy.units as u import numpy as np +from astropy.io.fits import BinTableHDU +from astropy.table import QTable from pyirf.binning import create_bins_per_decade from pyirf.io import ( create_aeff2d_hdu, create_background_2d_hdu, - create_background_3d_hdu, create_energy_dispersion_hdu, create_psf_table_hdu, ) from pyirf.irf import ( background_2d, - background_3d, effective_area_per_energy, effective_area_per_energy_and_fov, energy_dispersion, @@ -22,18 +24,18 @@ from ..core.traits import AstroQuantity, Float, Integer -class PsfIrf(Component): - """Collects the functionality for generating PSF IRFs.""" +class IrfTrueEnergyBase(Component): + """Base class for irf parameterizations binned in true energy.""" true_energy_min = AstroQuantity( help="Minimum value for True Energy bins", - default_value=0.005 * u.TeV, + default_value=u.Quantity(0.015, u.TeV), physical_type=u.physical.energy, ).tag(config=True) true_energy_max = AstroQuantity( help="Maximum value for True Energy bins", - default_value=200 * u.TeV, + default_value=u.Quantity(150, u.TeV), physical_type=u.physical.energy, ).tag(config=True) @@ -42,23 +44,6 @@ class PsfIrf(Component): default_value=10, ).tag(config=True) - source_offset_min = AstroQuantity( - help="Minimum value for Source offset for PSF IRF", - default_value=0 * u.deg, - physical_type=u.physical.angle, - ).tag(config=True) - - source_offset_max = AstroQuantity( - help="Maximum value for Source offset for PSF IRF", - default_value=1 * u.deg, - physical_type=u.physical.angle, - ).tag(config=True) - - source_offset_n_bins = Integer( - help="Number of bins for Source offset for PSF IRF", - default_value=100, - ).tag(config=True) - def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) self.true_energy_bins = create_bins_per_decade( @@ -66,43 +51,20 @@ def __init__(self, parent, **kwargs): self.true_energy_max.to(u.TeV), self.true_energy_n_bins_per_decade, ) - self.source_offset_bins = ( - np.linspace( - self.source_offset_min.to_value(u.deg), - self.source_offset_max.to_value(u.deg), - self.source_offset_n_bins + 1, - ) - * u.deg - ) - - def make_psf_table_hdu(self, signal_events, fov_offset_bins): - psf = psf_table( - events=signal_events, - true_energy_bins=self.true_energy_bins, - fov_offset_bins=fov_offset_bins, - source_offset_bins=self.source_offset_bins, - ) - return create_psf_table_hdu( - psf, - self.true_energy_bins, - self.source_offset_bins, - fov_offset_bins, - extname="PSF", - ) -class Background3dIrf(Component): - """Collects the functionality for generating 3D Background IRFs using square bins.""" +class IrfRecoEnergyBase(Component): + """Base class for irf parameterizations binned in reconstructed energy.""" reco_energy_min = AstroQuantity( help="Minimum value for Reco Energy bins", - default_value=0.005 * u.TeV, + default_value=u.Quantity(0.015, u.TeV), physical_type=u.physical.energy, ).tag(config=True) reco_energy_max = AstroQuantity( help="Maximum value for Reco Energy bins", - default_value=200 * u.TeV, + default_value=u.Quantity(150, u.TeV), physical_type=u.physical.energy, ).tag(config=True) @@ -111,130 +73,93 @@ class Background3dIrf(Component): default_value=10, ).tag(config=True) + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + self.reco_energy_bins = create_bins_per_decade( + self.reco_energy_min.to(u.TeV), + self.reco_energy_max.to(u.TeV), + self.reco_energy_n_bins_per_decade, + ) + + +class Irf2dBase(Component): + """Base class for radially symmetric irf parameterizations.""" + fov_offset_min = AstroQuantity( - help="Minimum value for Field of View offset for background IRF", - default_value=0 * u.deg, + help="Minimum value for FoV Offset bins", + default_value=u.Quantity(0, u.deg), physical_type=u.physical.angle, ).tag(config=True) fov_offset_max = AstroQuantity( - help="Maximum value for Field of View offset for background IRF", - default_value=1 * u.deg, + help="Maximum value for FoV offset bins", + default_value=u.Quantity(5, u.deg), physical_type=u.physical.angle, ).tag(config=True) fov_offset_n_bins = Integer( - help="Number of bins for Field of View offset for background IRF", + help="Number of FoV offset bins", default_value=1, ).tag(config=True) def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - self.reco_energy_bins = create_bins_per_decade( - self.reco_energy_min.to(u.TeV), - self.reco_energy_max.to(u.TeV), - self.reco_energy_n_bins_per_decade, - ) - self.fov_offset_bins = ( + self.fov_offset_bins = u.Quantity( np.linspace( self.fov_offset_min.to_value(u.deg), self.fov_offset_max.to_value(u.deg), self.fov_offset_n_bins + 1, - ) - * u.deg - ) - - def make_bkg3d_table_hdu(self, bkg_events, obs_time): - sel = bkg_events["selected"] - self.log.debug("%d background events selected" % sel.sum()) - self.log.debug("%f obs time" % obs_time.to_value(u.h)) - background_rate = background_3d( - bkg_events[sel], - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, - t_obs=obs_time, - ) - return create_background_3d_hdu( - background_rate, - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, - extname="BACKGROUND3D", + ), + u.deg, ) -class Background2dIrf(Component): - """Collects the functionality for generating 2D Background IRFs.""" +class PsfIrfBase(IrfTrueEnergyBase): + """Base class for parameterizations of the point spread function.""" - reco_energy_min = AstroQuantity( - help="Minimum value for Reco Energy bins", - default_value=0.005 * u.TeV, - physical_type=u.physical.energy, - ).tag(config=True) + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) - reco_energy_max = AstroQuantity( - help="Maximum value for Reco Energy bins", - default_value=200 * u.TeV, - physical_type=u.physical.energy, - ).tag(config=True) + @abstractmethod + def make_psf_hdu(self, events: QTable) -> BinTableHDU: + """ + Calculate the psf and create a fits binary table HDU in GAD format. - reco_energy_n_bins_per_decade = Integer( - help="Number of edges per decade for Reco Energy bins", - default_value=10, - ).tag(config=True) + Parameters + ---------- + events: astropy.table.QTable - fov_offset_min = AstroQuantity( - help="Minimum value for Field of View offset for background IRF", - default_value=0 * u.deg, - physical_type=u.physical.angle, - ).tag(config=True) + Returns + ------- + BinTableHDU + """ - fov_offset_max = AstroQuantity( - help="Maximum value for Field of View offset for background IRF", - default_value=1 * u.deg, - physical_type=u.physical.angle, - ).tag(config=True) - fov_offset_n_bins = Integer( - help="Number of bins for Field of View offset for background IRF", - default_value=1, - ).tag(config=True) +class BackgroundIrfBase(IrfRecoEnergyBase): + """Base class for parameterizations of the background rate.""" def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - self.reco_energy_bins = create_bins_per_decade( - self.reco_energy_min.to(u.TeV), - self.reco_energy_max.to(u.TeV), - self.reco_energy_n_bins_per_decade, - ) - self.fov_offset_bins = ( - np.linspace( - self.fov_offset_min.to_value(u.deg), - self.fov_offset_max.to_value(u.deg), - self.fov_offset_n_bins + 1, - ) - * u.deg - ) - def make_bkg2d_table_hdu(self, bkg_events, obs_time): - sel = bkg_events["selected"] - self.log.debug("%d background events selected" % sel.sum()) - self.log.debug("%f obs time" % obs_time.to_value(u.h)) + @abstractmethod + def make_bkg_hdu(self, events: QTable, obs_time: u.Quantity) -> BinTableHDU: + """ + Calculate the background rate and create a fits binary table HDU + in GAD format. - background_rate = background_2d( - bkg_events[sel], - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, - t_obs=obs_time, - ) - return create_background_2d_hdu( - background_rate, - self.reco_energy_bins, - fov_offset_bins=self.fov_offset_bins, - ) + Parameters + ---------- + events: astropy.table.QTable + obs_time: astropy.units.Quantity[time] + Returns + ------- + BinTableHDU + """ -class EnergyMigrationIrf(Component): - """Collects the functionality for generating Migration Matrix IRFs.""" + +class EnergyMigrationIrfBase(IrfTrueEnergyBase): + """Base class for parameterizations of the energy migration.""" energy_migration_min = Float( help="Minimum value of Energy Migration matrix", @@ -248,114 +173,195 @@ class EnergyMigrationIrf(Component): energy_migration_n_bins = Integer( help="Number of bins in log scale for Energy Migration matrix", - default_value=31, - ).tag(config=True) - - true_energy_min = AstroQuantity( - help="Minimum value for True Energy bins", - default_value=0.005 * u.TeV, - physical_type=u.physical.energy, - ).tag(config=True) - - true_energy_max = AstroQuantity( - help="Maximum value for True Energy bins", - default_value=200 * u.TeV, - physical_type=u.physical.energy, - ).tag(config=True) - - true_energy_n_bins_per_decade = Integer( - help="Number of edges per decade for True Energy bins", - default_value=10, + default_value=30, ).tag(config=True) def __init__(self, parent, **kwargs): - """ - Creates bins per decade for true MC energy. - """ super().__init__(parent=parent, **kwargs) - self.true_energy_bins = create_bins_per_decade( - self.true_energy_min.to(u.TeV), - self.true_energy_max.to(u.TeV), - self.true_energy_n_bins_per_decade, - ) self.migration_bins = np.linspace( self.energy_migration_min, self.energy_migration_max, - self.energy_migration_n_bins, + self.energy_migration_n_bins + 1, ) - def make_energy_dispersion_hdu(self, signal_events, fov_offset_bins, point_like): + @abstractmethod + def make_edisp_hdu(self, events: QTable, point_like: bool) -> BinTableHDU: + """ + Calculate the energy dispersion and create a fits binary table HDU + in GAD format. + + Parameters + ---------- + events: astropy.table.QTable + point_like: bool + + Returns + ------- + BinTableHDU + """ + + +class EffectiveAreaIrfBase(IrfTrueEnergyBase): + """Base class for parameterizations of the effective area.""" + + def __init__(self, parent, sim_info, **kwargs): + super().__init__(parent=parent, **kwargs) + self.sim_info = sim_info + + @abstractmethod + def make_aeff_hdu( + self, events: QTable, point_like: bool, signal_is_point_like: bool + ) -> BinTableHDU: + """ + Calculate the effective area and create a fits binary table HDU + in GAD format. + + Parameters + ---------- + events: QTable + point_like: bool + signal_is_point_like: bool + + Returns + ------- + BinTableHDU + """ + + +class EffectiveArea2dIrf(EffectiveAreaIrfBase, Irf2dBase): + """ + Radially symmetric parameterizations of the effective are in equidistant bins + of logarithmic true energy and field of view offset. + """ + + def __init__(self, parent, sim_info, **kwargs): + super().__init__(parent=parent, sim_info=sim_info, **kwargs) + + def make_aeff_hdu( + self, events: QTable, point_like: bool, signal_is_point_like: bool + ) -> BinTableHDU: + # For point-like gammas the effective area can only be calculated + # at one point in the FoV. + if signal_is_point_like: + aeff = effective_area_per_energy( + selected_events=events, + simulation_info=self.sim_info, + true_energy_bins=self.true_energy_bins, + ) + # +1 dimension for FOV offset + aeff = aeff[..., np.newaxis] + else: + aeff = effective_area_per_energy_and_fov( + selected_events=events, + simulation_info=self.sim_info, + true_energy_bins=self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, + ) + + return create_aeff2d_hdu( + effective_area=aeff, + true_energy_bins=self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, + point_like=point_like, + extname="EFFECTIVE AREA", + ) + + +class EnergyMigration2dIrf(EnergyMigrationIrfBase, Irf2dBase): + """ + Radially symmetric parameterizations of the energy migration in equidistant + bins of logarithmic true energy and field of view offset. + """ + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_edisp_hdu(self, events: QTable, point_like: bool) -> BinTableHDU: edisp = energy_dispersion( - selected_events=signal_events, + selected_events=events, true_energy_bins=self.true_energy_bins, - fov_offset_bins=fov_offset_bins, + fov_offset_bins=self.fov_offset_bins, migration_bins=self.migration_bins, ) return create_energy_dispersion_hdu( energy_dispersion=edisp, true_energy_bins=self.true_energy_bins, migration_bins=self.migration_bins, - fov_offset_bins=fov_offset_bins, + fov_offset_bins=self.fov_offset_bins, point_like=point_like, - extname="ENERGY DISPERSION", + extname="EDISP", ) -class EffectiveAreaIrf(Component): - """Collects the functionality for generating Effective Area IRFs.""" +class Background2dIrf(BackgroundIrfBase, Irf2dBase): + """ + Radially symmetric parameterization of the background rate in equidistant + bins of logarithmic reconstructed energy and field of view offset. + """ - true_energy_min = AstroQuantity( - help="Minimum value for True Energy bins", - default_value=0.005 * u.TeV, - physical_type=u.physical.energy, + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_bkg_hdu(self, events: QTable, obs_time: u.Quantity) -> BinTableHDU: + background_rate = background_2d( + events=events, + reco_energy_bins=self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + t_obs=obs_time, + ) + return create_background_2d_hdu( + background_2d=background_rate, + reco_energy_bins=self.reco_energy_bins, + fov_offset_bins=self.fov_offset_bins, + extname="BACKGROUND", + ) + + +class Psf3dIrf(PsfIrfBase, Irf2dBase): + """ + Radially symmetric point spread function calculated in equidistant bins + of source offset, logarithmic true energy, and field of view offset. + """ + + source_offset_min = AstroQuantity( + help="Minimum value for Source offset", + default_value=u.Quantity(0, u.deg), + physical_type=u.physical.angle, ).tag(config=True) - true_energy_max = AstroQuantity( - help="Maximum value for True Energy bins", - default_value=200 * u.TeV, - physical_type=u.physical.energy, + source_offset_max = AstroQuantity( + help="Maximum value for Source offset", + default_value=u.Quantity(1, u.deg), + physical_type=u.physical.angle, ).tag(config=True) - true_energy_n_bins_per_decade = Integer( - help="Number of bins per decade for True Energy bins", - default_value=10, + source_offset_n_bins = Integer( + help="Number of bins for Source offset", + default_value=100, ).tag(config=True) - def __init__(self, parent, sim_info, **kwargs): - """ - Creates bins per decade for true MC energy. - """ + def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - self.true_energy_bins = create_bins_per_decade( - self.true_energy_min.to(u.TeV), - self.true_energy_max.to(u.TeV), - self.true_energy_n_bins_per_decade, + self.source_offset_bins = u.Quantity( + np.linspace( + self.source_offset_min.to_value(u.deg), + self.source_offset_max.to_value(u.deg), + self.source_offset_n_bins + 1, + ), + u.deg, ) - self.sim_info = sim_info - def make_effective_area_hdu( - self, signal_events, fov_offset_bins, point_like, signal_is_point_like - ): - # For point-like gammas the effective area can only be calculated at one point in the FoV - if signal_is_point_like: - effective_area = effective_area_per_energy( - selected_events=signal_events, - simulation_info=self.sim_info, - true_energy_bins=self.true_energy_bins, - ) - # +1 dimension for FOV offset - effective_area = effective_area[..., np.newaxis] - else: - effective_area = effective_area_per_energy_and_fov( - selected_events=signal_events, - simulation_info=self.sim_info, - true_energy_bins=self.true_energy_bins, - fov_offset_bins=fov_offset_bins, - ) - return create_aeff2d_hdu( - effective_area, + def make_psf_hdu(self, events: QTable) -> BinTableHDU: + psf = psf_table( + events=events, true_energy_bins=self.true_energy_bins, - fov_offset_bins=fov_offset_bins, - point_like=point_like, - extname="EFFECTIVE AREA", + fov_offset_bins=self.fov_offset_bins, + source_offset_bins=self.source_offset_bins, + ) + return create_psf_table_hdu( + psf=psf, + true_energy_bins=self.true_energy_bins, + fov_offset_bins=self.fov_offset_bins, + source_offset_bins=self.source_offset_bins, + extname="PSF", ) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index d4a3ef5c667..5c2f57aae77 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -141,13 +141,13 @@ class GridOptimizer(Component): reco_energy_min = AstroQuantity( help="Minimum value for Reco Energy bins", - default_value=0.015 * u.TeV, + default_value=u.Quantity(0.015, u.TeV), physical_type=u.physical.energy, ).tag(config=True) reco_energy_max = AstroQuantity( help="Maximum value for Reco Energy bins", - default_value=200 * u.TeV, + default_value=u.Quantity(150, u.TeV), physical_type=u.physical.energy, ).tag(config=True) @@ -310,13 +310,13 @@ class ThetaCutsCalculator(Component): reco_energy_min = AstroQuantity( help="Minimum value for Reco Energy bins", - default_value=0.015 * u.TeV, + default_value=u.Quantity(0.015, u.TeV), physical_type=u.physical.energy, ).tag(config=True) reco_energy_max = AstroQuantity( help="Maximum value for Reco Energy bins", - default_value=200 * u.TeV, + default_value=u.Quantity(150, u.TeV), physical_type=u.physical.energy, ).tag(config=True) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 905d23425d8..f20efc6b006 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -1,4 +1,4 @@ -"""Module containing classes related to eveent preprocessing and selection""" +"""Module containing classes related to event preprocessing and selection""" from enum import Enum import astropy.units as u diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 107c77f5498..5132db351ce 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -16,15 +16,14 @@ from ..irf import ( SPECTRA, Background2dIrf, - Background3dIrf, - EffectiveAreaIrf, - EnergyMigrationIrf, + EffectiveArea2dIrf, + EnergyMigration2dIrf, EventPreProcessor, EventsLoader, FovOffsetBinning, OptimizationResultStore, OutputEnergyBinning, - PsfIrf, + Psf3dIrf, Spectra, check_bins_in_range, ) @@ -55,7 +54,7 @@ class IrfTool(Tool): gamma_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.CRAB_HEGRA, - help="Name of the pyirf spectra used for the simulated gamma spectrum", + help="Name of the spectra used for the simulated gamma spectrum", ).tag(config=True) proton_file = traits.Path( @@ -68,7 +67,7 @@ class IrfTool(Tool): proton_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_PROTON_SPECTRUM, - help="Name of the pyirf spectra used for the simulated proton spectrum", + help="Name of the spectra used for the simulated proton spectrum", ).tag(config=True) electron_file = traits.Path( @@ -81,7 +80,7 @@ class IrfTool(Tool): electron_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, - help="Name of the pyirf spectra used for the simulated electron spectrum", + help="Name of the spectra used for the simulated electron spectrum", ).tag(config=True) chunk_size = Integer( @@ -148,12 +147,11 @@ class IrfTool(Tool): classes = [ EventsLoader, Background2dIrf, - Background3dIrf, - EffectiveAreaIrf, - EnergyMigrationIrf, + EffectiveArea2dIrf, + EnergyMigration2dIrf, + Psf3dIrf, FovOffsetBinning, OutputEnergyBinning, - PsfIrf, ] def setup(self): @@ -207,11 +205,16 @@ def setup(self): ) self.bkg = Background2dIrf(parent=self) - self.bkg3 = Background3dIrf(parent=self) + check_bins_in_range(self.bkg.reco_energy_bins, self.opt_result.valid_energy) + check_bins_in_range(self.bkg.fov_offset_bins, self.opt_result.valid_offset) - self.mig_matrix = EnergyMigrationIrf(parent=self) + self.edisp = EnergyMigration2dIrf(parent=self) + check_bins_in_range(self.edisp.true_energy_bins, self.opt_result.valid_energy) + check_bins_in_range(self.edisp.fov_offset_bins, self.opt_result.valid_offset) if self.full_enclosure: - self.psf = PsfIrf(parent=self) + self.psf = Psf3dIrf(parent=self) + check_bins_in_range(self.psf.true_energy_bins, self.opt_result.valid_energy) + check_bins_in_range(self.psf.fov_offset_bins, self.opt_result.valid_offset) if self.do_benchmarks: self.b_output = self.output_path.with_name( @@ -310,25 +313,22 @@ def _stack_background(self, reduced_events): def _make_signal_irf_hdus(self, hdus): hdus.append( - self.aeff.make_effective_area_hdu( - signal_events=self.signal_events[self.signal_events["selected"]], - fov_offset_bins=self.fov_offset_bins, + self.aeff.make_aeff_hdu( + events=self.signal_events[self.signal_events["selected"]], point_like=not self.full_enclosure, signal_is_point_like=self.signal_is_point_like, ) ) hdus.append( - self.mig_matrix.make_energy_dispersion_hdu( - signal_events=self.signal_events[self.signal_events["selected"]], - fov_offset_bins=self.fov_offset_bins, + self.edisp.make_edisp_hdu( + events=self.signal_events[self.signal_events["selected"]], point_like=not self.full_enclosure, ) ) if self.full_enclosure: hdus.append( - self.psf.make_psf_table_hdu( - signal_events=self.signal_events[self.signal_events["selected"]], - fov_offset_bins=self.fov_offset_bins, + self.psf.make_psf_hdu( + events=self.signal_events[self.signal_events["selected"]], ) ) else: @@ -457,19 +457,39 @@ def start(self): "Loaded %d %s events" % (reduced_events[f"{sel.kind}_count"], sel.kind) ) if sel.kind == "gammas": - self.aeff = EffectiveAreaIrf(parent=self, sim_info=meta["sim_info"]) self.signal_is_point_like = ( meta["sim_info"].viewcone_max - meta["sim_info"].viewcone_min ).value == 0 - if self.signal_is_point_like: - self.log.info( - "The gamma input file contains point-like simulations." - " Therefore, the IRF is only calculated at a single point in the FoV." - " Changing `fov_offset_n_bins` to 1." - ) - self.bins.fov_offset_n_bins = 1 - self.fov_offset_bins = self.bins.fov_offset_bins() + if self.signal_is_point_like: + self.log.info( + "The gamma input file contains point-like simulations." + " Therefore, the IRF is only calculated at a single point" + " in the FoV. Changing `fov_offset_n_bins` to 1." + ) + self.bins.fov_offset_n_bins = 1 + self.fov_offset_bins = self.bins.fov_offset_bins() + self.edisp = EnergyMigration2dIrf(parent=self, fov_offset_n_bins=1) + self.aeff = EffectiveArea2dIrf( + parent=self, sim_info=meta["sim_info"], fov_offset_n_bins=1 + ) + if self.full_enclosure: + self.psf = Psf3dIrf(parent=self, fov_offset_n_bins=1) + + if self.do_background: + self.bkg = Background2dIrf(parent=self, fov_offset_n_bins=1) + + else: + self.aeff = EffectiveArea2dIrf( + parent=self, sim_info=meta["sim_info"] + ) + + check_bins_in_range( + self.aeff.true_energy_bins, self.opt_result.valid_energy + ) + check_bins_in_range( + self.aeff.fov_offset_bins, self.opt_result.valid_offset + ) reduced_events = self.calculate_selections(reduced_events) @@ -485,10 +505,10 @@ def start(self): hdus = self._make_signal_irf_hdus(hdus) if self.do_background: hdus.append( - self.bkg.make_bkg2d_table_hdu(self.background_events, self.obs_time) - ) - hdus.append( - self.bkg3.make_bkg3d_table_hdu(self.background_events, self.obs_time) + self.bkg.make_bkg_hdu( + self.background_events[self.background_events["selected"]], + self.obs_time, + ) ) self.hdus = hdus From 13daa9a7567acfa7e0a6876e7ba43f7f28c07d12 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 17:07:47 +0200 Subject: [PATCH 084/136] Make irf parameterizations configurable --- src/ctapipe/tools/make_irf.py | 77 ++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 5132db351ce..56a66ea1ce0 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -15,15 +15,15 @@ from ..core.traits import AstroQuantity, Bool, Float, Integer, flag from ..irf import ( SPECTRA, - Background2dIrf, - EffectiveArea2dIrf, - EnergyMigration2dIrf, + BackgroundIrfBase, + EffectiveAreaIrfBase, + EnergyMigrationIrfBase, EventPreProcessor, EventsLoader, FovOffsetBinning, OptimizationResultStore, OutputEnergyBinning, - Psf3dIrf, + PsfIrfBase, Spectra, check_bins_in_range, ) @@ -97,7 +97,7 @@ class IrfTool(Tool): ).tag(config=True) obs_time = AstroQuantity( - default_value=50.0 * u.hour, + default_value=u.Quantity(50, u.hour), physical_type=u.physical.time, help="Observation time in the form `` ``", ).tag(config=True) @@ -106,6 +106,30 @@ class IrfTool(Tool): default_value=0.2, help="Ratio between size of on and off regions." ).tag(config=True) + edisp_parameterization = traits.ComponentName( + EnergyMigrationIrfBase, + default_value="EnergyMigration2dIrf", + help="The parameterization of the energy migration to be used.", + ).tag(config=True) + + aeff_parameterization = traits.ComponentName( + EffectiveAreaIrfBase, + default_value="EffectiveArea2dIrf", + help="The parameterization of the effective area to be used.", + ).tag(config=True) + + psf_parameterization = traits.ComponentName( + PsfIrfBase, + default_value="Psf3dIrf", + help="The parameterization of the point spread function to be used.", + ).tag(config=True) + + bkg_parameterization = traits.ComponentName( + BackgroundIrfBase, + default_value="Background2dIrf", + help="The parameterization of the background rate to be used.", + ).tag(config=True) + full_enclosure = Bool( False, help=( @@ -146,10 +170,10 @@ class IrfTool(Tool): classes = [ EventsLoader, - Background2dIrf, - EffectiveArea2dIrf, - EnergyMigration2dIrf, - Psf3dIrf, + BackgroundIrfBase, + EffectiveAreaIrfBase, + EnergyMigrationIrfBase, + PsfIrfBase, FovOffsetBinning, OutputEnergyBinning, ] @@ -204,15 +228,19 @@ def setup(self): "At least one electron or proton file required when specifying `do_background`." ) - self.bkg = Background2dIrf(parent=self) + self.bkg = BackgroundIrfBase.from_name( + self.bkg_parameterization, parent=self + ) check_bins_in_range(self.bkg.reco_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.bkg.fov_offset_bins, self.opt_result.valid_offset) - self.edisp = EnergyMigration2dIrf(parent=self) + self.edisp = EnergyMigrationIrfBase.from_name( + self.edisp_parameterization, parent=self + ) check_bins_in_range(self.edisp.true_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.edisp.fov_offset_bins, self.opt_result.valid_offset) if self.full_enclosure: - self.psf = Psf3dIrf(parent=self) + self.psf = PsfIrfBase.from_name(self.psf_parameterization, parent=self) check_bins_in_range(self.psf.true_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.psf.fov_offset_bins, self.opt_result.valid_offset) @@ -469,19 +497,30 @@ def start(self): ) self.bins.fov_offset_n_bins = 1 self.fov_offset_bins = self.bins.fov_offset_bins() - self.edisp = EnergyMigration2dIrf(parent=self, fov_offset_n_bins=1) - self.aeff = EffectiveArea2dIrf( - parent=self, sim_info=meta["sim_info"], fov_offset_n_bins=1 + self.edisp = EnergyMigrationIrfBase.from_name( + self.edisp_parameterization, parent=self, fov_offset_n_bins=1 + ) + self.aeff = EffectiveAreaIrfBase.from_name( + self.aeff_parameterization, + parent=self, + sim_info=meta["sim_info"], + fov_offset_n_bins=1, ) if self.full_enclosure: - self.psf = Psf3dIrf(parent=self, fov_offset_n_bins=1) + self.psf = PsfIrfBase.from_name( + self.psf_parameterization, parent=self, fov_offset_n_bins=1 + ) if self.do_background: - self.bkg = Background2dIrf(parent=self, fov_offset_n_bins=1) + self.bkg = BackgroundIrfBase.from_name( + self.bkg_parameterization, parent=self, fov_offset_n_bins=1 + ) else: - self.aeff = EffectiveArea2dIrf( - parent=self, sim_info=meta["sim_info"] + self.aeff = EffectiveAreaIrfBase.from_name( + self.aeff_parameterization, + parent=self, + sim_info=meta["sim_info"], ) check_bins_in_range( From 1d1e194dd3fc62d57388e7c5dad63fbd2921c374 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 17:18:24 +0200 Subject: [PATCH 085/136] Move spectra definition to separate file --- src/ctapipe/irf/__init__.py | 8 ++------ src/ctapipe/irf/select.py | 23 +---------------------- src/ctapipe/irf/spectra.py | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 28 deletions(-) create mode 100644 src/ctapipe/irf/spectra.py diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 57a3f214189..b6ba54108d5 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -19,12 +19,8 @@ OptimizationResultStore, ThetaCutsCalculator, ) -from .select import ( - SPECTRA, - EventPreProcessor, - EventsLoader, - Spectra, -) +from .select import EventPreProcessor, EventsLoader +from .spectra import SPECTRA, Spectra __all__ = [ "Irf2dBase", diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index f20efc6b006..6338f5fccdb 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -1,18 +1,10 @@ """Module containing classes related to event preprocessing and selection""" -from enum import Enum - import astropy.units as u import numpy as np from astropy.coordinates import AltAz, SkyCoord from astropy.table import QTable, vstack from pyirf.simulations import SimulatedEventsInfo -from pyirf.spectral import ( - CRAB_HEGRA, - IRFDOC_ELECTRON_SPECTRUM, - IRFDOC_PROTON_SPECTRUM, - PowerLaw, - calculate_event_weights, -) +from pyirf.spectral import PowerLaw, calculate_event_weights from pyirf.utils import calculate_source_fov_offset, calculate_theta from ..coordinates import NominalFrame @@ -22,19 +14,6 @@ from ..irf import FovOffsetBinning -class Spectra(Enum): - CRAB_HEGRA = 1 - IRFDOC_ELECTRON_SPECTRUM = 2 - IRFDOC_PROTON_SPECTRUM = 3 - - -SPECTRA = { - Spectra.CRAB_HEGRA: CRAB_HEGRA, - Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, - Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, -} - - class EventPreProcessor(QualityQuery): """Defines preselection cuts and the necessary renaming of columns""" diff --git a/src/ctapipe/irf/spectra.py b/src/ctapipe/irf/spectra.py new file mode 100644 index 00000000000..fa726eadd10 --- /dev/null +++ b/src/ctapipe/irf/spectra.py @@ -0,0 +1,17 @@ +"""Definition of spectra to be used to calculate event weights for irf computation""" +from enum import Enum + +from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM + + +class Spectra(Enum): + CRAB_HEGRA = 1 + IRFDOC_ELECTRON_SPECTRUM = 2 + IRFDOC_PROTON_SPECTRUM = 3 + + +SPECTRA = { + Spectra.CRAB_HEGRA: CRAB_HEGRA, + Spectra.IRFDOC_ELECTRON_SPECTRUM: IRFDOC_ELECTRON_SPECTRUM, + Spectra.IRFDOC_PROTON_SPECTRUM: IRFDOC_PROTON_SPECTRUM, +} From 90e833c05a5302b612cd8c9fa8abdcfefbf76656 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 17:32:05 +0200 Subject: [PATCH 086/136] Revert change of edisp extname --- src/ctapipe/irf/irfs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 6058a770927..85d0e1ad615 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -289,7 +289,7 @@ def make_edisp_hdu(self, events: QTable, point_like: bool) -> BinTableHDU: migration_bins=self.migration_bins, fov_offset_bins=self.fov_offset_bins, point_like=point_like, - extname="EDISP", + extname="ENERGY MIGRATION", ) From eef9165880b673994488e8f74224d4f4715e94bf Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 18:32:19 +0200 Subject: [PATCH 087/136] Also calculate aeff for protons and electrons --- src/ctapipe/irf/irfs.py | 54 +++++++++++++++++++++++------------ src/ctapipe/irf/select.py | 5 +--- src/ctapipe/tools/make_irf.py | 53 ++++++++++++++++++++++------------ 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 85d0e1ad615..327a1cef25f 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -19,6 +19,7 @@ energy_dispersion, psf_table, ) +from pyirf.simulations import SimulatedEventsInfo from ..core import Component from ..core.traits import AstroQuantity, Float, Integer @@ -121,7 +122,7 @@ def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod - def make_psf_hdu(self, events: QTable) -> BinTableHDU: + def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: """ Calculate the psf and create a fits binary table HDU in GAD format. @@ -142,7 +143,9 @@ def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod - def make_bkg_hdu(self, events: QTable, obs_time: u.Quantity) -> BinTableHDU: + def make_bkg_hdu( + self, events: QTable, obs_time: u.Quantity, extname: str = "BACKGROUND" + ) -> BinTableHDU: """ Calculate the background rate and create a fits binary table HDU in GAD format. @@ -185,7 +188,9 @@ def __init__(self, parent, **kwargs): ) @abstractmethod - def make_edisp_hdu(self, events: QTable, point_like: bool) -> BinTableHDU: + def make_edisp_hdu( + self, events: QTable, point_like: bool, extname: str = "ENERGY MIGRATION" + ) -> BinTableHDU: """ Calculate the energy dispersion and create a fits binary table HDU in GAD format. @@ -204,13 +209,17 @@ def make_edisp_hdu(self, events: QTable, point_like: bool) -> BinTableHDU: class EffectiveAreaIrfBase(IrfTrueEnergyBase): """Base class for parameterizations of the effective area.""" - def __init__(self, parent, sim_info, **kwargs): + def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - self.sim_info = sim_info @abstractmethod def make_aeff_hdu( - self, events: QTable, point_like: bool, signal_is_point_like: bool + self, + events: QTable, + point_like: bool, + signal_is_point_like: bool, + sim_info: SimulatedEventsInfo, + extname: str = "EFFECTIVE AREA", ) -> BinTableHDU: """ Calculate the effective area and create a fits binary table HDU @@ -234,18 +243,23 @@ class EffectiveArea2dIrf(EffectiveAreaIrfBase, Irf2dBase): of logarithmic true energy and field of view offset. """ - def __init__(self, parent, sim_info, **kwargs): - super().__init__(parent=parent, sim_info=sim_info, **kwargs) + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) def make_aeff_hdu( - self, events: QTable, point_like: bool, signal_is_point_like: bool + self, + events: QTable, + point_like: bool, + signal_is_point_like: bool, + sim_info: SimulatedEventsInfo, + extname: str = "EFFECTIVE AREA", ) -> BinTableHDU: # For point-like gammas the effective area can only be calculated # at one point in the FoV. if signal_is_point_like: aeff = effective_area_per_energy( selected_events=events, - simulation_info=self.sim_info, + simulation_info=sim_info, true_energy_bins=self.true_energy_bins, ) # +1 dimension for FOV offset @@ -253,7 +267,7 @@ def make_aeff_hdu( else: aeff = effective_area_per_energy_and_fov( selected_events=events, - simulation_info=self.sim_info, + simulation_info=sim_info, true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, ) @@ -263,7 +277,7 @@ def make_aeff_hdu( true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, point_like=point_like, - extname="EFFECTIVE AREA", + extname=extname, ) @@ -276,7 +290,9 @@ class EnergyMigration2dIrf(EnergyMigrationIrfBase, Irf2dBase): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - def make_edisp_hdu(self, events: QTable, point_like: bool) -> BinTableHDU: + def make_edisp_hdu( + self, events: QTable, point_like: bool, extname: str = "ENERGY MIGRATION" + ) -> BinTableHDU: edisp = energy_dispersion( selected_events=events, true_energy_bins=self.true_energy_bins, @@ -289,7 +305,7 @@ def make_edisp_hdu(self, events: QTable, point_like: bool) -> BinTableHDU: migration_bins=self.migration_bins, fov_offset_bins=self.fov_offset_bins, point_like=point_like, - extname="ENERGY MIGRATION", + extname=extname, ) @@ -302,7 +318,9 @@ class Background2dIrf(BackgroundIrfBase, Irf2dBase): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - def make_bkg_hdu(self, events: QTable, obs_time: u.Quantity) -> BinTableHDU: + def make_bkg_hdu( + self, events: QTable, obs_time: u.Quantity, extname: str = "BACKGROUND" + ) -> BinTableHDU: background_rate = background_2d( events=events, reco_energy_bins=self.reco_energy_bins, @@ -313,7 +331,7 @@ def make_bkg_hdu(self, events: QTable, obs_time: u.Quantity) -> BinTableHDU: background_2d=background_rate, reco_energy_bins=self.reco_energy_bins, fov_offset_bins=self.fov_offset_bins, - extname="BACKGROUND", + extname=extname, ) @@ -351,7 +369,7 @@ def __init__(self, parent, **kwargs): u.deg, ) - def make_psf_hdu(self, events: QTable) -> BinTableHDU: + def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: psf = psf_table( events=events, true_energy_bins=self.true_energy_bins, @@ -363,5 +381,5 @@ def make_psf_hdu(self, events: QTable) -> BinTableHDU: true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, source_offset_bins=self.source_offset_bins, - extname="PSF", + extname=extname, ) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 6338f5fccdb..ab2105d1104 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -148,10 +148,7 @@ def load_preselected_events(self, chunk_size, obs_time, fov_bins): with TableLoader(self.file, parent=self, **opts) as load: header = self.epp.make_empty_table() sim_info, spectrum, obs_conf = self.get_metadata(load, obs_time) - if self.kind == "gammas": - meta = {"sim_info": sim_info, "spectrum": spectrum} - else: - meta = None + meta = {"sim_info": sim_info, "spectrum": spectrum} bits = [header] n_raw_events = 0 for _, _, events in load.read_subarray_events_chunked(chunk_size, **opts): diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 56a66ea1ce0..acbfbd2735a 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -239,6 +239,11 @@ def setup(self): ) check_bins_in_range(self.edisp.true_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.edisp.fov_offset_bins, self.opt_result.valid_offset) + self.aeff = EffectiveAreaIrfBase.from_name( + self.aeff_parameterization, parent=self + ) + check_bins_in_range(self.aeff.true_energy_bins, self.opt_result.valid_energy) + check_bins_in_range(self.aeff.fov_offset_bins, self.opt_result.valid_offset) if self.full_enclosure: self.psf = PsfIrfBase.from_name(self.psf_parameterization, parent=self) check_bins_in_range(self.psf.true_energy_bins, self.opt_result.valid_energy) @@ -339,12 +344,13 @@ def _stack_background(self, reduced_events): background = reduced_events[bkgs[0]] return background - def _make_signal_irf_hdus(self, hdus): + def _make_signal_irf_hdus(self, hdus, sim_info): hdus.append( self.aeff.make_aeff_hdu( events=self.signal_events[self.signal_events["selected"]], point_like=not self.full_enclosure, signal_is_point_like=self.signal_is_point_like, + sim_info=sim_info, ) ) hdus.append( @@ -481,6 +487,7 @@ def start(self): ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt + reduced_events[f"{sel.kind}_meta"] = meta self.log.debug( "Loaded %d %s events" % (reduced_events[f"{sel.kind}_count"], sel.kind) ) @@ -503,33 +510,17 @@ def start(self): self.aeff = EffectiveAreaIrfBase.from_name( self.aeff_parameterization, parent=self, - sim_info=meta["sim_info"], fov_offset_n_bins=1, ) if self.full_enclosure: self.psf = PsfIrfBase.from_name( self.psf_parameterization, parent=self, fov_offset_n_bins=1 ) - if self.do_background: self.bkg = BackgroundIrfBase.from_name( self.bkg_parameterization, parent=self, fov_offset_n_bins=1 ) - else: - self.aeff = EffectiveAreaIrfBase.from_name( - self.aeff_parameterization, - parent=self, - sim_info=meta["sim_info"], - ) - - check_bins_in_range( - self.aeff.true_energy_bins, self.opt_result.valid_energy - ) - check_bins_in_range( - self.aeff.fov_offset_bins, self.opt_result.valid_offset - ) - reduced_events = self.calculate_selections(reduced_events) self.signal_events = reduced_events["gammas"] @@ -541,7 +532,9 @@ def start(self): self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) hdus = [fits.PrimaryHDU()] - hdus = self._make_signal_irf_hdus(hdus) + hdus = self._make_signal_irf_hdus( + hdus, reduced_events["gammas_meta"]["sim_info"] + ) if self.do_background: hdus.append( self.bkg.make_bkg_hdu( @@ -549,6 +542,30 @@ def start(self): self.obs_time, ) ) + if "protons" in reduced_events.keys(): + hdus.append( + self.aeff.make_aeff_hdu( + events=reduced_events["protons"][ + reduced_events["protons"]["selected"] + ], + point_like=not self.full_enclosure, + signal_is_point_like=False, + sim_info=reduced_events["protons_meta"]["sim_info"], + extname="EFFECTIVE AREA PROTONS", + ) + ) + if "electrons" in reduced_events.keys(): + hdus.append( + self.aeff.make_aeff_hdu( + events=reduced_events["electrons"][ + reduced_events["electrons"]["selected"] + ], + point_like=not self.full_enclosure, + signal_is_point_like=False, + sim_info=reduced_events["electrons_meta"]["sim_info"], + extname="EFFECTIVE AREA ELECTRONS", + ) + ) self.hdus = hdus if self.do_benchmarks: From fc9a888200e9405a52621099176cd16796088359 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 7 May 2024 17:34:01 +0200 Subject: [PATCH 088/136] Update cut optimization components and make cut opt algorithm configurable --- src/ctapipe/irf/__init__.py | 10 +- src/ctapipe/irf/binning.py | 4 +- src/ctapipe/irf/irfs.py | 2 +- src/ctapipe/irf/optimize.py | 366 ++++++++++++------ src/ctapipe/tools/optimize_event_selection.py | 23 +- 5 files changed, 276 insertions(+), 129 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index b6ba54108d5..9683e7ab86b 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -14,10 +14,13 @@ PsfIrfBase, ) from .optimize import ( + CutOptimizerBase, + GhPercentileCutCalculator, GridOptimizer, OptimizationResult, OptimizationResultStore, - ThetaCutsCalculator, + PercentileCuts, + ThetaPercentileCutCalculator, ) from .select import EventPreProcessor, EventsLoader from .spectra import SPECTRA, Spectra @@ -36,13 +39,16 @@ "EffectiveArea2dIrf", "OptimizationResult", "OptimizationResultStore", + "CutOptimizerBase", "GridOptimizer", + "PercentileCuts", "OutputEnergyBinning", "FovOffsetBinning", "EventsLoader", "EventPreProcessor", "Spectra", - "ThetaCutsCalculator", + "GhPercentileCutCalculator", + "ThetaPercentileCutCalculator", "SPECTRA", "check_bins_in_range", ] diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index edf4ea6ab7b..6ee9ccb57ea 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -8,10 +8,10 @@ def check_bins_in_range(bins, range): - low = bins >= range.min # `pyirf.binning.create_bins_per_decade` includes the endpoint, if reasonably close. # So different choices of `n_bins_per_decade` can lead to mismatches, if the same - # `*_energy_max` is chosen. + # `*_energy_{min,max}` is chosen. + low = bins >= range.min * 0.9999999 hig = bins <= range.max * 1.0000001 if not all(low & hig): diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 327a1cef25f..a4ab3e8a04b 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -227,7 +227,7 @@ def make_aeff_hdu( Parameters ---------- - events: QTable + events: astropy.table.QTable point_like: bool signal_is_point_like: bool diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 5c2f57aae77..452bfeab423 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -1,5 +1,6 @@ """module containing optimization related functions and classes""" import operator +from abc import abstractmethod import astropy.units as u import numpy as np @@ -11,6 +12,7 @@ from ..core import Component, QualityQuery from ..core.traits import AstroQuantity, Float, Integer +from .select import EventPreProcessor class ResultValidRange: @@ -123,21 +125,8 @@ def read(self, file_name): ) -class GridOptimizer(Component): - """Performs cut optimization""" - - initial_gh_cut_efficency = Float( - default_value=0.4, help="Start value of gamma efficiency before optimization" - ).tag(config=True) - - max_gh_cut_efficiency = Float( - default_value=0.8, help="Maximum gamma efficiency requested" - ).tag(config=True) - - gh_cut_efficiency_step = Float( - default_value=0.1, - help="Stepsize used for scanning after optimal gammaness cut", - ).tag(config=True) +class CutOptimizerBase(Component): + """Base class for cut optimization algorithms.""" reco_energy_min = AstroQuantity( help="Minimum value for Reco Energy bins", @@ -156,18 +145,248 @@ class GridOptimizer(Component): default_value=5, ).tag(config=True) - def optimize_gh_cut( + @abstractmethod + def optimize_cuts( self, - signal, - background, - alpha, - min_fov_radius, - max_fov_radius, - theta, - precuts, - clf_prefix, - point_like, - ): + signal: QTable, + background: QTable, + alpha: float, + min_fov_radius: u.Quantity, + max_fov_radius: u.Quantity, + precuts: EventPreProcessor, + clf_prefix: str, + point_like: bool, + ) -> OptimizationResultStore: + """ + Optimize G/H (and optionally theta) cuts + and fill them in an ``OptimizationResult``. + + Parameters + ---------- + signal: astropy.table.QTable + Table containing signal events + background: astropy.table.QTable + Table containing background events + alpha: float + Size ratio of on region / off region + min_fov_radius: astropy.units.Quantity[angle] + Minimum distance from the fov center for background events + to be taken into account + max_fov_radius: astropy.units.Quantity[angle] + Maximum distance from the fov center for background events + to be taken into account + precuts: ctapipe.irf.EventPreProcessor + ``ctapipe.core.QualityQuery`` subclass containing preselection + criteria for events + clf_prefix: str + Prefix of the output from the G/H classifier for which the + cut will be optimized + point_like: bool + Whether a theta cut should be calculated (True) or only a + G/H cut (False) + """ + + +class GhPercentileCutCalculator(Component): + """Computes a percentile cut on gammaness.""" + + min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + + smoothing = Float( + default_value=None, + allow_none=True, + help="When given, the width (in units of bins) of gaussian smoothing applied", + ).tag(config=True) + + target_percentile = Integer( + default_value=68, + help="Percent of events in each energy bin to keep after the G/H cut", + ).tag(config=True) + + def calculate_gh_cut(self, gammaness, reco_energy, reco_energy_bins): + if self.smoothing and self.smoothing < 0: + self.smoothing = None + + return calculate_percentile_cut( + gammaness, + reco_energy, + reco_energy_bins, + smoothing=self.smoothing, + percentile=self.target_percentile, + fill_value=gammaness.max(), + min_events=self.min_counts, + ) + + +class ThetaPercentileCutCalculator(Component): + """Computes a percentile cut on theta.""" + + theta_min_angle = AstroQuantity( + default_value=u.Quantity(-1, u.deg), + physical_type=u.physical.angle, + help="Smallest angular cut value allowed (-1 means no cut)", + ).tag(config=True) + + theta_max_angle = AstroQuantity( + default_value=u.Quantity(0.32, u.deg), + physical_type=u.physical.angle, + help="Largest angular cut value allowed", + ).tag(config=True) + + min_counts = Integer( + default_value=10, + help="Minimum number of events in a bin to attempt to find a cut value", + ).tag(config=True) + + theta_fill_value = AstroQuantity( + default_value=u.Quantity(0.32, u.deg), + physical_type=u.physical.angle, + help="Angular cut value used for bins with too few events", + ).tag(config=True) + + smoothing = Float( + default_value=None, + allow_none=True, + help="When given, the width (in units of bins) of gaussian smoothing applied", + ).tag(config=True) + + target_percentile = Integer( + default_value=68, + help="Percent of events in each energy bin to keep after the theta cut", + ).tag(config=True) + + def calculate_theta_cut(self, theta, reco_energy, reco_energy_bins): + if self.theta_min_angle < 0 * u.deg: + theta_min_angle = None + else: + theta_min_angle = self.theta_min_angle + + if self.theta_max_angle < 0 * u.deg: + theta_max_angle = None + else: + theta_max_angle = self.theta_max_angle + + if self.smoothing and self.smoothing < 0: + self.smoothing = None + + return calculate_percentile_cut( + theta, + reco_energy, + reco_energy_bins, + min_value=theta_min_angle, + max_value=theta_max_angle, + smoothing=self.smoothing, + percentile=self.target_percentile, + fill_value=self.theta_fill_value, + min_events=self.min_counts, + ) + + +class PercentileCuts(CutOptimizerBase): + """ + Calculates G/H separation cut based on the percentile of signal events + to keep in each bin. + Optionally also calculates a percentile cut on theta based on the signal + events surviving this G/H cut. + """ + + classes = [GhPercentileCutCalculator, ThetaPercentileCutCalculator] + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + self.gh = GhPercentileCutCalculator(parent=self) + self.theta = ThetaPercentileCutCalculator(parent=self) + + def optimize_cuts( + self, + signal: QTable, + background: QTable, + alpha: float, + min_fov_radius: u.Quantity, + max_fov_radius: u.Quantity, + precuts: EventPreProcessor, + clf_prefix: str, + point_like: bool, + ) -> OptimizationResultStore: + if not isinstance(max_fov_radius, u.Quantity): + raise ValueError("max_fov_radius has to have a unit") + if not isinstance(min_fov_radius, u.Quantity): + raise ValueError("min_fov_radius has to have a unit") + + reco_energy_bins = create_bins_per_decade( + self.reco_energy_min.to(u.TeV), + self.reco_energy_max.to(u.TeV), + self.reco_energy_n_bins_per_decade, + ) + gh_cuts = self.gh.calculate_gh_cut( + signal["gh_score"], + signal["reco_energy"], + reco_energy_bins, + ) + if point_like: + gh_mask = evaluate_binned_cut( + signal["gh_score"], + signal["reco_energy"], + gh_cuts, + op=operator.ge, + ) + theta_cuts = self.theta.calculate_theta_cut( + signal["theta"][gh_mask], + signal["reco_energy"][gh_mask], + reco_energy_bins, + ) + + result_saver = OptimizationResultStore(precuts) + result_saver.set_result( + gh_cuts=gh_cuts, + valid_energy=[self.reco_energy_min, self.reco_energy_max], + valid_offset=[min_fov_radius, max_fov_radius], + clf_prefix=clf_prefix, + theta_cuts=theta_cuts if point_like else None, + ) + + return result_saver + + +class GridOptimizer(CutOptimizerBase): + """ + Optimizes a G/H cut for maximum sensitivity and + calculates a percentile cut on theta. + """ + + classes = [ThetaPercentileCutCalculator] + + initial_gh_cut_efficency = Float( + default_value=0.4, help="Start value of gamma efficiency before optimization" + ).tag(config=True) + + max_gh_cut_efficiency = Float( + default_value=0.8, help="Maximum gamma efficiency requested" + ).tag(config=True) + + gh_cut_efficiency_step = Float( + default_value=0.1, + help="Stepsize used for scanning after optimal gammaness cut", + ).tag(config=True) + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + self.theta = ThetaPercentileCutCalculator(parent=self) + + def optimize_cuts( + self, + signal: QTable, + background: QTable, + alpha: float, + min_fov_radius: u.Quantity, + max_fov_radius: u.Quantity, + precuts: EventPreProcessor, + clf_prefix: str, + point_like: bool, + ) -> OptimizationResultStore: if not isinstance(max_fov_radius, u.Quantity): raise ValueError("max_fov_radius has to have a unit") if not isinstance(min_fov_radius, u.Quantity): @@ -186,7 +405,7 @@ def optimize_gh_cut( bins=reco_energy_bins, fill_value=0.0, percentile=100 * (1 - self.initial_gh_cut_efficency), - min_events=25, + min_events=10, smoothing=1, ) initial_gh_mask = evaluate_binned_cut( @@ -196,7 +415,7 @@ def optimize_gh_cut( op=operator.gt, ) - theta_cuts = theta.calculate_theta_cuts( + theta_cuts = self.theta.calculate_theta_cut( signal["theta"][initial_gh_mask], signal["reco_energy"][initial_gh_mask], reco_energy_bins, @@ -242,9 +461,10 @@ def optimize_gh_cut( gh_cuts, operator.ge, ) - theta_cuts_opt = theta.calculate_theta_cuts( + theta_cuts_opt = self.theta.calculate_theta_cut( signal[signal["selected_gh"]]["theta"], signal[signal["selected_gh"]]["reco_energy"], + reco_energy_bins, ) result_saver = OptimizationResultStore(precuts) @@ -256,7 +476,7 @@ def optimize_gh_cut( theta_cuts=theta_cuts_opt if point_like else None, ) - return result_saver, opt_sens + return result_saver def _get_valid_energy_range(self, opt_sens): keep_mask = np.isfinite(opt_sens["significance"]) @@ -269,89 +489,3 @@ def _get_valid_energy_range(self, opt_sens): ] else: raise ValueError("Optimal significance curve has internal NaN bins") - - -class ThetaCutsCalculator(Component): - """Compute percentile cuts on theta""" - - theta_min_angle = AstroQuantity( - default_value=-1 * u.deg, - physical_type=u.physical.angle, - help="Smallest angular cut value allowed (-1 means no cut)", - ).tag(config=True) - - theta_max_angle = AstroQuantity( - default_value=0.32 * u.deg, - physical_type=u.physical.angle, - help="Largest angular cut value allowed", - ).tag(config=True) - - theta_min_counts = Integer( - default_value=10, - help="Minimum number of events in a bin to attempt to find a cut value", - ).tag(config=True) - - theta_fill_value = AstroQuantity( - default_value=0.32 * u.deg, - physical_type=u.physical.angle, - help="Angular cut value used for bins with too few events", - ).tag(config=True) - - theta_smoothing = Float( - default_value=None, - allow_none=True, - help="When given, the width (in units of bins) of gaussian smoothing applied (None)", - ).tag(config=True) - - target_percentile = Float( - default_value=68, - help="Percent of events in each energy bin to keep after the theta cut", - ).tag(config=True) - - reco_energy_min = AstroQuantity( - help="Minimum value for Reco Energy bins", - default_value=u.Quantity(0.015, u.TeV), - physical_type=u.physical.energy, - ).tag(config=True) - - reco_energy_max = AstroQuantity( - help="Maximum value for Reco Energy bins", - default_value=u.Quantity(150, u.TeV), - physical_type=u.physical.energy, - ).tag(config=True) - - reco_energy_n_bins_per_decade = Integer( - help="Number of bins per decade for Reco Energy bins", - default_value=5, - ).tag(config=True) - - def calculate_theta_cuts(self, theta, reco_energy, reco_energy_bins=None): - if reco_energy_bins is None: - reco_energy_bins = create_bins_per_decade( - self.reco_energy_min.to(u.TeV), - self.reco_energy_max.to(u.TeV), - self.reco_energy_n_bins_per_decade, - ) - - theta_min_angle = ( - None if self.theta_min_angle < 0 * u.deg else self.theta_min_angle - ) - theta_max_angle = ( - None if self.theta_max_angle < 0 * u.deg else self.theta_max_angle - ) - if self.theta_smoothing: - theta_smoothing = None if self.theta_smoothing < 0 else self.theta_smoothing - else: - theta_smoothing = self.theta_smoothing - - return calculate_percentile_cut( - theta, - reco_energy, - reco_energy_bins, - min_value=theta_min_angle, - max_value=theta_max_angle, - smoothing=theta_smoothing, - percentile=self.target_percentile, - fill_value=self.theta_fill_value, - min_events=self.theta_min_counts, - ) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 1cb2d264e0d..c2d9c707a74 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -6,11 +6,10 @@ from ..core.traits import AstroQuantity, Bool, Float, Integer, flag from ..irf import ( SPECTRA, + CutOptimizerBase, EventsLoader, FovOffsetBinning, - GridOptimizer, Spectra, - ThetaCutsCalculator, ) @@ -71,6 +70,12 @@ class IrfEventSelector(Tool): default_value=0.2, help="Ratio between size of on and off regions." ).tag(config=True) + optimization_algorithm = traits.ComponentName( + CutOptimizerBase, + default_value="GridOptimizer", + help="The cut optimization algorithm to be used.", + ).tag(config=True) + full_enclosure = Bool( False, help="Compute only the G/H separation cut needed for full enclosure IRF.", @@ -93,11 +98,12 @@ class IrfEventSelector(Tool): ) } - classes = [GridOptimizer, ThetaCutsCalculator, FovOffsetBinning, EventsLoader] + classes = [CutOptimizerBase, FovOffsetBinning, EventsLoader] def setup(self): - self.go = GridOptimizer(parent=self) - self.theta = ThetaCutsCalculator(parent=self) + self.optimizer = CutOptimizerBase.from_name( + self.optimization_algorithm, parent=self + ) self.bins = FovOffsetBinning(parent=self) self.particles = [ @@ -161,21 +167,22 @@ def start(self): "Optimizing cuts using %d signal and %d background events" % (len(self.signal_events), len(self.background_events)), ) - result, ope_sens = self.go.optimize_gh_cut( + result = self.optimizer.optimize_cuts( signal=self.signal_events, background=self.background_events, alpha=self.alpha, min_fov_radius=self.bins.fov_offset_min, max_fov_radius=self.bins.fov_offset_max, - theta=self.theta, precuts=self.particles[0].epp, # identical precuts for all particle types clf_prefix=self.particles[0].epp.gammaness_classifier, point_like=not self.full_enclosure, ) + self.result = result + def finish(self): self.log.info("Writing results to %s" % self.output_path) Provenance().add_output_file(self.output_path, role="Optimization Result") - result.write(self.output_path, self.overwrite) + self.result.write(self.output_path, self.overwrite) def main(): From 9bd327a8d268a91dcc9ec014aeb2806e8a46be29 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 7 May 2024 18:23:42 +0200 Subject: [PATCH 089/136] Clearer class names --- src/ctapipe/irf/__init__.py | 48 +++++++++---------- src/ctapipe/irf/irfs.py | 48 +++++++++---------- src/ctapipe/irf/optimize.py | 4 +- src/ctapipe/tools/make_irf.py | 48 +++++++++---------- src/ctapipe/tools/optimize_event_selection.py | 2 +- 5 files changed, 75 insertions(+), 75 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 9683e7ab86b..fb4edc6c2a4 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,46 +1,46 @@ """Top level module for the irf functionality""" from .binning import FovOffsetBinning, OutputEnergyBinning, check_bins_in_range from .irfs import ( - Background2dIrf, - BackgroundIrfBase, - EffectiveArea2dIrf, - EffectiveAreaIrfBase, - EnergyMigration2dIrf, - EnergyMigrationIrfBase, - Irf2dBase, - IrfRecoEnergyBase, - IrfTrueEnergyBase, - Psf3dIrf, - PsfIrfBase, + BackgroundRate2dMaker, + BackgroundRateMakerBase, + EffectiveArea2dMaker, + EffectiveAreaMakerBase, + EnergyMigration2dMaker, + EnergyMigrationMakerBase, + IrfMaker2dBase, + IrfMakerRecoEnergyBase, + IrfMakerTrueEnergyBase, + Psf3dMaker, + PsfMakerBase, ) from .optimize import ( CutOptimizerBase, GhPercentileCutCalculator, - GridOptimizer, OptimizationResult, OptimizationResultStore, PercentileCuts, + PointSourceSensitivityOptimizer, ThetaPercentileCutCalculator, ) from .select import EventPreProcessor, EventsLoader from .spectra import SPECTRA, Spectra __all__ = [ - "Irf2dBase", - "IrfRecoEnergyBase", - "IrfTrueEnergyBase", - "PsfIrfBase", - "BackgroundIrfBase", - "EnergyMigrationIrfBase", - "EffectiveAreaIrfBase", - "Psf3dIrf", - "Background2dIrf", - "EnergyMigration2dIrf", - "EffectiveArea2dIrf", + "IrfMaker2dBase", + "IrfMakerRecoEnergyBase", + "IrfMakerTrueEnergyBase", + "PsfMakerBase", + "BackgroundRateMakerBase", + "EnergyMigrationMakerBase", + "EffectiveAreaMakerBase", + "Psf3dMaker", + "BackgroundRate2dMaker", + "EnergyMigration2dMaker", + "EffectiveArea2dMaker", "OptimizationResult", "OptimizationResultStore", "CutOptimizerBase", - "GridOptimizer", + "PointSourceSensitivityOptimizer", "PercentileCuts", "OutputEnergyBinning", "FovOffsetBinning", diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index a4ab3e8a04b..419ee0ff75e 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -25,8 +25,8 @@ from ..core.traits import AstroQuantity, Float, Integer -class IrfTrueEnergyBase(Component): - """Base class for irf parameterizations binned in true energy.""" +class IrfMakerTrueEnergyBase(Component): + """Base class for creating irfs binned in true energy.""" true_energy_min = AstroQuantity( help="Minimum value for True Energy bins", @@ -54,8 +54,8 @@ def __init__(self, parent, **kwargs): ) -class IrfRecoEnergyBase(Component): - """Base class for irf parameterizations binned in reconstructed energy.""" +class IrfMakerRecoEnergyBase(Component): + """Base class for creating irfs binned in reconstructed energy.""" reco_energy_min = AstroQuantity( help="Minimum value for Reco Energy bins", @@ -83,8 +83,8 @@ def __init__(self, parent, **kwargs): ) -class Irf2dBase(Component): - """Base class for radially symmetric irf parameterizations.""" +class IrfMaker2dBase(Component): + """Base class for creating radially symmetric irfs.""" fov_offset_min = AstroQuantity( help="Minimum value for FoV Offset bins", @@ -115,8 +115,8 @@ def __init__(self, parent, **kwargs): ) -class PsfIrfBase(IrfTrueEnergyBase): - """Base class for parameterizations of the point spread function.""" +class PsfMakerBase(IrfMakerTrueEnergyBase): + """Base class for calculating the point spread function.""" def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) @@ -136,8 +136,8 @@ def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: """ -class BackgroundIrfBase(IrfRecoEnergyBase): - """Base class for parameterizations of the background rate.""" +class BackgroundRateMakerBase(IrfMakerRecoEnergyBase): + """Base class for calculating the background rate.""" def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) @@ -161,8 +161,8 @@ def make_bkg_hdu( """ -class EnergyMigrationIrfBase(IrfTrueEnergyBase): - """Base class for parameterizations of the energy migration.""" +class EnergyMigrationMakerBase(IrfMakerTrueEnergyBase): + """Base class for calculating the energy migration.""" energy_migration_min = Float( help="Minimum value of Energy Migration matrix", @@ -206,8 +206,8 @@ def make_edisp_hdu( """ -class EffectiveAreaIrfBase(IrfTrueEnergyBase): - """Base class for parameterizations of the effective area.""" +class EffectiveAreaMakerBase(IrfMakerTrueEnergyBase): + """Base class for calculating the effective area.""" def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) @@ -237,10 +237,10 @@ def make_aeff_hdu( """ -class EffectiveArea2dIrf(EffectiveAreaIrfBase, Irf2dBase): +class EffectiveArea2dMaker(EffectiveAreaMakerBase, IrfMaker2dBase): """ - Radially symmetric parameterizations of the effective are in equidistant bins - of logarithmic true energy and field of view offset. + Creates a radially symmetric parameterizations of the effective area in equidistant + bins of logarithmic true energy and field of view offset. """ def __init__(self, parent, **kwargs): @@ -281,10 +281,10 @@ def make_aeff_hdu( ) -class EnergyMigration2dIrf(EnergyMigrationIrfBase, Irf2dBase): +class EnergyMigration2dMaker(EnergyMigrationMakerBase, IrfMaker2dBase): """ - Radially symmetric parameterizations of the energy migration in equidistant - bins of logarithmic true energy and field of view offset. + Creates a radially symmetric parameterizations of the energy migration in + equidistant bins of logarithmic true energy and field of view offset. """ def __init__(self, parent, **kwargs): @@ -309,9 +309,9 @@ def make_edisp_hdu( ) -class Background2dIrf(BackgroundIrfBase, Irf2dBase): +class BackgroundRate2dMaker(BackgroundRateMakerBase, IrfMaker2dBase): """ - Radially symmetric parameterization of the background rate in equidistant + Creates a radially symmetric parameterization of the background rate in equidistant bins of logarithmic reconstructed energy and field of view offset. """ @@ -335,9 +335,9 @@ def make_bkg_hdu( ) -class Psf3dIrf(PsfIrfBase, Irf2dBase): +class Psf3dMaker(PsfMakerBase, IrfMaker2dBase): """ - Radially symmetric point spread function calculated in equidistant bins + Creates a radially symmetric point spread function calculated in equidistant bins of source offset, logarithmic true energy, and field of view offset. """ diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 452bfeab423..ccd5f6a9746 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -351,9 +351,9 @@ def optimize_cuts( return result_saver -class GridOptimizer(CutOptimizerBase): +class PointSourceSensitivityOptimizer(CutOptimizerBase): """ - Optimizes a G/H cut for maximum sensitivity and + Optimizes a G/H cut for maximum point source sensitivity and calculates a percentile cut on theta. """ diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index acbfbd2735a..4b7bccbc36c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -15,15 +15,15 @@ from ..core.traits import AstroQuantity, Bool, Float, Integer, flag from ..irf import ( SPECTRA, - BackgroundIrfBase, - EffectiveAreaIrfBase, - EnergyMigrationIrfBase, + BackgroundRateMakerBase, + EffectiveAreaMakerBase, + EnergyMigrationMakerBase, EventPreProcessor, EventsLoader, FovOffsetBinning, OptimizationResultStore, OutputEnergyBinning, - PsfIrfBase, + PsfMakerBase, Spectra, check_bins_in_range, ) @@ -107,26 +107,26 @@ class IrfTool(Tool): ).tag(config=True) edisp_parameterization = traits.ComponentName( - EnergyMigrationIrfBase, - default_value="EnergyMigration2dIrf", + EnergyMigrationMakerBase, + default_value="EnergyMigration2dMaker", help="The parameterization of the energy migration to be used.", ).tag(config=True) aeff_parameterization = traits.ComponentName( - EffectiveAreaIrfBase, - default_value="EffectiveArea2dIrf", + EffectiveAreaMakerBase, + default_value="EffectiveArea2dMaker", help="The parameterization of the effective area to be used.", ).tag(config=True) psf_parameterization = traits.ComponentName( - PsfIrfBase, - default_value="Psf3dIrf", + PsfMakerBase, + default_value="Psf3dMaker", help="The parameterization of the point spread function to be used.", ).tag(config=True) bkg_parameterization = traits.ComponentName( - BackgroundIrfBase, - default_value="Background2dIrf", + BackgroundRateMakerBase, + default_value="BackgroundRate2dMaker", help="The parameterization of the background rate to be used.", ).tag(config=True) @@ -170,10 +170,10 @@ class IrfTool(Tool): classes = [ EventsLoader, - BackgroundIrfBase, - EffectiveAreaIrfBase, - EnergyMigrationIrfBase, - PsfIrfBase, + BackgroundRateMakerBase, + EffectiveAreaMakerBase, + EnergyMigrationMakerBase, + PsfMakerBase, FovOffsetBinning, OutputEnergyBinning, ] @@ -228,24 +228,24 @@ def setup(self): "At least one electron or proton file required when specifying `do_background`." ) - self.bkg = BackgroundIrfBase.from_name( + self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self ) check_bins_in_range(self.bkg.reco_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.bkg.fov_offset_bins, self.opt_result.valid_offset) - self.edisp = EnergyMigrationIrfBase.from_name( + self.edisp = EnergyMigrationMakerBase.from_name( self.edisp_parameterization, parent=self ) check_bins_in_range(self.edisp.true_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.edisp.fov_offset_bins, self.opt_result.valid_offset) - self.aeff = EffectiveAreaIrfBase.from_name( + self.aeff = EffectiveAreaMakerBase.from_name( self.aeff_parameterization, parent=self ) check_bins_in_range(self.aeff.true_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.aeff.fov_offset_bins, self.opt_result.valid_offset) if self.full_enclosure: - self.psf = PsfIrfBase.from_name(self.psf_parameterization, parent=self) + self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) check_bins_in_range(self.psf.true_energy_bins, self.opt_result.valid_energy) check_bins_in_range(self.psf.fov_offset_bins, self.opt_result.valid_offset) @@ -504,20 +504,20 @@ def start(self): ) self.bins.fov_offset_n_bins = 1 self.fov_offset_bins = self.bins.fov_offset_bins() - self.edisp = EnergyMigrationIrfBase.from_name( + self.edisp = EnergyMigrationMakerBase.from_name( self.edisp_parameterization, parent=self, fov_offset_n_bins=1 ) - self.aeff = EffectiveAreaIrfBase.from_name( + self.aeff = EffectiveAreaMakerBase.from_name( self.aeff_parameterization, parent=self, fov_offset_n_bins=1, ) if self.full_enclosure: - self.psf = PsfIrfBase.from_name( + self.psf = PsfMakerBase.from_name( self.psf_parameterization, parent=self, fov_offset_n_bins=1 ) if self.do_background: - self.bkg = BackgroundIrfBase.from_name( + self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self, fov_offset_n_bins=1 ) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index c2d9c707a74..232717425a7 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -72,7 +72,7 @@ class IrfEventSelector(Tool): optimization_algorithm = traits.ComponentName( CutOptimizerBase, - default_value="GridOptimizer", + default_value="PointSourceSensitivityOptimizer", help="The cut optimization algorithm to be used.", ).tag(config=True) From 8f5cbd810eed9a82fe8f3ad69702b704f49d7f3a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 10 May 2024 18:18:53 +0200 Subject: [PATCH 090/136] Remove FoVOffsetBinning; save singular quantities in ResultValidRange --- src/ctapipe/irf/__init__.py | 4 +- src/ctapipe/irf/binning.py | 42 ++------------ src/ctapipe/irf/optimize.py | 57 ++++++++----------- src/ctapipe/irf/select.py | 16 +++--- src/ctapipe/tools/make_irf.py | 34 +++++------ src/ctapipe/tools/optimize_event_selection.py | 11 ++-- 6 files changed, 56 insertions(+), 108 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index fb4edc6c2a4..9daa3248c27 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,5 +1,5 @@ """Top level module for the irf functionality""" -from .binning import FovOffsetBinning, OutputEnergyBinning, check_bins_in_range +from .binning import OutputEnergyBinning, ResultValidRange, check_bins_in_range from .irfs import ( BackgroundRate2dMaker, BackgroundRateMakerBase, @@ -37,13 +37,13 @@ "BackgroundRate2dMaker", "EnergyMigration2dMaker", "EffectiveArea2dMaker", + "ResultValidRange", "OptimizationResult", "OptimizationResultStore", "CutOptimizerBase", "PointSourceSensitivityOptimizer", "PercentileCuts", "OutputEnergyBinning", - "FovOffsetBinning", "EventsLoader", "EventPreProcessor", "Spectra", diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 6ee9ccb57ea..5d1062a9b0b 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -1,6 +1,5 @@ """Collection of binning related functionality for the irf tools""" import astropy.units as u -import numpy as np from pyirf.binning import create_bins_per_decade from ..core import Component @@ -18,6 +17,12 @@ def check_bins_in_range(bins, range): raise ValueError(f"Valid range is {range.min} to {range.max}, got {bins}") +class ResultValidRange: + def __init__(self, bounds_table, prefix): + self.min = bounds_table[f"{prefix}_min"][0] + self.max = bounds_table[f"{prefix}_max"][0] + + class OutputEnergyBinning(Component): """Collects energy binning settings.""" @@ -76,38 +81,3 @@ def reco_energy_bins(self): self.reco_energy_n_bins_per_decade, ) return reco_energy - - -class FovOffsetBinning(Component): - """Collects FoV binning settings.""" - - fov_offset_min = AstroQuantity( - help="Minimum value for FoV Offset bins", - default_value=0.0 * u.deg, - physical_type=u.physical.angle, - ).tag(config=True) - - fov_offset_max = AstroQuantity( - help="Maximum value for FoV offset bins", - default_value=5.0 * u.deg, - physical_type=u.physical.angle, - ).tag(config=True) - - fov_offset_n_bins = Integer( - help="Number of bins for FoV offset bins", - default_value=1, - ).tag(config=True) - - def fov_offset_bins(self): - """ - Creates bins for single/multiple FoV offset. - """ - fov_offset = ( - np.linspace( - self.fov_offset_min.to_value(u.deg), - self.fov_offset_max.to_value(u.deg), - self.fov_offset_n_bins + 1, - ) - * u.deg - ) - return fov_offset diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index ccd5f6a9746..c3d05aa439b 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -12,15 +12,10 @@ from ..core import Component, QualityQuery from ..core.traits import AstroQuantity, Float, Integer +from .binning import ResultValidRange from .select import EventPreProcessor -class ResultValidRange: - def __init__(self, bounds_table, prefix): - self.min = bounds_table[f"{prefix}_min"] - self.max = bounds_table[f"{prefix}_max"] - - class OptimizationResult: def __init__(self, precuts, valid_energy, valid_offset, gh, theta): self.precuts = precuts @@ -145,14 +140,30 @@ class CutOptimizerBase(Component): default_value=5, ).tag(config=True) + min_fov_offset = AstroQuantity( + help=( + "Minimum distance from the fov center for background events " + "to be taken into account" + ), + default_value=u.Quantity(0, u.deg), + physical_type=u.physical.angle, + ).tag(config=True) + + max_fov_offset = AstroQuantity( + help=( + "Maximum distance from the fov center for background events " + "to be taken into account" + ), + default_value=u.Quantity(5, u.deg), + physical_type=u.physical.angle, + ).tag(config=True) + @abstractmethod def optimize_cuts( self, signal: QTable, background: QTable, alpha: float, - min_fov_radius: u.Quantity, - max_fov_radius: u.Quantity, precuts: EventPreProcessor, clf_prefix: str, point_like: bool, @@ -169,12 +180,6 @@ def optimize_cuts( Table containing background events alpha: float Size ratio of on region / off region - min_fov_radius: astropy.units.Quantity[angle] - Minimum distance from the fov center for background events - to be taken into account - max_fov_radius: astropy.units.Quantity[angle] - Maximum distance from the fov center for background events - to be taken into account precuts: ctapipe.irf.EventPreProcessor ``ctapipe.core.QualityQuery`` subclass containing preselection criteria for events @@ -305,17 +310,10 @@ def optimize_cuts( signal: QTable, background: QTable, alpha: float, - min_fov_radius: u.Quantity, - max_fov_radius: u.Quantity, precuts: EventPreProcessor, clf_prefix: str, point_like: bool, ) -> OptimizationResultStore: - if not isinstance(max_fov_radius, u.Quantity): - raise ValueError("max_fov_radius has to have a unit") - if not isinstance(min_fov_radius, u.Quantity): - raise ValueError("min_fov_radius has to have a unit") - reco_energy_bins = create_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), @@ -343,7 +341,7 @@ def optimize_cuts( result_saver.set_result( gh_cuts=gh_cuts, valid_energy=[self.reco_energy_min, self.reco_energy_max], - valid_offset=[min_fov_radius, max_fov_radius], + valid_offset=[self.min_fov_offset, self.max_fov_offset], clf_prefix=clf_prefix, theta_cuts=theta_cuts if point_like else None, ) @@ -381,17 +379,10 @@ def optimize_cuts( signal: QTable, background: QTable, alpha: float, - min_fov_radius: u.Quantity, - max_fov_radius: u.Quantity, precuts: EventPreProcessor, clf_prefix: str, point_like: bool, ) -> OptimizationResultStore: - if not isinstance(max_fov_radius, u.Quantity): - raise ValueError("max_fov_radius has to have a unit") - if not isinstance(min_fov_radius, u.Quantity): - raise ValueError("min_fov_radius has to have a unit") - reco_energy_bins = create_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), @@ -428,7 +419,7 @@ def optimize_cuts( theta_cuts["low"] = reco_energy_bins[:-1] theta_cuts["center"] = 0.5 * (reco_energy_bins[:-1] + reco_energy_bins[1:]) theta_cuts["high"] = reco_energy_bins[1:] - theta_cuts["cut"] = max_fov_radius + theta_cuts["cut"] = self.max_fov_offset self.log.info( "Optimizing G/H separation cut for best sensitivity " "with `max_fov_radius` as theta cut." @@ -448,8 +439,8 @@ def optimize_cuts( op=operator.ge, theta_cuts=theta_cuts, alpha=alpha, - fov_offset_max=max_fov_radius, - fov_offset_min=min_fov_radius, + fov_offset_max=self.max_fov_offset, + fov_offset_min=self.min_fov_offset, ) valid_energy = self._get_valid_energy_range(opt_sens) @@ -471,7 +462,7 @@ def optimize_cuts( result_saver.set_result( gh_cuts=gh_cuts, valid_energy=valid_energy, - valid_offset=[min_fov_radius, max_fov_radius], + valid_offset=[self.min_fov_offset, self.max_fov_offset], clf_prefix=clf_prefix, theta_cuts=theta_cuts_opt if point_like else None, ) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index ab2105d1104..8bf5bde481b 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -11,7 +11,7 @@ from ..core import Component, QualityQuery from ..core.traits import List, Tuple, Unicode from ..io import TableLoader -from ..irf import FovOffsetBinning +from .binning import ResultValidRange class EventPreProcessor(QualityQuery): @@ -143,7 +143,7 @@ def __init__(self, kind, file, target_spectrum, **kwargs): self.kind = kind self.file = file - def load_preselected_events(self, chunk_size, obs_time, fov_bins): + def load_preselected_events(self, chunk_size, obs_time, valid_fov): opts = dict(dl2=True, simulated=True) with TableLoader(self.file, parent=self, **opts) as load: header = self.epp.make_empty_table() @@ -155,7 +155,7 @@ def load_preselected_events(self, chunk_size, obs_time, fov_bins): selected = events[self.epp.get_table_mask(events)] selected = self.epp.normalise_column_names(selected) selected = self.make_derived_columns( - selected, spectrum, obs_conf, fov_bins + selected, spectrum, obs_conf, valid_fov ) bits.append(selected) n_raw_events += len(events) @@ -191,7 +191,7 @@ def get_metadata(self, loader, obs_time): obs, ) - def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): + def make_derived_columns(self, events, spectrum, obs_conf, valid_fov): if obs_conf["subarray_pointing_lat"].std() < 1e-3: assert all(obs_conf["subarray_pointing_frame"] == 0) # Lets suppose 0 means ALTAZ @@ -234,12 +234,10 @@ def make_derived_columns(self, events, spectrum, obs_conf, fov_bins): spectrum.normalization.unit * u.sr ) ): - if isinstance(fov_bins, FovOffsetBinning): - spectrum = spectrum.integrate_cone( - fov_bins.fov_offset_min, fov_bins.fov_offset_max - ) + if isinstance(valid_fov, ResultValidRange): + spectrum = spectrum.integrate_cone(valid_fov.min, valid_fov.max) else: - spectrum = spectrum.integrate_cone(fov_bins[0], fov_bins[-1]) + spectrum = spectrum.integrate_cone(valid_fov[0], valid_fov[-1]) events["weight"] = calculate_event_weights( events["true_energy"], diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 4b7bccbc36c..728aa8354ef 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -20,7 +20,6 @@ EnergyMigrationMakerBase, EventPreProcessor, EventsLoader, - FovOffsetBinning, OptimizationResultStore, OutputEnergyBinning, PsfMakerBase, @@ -174,22 +173,16 @@ class IrfTool(Tool): EffectiveAreaMakerBase, EnergyMigrationMakerBase, PsfMakerBase, - FovOffsetBinning, OutputEnergyBinning, ] def setup(self): self.e_bins = OutputEnergyBinning(parent=self) - self.bins = FovOffsetBinning(parent=self) - self.opt_result = OptimizationResultStore().read(self.cuts_file) self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() - self.fov_offset_bins = self.bins.fov_offset_bins() - check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) - check_bins_in_range(self.fov_offset_bins, self.opt_result.valid_offset) if not self.full_enclosure and self.opt_result.theta_cuts is None: raise ToolConfigurationError( @@ -367,12 +360,10 @@ def _make_signal_irf_hdus(self, hdus, sim_info): ) else: # TODO: Support fov binning - if self.bins.fov_offset_n_bins > 1: - self.log.warning( - "Currently no fov binning is supported for RAD_MAX. " - "Using `fov_offset_bins = [fov_offset_min, fov_offset_max]`." - ) - + self.log.debug( + "Currently no fov binning is supported for RAD_MAX. " + "Using `fov_offset_bins = [valid_offset.min, valid_offset.max]`." + ) hdus.append( create_rad_max_hdu( rad_max=self.opt_result.theta_cuts["cut"].reshape(-1, 1), @@ -381,7 +372,11 @@ def _make_signal_irf_hdus(self, hdus, sim_info): self.opt_result.theta_cuts["high"][-1], ), fov_offset_bins=u.Quantity( - [self.bins.fov_offset_min, self.bins.fov_offset_max] + [ + self.opt_result.valid_offset.min.to_value(u.deg), + self.opt_result.valid_offset.max.to_value(u.deg), + ], + u.deg, ), ) ) @@ -417,7 +412,7 @@ def _make_benchmark_hdus(self, hdus): theta_cuts["center"] = 0.5 * ( self.reco_energy_bins[:-1] + self.reco_energy_bins[1:] ) - theta_cuts["cut"] = self.bins.fov_offset_max + theta_cuts["cut"] = self.opt_result.valid_offset.max else: theta_cuts = self.opt_result.theta_cuts @@ -430,8 +425,8 @@ def _make_benchmark_hdus(self, hdus): reco_energy_bins=self.reco_energy_bins, theta_cuts=theta_cuts, alpha=self.alpha, - fov_offset_min=self.bins.fov_offset_min, - fov_offset_max=self.bins.fov_offset_max, + fov_offset_min=self.opt_result.valid_offset.min, + fov_offset_max=self.opt_result.valid_offset.max, ) sensitivity = calculate_sensitivity( signal_hist, background_hist, alpha=self.alpha @@ -483,7 +478,7 @@ def start(self): evs, cnt, meta = sel.load_preselected_events( self.chunk_size, self.obs_time, - self.fov_offset_bins, + self.opt_result.valid_offset, ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt @@ -502,8 +497,6 @@ def start(self): " Therefore, the IRF is only calculated at a single point" " in the FoV. Changing `fov_offset_n_bins` to 1." ) - self.bins.fov_offset_n_bins = 1 - self.fov_offset_bins = self.bins.fov_offset_bins() self.edisp = EnergyMigrationMakerBase.from_name( self.edisp_parameterization, parent=self, fov_offset_n_bins=1 ) @@ -529,7 +522,6 @@ def start(self): self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) self.log.debug("Reco Energy bins: %s" % str(self.reco_energy_bins.value)) - self.log.debug("FoV offset bins: %s" % str(self.fov_offset_bins)) hdus = [fits.PrimaryHDU()] hdus = self._make_signal_irf_hdus( diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 232717425a7..92696633ed1 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -8,7 +8,6 @@ SPECTRA, CutOptimizerBase, EventsLoader, - FovOffsetBinning, Spectra, ) @@ -98,14 +97,12 @@ class IrfEventSelector(Tool): ) } - classes = [CutOptimizerBase, FovOffsetBinning, EventsLoader] + classes = [CutOptimizerBase, EventsLoader] def setup(self): self.optimizer = CutOptimizerBase.from_name( self.optimization_algorithm, parent=self ) - self.bins = FovOffsetBinning(parent=self) - self.particles = [ EventsLoader( parent=self, @@ -134,7 +131,9 @@ def start(self): reduced_events = dict() for sel in self.particles: evs, cnt, meta = sel.load_preselected_events( - self.chunk_size, self.obs_time, self.bins + self.chunk_size, + self.obs_time, + [self.optimizer.min_fov_offset, self.optimizer.max_fov_offset], ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt @@ -171,8 +170,6 @@ def start(self): signal=self.signal_events, background=self.background_events, alpha=self.alpha, - min_fov_radius=self.bins.fov_offset_min, - max_fov_radius=self.bins.fov_offset_max, precuts=self.particles[0].epp, # identical precuts for all particle types clf_prefix=self.particles[0].epp.gammaness_classifier, point_like=not self.full_enclosure, From 892fb06e73197f874cefbc8d525bb06ba4027247 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 10 May 2024 18:29:42 +0200 Subject: [PATCH 091/136] No cut on background events based on its true origin position --- src/ctapipe/tools/make_irf.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 728aa8354ef..cc5cb1956d1 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -292,29 +292,14 @@ def calculate_selections(self, reduced_events: dict) -> dict: self.opt_result.gh_cuts, operator.ge, ) - if not self.full_enclosure: - reduced_events[bg_type]["selected_theta"] = evaluate_binned_cut( - reduced_events[bg_type]["theta"], - reduced_events[bg_type]["reco_energy"], - self.opt_result.theta_cuts, - operator.le, - ) - reduced_events[bg_type]["selected"] = ( - reduced_events[bg_type]["selected_theta"] - & reduced_events[bg_type]["selected_gh"] - ) - else: - reduced_events[bg_type]["selected"] = reduced_events[bg_type][ - "selected_gh" - ] if self.do_background: self.log.debug( "Keeping %d signal, %d proton events, and %d electron events" % ( sum(reduced_events["gammas"]["selected"]), - sum(reduced_events["protons"]["selected"]), - sum(reduced_events["electrons"]["selected"]), + sum(reduced_events["protons"]["selected_gh"]), + sum(reduced_events["electrons"]["selected_gh"]), ) ) else: @@ -530,7 +515,7 @@ def start(self): if self.do_background: hdus.append( self.bkg.make_bkg_hdu( - self.background_events[self.background_events["selected"]], + self.background_events[self.background_events["selected_gh"]], self.obs_time, ) ) @@ -538,7 +523,7 @@ def start(self): hdus.append( self.aeff.make_aeff_hdu( events=reduced_events["protons"][ - reduced_events["protons"]["selected"] + reduced_events["protons"]["selected_gh"] ], point_like=not self.full_enclosure, signal_is_point_like=False, @@ -550,7 +535,7 @@ def start(self): hdus.append( self.aeff.make_aeff_hdu( events=reduced_events["electrons"][ - reduced_events["electrons"]["selected"] + reduced_events["electrons"]["selected_gh"] ], point_like=not self.full_enclosure, signal_is_point_like=False, From 6eac846089662c9f7ed0fa839f487a9c53fadbee Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 13 May 2024 17:29:11 +0200 Subject: [PATCH 092/136] Split out input handling code to separate helper to keep logic of init more clear --- src/ctapipe/irf/optimize.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index c3d05aa439b..5f448bf15f1 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -44,6 +44,10 @@ def __repr__(self): class OptimizationResultStore: def __init__(self, precuts=None): + self._init_precuts(precuts) + self._results = None + + def _init_precuts(self, precuts): if precuts: if isinstance(precuts, QualityQuery): self._precuts = precuts.quality_criteria @@ -56,8 +60,6 @@ def __init__(self, precuts=None): else: self._precuts = None - self._results = None - def set_result( self, gh_cuts, valid_energy, valid_offset, clf_prefix, theta_cuts=None ): From 448cb559871b75bfe62eff7e9ccb7c8c0dbb7643 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 13 May 2024 17:34:03 +0200 Subject: [PATCH 093/136] Fixed typo --- src/ctapipe/irf/visualisation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ctapipe/irf/visualisation.py b/src/ctapipe/irf/visualisation.py index 95f98c4f739..468212a0c28 100644 --- a/src/ctapipe/irf/visualisation.py +++ b/src/ctapipe/irf/visualisation.py @@ -83,7 +83,7 @@ def plot_2D_table_with_col_stats( }, ): """Function to draw 2d histogram along with columnwise statistics - the conten values shown depending on stat_kind: + the plotted errorbars shown depending on stat_kind: 0 -> mean + standard deviation 1 -> median + standard deviation 2 -> median + user specified quantiles around median (default 0.1 to 0.9) @@ -295,7 +295,6 @@ def plot_hist2D( norm="log", cmap="viridis", ): - if isinstance(hist, u.Quantity): hist = hist.value From 619e3feebab0d37fcb1b3723e3fc5756913cdf09 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 14:16:34 +0200 Subject: [PATCH 094/136] Set min pyirf version to handle astropy 6.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f231c4dadd9..685a3be27a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "numba >=0.56", "numpy ~=1.16", "psutil", - "pyirf", + "pyirf >0.10.1", "pyyaml >=5.1", "requests", "scikit-learn !=1.4.0", # 1.4.0 breaks with astropy tables, before and after works From 2be3d7dfb8c49fd11e1f293e6ff4e8a5f2456c24 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 14:20:58 +0200 Subject: [PATCH 095/136] Create a minimal changelog --- docs/changes/2473.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/changes/2473.feature.rst diff --git a/docs/changes/2473.feature.rst b/docs/changes/2473.feature.rst new file mode 100644 index 00000000000..37a898ba437 --- /dev/null +++ b/docs/changes/2473.feature.rst @@ -0,0 +1 @@ +Add a `make-irf tool` able to produce irfs given a gamma, proton and electron DL2 input files. From 96f1cdc684aaa31398b280646576a970c14c6f3c Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 14 May 2024 16:07:38 +0200 Subject: [PATCH 096/136] Use classes_with_traits() in tools --- src/ctapipe/tools/make_irf.py | 20 ++++++++++--------- src/ctapipe/tools/optimize_event_selection.py | 4 ++-- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index cc5cb1956d1..60dced7acb9 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -12,7 +12,7 @@ from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core import Provenance, Tool, ToolConfigurationError, traits -from ..core.traits import AstroQuantity, Bool, Float, Integer, flag +from ..core.traits import AstroQuantity, Bool, Float, Integer, classes_with_traits, flag from ..irf import ( SPECTRA, BackgroundRateMakerBase, @@ -167,14 +167,16 @@ class IrfTool(Tool): ), } - classes = [ - EventsLoader, - BackgroundRateMakerBase, - EffectiveAreaMakerBase, - EnergyMigrationMakerBase, - PsfMakerBase, - OutputEnergyBinning, - ] + classes = ( + [ + EventsLoader, + OutputEnergyBinning, + ] + + classes_with_traits(BackgroundRateMakerBase) + + classes_with_traits(EffectiveAreaMakerBase) + + classes_with_traits(EnergyMigrationMakerBase) + + classes_with_traits(PsfMakerBase) + ) def setup(self): self.e_bins = OutputEnergyBinning(parent=self) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 92696633ed1..2104d8fe8b8 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -3,7 +3,7 @@ from astropy.table import vstack from ..core import Provenance, Tool, traits -from ..core.traits import AstroQuantity, Bool, Float, Integer, flag +from ..core.traits import AstroQuantity, Bool, Float, Integer, classes_with_traits, flag from ..irf import ( SPECTRA, CutOptimizerBase, @@ -97,7 +97,7 @@ class IrfEventSelector(Tool): ) } - classes = [CutOptimizerBase, EventsLoader] + classes = [EventsLoader] + classes_with_traits(CutOptimizerBase) def setup(self): self.optimizer = CutOptimizerBase.from_name( From 5ef157736eed868c92dcb46ed87d8b65e873307e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 16:12:30 +0200 Subject: [PATCH 097/136] cleaning up changelogs --- docs/changes/2315.irf-maker.rst | 1 - docs/changes/2411.features.rst | 3 --- docs/changes/2473.feature.rst | 4 +++- 3 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 docs/changes/2315.irf-maker.rst delete mode 100644 docs/changes/2411.features.rst diff --git a/docs/changes/2315.irf-maker.rst b/docs/changes/2315.irf-maker.rst deleted file mode 100644 index 37a898ba437..00000000000 --- a/docs/changes/2315.irf-maker.rst +++ /dev/null @@ -1 +0,0 @@ -Add a `make-irf tool` able to produce irfs given a gamma, proton and electron DL2 input files. diff --git a/docs/changes/2411.features.rst b/docs/changes/2411.features.rst deleted file mode 100644 index 49cb45370dc..00000000000 --- a/docs/changes/2411.features.rst +++ /dev/null @@ -1,3 +0,0 @@ -Add option ``override_obs_id`` to ``SimTelEventSource`` which allows -assigning new, unique ``obs_ids`` in case productions reuse CORSIKA run -numbers. diff --git a/docs/changes/2473.feature.rst b/docs/changes/2473.feature.rst index 37a898ba437..94106872294 100644 --- a/docs/changes/2473.feature.rst +++ b/docs/changes/2473.feature.rst @@ -1 +1,3 @@ -Add a `make-irf tool` able to produce irfs given a gamma, proton and electron DL2 input files. +Add a ``ctapipe-make-irf`` tool and a able to produce irfs given a cut-selection file and gamma, proton, and electron DL2 input files. + +Add a ``ctapipe-optimize-event-selection`` tool to produce cut-selection files. From a28eca78db5834b055400c318678ea507392a635 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 16:45:17 +0200 Subject: [PATCH 098/136] Added messages explaining which bin range was failing to pass the check --- src/ctapipe/irf/binning.py | 6 ++++-- src/ctapipe/tools/make_irf.py | 40 ++++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 5d1062a9b0b..7e7ebc23680 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -6,7 +6,7 @@ from ..core.traits import AstroQuantity, Integer -def check_bins_in_range(bins, range): +def check_bins_in_range(bins, range, source="result"): # `pyirf.binning.create_bins_per_decade` includes the endpoint, if reasonably close. # So different choices of `n_bins_per_decade` can lead to mismatches, if the same # `*_energy_{min,max}` is chosen. @@ -14,7 +14,9 @@ def check_bins_in_range(bins, range): hig = bins <= range.max * 1.0000001 if not all(low & hig): - raise ValueError(f"Valid range is {range.min} to {range.max}, got {bins}") + raise ValueError( + f"Valid range for {source} is {range.min} to {range.max}, got {bins}" + ) class ResultValidRange: diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 60dced7acb9..98912579cfb 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -226,23 +226,47 @@ def setup(self): self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self ) - check_bins_in_range(self.bkg.reco_energy_bins, self.opt_result.valid_energy) - check_bins_in_range(self.bkg.fov_offset_bins, self.opt_result.valid_offset) + check_bins_in_range( + self.bkg.reco_energy_bins, + self.opt_result.valid_energy, + "background energy reco", + ) + check_bins_in_range( + self.bkg.fov_offset_bins, + self.opt_result.valid_offset, + "background fov offset", + ) self.edisp = EnergyMigrationMakerBase.from_name( self.edisp_parameterization, parent=self ) - check_bins_in_range(self.edisp.true_energy_bins, self.opt_result.valid_energy) - check_bins_in_range(self.edisp.fov_offset_bins, self.opt_result.valid_offset) + check_bins_in_range( + self.edisp.true_energy_bins, + self.opt_result.valid_energy, + "Edisp energy true", + ) + check_bins_in_range( + self.edisp.fov_offset_bins, self.opt_result.valid_offset, "Edisp fov offset" + ) self.aeff = EffectiveAreaMakerBase.from_name( self.aeff_parameterization, parent=self ) - check_bins_in_range(self.aeff.true_energy_bins, self.opt_result.valid_energy) - check_bins_in_range(self.aeff.fov_offset_bins, self.opt_result.valid_offset) + check_bins_in_range( + self.aeff.true_energy_bins, self.opt_result.valid_energy, "Aeff energy true" + ) + check_bins_in_range( + self.aeff.fov_offset_bins, self.opt_result.valid_offset, "Aeff fov offset" + ) if self.full_enclosure: self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) - check_bins_in_range(self.psf.true_energy_bins, self.opt_result.valid_energy) - check_bins_in_range(self.psf.fov_offset_bins, self.opt_result.valid_offset) + check_bins_in_range( + self.psf.true_energy_bins, + self.opt_result.valid_energy, + "PSF energy true", + ) + check_bins_in_range( + self.psf.fov_offset_bins, self.opt_result.valid_offset, "PSF fov offset" + ) if self.do_benchmarks: self.b_output = self.output_path.with_name( From fd5f6e60362e0d980a05ebd15ae8794808a34a14 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 14 May 2024 17:59:26 +0200 Subject: [PATCH 099/136] Respect bounds over n_bins_per_decade for energy binning --- src/ctapipe/irf/__init__.py | 8 ++++++- src/ctapipe/irf/binning.py | 43 ++++++++++++++++++++++++++++++------- src/ctapipe/irf/irfs.py | 6 +++--- src/ctapipe/irf/optimize.py | 7 +++--- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 9daa3248c27..42c669b34ec 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,5 +1,10 @@ """Top level module for the irf functionality""" -from .binning import OutputEnergyBinning, ResultValidRange, check_bins_in_range +from .binning import ( + OutputEnergyBinning, + ResultValidRange, + check_bins_in_range, + make_bins_per_decade, +) from .irfs import ( BackgroundRate2dMaker, BackgroundRateMakerBase, @@ -51,4 +56,5 @@ "ThetaPercentileCutCalculator", "SPECTRA", "check_bins_in_range", + "make_bins_per_decade", ] diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 7e7ebc23680..032da1ec640 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -1,17 +1,14 @@ """Collection of binning related functionality for the irf tools""" import astropy.units as u -from pyirf.binning import create_bins_per_decade +import numpy as np from ..core import Component from ..core.traits import AstroQuantity, Integer def check_bins_in_range(bins, range, source="result"): - # `pyirf.binning.create_bins_per_decade` includes the endpoint, if reasonably close. - # So different choices of `n_bins_per_decade` can lead to mismatches, if the same - # `*_energy_{min,max}` is chosen. - low = bins >= range.min * 0.9999999 - hig = bins <= range.max * 1.0000001 + low = bins >= range.min + hig = bins <= range.max if not all(low & hig): raise ValueError( @@ -19,6 +16,36 @@ def check_bins_in_range(bins, range, source="result"): ) +@u.quantity_input(e_min=u.TeV, e_max=u.TeV) +def make_bins_per_decade(e_min, e_max, n_bins_per_decade=5): + """ + Create energy bins with at least ``bins_per_decade`` bins per decade. + The number of bins is calculated as + ``n_bins = ceil((log10(e_max) - log10(e_min)) * n_bins_per_decade)``. + + Parameters + ---------- + e_min: u.Quantity[energy] + Minimum energy, inclusive + e_max: u.Quantity[energy] + Maximum energy, inclusive + n_bins_per_decade: int + Minimum number of bins per decade + + Returns + ------- + bins: u.Quantity[energy] + The created bin array, will have units of ``e_min`` + """ + unit = e_min.unit + log_lower = np.log10(e_min.to_value(unit)) + log_upper = np.log10(e_max.to_value(unit)) + + n_bins = int(np.ceil((log_upper - log_lower) * n_bins_per_decade)) + + return u.Quantity(np.logspace(log_lower, log_upper, n_bins), unit, copy=False) + + class ResultValidRange: def __init__(self, bounds_table, prefix): self.min = bounds_table[f"{prefix}_min"][0] @@ -66,7 +93,7 @@ def true_energy_bins(self): """ Creates bins per decade for true MC energy using pyirf function. """ - true_energy = create_bins_per_decade( + true_energy = make_bins_per_decade( self.true_energy_min.to(u.TeV), self.true_energy_max.to(u.TeV), self.true_energy_n_bins_per_decade, @@ -77,7 +104,7 @@ def reco_energy_bins(self): """ Creates bins per decade for reconstructed MC energy using pyirf function. """ - reco_energy = create_bins_per_decade( + reco_energy = make_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 419ee0ff75e..83f929e9d81 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -5,7 +5,6 @@ import numpy as np from astropy.io.fits import BinTableHDU from astropy.table import QTable -from pyirf.binning import create_bins_per_decade from pyirf.io import ( create_aeff2d_hdu, create_background_2d_hdu, @@ -23,6 +22,7 @@ from ..core import Component from ..core.traits import AstroQuantity, Float, Integer +from .binning import make_bins_per_decade class IrfMakerTrueEnergyBase(Component): @@ -47,7 +47,7 @@ class IrfMakerTrueEnergyBase(Component): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - self.true_energy_bins = create_bins_per_decade( + self.true_energy_bins = make_bins_per_decade( self.true_energy_min.to(u.TeV), self.true_energy_max.to(u.TeV), self.true_energy_n_bins_per_decade, @@ -76,7 +76,7 @@ class IrfMakerRecoEnergyBase(Component): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) - self.reco_energy_bins = create_bins_per_decade( + self.reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 5f448bf15f1..a3aa4f1f21b 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -6,13 +6,12 @@ import numpy as np from astropy.io import fits from astropy.table import QTable, Table -from pyirf.binning import create_bins_per_decade from pyirf.cut_optimization import optimize_gh_cut from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut from ..core import Component, QualityQuery from ..core.traits import AstroQuantity, Float, Integer -from .binning import ResultValidRange +from .binning import ResultValidRange, make_bins_per_decade from .select import EventPreProcessor @@ -316,7 +315,7 @@ def optimize_cuts( clf_prefix: str, point_like: bool, ) -> OptimizationResultStore: - reco_energy_bins = create_bins_per_decade( + reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, @@ -385,7 +384,7 @@ def optimize_cuts( clf_prefix: str, point_like: bool, ) -> OptimizationResultStore: - reco_energy_bins = create_bins_per_decade( + reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, From 283a7d587f9a3e91709af0cb9b08c370d07b7ef6 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 18:09:14 +0200 Subject: [PATCH 100/136] Made range check optionally only emit warning, made output a bit more readable --- src/ctapipe/irf/binning.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 032da1ec640..44a7d5d04ba 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -1,19 +1,35 @@ """Collection of binning related functionality for the irf tools""" + +import logging + import astropy.units as u import numpy as np from ..core import Component from ..core.traits import AstroQuantity, Integer +logger = logging.getLogger(__name__) -def check_bins_in_range(bins, range, source="result"): - low = bins >= range.min - hig = bins <= range.max +def check_bins_in_range(bins, range, source="result", raise_error=True): + # `pyirf.binning.create_bins_per_decade` includes the endpoint, if reasonably close. + # So different choices of `n_bins_per_decade` can lead to mismatches, if the same + # `*_energy_{min,max}` is chosen. + low = bins >= range.min * 0.9999999 + hig = bins <= range.max * 1.0000001 if not all(low & hig): - raise ValueError( - f"Valid range for {source} is {range.min} to {range.max}, got {bins}" - ) + with np.printoptions(edgeitems=2, threshold=6, precision=4): + bins = np.array2string(bins) + min_val = np.array2string(range.min) + max_val = np.array2string(range.max) + if raise_error: + raise ValueError( + f"Valid range for {source} is {min_val} to {max_val}, got {bins}" + ) + else: + logger.warning( + f"Valid range for {source} is {min_val} to {max_val}, got {bins}", + ) @u.quantity_input(e_min=u.TeV, e_max=u.TeV) From bd0d5840e6c4df84e02e53cbd298d268d154a725 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 18:10:16 +0200 Subject: [PATCH 101/136] Added tracking of where range check came from, made check failure optionally emit warning --- src/ctapipe/tools/make_irf.py | 36 ++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 98912579cfb..3c5469ec9ef 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,4 +1,5 @@ """Tool to generate IRFs""" + import operator import astropy.units as u @@ -42,6 +43,11 @@ class IrfTool(Tool): help="Produce IRF related benchmarks", ).tag(config=True) + range_check_error = Bool( + True, + help="Raise error if asking for IRFs outside range where cut optimisation is valid", + ).tag(config=True) + cuts_file = traits.Path( default_value=None, directory_ok=False, help="Path to optimized cuts input file" ).tag(config=True) @@ -184,7 +190,11 @@ def setup(self): self.reco_energy_bins = self.e_bins.reco_energy_bins() self.true_energy_bins = self.e_bins.true_energy_bins() - check_bins_in_range(self.reco_energy_bins, self.opt_result.valid_energy) + check_bins_in_range( + self.reco_energy_bins, + self.opt_result.valid_energy, + raise_error=self.range_check_error, + ) if not self.full_enclosure and self.opt_result.theta_cuts is None: raise ToolConfigurationError( @@ -230,11 +240,13 @@ def setup(self): self.bkg.reco_energy_bins, self.opt_result.valid_energy, "background energy reco", + raise_error=self.range_check_error, ) check_bins_in_range( self.bkg.fov_offset_bins, self.opt_result.valid_offset, "background fov offset", + raise_error=self.range_check_error, ) self.edisp = EnergyMigrationMakerBase.from_name( @@ -244,18 +256,28 @@ def setup(self): self.edisp.true_energy_bins, self.opt_result.valid_energy, "Edisp energy true", + raise_error=self.range_check_error, ) check_bins_in_range( - self.edisp.fov_offset_bins, self.opt_result.valid_offset, "Edisp fov offset" + self.edisp.fov_offset_bins, + self.opt_result.valid_offset, + "Edisp fov offset", + raise_error=self.range_check_error, ) self.aeff = EffectiveAreaMakerBase.from_name( self.aeff_parameterization, parent=self ) check_bins_in_range( - self.aeff.true_energy_bins, self.opt_result.valid_energy, "Aeff energy true" + self.aeff.true_energy_bins, + self.opt_result.valid_energy, + "Aeff energy true", + raise_error=self.range_check_error, ) check_bins_in_range( - self.aeff.fov_offset_bins, self.opt_result.valid_offset, "Aeff fov offset" + self.aeff.fov_offset_bins, + self.opt_result.valid_offset, + "Aeff fov offset", + raise_error=self.range_check_error, ) if self.full_enclosure: self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) @@ -263,9 +285,13 @@ def setup(self): self.psf.true_energy_bins, self.opt_result.valid_energy, "PSF energy true", + raise_error=self.range_check_error, ) check_bins_in_range( - self.psf.fov_offset_bins, self.opt_result.valid_offset, "PSF fov offset" + self.psf.fov_offset_bins, + self.opt_result.valid_offset, + "PSF fov offset", + raise_error=self.range_check_error, ) if self.do_benchmarks: From 93eddef8cec6664ca5b00395d09841b268e67f9d Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 18:41:29 +0200 Subject: [PATCH 102/136] Discared Lukas changes in error --- src/ctapipe/irf/binning.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 44a7d5d04ba..4bc4020f67e 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -10,12 +10,10 @@ logger = logging.getLogger(__name__) + def check_bins_in_range(bins, range, source="result", raise_error=True): - # `pyirf.binning.create_bins_per_decade` includes the endpoint, if reasonably close. - # So different choices of `n_bins_per_decade` can lead to mismatches, if the same - # `*_energy_{min,max}` is chosen. - low = bins >= range.min * 0.9999999 - hig = bins <= range.max * 1.0000001 + low = bins >= range.min + hig = bins <= range.max if not all(low & hig): with np.printoptions(edgeitems=2, threshold=6, precision=4): From a7d30fbc8f19643adcecef5b4bf1e7751508aa9b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 15:13:29 +0200 Subject: [PATCH 103/136] Fixed some code quality issues from sonar --- src/ctapipe/irf/vis_utils.py | 64 ++++++++++++ src/ctapipe/irf/visualisation.py | 162 ++++++++----------------------- 2 files changed, 102 insertions(+), 124 deletions(-) create mode 100644 src/ctapipe/irf/vis_utils.py diff --git a/src/ctapipe/irf/vis_utils.py b/src/ctapipe/irf/vis_utils.py new file mode 100644 index 00000000000..7c647ae3701 --- /dev/null +++ b/src/ctapipe/irf/vis_utils.py @@ -0,0 +1,64 @@ +import numpy as np +import scipy.stats as st + + +def find_columnwise_stats(table, col_bins, percentiles, density=False): + tab = np.squeeze(table) + out = np.ones((tab.shape[1], 5)) * -1 + # This loop over the columns seems unavoidable, + # so having a reasonable number of bins in that + # direction is good + for idx, col in enumerate(tab.T): + if (col > 0).sum() == 0: + continue + col_est = st.rv_histogram((col, col_bins), density=density) + out[idx, 0] = col_est.mean() + out[idx, 1] = col_est.median() + out[idx, 2] = col_est.std() + out[idx, 3] = col_est.ppf(percentiles[0]) + out[idx, 4] = col_est.ppf(percentiles[1]) + return out + + +def rebin_x_2d_hist(hist, xbins, x_cent, num_bins_merge=3): + num_y, num_x = hist.shape + if (num_x) % num_bins_merge == 0: + rebin_x = xbins[::num_bins_merge] + rebin_xcent = x_cent.reshape(-1, num_bins_merge).mean(axis=1) + rebin_hist = hist.reshape(num_y, -1, num_bins_merge).sum(axis=2) + return rebin_x, rebin_xcent, rebin_hist + else: + raise ValueError( + f"Could not merge {num_bins_merge} along axis of dimension {num_x}" + ) + + +def get_2d_hist_from_table(x_prefix, y_prefix, table, column): + x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" + y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" + + xbins = np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])) + ybins = np.hstack((table[y_lo_name][0], table[y_hi_name][0][-1])) + + if isinstance(column, str): + mat_vals = np.squeeze(table[column]) + else: + mat_vals = column + + return mat_vals, xbins, ybins + + +def get_bin_centers(bins): + return np.convolve(bins, kernel=[0.5, 0.5], mode="valid") + + +def get_x_bin_values_with_rebinning(num_rebin, xbins, xcent, mat_vals, density): + if num_rebin > 1: + rebin_x, rebin_xcent, rebin_hist = rebin_x_2d_hist( + mat_vals, xbins, xcent, num_bins_merge=num_rebin + ) + density = False + else: + rebin_x, rebin_xcent, rebin_hist = xbins, xcent, mat_vals + + return rebin_x, rebin_xcent, rebin_hist, density diff --git a/src/ctapipe/irf/visualisation.py b/src/ctapipe/irf/visualisation.py index 468212a0c28..57a63f0338d 100644 --- a/src/ctapipe/irf/visualisation.py +++ b/src/ctapipe/irf/visualisation.py @@ -1,71 +1,37 @@ import astropy.units as u import matplotlib.pyplot as plt import numpy as np -import scipy.stats as st from astropy.visualization import quantity_support from matplotlib.colors import LogNorm from pyirf.binning import join_bin_lo_hi +from .vis_utils import ( + find_columnwise_stats, + get_2d_hist_from_table, + get_bin_centers, + get_x_bin_values_with_rebinning, +) + quantity_support() -def plot_2D_irf_table( +def plot_2d_irf_table( ax, table, column, x_prefix, y_prefix, x_label=None, y_label=None, **mpl_args ): - x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" - y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" - - xbins = np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])) + mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) - ybins = np.hstack((table[y_lo_name][0], table[y_hi_name][0][-1])) if not x_label: x_label = x_prefix if not y_label: y_label = y_prefix - if isinstance(column, str): - mat_vals = np.squeeze(table[column]) - else: - mat_vals = column - - plot = plot_hist2D( + plot = plot_hist2d( ax, mat_vals, xbins, ybins, xlabel=x_label, ylabel=y_label, **mpl_args ) plt.colorbar(plot) return ax -def rebin_x_2D_hist(hist, xbins, x_cent, num_bins_merge=3): - num_y, num_x = hist.shape - if (num_x) % num_bins_merge == 0: - rebin_x = xbins[::num_bins_merge] - rebin_xcent = x_cent.reshape(-1, num_bins_merge).mean(axis=1) - rebin_hist = hist.reshape(num_y, -1, num_bins_merge).sum(axis=2) - return rebin_x, rebin_xcent, rebin_hist - else: - raise ValueError( - f"Could not merge {num_bins_merge} along axis of dimension {num_x}" - ) - - -def find_columnwise_stats(table, col_bins, percentiles, density=False): - tab = np.squeeze(table) - out = np.ones((tab.shape[1], 5)) * -1 - # This loop over the columns seems unavoidable, - # so having a reasonable number of bins in that - # direction is good - for idx, col in enumerate(tab.T): - if (col > 0).sum() == 0: - continue - col_est = st.rv_histogram((col, col_bins), density=density) - out[idx, 0] = col_est.mean() - out[idx, 1] = col_est.median() - out[idx, 2] = col_est.std() - out[idx, 3] = col_est.ppf(percentiles[0]) - out[idx, 4] = col_est.ppf(percentiles[1]) - return out - - -def plot_2D_table_with_col_stats( +def plot_2d_table_with_col_stats( ax, table, column, @@ -88,35 +54,14 @@ def plot_2D_table_with_col_stats( 1 -> median + standard deviation 2 -> median + user specified quantiles around median (default 0.1 to 0.9) """ - x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" - y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" - - xbins = np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])) - - ybins = np.hstack((table[y_lo_name][0], table[y_hi_name][0][-1])) - xcent = np.convolve( - [0.5, 0.5], np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])), "valid" + mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) + xcent = get_bin_centers(xbins) + rebin_x, rebin_xcent, rebin_hist = get_x_bin_values_with_rebinning( + num_rebin, xbins, xcent, mat_vals, density ) - if not x_label: - x_label = x_prefix - if not y_label: - y_label = y_prefix - if isinstance(column, str): - mat_vals = np.squeeze(table[column]) - else: - mat_vals = column - if num_rebin > 1: - rebin_x, rebin_xcent, rebin_hist = rebin_x_2D_hist( - mat_vals, xbins, xcent, num_bins_merge=num_rebin - ) - density = False - else: - rebin_x, rebin_xcent, rebin_hist = xbins, xcent, mat_vals - - stats = find_columnwise_stats(rebin_hist, ybins, quantiles, density) - plot = plot_hist2D( + plot = plot_hist2d( ax, rebin_hist, rebin_x, @@ -127,36 +72,25 @@ def plot_2D_table_with_col_stats( ) plt.colorbar(plot) - sel = stats[:, 0] > 0 - if stat_kind == 1: - y_idx = 0 - err = stats[sel, 2] - label = "mean + std" - if stat_kind == 2: - y_idx = 1 - err = stats[sel, 2] - label = "median + std" - if stat_kind == 3: - y_idx = 1 - err = np.zeros_like(stats[:, 3:]) - err[sel, 0] = stats[sel, 1] - stats[sel, 3] - err[sel, 1] = stats[sel, 4] - stats[sel, 1] - err = err[sel, :].T - label = f"median + IRQ[{quantiles[0]:.2f},{quantiles[1]:.2f}]" - - ax.errorbar( - x=rebin_xcent[sel], - y=stats[sel, y_idx], - yerr=err, - label=label, - **mpl_args["stats"], + ax = plot_2d_table_col_stats( + ax, + table, + column, + x_prefix, + y_prefix, + num_rebin, + stat_kind, + quantiles, + x_label, + y_label, + density, + mpl_args, + lbl_prefix="", ) - ax.legend(loc="best") - return ax -def plot_2D_table_col_stats( +def plot_2d_table_col_stats( ax, table, column, @@ -171,38 +105,18 @@ def plot_2D_table_col_stats( lbl_prefix="", mpl_args={"xscale": "log"}, ): - """Function to draw columnwise statistics of 2D hist + """Function to draw columnwise statistics of 2d hist the content values shown depending on stat_kind: 0 -> mean + standard deviation 1 -> median + standard deviation 2 -> median + user specified quantiles around median (default 0.1 to 0.9) """ - x_lo_name, x_hi_name = f"{x_prefix}_LO", f"{x_prefix}_HI" - y_lo_name, y_hi_name = f"{y_prefix}_LO", f"{y_prefix}_HI" - xbins = np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])) - - ybins = np.hstack((table[y_lo_name][0], table[y_hi_name][0][-1])) - - xcent = np.convolve( - [0.5, 0.5], np.hstack((table[x_lo_name][0], table[x_hi_name][0][-1])), "valid" + mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) + xcent = get_bin_centers(xbins) + rebin_x, rebin_xcent, rebin_hist = get_x_bin_values_with_rebinning( + num_rebin, xbins, xcent, mat_vals, density ) - if not x_label: - x_label = x_prefix - if not y_label: - y_label = y_prefix - if isinstance(column, str): - mat_vals = np.squeeze(table[column]) - else: - mat_vals = column - - if num_rebin > 1: - rebin_x, rebin_xcent, rebin_hist = rebin_x_2D_hist( - mat_vals, xbins, xcent, num_bins_merge=num_rebin - ) - density = False - else: - rebin_xcent, rebin_hist = xcent, mat_vals stats = find_columnwise_stats(rebin_hist, ybins, quantiles, density) @@ -262,7 +176,7 @@ def plot_irf_table( ax.stairs(vals, bins, label=label, **mpl_args) -def plot_hist2D_as_contour( +def plot_hist2d_as_contour( ax, hist, xedges, @@ -283,7 +197,7 @@ def plot_hist2D_as_contour( return out -def plot_hist2D( +def plot_hist2d( ax, hist, xedges, From 2d4c9508b4dfc95a65bcaa37e57212bdffe6654a Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 15:18:32 +0200 Subject: [PATCH 104/136] Fixed typo --- src/ctapipe/irf/vis_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctapipe/irf/vis_utils.py b/src/ctapipe/irf/vis_utils.py index 7c647ae3701..e05812e1c84 100644 --- a/src/ctapipe/irf/vis_utils.py +++ b/src/ctapipe/irf/vis_utils.py @@ -49,7 +49,7 @@ def get_2d_hist_from_table(x_prefix, y_prefix, table, column): def get_bin_centers(bins): - return np.convolve(bins, kernel=[0.5, 0.5], mode="valid") + return np.convolve(bins, [0.5, 0.5], mode="valid") def get_x_bin_values_with_rebinning(num_rebin, xbins, xcent, mat_vals, density): From f809ca0fe327a508f934506218f2a810b8037ddd Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 17:23:40 +0200 Subject: [PATCH 105/136] Fixed problems where PSF was not always being generate --- src/ctapipe/tools/make_irf.py | 49 ++++++++++++++++------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 3c5469ec9ef..5d98fe72755 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -279,20 +279,19 @@ def setup(self): "Aeff fov offset", raise_error=self.range_check_error, ) - if self.full_enclosure: - self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) - check_bins_in_range( - self.psf.true_energy_bins, - self.opt_result.valid_energy, - "PSF energy true", - raise_error=self.range_check_error, - ) - check_bins_in_range( - self.psf.fov_offset_bins, - self.opt_result.valid_offset, - "PSF fov offset", - raise_error=self.range_check_error, - ) + self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) + check_bins_in_range( + self.psf.true_energy_bins, + self.opt_result.valid_energy, + "PSF energy true", + raise_error=self.range_check_error, + ) + check_bins_in_range( + self.psf.fov_offset_bins, + self.opt_result.valid_offset, + "PSF fov offset", + raise_error=self.range_check_error, + ) if self.do_benchmarks: self.b_output = self.output_path.with_name( @@ -389,16 +388,15 @@ def _make_signal_irf_hdus(self, hdus, sim_info): point_like=not self.full_enclosure, ) ) - if self.full_enclosure: - hdus.append( - self.psf.make_psf_hdu( - events=self.signal_events[self.signal_events["selected"]], - ) + hdus.append( + self.psf.make_psf_hdu( + events=self.signal_events[self.signal_events["selected"]], ) - else: + ) + if not self.full_enclosure: # TODO: Support fov binning self.log.debug( - "Currently no fov binning is supported for RAD_MAX. " + "Currently multiple fov binns is not supported for RAD_MAX. " "Using `fov_offset_bins = [valid_offset.min, valid_offset.max]`." ) hdus.append( @@ -529,7 +527,7 @@ def start(self): ).value == 0 if self.signal_is_point_like: - self.log.info( + self.log.warning( "The gamma input file contains point-like simulations." " Therefore, the IRF is only calculated at a single point" " in the FoV. Changing `fov_offset_n_bins` to 1." @@ -542,10 +540,9 @@ def start(self): parent=self, fov_offset_n_bins=1, ) - if self.full_enclosure: - self.psf = PsfMakerBase.from_name( - self.psf_parameterization, parent=self, fov_offset_n_bins=1 - ) + self.psf = PsfMakerBase.from_name( + self.psf_parameterization, parent=self, fov_offset_n_bins=1 + ) if self.do_background: self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self, fov_offset_n_bins=1 From d8771e7a7d1b9596f10097c7b7ded729d8bf47e5 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 17:25:30 +0200 Subject: [PATCH 106/136] Added optional colorbar plotting to plot_hist2d, additional small fixes --- src/ctapipe/irf/visualisation.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ctapipe/irf/visualisation.py b/src/ctapipe/irf/visualisation.py index 57a63f0338d..8880d0f3a53 100644 --- a/src/ctapipe/irf/visualisation.py +++ b/src/ctapipe/irf/visualisation.py @@ -57,7 +57,7 @@ def plot_2d_table_with_col_stats( mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) xcent = get_bin_centers(xbins) - rebin_x, rebin_xcent, rebin_hist = get_x_bin_values_with_rebinning( + rebin_x, rebin_xcent, rebin_hist, density = get_x_bin_values_with_rebinning( num_rebin, xbins, xcent, mat_vals, density ) @@ -84,7 +84,7 @@ def plot_2d_table_with_col_stats( x_label, y_label, density, - mpl_args, + mpl_args=mpl_args, lbl_prefix="", ) return ax @@ -114,7 +114,7 @@ def plot_2d_table_col_stats( mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) xcent = get_bin_centers(xbins) - rebin_x, rebin_xcent, rebin_hist = get_x_bin_values_with_rebinning( + rebin_x, rebin_xcent, rebin_hist, density = get_x_bin_values_with_rebinning( num_rebin, xbins, xcent, mat_vals, density ) @@ -208,6 +208,7 @@ def plot_hist2d( yscale="linear", norm="log", cmap="viridis", + colorbar=False, ): if isinstance(hist, u.Quantity): hist = hist.value @@ -218,4 +219,6 @@ def plot_hist2d( xg, yg = np.meshgrid(xedges, yedges) out = ax.pcolormesh(xg, yg, hist, norm=norm, cmap=cmap) ax.set(xscale=xscale, xlabel=xlabel, yscale=yscale, ylabel=ylabel) + if colorbar: + plt.colorbar(out) return out From 6a98c351e45ef62ba6b9ed284324f0f822468aa8 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 16 May 2024 16:43:48 +0200 Subject: [PATCH 107/136] Move benchmarks into dedicated Components; remove OutputEnergyBinning --- src/ctapipe/irf/__init__.py | 22 +++-- src/ctapipe/irf/benchmarks.py | 159 ++++++++++++++++++++++++++++++++++ src/ctapipe/irf/binning.py | 76 +++++++++++----- src/ctapipe/irf/irfs.py | 131 +++++++--------------------- src/ctapipe/tools/make_irf.py | 154 +++++++++++++++----------------- 5 files changed, 329 insertions(+), 213 deletions(-) create mode 100644 src/ctapipe/irf/benchmarks.py diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 42c669b34ec..0fd45befa01 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,7 +1,14 @@ """Top level module for the irf functionality""" +from .benchmarks import ( + AngularResolutionMaker, + EnergyBiasResolutionMaker, + SensitivityMaker, +) from .binning import ( - OutputEnergyBinning, + FoVOffsetBinsBase, + RecoEnergyBinsBase, ResultValidRange, + TrueEnergyBinsBase, check_bins_in_range, make_bins_per_decade, ) @@ -12,9 +19,6 @@ EffectiveAreaMakerBase, EnergyMigration2dMaker, EnergyMigrationMakerBase, - IrfMaker2dBase, - IrfMakerRecoEnergyBase, - IrfMakerTrueEnergyBase, Psf3dMaker, PsfMakerBase, ) @@ -31,9 +35,12 @@ from .spectra import SPECTRA, Spectra __all__ = [ - "IrfMaker2dBase", - "IrfMakerRecoEnergyBase", - "IrfMakerTrueEnergyBase", + "AngularResolutionMaker", + "EnergyBiasResolutionMaker", + "SensitivityMaker", + "TrueEnergyBinsBase", + "RecoEnergyBinsBase", + "FoVOffsetBinsBase", "PsfMakerBase", "BackgroundRateMakerBase", "EnergyMigrationMakerBase", @@ -48,7 +55,6 @@ "CutOptimizerBase", "PointSourceSensitivityOptimizer", "PercentileCuts", - "OutputEnergyBinning", "EventsLoader", "EventPreProcessor", "Spectra", diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py new file mode 100644 index 00000000000..9e80cb9e372 --- /dev/null +++ b/src/ctapipe/irf/benchmarks.py @@ -0,0 +1,159 @@ +"""Components to generate benchmarks""" +import astropy.units as u +import numpy as np +from astropy.io.fits import BinTableHDU +from astropy.table import QTable +from pyirf.benchmarks import angular_resolution, energy_bias_resolution +from pyirf.binning import create_histogram_table +from pyirf.sensitivity import calculate_sensitivity, estimate_background + +from ..core.traits import Bool, Float +from .binning import RecoEnergyBinsBase, TrueEnergyBinsBase +from .spectra import SPECTRA, Spectra + + +class EnergyBiasResolutionMaker(TrueEnergyBinsBase): + """ + Calculates the bias and the resolution of the energy prediction in bins of + true energy. + """ + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_bias_resolution_hdu( + self, events: QTable, extname: str = "ENERGY BIAS RESOLUTION" + ): + """ + Calculate the bias and resolution of the energy prediction. + + Parameters + ---------- + events: astropy.table.QTable + Reconstructed events to be used. + extname: str + Name of the BinTableHDU. + + Returns + ------- + BinTableHDU + """ + bias_resolution = energy_bias_resolution( + events=events, + energy_bins=self.true_energy_bins, + bias_function=np.mean, + energy_type="true", + ) + return BinTableHDU(bias_resolution, name=extname) + + +class AngularResolutionMaker(TrueEnergyBinsBase, RecoEnergyBinsBase): + """ + Calculates the angular resolution in bins of either true or reconstructed energy. + """ + + # Use reconstructed energy by default for the sake of current pipeline comparisons + use_true_energy = Bool( + False, + help="Use true energy instead of reconstructed energy for energy binning.", + ).tag(config=True) + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_angular_resolution_hdu( + self, events: QTable, extname: str = "ANGULAR RESOLUTION" + ): + """ + Calculate the angular resolution. + + Parameters + ---------- + events: astropy.table.QTable + Reconstructed events to be used. + extname: str + Name of the BinTableHDU. + + Returns + ------- + BinTableHDU + """ + if self.use_true_energy: + bins = self.true_energy_bins + energy_type = "true" + else: + bins = self.reco_energy_bins + energy_type = "reco" + + ang_res = angular_resolution( + events=events, + energy_bins=bins, + energy_type=energy_type, + ) + return BinTableHDU(ang_res, name=extname) + + +class SensitivityMaker(RecoEnergyBinsBase): + """Calculates the point source sensitivity in bins of reconstructed energy.""" + + alpha = Float( + default_value=0.2, help="Ratio between size of the on and the off region." + ).tag(config=True) + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_sensitivity_hdu( + self, + signal_events: QTable, + background_events: QTable, + theta_cut: QTable, + fov_offset_min: u.Quantity, + fov_offset_max: u.Quantity, + gamma_spectrum: Spectra, + extname: str = "SENSITIVITY", + ): + """ + Calculate the point source sensitivity + based on ``pyirf.sensitivity.calculate_sensitivity``. + + Parameters + ---------- + signal_events: astropy.table.QTable + Reconstructed signal events to be used. + background_events: astropy.table.QTable + Reconstructed background events to be used. + theta_cut: QTable + Direction cut that was applied on ``signal_events``. + fov_offset_min: astropy.units.Quantity[angle] + Minimum distance from the fov center for background events to be taken into account. + fov_offset_max: astropy.units.Quantity[angle] + Maximum distance from the fov center for background events to be taken into account. + gamma_spectrum: ctapipe.irf.Spectra + Spectra by which to scale the relative sensitivity to get the flux sensitivity. + extname: str + Name of the BinTableHDU. + + Returns + ------- + BinTableHDU + """ + signal_hist = create_histogram_table( + events=signal_events, bins=self.reco_energy_bins + ) + bkg_hist = estimate_background( + events=background_events, + reco_energy_bins=self.reco_energy_bins, + theta_cuts=theta_cut, + alpha=self.alpha, + fov_offset_min=fov_offset_min, + fov_offset_max=fov_offset_max, + ) + sens = calculate_sensitivity( + signal_hist=signal_hist, background_hist=bkg_hist, alpha=self.alpha + ) + source_spectrum = SPECTRA[gamma_spectrum] + sens["flux_sensitivity"] = sens["relative_sensitivity"] * source_spectrum( + sens["reco_energy_center"] + ) + return BinTableHDU(sens, name=extname) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 4bc4020f67e..baa43751a97 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -66,18 +66,18 @@ def __init__(self, bounds_table, prefix): self.max = bounds_table[f"{prefix}_max"][0] -class OutputEnergyBinning(Component): - """Collects energy binning settings.""" +class TrueEnergyBinsBase(Component): + """Base class for creating irfs or benchmarks binned in true energy.""" true_energy_min = AstroQuantity( help="Minimum value for True Energy bins", - default_value=0.015 * u.TeV, + default_value=u.Quantity(0.015, u.TeV), physical_type=u.physical.energy, ).tag(config=True) true_energy_max = AstroQuantity( help="Maximum value for True Energy bins", - default_value=150 * u.TeV, + default_value=u.Quantity(150, u.TeV), physical_type=u.physical.energy, ).tag(config=True) @@ -86,15 +86,27 @@ class OutputEnergyBinning(Component): default_value=10, ).tag(config=True) + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + self.true_energy_bins = make_bins_per_decade( + self.true_energy_min.to(u.TeV), + self.true_energy_max.to(u.TeV), + self.true_energy_n_bins_per_decade, + ) + + +class RecoEnergyBinsBase(Component): + """Base class for creating irfs or benchmarks binned in reconstructed energy.""" + reco_energy_min = AstroQuantity( help="Minimum value for Reco Energy bins", - default_value=0.015 * u.TeV, + default_value=u.Quantity(0.015, u.TeV), physical_type=u.physical.energy, ).tag(config=True) reco_energy_max = AstroQuantity( help="Maximum value for Reco Energy bins", - default_value=150 * u.TeV, + default_value=u.Quantity(150, u.TeV), physical_type=u.physical.energy, ).tag(config=True) @@ -103,24 +115,42 @@ class OutputEnergyBinning(Component): default_value=5, ).tag(config=True) - def true_energy_bins(self): - """ - Creates bins per decade for true MC energy using pyirf function. - """ - true_energy = make_bins_per_decade( - self.true_energy_min.to(u.TeV), - self.true_energy_max.to(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. - """ - reco_energy = make_bins_per_decade( + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + self.reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, ) - return reco_energy + + +class FoVOffsetBinsBase(Component): + """Base class for creating radially symmetric irfs or benchmarks.""" + + fov_offset_min = AstroQuantity( + help="Minimum value for FoV Offset bins", + default_value=u.Quantity(0, u.deg), + physical_type=u.physical.angle, + ).tag(config=True) + + fov_offset_max = AstroQuantity( + help="Maximum value for FoV offset bins", + default_value=u.Quantity(5, u.deg), + physical_type=u.physical.angle, + ).tag(config=True) + + fov_offset_n_bins = Integer( + help="Number of FoV offset bins", + default_value=1, + ).tag(config=True) + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + self.fov_offset_bins = u.Quantity( + np.linspace( + self.fov_offset_min.to_value(u.deg), + self.fov_offset_max.to_value(u.deg), + self.fov_offset_n_bins + 1, + ), + u.deg, + ) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 83f929e9d81..36820a7354e 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -20,102 +20,11 @@ ) from pyirf.simulations import SimulatedEventsInfo -from ..core import Component from ..core.traits import AstroQuantity, Float, Integer -from .binning import make_bins_per_decade +from .binning import FoVOffsetBinsBase, RecoEnergyBinsBase, TrueEnergyBinsBase -class IrfMakerTrueEnergyBase(Component): - """Base class for creating irfs binned in true energy.""" - - true_energy_min = AstroQuantity( - help="Minimum value for True Energy bins", - default_value=u.Quantity(0.015, u.TeV), - physical_type=u.physical.energy, - ).tag(config=True) - - true_energy_max = AstroQuantity( - help="Maximum value for True Energy bins", - default_value=u.Quantity(150, u.TeV), - physical_type=u.physical.energy, - ).tag(config=True) - - true_energy_n_bins_per_decade = Integer( - help="Number of edges per decade for True Energy bins", - default_value=10, - ).tag(config=True) - - def __init__(self, parent, **kwargs): - super().__init__(parent=parent, **kwargs) - self.true_energy_bins = make_bins_per_decade( - self.true_energy_min.to(u.TeV), - self.true_energy_max.to(u.TeV), - self.true_energy_n_bins_per_decade, - ) - - -class IrfMakerRecoEnergyBase(Component): - """Base class for creating irfs binned in reconstructed energy.""" - - reco_energy_min = AstroQuantity( - help="Minimum value for Reco Energy bins", - default_value=u.Quantity(0.015, u.TeV), - physical_type=u.physical.energy, - ).tag(config=True) - - reco_energy_max = AstroQuantity( - help="Maximum value for Reco Energy bins", - default_value=u.Quantity(150, u.TeV), - physical_type=u.physical.energy, - ).tag(config=True) - - reco_energy_n_bins_per_decade = Integer( - help="Number of edges per decade for Reco Energy bins", - default_value=10, - ).tag(config=True) - - def __init__(self, parent, **kwargs): - super().__init__(parent=parent, **kwargs) - self.reco_energy_bins = make_bins_per_decade( - self.reco_energy_min.to(u.TeV), - self.reco_energy_max.to(u.TeV), - self.reco_energy_n_bins_per_decade, - ) - - -class IrfMaker2dBase(Component): - """Base class for creating radially symmetric irfs.""" - - fov_offset_min = AstroQuantity( - help="Minimum value for FoV Offset bins", - default_value=u.Quantity(0, u.deg), - physical_type=u.physical.angle, - ).tag(config=True) - - fov_offset_max = AstroQuantity( - help="Maximum value for FoV offset bins", - default_value=u.Quantity(5, u.deg), - physical_type=u.physical.angle, - ).tag(config=True) - - fov_offset_n_bins = Integer( - help="Number of FoV offset bins", - default_value=1, - ).tag(config=True) - - def __init__(self, parent, **kwargs): - super().__init__(parent=parent, **kwargs) - self.fov_offset_bins = u.Quantity( - np.linspace( - self.fov_offset_min.to_value(u.deg), - self.fov_offset_max.to_value(u.deg), - self.fov_offset_n_bins + 1, - ), - u.deg, - ) - - -class PsfMakerBase(IrfMakerTrueEnergyBase): +class PsfMakerBase(TrueEnergyBinsBase): """Base class for calculating the point spread function.""" def __init__(self, parent, **kwargs): @@ -129,6 +38,9 @@ def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: Parameters ---------- events: astropy.table.QTable + Reconstructed events to be used. + extname: str + Name for the BinTableHDU. Returns ------- @@ -136,7 +48,7 @@ def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: """ -class BackgroundRateMakerBase(IrfMakerRecoEnergyBase): +class BackgroundRateMakerBase(RecoEnergyBinsBase): """Base class for calculating the background rate.""" def __init__(self, parent, **kwargs): @@ -153,7 +65,12 @@ def make_bkg_hdu( Parameters ---------- events: astropy.table.QTable + Reconstructed events to be used. obs_time: astropy.units.Quantity[time] + Observation time. This must match with how the individual event + weights are calculated. + extname: str + Name for the BinTableHDU. Returns ------- @@ -161,7 +78,7 @@ def make_bkg_hdu( """ -class EnergyMigrationMakerBase(IrfMakerTrueEnergyBase): +class EnergyMigrationMakerBase(TrueEnergyBinsBase): """Base class for calculating the energy migration.""" energy_migration_min = Float( @@ -198,7 +115,12 @@ def make_edisp_hdu( Parameters ---------- events: astropy.table.QTable + Reconstructed events to be used. point_like: bool + If a direction cut was applied on ``events``, pass ``True``, else ``False`` + for a full-enclosure energy dispersion. + extname: str + Name for the BinTableHDU. Returns ------- @@ -206,7 +128,7 @@ def make_edisp_hdu( """ -class EffectiveAreaMakerBase(IrfMakerTrueEnergyBase): +class EffectiveAreaMakerBase(TrueEnergyBinsBase): """Base class for calculating the effective area.""" def __init__(self, parent, **kwargs): @@ -228,8 +150,17 @@ def make_aeff_hdu( Parameters ---------- events: astropy.table.QTable + Reconstructed events to be used. point_like: bool + If a direction cut was applied on ``events``, pass ``True``, else ``False`` + for a full-enclosure effective area. signal_is_point_like: bool + If ``events`` were simulated only at a single point in the field of view, + pass ``True``, else ``False``. + sim_info: pyirf.simulations.SimulatedEventsInfoa + The overall statistics of the simulated events. + extname: str + Name of the BinTableHDU. Returns ------- @@ -237,7 +168,7 @@ def make_aeff_hdu( """ -class EffectiveArea2dMaker(EffectiveAreaMakerBase, IrfMaker2dBase): +class EffectiveArea2dMaker(EffectiveAreaMakerBase, FoVOffsetBinsBase): """ Creates a radially symmetric parameterizations of the effective area in equidistant bins of logarithmic true energy and field of view offset. @@ -281,7 +212,7 @@ def make_aeff_hdu( ) -class EnergyMigration2dMaker(EnergyMigrationMakerBase, IrfMaker2dBase): +class EnergyMigration2dMaker(EnergyMigrationMakerBase, FoVOffsetBinsBase): """ Creates a radially symmetric parameterizations of the energy migration in equidistant bins of logarithmic true energy and field of view offset. @@ -309,7 +240,7 @@ def make_edisp_hdu( ) -class BackgroundRate2dMaker(BackgroundRateMakerBase, IrfMaker2dBase): +class BackgroundRate2dMaker(BackgroundRateMakerBase, FoVOffsetBinsBase): """ Creates a radially symmetric parameterization of the background rate in equidistant bins of logarithmic reconstructed energy and field of view offset. @@ -335,7 +266,7 @@ def make_bkg_hdu( ) -class Psf3dMaker(PsfMakerBase, IrfMaker2dBase): +class Psf3dMaker(PsfMakerBase, FoVOffsetBinsBase): """ Creates a radially symmetric point spread function calculated in equidistant bins of source offset, logarithmic true energy, and field of view offset. diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 5d98fe72755..b8aec7b0c1e 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,29 +1,27 @@ """Tool to generate IRFs""" - import operator import astropy.units as u import numpy as np from astropy.io import fits from astropy.table import QTable, vstack -from pyirf.benchmarks import angular_resolution, energy_bias_resolution -from pyirf.binning import create_histogram_table from pyirf.cuts import evaluate_binned_cut from pyirf.io import create_rad_max_hdu -from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core import Provenance, Tool, ToolConfigurationError, traits -from ..core.traits import AstroQuantity, Bool, Float, Integer, classes_with_traits, flag +from ..core.traits import AstroQuantity, Bool, Integer, classes_with_traits, flag from ..irf import ( SPECTRA, + AngularResolutionMaker, BackgroundRateMakerBase, EffectiveAreaMakerBase, + EnergyBiasResolutionMaker, EnergyMigrationMakerBase, EventPreProcessor, EventsLoader, OptimizationResultStore, - OutputEnergyBinning, PsfMakerBase, + SensitivityMaker, Spectra, check_bins_in_range, ) @@ -107,10 +105,6 @@ class IrfTool(Tool): help="Observation time in the form `` ``", ).tag(config=True) - alpha = Float( - default_value=0.2, help="Ratio between size of on and off regions." - ).tag(config=True) - edisp_parameterization = traits.ComponentName( EnergyMigrationMakerBase, default_value="EnergyMigration2dMaker", @@ -176,7 +170,9 @@ class IrfTool(Tool): classes = ( [ EventsLoader, - OutputEnergyBinning, + AngularResolutionMaker, + EnergyBiasResolutionMaker, + SensitivityMaker, ] + classes_with_traits(BackgroundRateMakerBase) + classes_with_traits(EffectiveAreaMakerBase) @@ -185,17 +181,8 @@ class IrfTool(Tool): ) def setup(self): - self.e_bins = OutputEnergyBinning(parent=self) self.opt_result = OptimizationResultStore().read(self.cuts_file) - self.reco_energy_bins = self.e_bins.reco_energy_bins() - self.true_energy_bins = self.e_bins.true_energy_bins() - check_bins_in_range( - self.reco_energy_bins, - self.opt_result.valid_energy, - raise_error=self.range_check_error, - ) - if not self.full_enclosure and self.opt_result.theta_cuts is None: raise ToolConfigurationError( "Computing a point-like IRF requires an (optimized) theta cut." @@ -236,6 +223,7 @@ def setup(self): self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self ) + # TODO: Loop over all these bin checks or change `check_bins_in_range` check_bins_in_range( self.bkg.reco_energy_bins, self.opt_result.valid_energy, @@ -297,6 +285,29 @@ def setup(self): self.b_output = self.output_path.with_name( self.output_path.name.replace(".fits", "-benchmark.fits") ) + self.ang_res = AngularResolutionMaker(parent=self) + check_bins_in_range( + self.ang_res.true_energy_bins + if self.ang_res.use_true_energy + else self.ang_res.reco_energy_bins, + self.opt_result.valid_energy, + "Angular resolution energy", + raise_error=self.range_check_error, + ) + self.bias_res = EnergyBiasResolutionMaker(parent=self) + check_bins_in_range( + self.bias_res.true_energy_bins, + self.opt_result.valid_energy, + "Bias resolution energy", + raise_error=self.range_check_error, + ) + self.sens = SensitivityMaker(parent=self) + check_bins_in_range( + self.sens.reco_energy_bins, + self.opt_result.valid_energy, + "Sensitivity energy", + raise_error=self.range_check_error, + ) def calculate_selections(self, reduced_events: dict) -> dict: """ @@ -418,23 +429,16 @@ def _make_signal_irf_hdus(self, hdus, sim_info): return hdus def _make_benchmark_hdus(self, hdus): - bias_resolution = energy_bias_resolution( - self.signal_events[self.signal_events["selected"]], - self.true_energy_bins, - bias_function=np.mean, - energy_type="true", + hdus.append( + self.bias_res.make_bias_resolution_hdu( + events=self.signal_events[self.signal_events["selected"]], + ) ) - hdus.append(fits.BinTableHDU(bias_resolution, name="ENERGY_BIAS_RESOLUTION")) - - # Here we use reconstructed energy instead of true energy for the sake of - # current pipelines comparisons - ang_res = angular_resolution( - self.signal_events[self.signal_events["selected_gh"]], - self.reco_energy_bins, - energy_type="reco", + hdus.append( + self.ang_res.make_angular_resolution_hdu( + events=self.signal_events[self.signal_events["selected_gh"]], + ) ) - hdus.append(fits.BinTableHDU(ang_res, name="ANGULAR_RESOLUTION")) - if self.do_background: if self.full_enclosure: # Create a dummy theta cut since `pyirf.sensitivity.estimate_background` @@ -445,35 +449,24 @@ def _make_benchmark_hdus(self, hdus): ) theta_cuts = QTable() theta_cuts["center"] = 0.5 * ( - self.reco_energy_bins[:-1] + self.reco_energy_bins[1:] + self.sens.reco_energy_bins[:-1] + self.sens.reco_energy_bins[1:] ) theta_cuts["cut"] = self.opt_result.valid_offset.max else: theta_cuts = self.opt_result.theta_cuts - signal_hist = create_histogram_table( - self.signal_events[self.signal_events["selected"]], - bins=self.reco_energy_bins, - ) - background_hist = estimate_background( - self.background_events[self.background_events["selected_gh"]], - reco_energy_bins=self.reco_energy_bins, - theta_cuts=theta_cuts, - alpha=self.alpha, - fov_offset_min=self.opt_result.valid_offset.min, - fov_offset_max=self.opt_result.valid_offset.max, - ) - sensitivity = calculate_sensitivity( - signal_hist, background_hist, alpha=self.alpha + hdus.append( + self.sens.make_sensitivity_hdu( + signal_events=self.signal_events[self.signal_events["selected"]], + background_events=self.background_events[ + self.background_events["selected_gh"] + ], + theta_cut=theta_cuts, + fov_offset_min=self.opt_result.valid_offset.min, + fov_offset_max=self.opt_result.valid_offset.max, + gamma_spectrum=self.gamma_target_spectrum, + ) ) - gamma_spectrum = SPECTRA[self.gamma_target_spectrum] - # scale relative sensitivity by Crab flux to get the flux sensitivity - sensitivity["flux_sensitivity"] = sensitivity[ - "relative_sensitivity" - ] * gamma_spectrum(sensitivity["reco_energy_center"]) - - hdus.append(fits.BinTableHDU(sensitivity, name="SENSITIVITY")) - return hdus def start(self): @@ -526,27 +519,27 @@ def start(self): meta["sim_info"].viewcone_max - meta["sim_info"].viewcone_min ).value == 0 - if self.signal_is_point_like: - self.log.warning( - "The gamma input file contains point-like simulations." - " Therefore, the IRF is only calculated at a single point" - " in the FoV. Changing `fov_offset_n_bins` to 1." - ) - self.edisp = EnergyMigrationMakerBase.from_name( - self.edisp_parameterization, parent=self, fov_offset_n_bins=1 - ) - self.aeff = EffectiveAreaMakerBase.from_name( - self.aeff_parameterization, - parent=self, - fov_offset_n_bins=1, - ) - self.psf = PsfMakerBase.from_name( - self.psf_parameterization, parent=self, fov_offset_n_bins=1 - ) - if self.do_background: - self.bkg = BackgroundRateMakerBase.from_name( - self.bkg_parameterization, parent=self, fov_offset_n_bins=1 - ) + if self.signal_is_point_like: + self.log.warning( + "The gamma input file contains point-like simulations." + " Therefore, the IRF is only calculated at a single point" + " in the FoV. Changing `fov_offset_n_bins` to 1." + ) + self.edisp = EnergyMigrationMakerBase.from_name( + self.edisp_parameterization, parent=self, fov_offset_n_bins=1 + ) + self.aeff = EffectiveAreaMakerBase.from_name( + self.aeff_parameterization, + parent=self, + fov_offset_n_bins=1, + ) + self.psf = PsfMakerBase.from_name( + self.psf_parameterization, parent=self, fov_offset_n_bins=1 + ) + if self.do_background: + self.bkg = BackgroundRateMakerBase.from_name( + self.bkg_parameterization, parent=self, fov_offset_n_bins=1 + ) reduced_events = self.calculate_selections(reduced_events) @@ -554,9 +547,6 @@ def start(self): if self.do_background: self.background_events = self._stack_background(reduced_events) - self.log.debug("True Energy bins: %s" % str(self.true_energy_bins.value)) - self.log.debug("Reco Energy bins: %s" % str(self.reco_energy_bins.value)) - hdus = [fits.PrimaryHDU()] hdus = self._make_signal_irf_hdus( hdus, reduced_events["gammas_meta"]["sim_info"] From 01752120cf4e97bd41f27144a43409273a9a317a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 16 May 2024 17:32:57 +0200 Subject: [PATCH 108/136] Add type hints to EventsLoader and EventPreProcessor --- src/ctapipe/irf/select.py | 25 +++++++++++++------ src/ctapipe/tools/make_irf.py | 7 +++--- src/ctapipe/tools/optimize_event_selection.py | 7 +++--- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 8bf5bde481b..ee762950d1c 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -1,8 +1,10 @@ """Module containing classes related to event preprocessing and selection""" +from pathlib import Path + import astropy.units as u import numpy as np from astropy.coordinates import AltAz, SkyCoord -from astropy.table import QTable, vstack +from astropy.table import QTable, Table, vstack from pyirf.simulations import SimulatedEventsInfo from pyirf.spectral import PowerLaw, calculate_event_weights from pyirf.utils import calculate_source_fov_offset, calculate_theta @@ -12,6 +14,7 @@ from ..core.traits import List, Tuple, Unicode from ..io import TableLoader from .binning import ResultValidRange +from .spectra import SPECTRA, Spectra class EventPreProcessor(QualityQuery): @@ -48,7 +51,7 @@ class EventPreProcessor(QualityQuery): default_value=[], ).tag(config=True) - def normalise_column_names(self, events): + def normalise_column_names(self, events: Table) -> QTable: keep_columns = [ "obs_id", "event_id", @@ -74,7 +77,7 @@ def normalise_column_names(self, events): events.rename_columns(rename_from, rename_to) return events - def make_empty_table(self): + def make_empty_table(self) -> QTable: """This function defines the columns later functions expect to be present in the event table""" columns = [ "obs_id", @@ -135,15 +138,17 @@ def make_empty_table(self): class EventsLoader(Component): classes = [EventPreProcessor] - def __init__(self, kind, file, target_spectrum, **kwargs): + def __init__(self, kind: str, file: Path, target_spectrum: Spectra, **kwargs): super().__init__(**kwargs) self.epp = EventPreProcessor(parent=self) - self.target_spectrum = target_spectrum + self.target_spectrum = SPECTRA[target_spectrum] self.kind = kind self.file = file - def load_preselected_events(self, chunk_size, obs_time, valid_fov): + def load_preselected_events( + self, chunk_size: int, obs_time: u.Quantity, valid_fov + ) -> tuple[QTable, int, dict]: opts = dict(dl2=True, simulated=True) with TableLoader(self.file, parent=self, **opts) as load: header = self.epp.make_empty_table() @@ -164,7 +169,9 @@ def load_preselected_events(self, chunk_size, obs_time, valid_fov): table = vstack(bits, join_type="exact", metadata_conflicts="silent") return table, n_raw_events, meta - def get_metadata(self, loader, obs_time): + def get_metadata( + self, loader: TableLoader, obs_time: u.Quantity + ) -> tuple[SimulatedEventsInfo, PowerLaw, Table]: obs = loader.read_observation_information() sim = loader.read_simulation_configuration() show = loader.read_shower_distribution() @@ -191,7 +198,9 @@ def get_metadata(self, loader, obs_time): obs, ) - def make_derived_columns(self, events, spectrum, obs_conf, valid_fov): + def make_derived_columns( + self, events: QTable, spectrum: PowerLaw, obs_conf: Table, valid_fov + ) -> QTable: if obs_conf["subarray_pointing_lat"].std() < 1e-3: assert all(obs_conf["subarray_pointing_frame"] == 0) # Lets suppose 0 means ALTAZ diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index b8aec7b0c1e..1862a1ebe8c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -11,7 +11,6 @@ from ..core import Provenance, Tool, ToolConfigurationError, traits from ..core.traits import AstroQuantity, Bool, Integer, classes_with_traits, flag from ..irf import ( - SPECTRA, AngularResolutionMaker, BackgroundRateMakerBase, EffectiveAreaMakerBase, @@ -193,7 +192,7 @@ def setup(self): parent=self, kind="gammas", file=self.gamma_file, - target_spectrum=SPECTRA[self.gamma_target_spectrum], + target_spectrum=self.gamma_target_spectrum, ), ] if self.do_background: @@ -203,7 +202,7 @@ def setup(self): parent=self, kind="protons", file=self.proton_file, - target_spectrum=SPECTRA[self.proton_target_spectrum], + target_spectrum=self.proton_target_spectrum, ) ) if self.electron_file: @@ -212,7 +211,7 @@ def setup(self): parent=self, kind="electrons", file=self.electron_file, - target_spectrum=SPECTRA[self.electron_target_spectrum], + target_spectrum=self.electron_target_spectrum, ) ) if len(self.particles) == 1: diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 2104d8fe8b8..38621fa0e7f 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -5,7 +5,6 @@ from ..core import Provenance, Tool, traits from ..core.traits import AstroQuantity, Bool, Float, Integer, classes_with_traits, flag from ..irf import ( - SPECTRA, CutOptimizerBase, EventsLoader, Spectra, @@ -108,19 +107,19 @@ def setup(self): parent=self, kind="gammas", file=self.gamma_file, - target_spectrum=SPECTRA[self.gamma_sim_spectrum], + target_spectrum=self.gamma_sim_spectrum, ), EventsLoader( parent=self, kind="protons", file=self.proton_file, - target_spectrum=SPECTRA[self.proton_sim_spectrum], + target_spectrum=self.proton_sim_spectrum, ), EventsLoader( parent=self, kind="electrons", file=self.electron_file, - target_spectrum=SPECTRA[self.electron_sim_spectrum], + target_spectrum=self.electron_sim_spectrum, ), ] From 32c9fbe374651971107d2fb814cabf7c34e78542 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 17 May 2024 19:02:50 +0200 Subject: [PATCH 109/136] Add fov binning for benchmarks --- src/ctapipe/irf/__init__.py | 18 ++- src/ctapipe/irf/benchmarks.py | 208 ++++++++++++++++++++++++++-------- src/ctapipe/tools/make_irf.py | 77 +++++++++++-- 3 files changed, 239 insertions(+), 64 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 0fd45befa01..1595ce6b314 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,8 +1,11 @@ """Top level module for the irf functionality""" from .benchmarks import ( - AngularResolutionMaker, - EnergyBiasResolutionMaker, - SensitivityMaker, + AngularResolution2dMaker, + AngularResolutionMakerBase, + EnergyBiasResolution2dMaker, + EnergyBiasResolutionMakerBase, + Sensitivity2dMaker, + SensitivityMakerBase, ) from .binning import ( FoVOffsetBinsBase, @@ -35,9 +38,12 @@ from .spectra import SPECTRA, Spectra __all__ = [ - "AngularResolutionMaker", - "EnergyBiasResolutionMaker", - "SensitivityMaker", + "AngularResolutionMakerBase", + "AngularResolution2dMaker", + "EnergyBiasResolutionMakerBase", + "EnergyBiasResolution2dMaker", + "SensitivityMakerBase", + "Sensitivity2dMaker", "TrueEnergyBinsBase", "RecoEnergyBinsBase", "FoVOffsetBinsBase", diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index 9e80cb9e372..ae6bd4f2ea4 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -1,29 +1,46 @@ """Components to generate benchmarks""" +from abc import abstractmethod + import astropy.units as u import numpy as np -from astropy.io.fits import BinTableHDU +from astropy.io.fits import BinTableHDU, Header from astropy.table import QTable from pyirf.benchmarks import angular_resolution, energy_bias_resolution -from pyirf.binning import create_histogram_table +from pyirf.binning import calculate_bin_indices, create_histogram_table, split_bin_lo_hi from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core.traits import Bool, Float -from .binning import RecoEnergyBinsBase, TrueEnergyBinsBase +from .binning import FoVOffsetBinsBase, RecoEnergyBinsBase, TrueEnergyBinsBase from .spectra import SPECTRA, Spectra -class EnergyBiasResolutionMaker(TrueEnergyBinsBase): +def _get_2d_result_table( + events: QTable, e_bins: u.Quantity, fov_bins: u.Quantity +) -> tuple[QTable, np.ndarray, tuple[int, int]]: + result = QTable() + result["ENERG_LO"], result["ENERG_HI"] = split_bin_lo_hi( + e_bins[np.newaxis, :].to(u.TeV) + ) + result["THETA_LO"], result["THETA_HI"] = split_bin_lo_hi( + fov_bins[np.newaxis, :].to(u.deg) + ) + fov_bin_index, _ = calculate_bin_indices(events["true_source_fov_offset"], fov_bins) + mat_shape = (len(e_bins) - 1, len(fov_bins) - 1) + return result, fov_bin_index, mat_shape + + +class EnergyBiasResolutionMakerBase(TrueEnergyBinsBase): """ - Calculates the bias and the resolution of the energy prediction in bins of - true energy. + Base class for calculating the bias and resolution of the energy prediciton. """ def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) + @abstractmethod def make_bias_resolution_hdu( self, events: QTable, extname: str = "ENERGY BIAS RESOLUTION" - ): + ) -> BinTableHDU: """ Calculate the bias and resolution of the energy prediction. @@ -38,18 +55,46 @@ def make_bias_resolution_hdu( ------- BinTableHDU """ - bias_resolution = energy_bias_resolution( + + +class EnergyBiasResolution2dMaker(EnergyBiasResolutionMakerBase, FoVOffsetBinsBase): + """ + Calculates the bias and the resolution of the energy prediction in bins of + true energy and fov offset. + """ + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_bias_resolution_hdu( + self, events: QTable, extname: str = "ENERGY BIAS RESOLUTION" + ) -> BinTableHDU: + result, fov_bin_idx, mat_shape = _get_2d_result_table( events=events, - energy_bins=self.true_energy_bins, - bias_function=np.mean, - energy_type="true", + e_bins=self.true_energy_bins, + fov_bins=self.fov_offset_bins, ) - return BinTableHDU(bias_resolution, name=extname) + result["N_EVENTS"] = np.zeros(mat_shape)[np.newaxis, ...] + result["BIAS"] = np.full(mat_shape, np.nan)[np.newaxis, ...] + result["RESOLUTI"] = np.full(mat_shape, np.nan)[np.newaxis, ...] + + for i in range(len(self.fov_offset_bins) - 1): + bias_resolution = energy_bias_resolution( + events=events[fov_bin_idx == i], + energy_bins=self.true_energy_bins, + bias_function=np.mean, + energy_type="true", + ) + result["N_EVENTS"][..., i] = bias_resolution["n_events"] + result["BIAS"][..., i] = bias_resolution["bias"] + result["RESOLUTI"][..., i] = bias_resolution["resolution"] + return BinTableHDU(result, name=extname) -class AngularResolutionMaker(TrueEnergyBinsBase, RecoEnergyBinsBase): + +class AngularResolutionMakerBase(TrueEnergyBinsBase, RecoEnergyBinsBase): """ - Calculates the angular resolution in bins of either true or reconstructed energy. + Base class for calculating the angular resolution. """ # Use reconstructed energy by default for the sake of current pipeline comparisons @@ -61,9 +106,10 @@ class AngularResolutionMaker(TrueEnergyBinsBase, RecoEnergyBinsBase): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) + @abstractmethod def make_angular_resolution_hdu( self, events: QTable, extname: str = "ANGULAR RESOLUTION" - ): + ) -> BinTableHDU: """ Calculate the angular resolution. @@ -78,23 +124,53 @@ def make_angular_resolution_hdu( ------- BinTableHDU """ + + +class AngularResolution2dMaker(AngularResolutionMakerBase, FoVOffsetBinsBase): + """ + Calculates the angular resolution in bins of either true or reconstructed energy + and fov offset. + """ + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_angular_resolution_hdu( + self, events: QTable, extname: str = "ANGULAR RESOLUTION" + ) -> BinTableHDU: if self.use_true_energy: - bins = self.true_energy_bins + e_bins = self.true_energy_bins energy_type = "true" else: - bins = self.reco_energy_bins + e_bins = self.reco_energy_bins energy_type = "reco" - ang_res = angular_resolution( + result, fov_bin_idx, mat_shape = _get_2d_result_table( events=events, - energy_bins=bins, - energy_type=energy_type, + e_bins=e_bins, + fov_bins=self.fov_offset_bins, ) - return BinTableHDU(ang_res, name=extname) + result["N_EVENTS"] = np.zeros(mat_shape)[np.newaxis, ...] + result["ANG_RES"] = u.Quantity( + np.full(mat_shape, np.nan)[np.newaxis, ...], events["theta"].unit + ) + + for i in range(len(self.fov_offset_bins) - 1): + ang_res = angular_resolution( + events=events[fov_bin_idx == i], + energy_bins=e_bins, + energy_type=energy_type, + ) + result["N_EVENTS"][..., i] = ang_res["n_events"] + result["ANG_RES"][..., i] = ang_res["angular_resolution"] + header = Header() + header["E_TYPE"] = energy_type.upper() + return BinTableHDU(result, header=header, name=extname) -class SensitivityMaker(RecoEnergyBinsBase): - """Calculates the point source sensitivity in bins of reconstructed energy.""" + +class SensitivityMakerBase(RecoEnergyBinsBase): + """Base class for calculating the point source sensitivity.""" alpha = Float( default_value=0.2, help="Ratio between size of the on and the off region." @@ -103,16 +179,15 @@ class SensitivityMaker(RecoEnergyBinsBase): def __init__(self, parent, **kwargs): super().__init__(parent=parent, **kwargs) + @abstractmethod def make_sensitivity_hdu( self, signal_events: QTable, background_events: QTable, theta_cut: QTable, - fov_offset_min: u.Quantity, - fov_offset_max: u.Quantity, gamma_spectrum: Spectra, extname: str = "SENSITIVITY", - ): + ) -> BinTableHDU: """ Calculate the point source sensitivity based on ``pyirf.sensitivity.calculate_sensitivity``. @@ -125,10 +200,6 @@ def make_sensitivity_hdu( Reconstructed background events to be used. theta_cut: QTable Direction cut that was applied on ``signal_events``. - fov_offset_min: astropy.units.Quantity[angle] - Minimum distance from the fov center for background events to be taken into account. - fov_offset_max: astropy.units.Quantity[angle] - Maximum distance from the fov center for background events to be taken into account. gamma_spectrum: ctapipe.irf.Spectra Spectra by which to scale the relative sensitivity to get the flux sensitivity. extname: str @@ -138,22 +209,65 @@ def make_sensitivity_hdu( ------- BinTableHDU """ - signal_hist = create_histogram_table( - events=signal_events, bins=self.reco_energy_bins - ) - bkg_hist = estimate_background( - events=background_events, - reco_energy_bins=self.reco_energy_bins, - theta_cuts=theta_cut, - alpha=self.alpha, - fov_offset_min=fov_offset_min, - fov_offset_max=fov_offset_max, - ) - sens = calculate_sensitivity( - signal_hist=signal_hist, background_hist=bkg_hist, alpha=self.alpha - ) + + +class Sensitivity2dMaker(SensitivityMakerBase, FoVOffsetBinsBase): + """ + Calculates the point source sensitivity in bins of reconstructed energy + and fov offset. + """ + + def __init__(self, parent, **kwargs): + super().__init__(parent=parent, **kwargs) + + def make_sensitivity_hdu( + self, + signal_events: QTable, + background_events: QTable, + theta_cut: QTable, + gamma_spectrum: Spectra, + extname: str = "SENSITIVITY", + ) -> BinTableHDU: source_spectrum = SPECTRA[gamma_spectrum] - sens["flux_sensitivity"] = sens["relative_sensitivity"] * source_spectrum( - sens["reco_energy_center"] + result, fov_bin_idx, mat_shape = _get_2d_result_table( + events=signal_events, + e_bins=self.reco_energy_bins, + fov_bins=self.fov_offset_bins, + ) + result["N_SIG"] = np.zeros(mat_shape)[np.newaxis, ...] + result["N_SIG_W"] = np.zeros(mat_shape)[np.newaxis, ...] + result["N_BKG"] = np.zeros(mat_shape)[np.newaxis, ...] + result["N_BKG_W"] = np.zeros(mat_shape)[np.newaxis, ...] + result["SIGNIFIC"] = np.full(mat_shape, np.nan)[np.newaxis, ...] + result["REL_SEN"] = np.full(mat_shape, np.nan)[np.newaxis, ...] + result["FLUX_SEN"] = u.Quantity( + np.full(mat_shape, np.nan)[np.newaxis, ...], 1 / (u.TeV * u.s * u.cm**2) ) - return BinTableHDU(sens, name=extname) + for i in range(len(self.fov_offset_bins) - 1): + signal_hist = create_histogram_table( + events=signal_events[fov_bin_idx == i], bins=self.reco_energy_bins + ) + bkg_hist = estimate_background( + events=background_events, + reco_energy_bins=self.reco_energy_bins, + theta_cuts=theta_cut, + alpha=self.alpha, + fov_offset_min=self.fov_offset_bins[i], + fov_offset_max=self.fov_offset_bins[i + 1], + ) + sens = calculate_sensitivity( + signal_hist=signal_hist, background_hist=bkg_hist, alpha=self.alpha + ) + result["N_SIG"][..., i] = sens["n_signal"] + result["N_SIG_W"][..., i] = sens["n_signal_weighted"] + result["N_BKG"][..., i] = sens["n_background"] + result["N_BKG_W"][..., i] = sens["n_background_weighted"] + result["SIGNIFIC"][..., i] = sens["significance"] + result["REL_SEN"][..., i] = sens["relative_sensitivity"] + result["FLUX_SEN"][..., i] = sens["relative_sensitivity"] * source_spectrum( + sens["reco_energy_center"] + ) + + header = Header() + header["ALPHA"] = self.alpha + return BinTableHDU(result, header=header, name=extname) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 1862a1ebe8c..a3065699b8e 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -11,16 +11,16 @@ from ..core import Provenance, Tool, ToolConfigurationError, traits from ..core.traits import AstroQuantity, Bool, Integer, classes_with_traits, flag from ..irf import ( - AngularResolutionMaker, + AngularResolutionMakerBase, BackgroundRateMakerBase, EffectiveAreaMakerBase, - EnergyBiasResolutionMaker, + EnergyBiasResolutionMakerBase, EnergyMigrationMakerBase, EventPreProcessor, EventsLoader, OptimizationResultStore, PsfMakerBase, - SensitivityMaker, + SensitivityMakerBase, Spectra, check_bins_in_range, ) @@ -128,6 +128,27 @@ class IrfTool(Tool): help="The parameterization of the background rate to be used.", ).tag(config=True) + energy_bias_res_parameterization = traits.ComponentName( + EnergyBiasResolutionMakerBase, + default_value="EnergyBiasResolution2dMaker", + help=( + "The parameterization of the bias and resolution benchmark " + "for the energy prediction." + ), + ).tag(config=True) + + ang_res_parameterization = traits.ComponentName( + AngularResolutionMakerBase, + default_value="AngularResolution2dMaker", + help="The parameterization of the angular resolution benchmark.", + ).tag(config=True) + + sens_parameterization = traits.ComponentName( + SensitivityMakerBase, + default_value="Sensitivity2dMaker", + help="The parameterization of the point source sensitivity benchmark.", + ).tag(config=True) + full_enclosure = Bool( False, help=( @@ -169,14 +190,14 @@ class IrfTool(Tool): classes = ( [ EventsLoader, - AngularResolutionMaker, - EnergyBiasResolutionMaker, - SensitivityMaker, ] + classes_with_traits(BackgroundRateMakerBase) + classes_with_traits(EffectiveAreaMakerBase) + classes_with_traits(EnergyMigrationMakerBase) + classes_with_traits(PsfMakerBase) + + classes_with_traits(AngularResolutionMakerBase) + + classes_with_traits(EnergyBiasResolutionMakerBase) + + classes_with_traits(SensitivityMakerBase) ) def setup(self): @@ -284,7 +305,9 @@ def setup(self): self.b_output = self.output_path.with_name( self.output_path.name.replace(".fits", "-benchmark.fits") ) - self.ang_res = AngularResolutionMaker(parent=self) + self.ang_res = AngularResolutionMakerBase.from_name( + self.ang_res_parameterization, parent=self + ) check_bins_in_range( self.ang_res.true_energy_bins if self.ang_res.use_true_energy @@ -293,20 +316,42 @@ def setup(self): "Angular resolution energy", raise_error=self.range_check_error, ) - self.bias_res = EnergyBiasResolutionMaker(parent=self) + check_bins_in_range( + self.ang_res.fov_offset_bins, + self.opt_result.valid_offset, + "Angular resolution fov offset", + raise_error=self.range_check_error, + ) + self.bias_res = EnergyBiasResolutionMakerBase.from_name( + self.energy_bias_res_parameterization, parent=self + ) check_bins_in_range( self.bias_res.true_energy_bins, self.opt_result.valid_energy, "Bias resolution energy", raise_error=self.range_check_error, ) - self.sens = SensitivityMaker(parent=self) + check_bins_in_range( + self.bias_res.fov_offset_bins, + self.opt_result.valid_offset, + "Bias resolution fov offset", + raise_error=self.range_check_error, + ) + self.sens = SensitivityMakerBase.from_name( + self.sens_parameterization, parent=self + ) check_bins_in_range( self.sens.reco_energy_bins, self.opt_result.valid_energy, "Sensitivity energy", raise_error=self.range_check_error, ) + check_bins_in_range( + self.sens.fov_offset_bins, + self.opt_result.valid_offset, + "Sensitivity fov offset", + raise_error=self.range_check_error, + ) def calculate_selections(self, reduced_events: dict) -> dict: """ @@ -461,8 +506,6 @@ def _make_benchmark_hdus(self, hdus): self.background_events["selected_gh"] ], theta_cut=theta_cuts, - fov_offset_min=self.opt_result.valid_offset.min, - fov_offset_max=self.opt_result.valid_offset.max, gamma_spectrum=self.gamma_target_spectrum, ) ) @@ -539,6 +582,18 @@ def start(self): self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self, fov_offset_n_bins=1 ) + if self.do_benchmarks: + self.ang_res = AngularResolutionMakerBase.from_name( + self.ang_res_parameterization, parent=self, fov_offset_n_bins=1 + ) + self.bias_res = EnergyBiasResolutionMakerBase.from_name( + self.energy_bias_res_parameterization, + parent=self, + fov_offset_n_bins=1, + ) + self.sens = SensitivityMakerBase.from_name( + self.sens_parameterization, parent=self, fov_offset_n_bins=1 + ) reduced_events = self.calculate_selections(reduced_events) From 576b8879d4525f34cdabddcad95fae5b80a2194e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 4 Jun 2024 18:04:13 +0200 Subject: [PATCH 110/136] Only compute psf for full enclosure irf --- src/ctapipe/tools/make_irf.py | 48 +++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index a3065699b8e..1a459cae591 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,4 +1,5 @@ """Tool to generate IRFs""" + import operator import astropy.units as u @@ -287,19 +288,20 @@ def setup(self): "Aeff fov offset", raise_error=self.range_check_error, ) - self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) - check_bins_in_range( - self.psf.true_energy_bins, - self.opt_result.valid_energy, - "PSF energy true", - raise_error=self.range_check_error, - ) - check_bins_in_range( - self.psf.fov_offset_bins, - self.opt_result.valid_offset, - "PSF fov offset", - raise_error=self.range_check_error, - ) + if self.full_enclosure: + self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) + check_bins_in_range( + self.psf.true_energy_bins, + self.opt_result.valid_energy, + "PSF energy true", + raise_error=self.range_check_error, + ) + check_bins_in_range( + self.psf.fov_offset_bins, + self.opt_result.valid_offset, + "PSF fov offset", + raise_error=self.range_check_error, + ) if self.do_benchmarks: self.b_output = self.output_path.with_name( @@ -443,15 +445,16 @@ def _make_signal_irf_hdus(self, hdus, sim_info): point_like=not self.full_enclosure, ) ) - hdus.append( - self.psf.make_psf_hdu( - events=self.signal_events[self.signal_events["selected"]], + if self.full_enclosure: + hdus.append( + self.psf.make_psf_hdu( + events=self.signal_events[self.signal_events["selected"]] + ) ) - ) - if not self.full_enclosure: + else: # TODO: Support fov binning self.log.debug( - "Currently multiple fov binns is not supported for RAD_MAX. " + "Currently multiple fov bins is not supported for RAD_MAX. " "Using `fov_offset_bins = [valid_offset.min, valid_offset.max]`." ) hdus.append( @@ -575,9 +578,10 @@ def start(self): parent=self, fov_offset_n_bins=1, ) - self.psf = PsfMakerBase.from_name( - self.psf_parameterization, parent=self, fov_offset_n_bins=1 - ) + if self.full_enclosure: + self.psf = PsfMakerBase.from_name( + self.psf_parameterization, parent=self, fov_offset_n_bins=1 + ) if self.do_background: self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self, fov_offset_n_bins=1 From 8ad21816810e935c89aa9d66967ef51ded273700 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 6 Jun 2024 15:29:55 +0200 Subject: [PATCH 111/136] Allow user renaming of cols and check for needed cols after --- src/ctapipe/irf/select.py | 47 ++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index ee762950d1c..eaef7848626 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -1,4 +1,5 @@ """Module containing classes related to event preprocessing and selection""" + from pathlib import Path import astropy.units as u @@ -24,10 +25,12 @@ class EventPreProcessor(QualityQuery): default_value="RandomForestRegressor", help="Prefix of the reco `_energy` column", ).tag(config=True) + geometry_reconstructor = Unicode( default_value="HillasReconstructor", help="Prefix of the `_alt` and `_az` reco geometry columns", ).tag(config=True) + gammaness_classifier = Unicode( default_value="RandomForestClassifier", help="Prefix of the classifier `_prediction` column", @@ -47,7 +50,7 @@ class EventPreProcessor(QualityQuery): rename_columns = List( help="List containing translation pairs new and old column names" "used when processing input with names differing from the CTA prod5b format" - "Ex: [('valid_geom','HillasReconstructor_is_valid')]", + "Ex: [('alt_from_new_algorithm','reco_alt')]", default_value=[], ).tag(config=True) @@ -59,26 +62,50 @@ def normalise_column_names(self, events: Table) -> QTable: "true_az", "true_alt", ] - rename_from = [ - f"{self.energy_reconstructor}_energy", - f"{self.geometry_reconstructor}_az", - f"{self.geometry_reconstructor}_alt", - f"{self.gammaness_classifier}_prediction", - ] - rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] - + standard_renames = { + "reco_energy": f"{self.energy_reconstructor}_energy", + "reco_az": f"{self.geometry_reconstructor}_az", + "reco_alt": f"{self.geometry_reconstructor}_alt", + "gh_score": f"{self.gammaness_classifier}_prediction", + } + rename_from = [] + rename_to = [] # We never enter the loop if rename_columns is empty for new, old in self.rename_columns: + if new in standard_renames.keys(): + self.log.warning( + f"Column '{old}' will be used as '{new}' " + f"instead of {standard_renames[new]}." + ) + standard_renames.pop(new) + rename_from.append(old) rename_to.append(new) + for new, old in standard_renames.items(): + if old in events.colnames: + rename_from.append(old) + rename_to.append(new) + + # check that all needed reco columns are defined + for c in ["reco_energy", "reco_az", "reco_alt", "gh_score"]: + if c not in rename_to: + raise ValueError( + f"No column corresponding to {c} is defined in " + f"EventPreProcessor.rename_columns and {standard_renames[c]} " + "is not in the given data." + ) + keep_columns.extend(rename_from) events = QTable(events[keep_columns], copy=False) events.rename_columns(rename_from, rename_to) return events def make_empty_table(self) -> QTable: - """This function defines the columns later functions expect to be present in the event table""" + """ + This function defines the columns later functions expect to be present + in the event table. + """ columns = [ "obs_id", "event_id", From 85a0dbcd053927ba3382a0315f0ee21345d0fe32 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 6 Jun 2024 18:27:19 +0200 Subject: [PATCH 112/136] Add tests for event loading and selection code; minor bugfix --- src/ctapipe/irf/select.py | 2 +- src/ctapipe/irf/tests/test_select.py | 171 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 src/ctapipe/irf/tests/test_select.py diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index eaef7848626..8547ea57a8d 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -71,7 +71,7 @@ def normalise_column_names(self, events: Table) -> QTable: rename_from = [] rename_to = [] # We never enter the loop if rename_columns is empty - for new, old in self.rename_columns: + for old, new in self.rename_columns: if new in standard_renames.keys(): self.log.warning( f"Column '{old}' will be used as '{new}' " diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_select.py new file mode 100644 index 00000000000..73d16036aa4 --- /dev/null +++ b/src/ctapipe/irf/tests/test_select.py @@ -0,0 +1,171 @@ +import astropy.units as u +import pytest +from astropy.table import Table +from pyirf.simulations import SimulatedEventsInfo +from pyirf.spectral import PowerLaw +from traitlets.config import Config + +from ctapipe.core.tool import run_tool + + +@pytest.fixture(scope="module") +def dummy_table(): + """Dummy table to test column renaming.""" + return Table( + { + "obs_id": [1, 1, 1, 2, 3, 3], + "event_id": [1, 2, 3, 1, 1, 2], + "true_energy": [0.99, 10, 0.37, 2.1, 73.4, 1] * u.TeV, + "dummy_energy": [1, 10, 0.4, 2.5, 73, 1] * u.TeV, + "classifier_prediction": [1, 0.3, 0.87, 0.93, 0, 0.1], + "true_alt": [60, 60, 60, 60, 60, 60] * u.deg, + "alt_geom": [58.5, 61.2, 59, 71.6, 60, 62] * u.deg, + "true_az": [13, 13, 13, 13, 13, 13] * u.deg, + "az_geom": [12.5, 13, 11.8, 15.1, 14.7, 12.8] * u.deg, + } + ) + + +@pytest.fixture(scope="module") +def gamma_diffuse_full_reco_file( + gamma_train_clf, + particle_classifier_path, + model_tmp_path, +): + """ + Energy reconstruction and geometric origin reconstruction have already been done. + """ + from ctapipe.tools.apply_models import ApplyModels + + output_path = model_tmp_path / "gamma_diffuse_full_reco.dl2.h5" + run_tool( + ApplyModels(), + argv=[ + f"--input={gamma_train_clf}", + f"--output={output_path}", + f"--reconstructor={particle_classifier_path}", + "--no-dl1-parameters", + "--StereoMeanCombiner.weights=konrad", + ], + raises=True, + ) + return output_path + + +@pytest.fixture(scope="module") +def proton_full_reco_file( + proton_train_clf, + particle_classifier_path, + model_tmp_path, +): + """ + Energy reconstruction and geometric origin reconstruction have already been done. + """ + from ctapipe.tools.apply_models import ApplyModels + + output_path = model_tmp_path / "proton_full_reco.dl2.h5" + run_tool( + ApplyModels(), + argv=[ + f"--input={proton_train_clf}", + f"--output={output_path}", + f"--reconstructor={particle_classifier_path}", + "--no-dl1-parameters", + "--StereoMeanCombiner.weights=konrad", + ], + raises=True, + ) + return output_path + + +def test_normalise_column_names(dummy_table): + from ctapipe.irf import EventPreProcessor + + epp = EventPreProcessor( + energy_reconstructor="dummy", + geometry_reconstructor="geom", + gammaness_classifier="classifier", + rename_columns=[("alt_geom", "reco_alt"), ("az_geom", "reco_az")], + ) + norm_table = epp.normalise_column_names(dummy_table) + + needed_cols = [ + "obs_id", + "event_id", + "true_energy", + "true_alt", + "true_az", + "reco_energy", + "reco_alt", + "reco_az", + "gh_score", + ] + for c in needed_cols: + assert c in norm_table.colnames + + # error if reco_{alt,az} is missing because of no-standard name + with pytest.raises(ValueError, match="No column corresponding"): + epp = EventPreProcessor( + energy_reconstructor="dummy", + geometry_reconstructor="geom", + gammaness_classifier="classifier", + ) + norm_table = epp.normalise_column_names(dummy_table) + + +def test_events_loader(gamma_diffuse_full_reco_file): + from ctapipe.irf import EventsLoader, Spectra + + config = Config( + { + "EventPreProcessor": { + "energy_reconstructor": "ExtraTreesRegressor", + "geometry_reconstructor": "HillasReconstructor", + "gammaness_classifier": "ExtraTreesClassifier", + "quality_criteria": [ + ( + "multiplicity 4", + "np.count_nonzero(tels_with_trigger,axis=1) >= 4", + ), + ("valid classifier", "ExtraTreesClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "ExtraTreesRegressor_is_valid"), + ], + } + } + ) + loader = EventsLoader( + config=config, + kind="gammas", + file=gamma_diffuse_full_reco_file, + target_spectrum=Spectra.CRAB_HEGRA, + ) + events, count, meta = loader.load_preselected_events( + chunk_size=10000, + obs_time=u.Quantity(50, u.h), + valid_fov=u.Quantity([0, 1], u.deg), + ) + + columns = [ + "obs_id", + "event_id", + "true_energy", + "true_az", + "true_alt", + "reco_energy", + "reco_az", + "reco_alt", + "reco_fov_lat", + "reco_fov_lon", + "gh_score", + "pointing_az", + "pointing_alt", + "theta", + "true_source_fov_offset", + "reco_source_fov_offset", + "weight", + ] + assert columns.sort() == events.colnames.sort() + + assert isinstance(meta["sim_info"], SimulatedEventsInfo) + assert isinstance(meta["spectrum"], PowerLaw) From befa92b21cc56113d0c1f5fa7c7e6e9fcaed095b Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 7 Jun 2024 15:29:32 +0200 Subject: [PATCH 113/136] Shorten bins in range checks a bit --- src/ctapipe/irf/binning.py | 10 +-- src/ctapipe/tools/make_irf.py | 114 ++++++++++++---------------------- 2 files changed, 44 insertions(+), 80 deletions(-) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index baa43751a97..7cfd597d1ee 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -11,15 +11,15 @@ logger = logging.getLogger(__name__) -def check_bins_in_range(bins, range, source="result", raise_error=True): - low = bins >= range.min - hig = bins <= range.max +def check_bins_in_range(bins, valid_range, source="result", raise_error=True): + low = bins >= valid_range.min + hig = bins <= valid_range.max if not all(low & hig): with np.printoptions(edgeitems=2, threshold=6, precision=4): bins = np.array2string(bins) - min_val = np.array2string(range.min) - max_val = np.array2string(range.max) + min_val = np.array2string(valid_range.min) + max_val = np.array2string(valid_range.max) if raise_error: raise ValueError( f"Valid range for {source} is {min_val} to {max_val}, got {bins}" diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 1a459cae591..ba24adf6ea7 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,6 +1,7 @@ """Tool to generate IRFs""" import operator +from functools import partial import astropy.units as u import numpy as np @@ -209,6 +210,16 @@ def setup(self): "Computing a point-like IRF requires an (optimized) theta cut." ) + check_e_bins = partial( + check_bins_in_range, + valid_range=self.opt_result.valid_energy, + raise_error=self.range_check_error, + ) + check_fov_offset_bins = partial( + check_bins_in_range, + valid_range=self.opt_result.valid_offset, + raise_error=self.range_check_error, + ) self.particles = [ EventsLoader( parent=self, @@ -244,63 +255,31 @@ def setup(self): self.bkg = BackgroundRateMakerBase.from_name( self.bkg_parameterization, parent=self ) - # TODO: Loop over all these bin checks or change `check_bins_in_range` - check_bins_in_range( - self.bkg.reco_energy_bins, - self.opt_result.valid_energy, - "background energy reco", - raise_error=self.range_check_error, + check_e_bins( + bins=self.bkg.reco_energy_bins, source="background reco energy" ) - check_bins_in_range( - self.bkg.fov_offset_bins, - self.opt_result.valid_offset, - "background fov offset", - raise_error=self.range_check_error, + check_fov_offset_bins( + bins=self.bkg.fov_offset_bins, source="background fov offset" ) self.edisp = EnergyMigrationMakerBase.from_name( self.edisp_parameterization, parent=self ) - check_bins_in_range( - self.edisp.true_energy_bins, - self.opt_result.valid_energy, - "Edisp energy true", - raise_error=self.range_check_error, - ) - check_bins_in_range( - self.edisp.fov_offset_bins, - self.opt_result.valid_offset, - "Edisp fov offset", - raise_error=self.range_check_error, + check_e_bins(bins=self.edisp.true_energy_bins, source="Edisp true energy") + check_fov_offset_bins( + bins=self.edisp.fov_offset_bins, source="Edisp fov offset" ) self.aeff = EffectiveAreaMakerBase.from_name( self.aeff_parameterization, parent=self ) - check_bins_in_range( - self.aeff.true_energy_bins, - self.opt_result.valid_energy, - "Aeff energy true", - raise_error=self.range_check_error, - ) - check_bins_in_range( - self.aeff.fov_offset_bins, - self.opt_result.valid_offset, - "Aeff fov offset", - raise_error=self.range_check_error, - ) + check_e_bins(bins=self.aeff.true_energy_bins, source="Aeff true energy") + check_fov_offset_bins(bins=self.aeff.fov_offset_bins, source="Aeff fov offset") + if self.full_enclosure: self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) - check_bins_in_range( - self.psf.true_energy_bins, - self.opt_result.valid_energy, - "PSF energy true", - raise_error=self.range_check_error, - ) - check_bins_in_range( - self.psf.fov_offset_bins, - self.opt_result.valid_offset, - "PSF fov offset", - raise_error=self.range_check_error, + check_e_bins(bins=self.psf.true_energy_bins, source="PSF true energy") + check_fov_offset_bins( + bins=self.psf.fov_offset_bins, source="PSF fov offset" ) if self.do_benchmarks: @@ -310,49 +289,34 @@ def setup(self): self.ang_res = AngularResolutionMakerBase.from_name( self.ang_res_parameterization, parent=self ) - check_bins_in_range( - self.ang_res.true_energy_bins + check_e_bins( + bins=self.ang_res.true_energy_bins if self.ang_res.use_true_energy else self.ang_res.reco_energy_bins, - self.opt_result.valid_energy, - "Angular resolution energy", - raise_error=self.range_check_error, + source="Angular resolution energy", ) - check_bins_in_range( - self.ang_res.fov_offset_bins, - self.opt_result.valid_offset, - "Angular resolution fov offset", - raise_error=self.range_check_error, + check_fov_offset_bins( + bins=self.ang_res.fov_offset_bins, + source="Angular resolution fov offset", ) self.bias_res = EnergyBiasResolutionMakerBase.from_name( self.energy_bias_res_parameterization, parent=self ) - check_bins_in_range( - self.bias_res.true_energy_bins, - self.opt_result.valid_energy, - "Bias resolution energy", - raise_error=self.range_check_error, + check_e_bins( + bins=self.bias_res.true_energy_bins, + source="Bias resolution true energy", ) - check_bins_in_range( - self.bias_res.fov_offset_bins, - self.opt_result.valid_offset, - "Bias resolution fov offset", - raise_error=self.range_check_error, + check_fov_offset_bins( + bins=self.bias_res.fov_offset_bins, source="Bias resolution fov offset" ) self.sens = SensitivityMakerBase.from_name( self.sens_parameterization, parent=self ) - check_bins_in_range( - self.sens.reco_energy_bins, - self.opt_result.valid_energy, - "Sensitivity energy", - raise_error=self.range_check_error, + check_e_bins( + bins=self.sens.reco_energy_bins, source="Sensitivity reco energy" ) - check_bins_in_range( - self.sens.fov_offset_bins, - self.opt_result.valid_offset, - "Sensitivity fov offset", - raise_error=self.range_check_error, + check_fov_offset_bins( + bins=self.sens.fov_offset_bins, source="Sensitivity fov offset" ) def calculate_selections(self, reduced_events: dict) -> dict: From 58a8f13447e7cf92d4977d3cbc0f7ff3ebcb8801 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 12:01:38 +0200 Subject: [PATCH 114/136] Fix number of bin edges in make_bins_per_decade --- src/ctapipe/irf/binning.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 7cfd597d1ee..0a292c2bcba 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -33,7 +33,7 @@ def check_bins_in_range(bins, valid_range, source="result", raise_error=True): @u.quantity_input(e_min=u.TeV, e_max=u.TeV) def make_bins_per_decade(e_min, e_max, n_bins_per_decade=5): """ - Create energy bins with at least ``bins_per_decade`` bins per decade. + Create energy bins with at least ``n_bins_per_decade`` bins per decade. The number of bins is calculated as ``n_bins = ceil((log10(e_max) - log10(e_min)) * n_bins_per_decade)``. @@ -57,7 +57,7 @@ def make_bins_per_decade(e_min, e_max, n_bins_per_decade=5): n_bins = int(np.ceil((log_upper - log_lower) * n_bins_per_decade)) - return u.Quantity(np.logspace(log_lower, log_upper, n_bins), unit, copy=False) + return u.Quantity(np.logspace(log_lower, log_upper, n_bins + 1), unit, copy=False) class ResultValidRange: From adabf0db8dec92afe6dea8eae2203a10f149cfe7 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 12:19:54 +0200 Subject: [PATCH 115/136] parent=None as default for all components --- src/ctapipe/irf/benchmarks.py | 12 ++++++------ src/ctapipe/irf/binning.py | 6 +++--- src/ctapipe/irf/irfs.py | 16 ++++++++-------- src/ctapipe/irf/optimize.py | 4 ++-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index ae6bd4f2ea4..b1f721d6a12 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -34,7 +34,7 @@ class EnergyBiasResolutionMakerBase(TrueEnergyBinsBase): Base class for calculating the bias and resolution of the energy prediciton. """ - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod @@ -63,7 +63,7 @@ class EnergyBiasResolution2dMaker(EnergyBiasResolutionMakerBase, FoVOffsetBinsBa true energy and fov offset. """ - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_bias_resolution_hdu( @@ -103,7 +103,7 @@ class AngularResolutionMakerBase(TrueEnergyBinsBase, RecoEnergyBinsBase): help="Use true energy instead of reconstructed energy for energy binning.", ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod @@ -132,7 +132,7 @@ class AngularResolution2dMaker(AngularResolutionMakerBase, FoVOffsetBinsBase): and fov offset. """ - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_angular_resolution_hdu( @@ -176,7 +176,7 @@ class SensitivityMakerBase(RecoEnergyBinsBase): default_value=0.2, help="Ratio between size of the on and the off region." ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod @@ -217,7 +217,7 @@ class Sensitivity2dMaker(SensitivityMakerBase, FoVOffsetBinsBase): and fov offset. """ - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_sensitivity_hdu( diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 0a292c2bcba..5f474b3738d 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -86,7 +86,7 @@ class TrueEnergyBinsBase(Component): default_value=10, ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.true_energy_bins = make_bins_per_decade( self.true_energy_min.to(u.TeV), @@ -115,7 +115,7 @@ class RecoEnergyBinsBase(Component): default_value=5, ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), @@ -144,7 +144,7 @@ class FoVOffsetBinsBase(Component): default_value=1, ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.fov_offset_bins = u.Quantity( np.linspace( diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 36820a7354e..f0e3f8ddf95 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -27,7 +27,7 @@ class PsfMakerBase(TrueEnergyBinsBase): """Base class for calculating the point spread function.""" - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod @@ -51,7 +51,7 @@ def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: class BackgroundRateMakerBase(RecoEnergyBinsBase): """Base class for calculating the background rate.""" - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod @@ -96,7 +96,7 @@ class EnergyMigrationMakerBase(TrueEnergyBinsBase): default_value=30, ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.migration_bins = np.linspace( self.energy_migration_min, @@ -131,7 +131,7 @@ def make_edisp_hdu( class EffectiveAreaMakerBase(TrueEnergyBinsBase): """Base class for calculating the effective area.""" - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @abstractmethod @@ -174,7 +174,7 @@ class EffectiveArea2dMaker(EffectiveAreaMakerBase, FoVOffsetBinsBase): bins of logarithmic true energy and field of view offset. """ - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_aeff_hdu( @@ -218,7 +218,7 @@ class EnergyMigration2dMaker(EnergyMigrationMakerBase, FoVOffsetBinsBase): equidistant bins of logarithmic true energy and field of view offset. """ - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_edisp_hdu( @@ -246,7 +246,7 @@ class BackgroundRate2dMaker(BackgroundRateMakerBase, FoVOffsetBinsBase): bins of logarithmic reconstructed energy and field of view offset. """ - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_bkg_hdu( @@ -289,7 +289,7 @@ class Psf3dMaker(PsfMakerBase, FoVOffsetBinsBase): default_value=100, ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.source_offset_bins = u.Quantity( np.linspace( diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index a3aa4f1f21b..5cdb0b4f3ad 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -301,7 +301,7 @@ class PercentileCuts(CutOptimizerBase): classes = [GhPercentileCutCalculator, ThetaPercentileCutCalculator] - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.gh = GhPercentileCutCalculator(parent=self) self.theta = ThetaPercentileCutCalculator(parent=self) @@ -371,7 +371,7 @@ class PointSourceSensitivityOptimizer(CutOptimizerBase): help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) - def __init__(self, parent, **kwargs): + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.theta = ThetaPercentileCutCalculator(parent=self) From 594073f1ddcde160be48501be8d2098505a2681f Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 12:32:40 +0200 Subject: [PATCH 116/136] Add tests for binning --- src/ctapipe/irf/tests/test_binning.py | 108 ++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/ctapipe/irf/tests/test_binning.py diff --git a/src/ctapipe/irf/tests/test_binning.py b/src/ctapipe/irf/tests/test_binning.py new file mode 100644 index 00000000000..6ae9fc8f59e --- /dev/null +++ b/src/ctapipe/irf/tests/test_binning.py @@ -0,0 +1,108 @@ +import logging + +import astropy.units as u +import numpy as np +import pytest +from astropy.table import QTable + + +def test_check_bins_in_range(caplog): + from ctapipe.irf import ResultValidRange, check_bins_in_range + + valid_range = ResultValidRange( + bounds_table=QTable( + rows=[u.Quantity([0.03, 200], u.TeV)], names=["energy_min", "energy_max"] + ), + prefix="energy", + ) + + # bins are in range + bins = u.Quantity(np.logspace(-1, 2, 10), u.TeV) + check_bins_in_range(bins, valid_range) + + # bins are too small + bins = u.Quantity(np.logspace(-2, 2, 10), u.TeV) + with pytest.raises(ValueError, match="Valid range for"): + check_bins_in_range(bins, valid_range) + + # bins are too big + bins = u.Quantity(np.logspace(-1, 3, 10), u.TeV) + with pytest.raises(ValueError, match="Valid range for"): + check_bins_in_range(bins, valid_range) + + # bins are too big and too small + bins = u.Quantity(np.logspace(-2, 3, 10), u.TeV) + with pytest.raises(ValueError, match="Valid range for"): + check_bins_in_range(bins, valid_range) + + caplog.set_level(logging.WARNING, logger="ctapipe") + check_bins_in_range(bins, valid_range, raise_error=False) + assert "Valid range for result is" in caplog.text + + +def test_make_bins_per_decade(): + from ctapipe.irf import make_bins_per_decade + + bins = make_bins_per_decade(100 * u.GeV, 100 * u.TeV) + assert bins.unit == u.GeV + assert len(bins) == 16 + assert bins[0] == 100 * u.GeV + assert np.allclose(np.diff(np.log10(bins.to_value(u.GeV))), 0.2) + + bins = make_bins_per_decade(100 * u.GeV, 100 * u.TeV, 10) + assert len(bins) == 31 + assert np.allclose(np.diff(np.log10(bins.to_value(u.GeV))), 0.1) + + # respect boundaries over n_bins_per_decade + bins = make_bins_per_decade(100 * u.GeV, 105 * u.TeV) + assert len(bins) == 17 + assert np.isclose(bins[-1], 105 * u.TeV, rtol=1e-9) + + +def test_true_energy_bins_base(): + from ctapipe.irf import TrueEnergyBinsBase + + binning = TrueEnergyBinsBase( + true_energy_min=0.02 * u.TeV, + true_energy_max=200 * u.TeV, + true_energy_n_bins_per_decade=7, + ) + assert len(binning.true_energy_bins) == 29 + assert binning.true_energy_bins.unit == u.TeV + assert np.isclose(binning.true_energy_bins[0], binning.true_energy_min, rtol=1e-9) + assert np.isclose(binning.true_energy_bins[-1], binning.true_energy_max, rtol=1e-9) + assert np.allclose( + np.diff(np.log10(binning.true_energy_bins.to_value(u.TeV))), 1 / 7 + ) + + +def test_reco_energy_bins_base(): + from ctapipe.irf import RecoEnergyBinsBase + + binning = RecoEnergyBinsBase( + reco_energy_min=0.02 * u.TeV, + reco_energy_max=200 * u.TeV, + reco_energy_n_bins_per_decade=4, + ) + assert len(binning.reco_energy_bins) == 17 + assert binning.reco_energy_bins.unit == u.TeV + assert np.isclose(binning.reco_energy_bins[0], binning.reco_energy_min, rtol=1e-9) + assert np.isclose(binning.reco_energy_bins[-1], binning.reco_energy_max, rtol=1e-9) + assert np.allclose( + np.diff(np.log10(binning.reco_energy_bins.to_value(u.TeV))), 0.25 + ) + + +def test_fov_offset_bins_base(): + from ctapipe.irf import FoVOffsetBinsBase + + binning = FoVOffsetBinsBase( + # use default for fov_offset_min + fov_offset_max=3 * u.deg, + fov_offset_n_bins=3, + ) + assert len(binning.fov_offset_bins) == 4 + assert binning.fov_offset_bins.unit == u.deg + assert np.isclose(binning.fov_offset_bins[0], binning.fov_offset_min, rtol=1e-9) + assert np.isclose(binning.fov_offset_bins[-1], binning.fov_offset_max, rtol=1e-9) + assert np.allclose(np.diff(binning.fov_offset_bins.to_value(u.deg)), 1) From 0b796cd7d170c9dae64a2b44a057a5528fa4e06b Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 13:47:25 +0200 Subject: [PATCH 117/136] Fix caplog binning test --- src/ctapipe/irf/tests/test_binning.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ctapipe/irf/tests/test_binning.py b/src/ctapipe/irf/tests/test_binning.py index 6ae9fc8f59e..00a7648d7b7 100644 --- a/src/ctapipe/irf/tests/test_binning.py +++ b/src/ctapipe/irf/tests/test_binning.py @@ -35,9 +35,9 @@ def test_check_bins_in_range(caplog): with pytest.raises(ValueError, match="Valid range for"): check_bins_in_range(bins, valid_range) - caplog.set_level(logging.WARNING, logger="ctapipe") - check_bins_in_range(bins, valid_range, raise_error=False) - assert "Valid range for result is" in caplog.text + with caplog.at_level(logging.WARNING): + check_bins_in_range(bins, valid_range, raise_error=False) + assert "Valid range for result is" in caplog.text def test_make_bins_per_decade(): From bd24bcde87b5d76aeca1c7701b1473282cc82ce2 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 14:24:17 +0200 Subject: [PATCH 118/136] Do not use caplog in test, due to problems with pytest-xdist --- src/ctapipe/irf/tests/test_binning.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ctapipe/irf/tests/test_binning.py b/src/ctapipe/irf/tests/test_binning.py index 00a7648d7b7..608fee6d649 100644 --- a/src/ctapipe/irf/tests/test_binning.py +++ b/src/ctapipe/irf/tests/test_binning.py @@ -6,7 +6,7 @@ from astropy.table import QTable -def test_check_bins_in_range(caplog): +def test_check_bins_in_range(tmp_path): from ctapipe.irf import ResultValidRange, check_bins_in_range valid_range = ResultValidRange( @@ -35,9 +35,13 @@ def test_check_bins_in_range(caplog): with pytest.raises(ValueError, match="Valid range for"): check_bins_in_range(bins, valid_range) - with caplog.at_level(logging.WARNING): - check_bins_in_range(bins, valid_range, raise_error=False) - assert "Valid range for result is" in caplog.text + logger = logging.getLogger("ctapipe.irf.binning") + logpath = tmp_path / "test_check_bins_in_range.log" + handler = logging.FileHandler(logpath) + logger.addHandler(handler) + + check_bins_in_range(bins, valid_range, raise_error=False) + assert "Valid range for result is" in logpath.read_text() def test_make_bins_per_decade(): From 72c827eb5ddf47720a038a8b476dfa7704e2991a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 16:13:59 +0200 Subject: [PATCH 119/136] Do not expose base classes --- src/ctapipe/irf/__init__.py | 23 +------------------ src/ctapipe/tools/make_irf.py | 18 +++++++++------ src/ctapipe/tools/optimize_event_selection.py | 8 +++---- 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 1595ce6b314..836673b31a4 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,32 +1,22 @@ """Top level module for the irf functionality""" + from .benchmarks import ( AngularResolution2dMaker, - AngularResolutionMakerBase, EnergyBiasResolution2dMaker, - EnergyBiasResolutionMakerBase, Sensitivity2dMaker, - SensitivityMakerBase, ) from .binning import ( - FoVOffsetBinsBase, - RecoEnergyBinsBase, ResultValidRange, - TrueEnergyBinsBase, check_bins_in_range, make_bins_per_decade, ) from .irfs import ( BackgroundRate2dMaker, - BackgroundRateMakerBase, EffectiveArea2dMaker, - EffectiveAreaMakerBase, EnergyMigration2dMaker, - EnergyMigrationMakerBase, Psf3dMaker, - PsfMakerBase, ) from .optimize import ( - CutOptimizerBase, GhPercentileCutCalculator, OptimizationResult, OptimizationResultStore, @@ -38,19 +28,9 @@ from .spectra import SPECTRA, Spectra __all__ = [ - "AngularResolutionMakerBase", "AngularResolution2dMaker", - "EnergyBiasResolutionMakerBase", "EnergyBiasResolution2dMaker", - "SensitivityMakerBase", "Sensitivity2dMaker", - "TrueEnergyBinsBase", - "RecoEnergyBinsBase", - "FoVOffsetBinsBase", - "PsfMakerBase", - "BackgroundRateMakerBase", - "EnergyMigrationMakerBase", - "EffectiveAreaMakerBase", "Psf3dMaker", "BackgroundRate2dMaker", "EnergyMigration2dMaker", @@ -58,7 +38,6 @@ "ResultValidRange", "OptimizationResult", "OptimizationResultStore", - "CutOptimizerBase", "PointSourceSensitivityOptimizer", "PercentileCuts", "EventsLoader", diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index ba24adf6ea7..a39262071a5 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -13,19 +13,23 @@ from ..core import Provenance, Tool, ToolConfigurationError, traits from ..core.traits import AstroQuantity, Bool, Integer, classes_with_traits, flag from ..irf import ( - AngularResolutionMakerBase, - BackgroundRateMakerBase, - EffectiveAreaMakerBase, - EnergyBiasResolutionMakerBase, - EnergyMigrationMakerBase, EventPreProcessor, EventsLoader, OptimizationResultStore, - PsfMakerBase, - SensitivityMakerBase, Spectra, check_bins_in_range, ) +from ..irf.benchmarks import ( + AngularResolutionMakerBase, + EnergyBiasResolutionMakerBase, + SensitivityMakerBase, +) +from ..irf.irfs import ( + BackgroundRateMakerBase, + EffectiveAreaMakerBase, + EnergyMigrationMakerBase, + PsfMakerBase, +) class IrfTool(Tool): diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 38621fa0e7f..c765c54621e 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -1,14 +1,12 @@ """Tool to generate selections for IRFs production""" + import astropy.units as u from astropy.table import vstack from ..core import Provenance, Tool, traits from ..core.traits import AstroQuantity, Bool, Float, Integer, classes_with_traits, flag -from ..irf import ( - CutOptimizerBase, - EventsLoader, - Spectra, -) +from ..irf import EventsLoader, Spectra +from ..irf.optimize import CutOptimizerBase class IrfEventSelector(Tool): From 1e0407c259f3ad9983996325e1a55e4de8176022 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 16:36:52 +0200 Subject: [PATCH 120/136] Add missing docstring --- src/ctapipe/irf/binning.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 5f474b3738d..97b5e15d829 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -12,6 +12,24 @@ def check_bins_in_range(bins, valid_range, source="result", raise_error=True): + """ + Check whether ``bins`` are within a ``valid_range`` and either warn + or raise an error if not. + + Parameters + ---------- + bins: u.Quantity + The bins to be checked. + valid_range: ctapipe.irf.ResultValidRange + Range for which bins are valid. + E.g. the range in which G/H cuts are calculated. + source: str + Description of which bins are being checked to give useful + warnings/ error messages. + raise_error: bool + Whether to raise an error (True) or give a warning (False) if + ``bins`` exceed ``valid_range``. + """ low = bins >= valid_range.min hig = bins <= valid_range.max From 8d8b9fb056d143f7d0ab413b75fd49eb0c488d65 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 16:38:57 +0200 Subject: [PATCH 121/136] Fix imports in binning tests --- src/ctapipe/irf/tests/test_binning.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ctapipe/irf/tests/test_binning.py b/src/ctapipe/irf/tests/test_binning.py index 608fee6d649..e905b1fe7d1 100644 --- a/src/ctapipe/irf/tests/test_binning.py +++ b/src/ctapipe/irf/tests/test_binning.py @@ -64,7 +64,7 @@ def test_make_bins_per_decade(): def test_true_energy_bins_base(): - from ctapipe.irf import TrueEnergyBinsBase + from ctapipe.irf.binning import TrueEnergyBinsBase binning = TrueEnergyBinsBase( true_energy_min=0.02 * u.TeV, @@ -81,7 +81,7 @@ def test_true_energy_bins_base(): def test_reco_energy_bins_base(): - from ctapipe.irf import RecoEnergyBinsBase + from ctapipe.irf.binning import RecoEnergyBinsBase binning = RecoEnergyBinsBase( reco_energy_min=0.02 * u.TeV, @@ -98,7 +98,7 @@ def test_reco_energy_bins_base(): def test_fov_offset_bins_base(): - from ctapipe.irf import FoVOffsetBinsBase + from ctapipe.irf.binning import FoVOffsetBinsBase binning = FoVOffsetBinsBase( # use default for fov_offset_min From 70f45750f021a55d5966859e3291eefad8ff5f6f Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 18 Jul 2024 15:17:08 +0200 Subject: [PATCH 122/136] Remove wrong bin-in-range checks; fix valid_fov_offset --- src/ctapipe/irf/optimize.py | 22 +++---- src/ctapipe/tools/make_irf.py | 61 ++++++------------- src/ctapipe/tools/optimize_event_selection.py | 2 +- 3 files changed, 30 insertions(+), 55 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 5cdb0b4f3ad..534539c8e05 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -1,4 +1,5 @@ """module containing optimization related functions and classes""" + import operator from abc import abstractmethod @@ -141,7 +142,7 @@ class CutOptimizerBase(Component): default_value=5, ).tag(config=True) - min_fov_offset = AstroQuantity( + min_bkg_fov_offset = AstroQuantity( help=( "Minimum distance from the fov center for background events " "to be taken into account" @@ -150,7 +151,7 @@ class CutOptimizerBase(Component): physical_type=u.physical.angle, ).tag(config=True) - max_fov_offset = AstroQuantity( + max_bkg_fov_offset = AstroQuantity( help=( "Maximum distance from the fov center for background events " "to be taken into account" @@ -342,11 +343,11 @@ def optimize_cuts( result_saver.set_result( gh_cuts=gh_cuts, valid_energy=[self.reco_energy_min, self.reco_energy_max], - valid_offset=[self.min_fov_offset, self.max_fov_offset], + # A single set of cuts is calculated for the whole fov atm + valid_offset=[0 * u.deg, np.inf * u.deg], clf_prefix=clf_prefix, theta_cuts=theta_cuts if point_like else None, ) - return result_saver @@ -420,10 +421,10 @@ def optimize_cuts( theta_cuts["low"] = reco_energy_bins[:-1] theta_cuts["center"] = 0.5 * (reco_energy_bins[:-1] + reco_energy_bins[1:]) theta_cuts["high"] = reco_energy_bins[1:] - theta_cuts["cut"] = self.max_fov_offset + theta_cuts["cut"] = self.max_bkg_fov_offset self.log.info( "Optimizing G/H separation cut for best sensitivity " - "with `max_fov_radius` as theta cut." + "with `max_bkg_fov_offset` as theta cut." ) gh_cut_efficiencies = np.arange( @@ -431,7 +432,6 @@ def optimize_cuts( self.max_gh_cut_efficiency + self.gh_cut_efficiency_step / 2, self.gh_cut_efficiency_step, ) - opt_sens, gh_cuts = optimize_gh_cut( signal, background, @@ -440,8 +440,8 @@ def optimize_cuts( op=operator.ge, theta_cuts=theta_cuts, alpha=alpha, - fov_offset_max=self.max_fov_offset, - fov_offset_min=self.min_fov_offset, + fov_offset_max=self.max_bkg_fov_offset, + fov_offset_min=self.min_bkg_fov_offset, ) valid_energy = self._get_valid_energy_range(opt_sens) @@ -463,11 +463,11 @@ def optimize_cuts( result_saver.set_result( gh_cuts=gh_cuts, valid_energy=valid_energy, - valid_offset=[self.min_fov_offset, self.max_fov_offset], + # A single set of cuts is calculated for the whole fov atm + valid_offset=[0 * u.deg, np.inf * u.deg], clf_prefix=clf_prefix, theta_cuts=theta_cuts_opt if point_like else None, ) - return result_saver def _get_valid_energy_range(self, opt_sens): diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index a39262071a5..870e6c676a3 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -47,7 +47,7 @@ class IrfTool(Tool): ).tag(config=True) range_check_error = Bool( - True, + False, help="Raise error if asking for IRFs outside range where cut optimisation is valid", ).tag(config=True) @@ -219,11 +219,6 @@ def setup(self): valid_range=self.opt_result.valid_energy, raise_error=self.range_check_error, ) - check_fov_offset_bins = partial( - check_bins_in_range, - valid_range=self.opt_result.valid_offset, - raise_error=self.range_check_error, - ) self.particles = [ EventsLoader( parent=self, @@ -262,29 +257,16 @@ def setup(self): check_e_bins( bins=self.bkg.reco_energy_bins, source="background reco energy" ) - check_fov_offset_bins( - bins=self.bkg.fov_offset_bins, source="background fov offset" - ) self.edisp = EnergyMigrationMakerBase.from_name( self.edisp_parameterization, parent=self ) - check_e_bins(bins=self.edisp.true_energy_bins, source="Edisp true energy") - check_fov_offset_bins( - bins=self.edisp.fov_offset_bins, source="Edisp fov offset" - ) self.aeff = EffectiveAreaMakerBase.from_name( self.aeff_parameterization, parent=self ) - check_e_bins(bins=self.aeff.true_energy_bins, source="Aeff true energy") - check_fov_offset_bins(bins=self.aeff.fov_offset_bins, source="Aeff fov offset") if self.full_enclosure: self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) - check_e_bins(bins=self.psf.true_energy_bins, source="PSF true energy") - check_fov_offset_bins( - bins=self.psf.fov_offset_bins, source="PSF fov offset" - ) if self.do_benchmarks: self.b_output = self.output_path.with_name( @@ -293,35 +275,20 @@ def setup(self): self.ang_res = AngularResolutionMakerBase.from_name( self.ang_res_parameterization, parent=self ) - check_e_bins( - bins=self.ang_res.true_energy_bins - if self.ang_res.use_true_energy - else self.ang_res.reco_energy_bins, - source="Angular resolution energy", - ) - check_fov_offset_bins( - bins=self.ang_res.fov_offset_bins, - source="Angular resolution fov offset", - ) + if not self.ang_res.use_true_energy: + check_e_bins( + bins=self.ang_res.reco_energy_bins, + source="Angular resolution energy", + ) self.bias_res = EnergyBiasResolutionMakerBase.from_name( self.energy_bias_res_parameterization, parent=self ) - check_e_bins( - bins=self.bias_res.true_energy_bins, - source="Bias resolution true energy", - ) - check_fov_offset_bins( - bins=self.bias_res.fov_offset_bins, source="Bias resolution fov offset" - ) self.sens = SensitivityMakerBase.from_name( self.sens_parameterization, parent=self ) check_e_bins( bins=self.sens.reco_energy_bins, source="Sensitivity reco energy" ) - check_fov_offset_bins( - bins=self.sens.fov_offset_bins, source="Sensitivity fov offset" - ) def calculate_selections(self, reduced_events: dict) -> dict: """ @@ -466,7 +433,7 @@ def _make_benchmark_hdus(self, hdus): theta_cuts["center"] = 0.5 * ( self.sens.reco_energy_bins[:-1] + self.sens.reco_energy_bins[1:] ) - theta_cuts["cut"] = self.opt_result.valid_offset.max + theta_cuts["cut"] = self.sens.fov_offset_max else: theta_cuts = self.opt_result.theta_cuts @@ -516,10 +483,18 @@ def start(self): sel.epp.gammaness_classifier = self.opt_result.gh_cuts.meta["CLFNAME"] self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) + # TODO: This fov range is only used for the event weights for the sensitivity calculation. + # This should only be done if `do_benchmarks == True` and for each fov bin + # for which the sensitivity is calculated. + if self.do_benchmarks: + valid_fov = [self.sens.fov_offset_min, self.sens.fov_offset_max] + else: + valid_fov = [0, 5] * u.deg + evs, cnt, meta = sel.load_preselected_events( - self.chunk_size, - self.obs_time, - self.opt_result.valid_offset, + chunk_size=self.chunk_size, + obs_time=self.obs_time, + valid_fov=valid_fov, ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index c765c54621e..a5bc6184a91 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -130,7 +130,7 @@ def start(self): evs, cnt, meta = sel.load_preselected_events( self.chunk_size, self.obs_time, - [self.optimizer.min_fov_offset, self.optimizer.max_fov_offset], + [self.optimizer.min_bkg_fov_offset, self.optimizer.max_bkg_fov_offset], ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt From dd97404649bb8dc6ab355cf37aa846165738ecda Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 18 Jul 2024 17:44:50 +0200 Subject: [PATCH 123/136] Only compute event weights, if a sensitivity is computed --- src/ctapipe/irf/select.py | 26 +++++++++---------- src/ctapipe/irf/tests/test_select.py | 6 +++-- src/ctapipe/tools/make_irf.py | 26 ++++++++++--------- src/ctapipe/tools/optimize_event_selection.py | 21 ++++++++------- 4 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 8547ea57a8d..316e42677d1 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -14,7 +14,6 @@ from ..core import Component, QualityQuery from ..core.traits import List, Tuple, Unicode from ..io import TableLoader -from .binning import ResultValidRange from .spectra import SPECTRA, Spectra @@ -123,7 +122,6 @@ def make_empty_table(self) -> QTable: "theta", "true_source_fov_offset", "reco_source_fov_offset", - "weight", ] units = { "true_energy": u.TeV, @@ -174,7 +172,7 @@ def __init__(self, kind: str, file: Path, target_spectrum: Spectra, **kwargs): self.file = file def load_preselected_events( - self, chunk_size: int, obs_time: u.Quantity, valid_fov + self, chunk_size: int, obs_time: u.Quantity ) -> tuple[QTable, int, dict]: opts = dict(dl2=True, simulated=True) with TableLoader(self.file, parent=self, **opts) as load: @@ -186,9 +184,7 @@ def load_preselected_events( for _, _, events in load.read_subarray_events_chunked(chunk_size, **opts): selected = events[self.epp.get_table_mask(events)] selected = self.epp.normalise_column_names(selected) - selected = self.make_derived_columns( - selected, spectrum, obs_conf, valid_fov - ) + selected = self.make_derived_columns(selected, obs_conf) bits.append(selected) n_raw_events += len(events) @@ -225,9 +221,7 @@ def get_metadata( obs, ) - def make_derived_columns( - self, events: QTable, spectrum: PowerLaw, obs_conf: Table, valid_fov - ) -> QTable: + def make_derived_columns(self, events: QTable, obs_conf: Table) -> QTable: if obs_conf["subarray_pointing_lat"].std() < 1e-3: assert all(obs_conf["subarray_pointing_frame"] == 0) # Lets suppose 0 means ALTAZ @@ -264,21 +258,25 @@ def make_derived_columns( events["reco_fov_lon"] = -reco_nominal.fov_lon # minus for GADF events["reco_fov_lat"] = reco_nominal.fov_lat + return events + + def make_event_weights( + self, + events: QTable, + spectrum: PowerLaw, + fov_range: tuple[u.Quantity, u.Quantity], + ) -> QTable: if ( self.kind == "gammas" and self.target_spectrum.normalization.unit.is_equivalent( spectrum.normalization.unit * u.sr ) ): - if isinstance(valid_fov, ResultValidRange): - spectrum = spectrum.integrate_cone(valid_fov.min, valid_fov.max) - else: - spectrum = spectrum.integrate_cone(valid_fov[0], valid_fov[-1]) + spectrum = spectrum.integrate_cone(fov_range[0], fov_range[1]) events["weight"] = calculate_event_weights( events["true_energy"], target_spectrum=self.target_spectrum, simulated_spectrum=spectrum, ) - return events diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_select.py index 73d16036aa4..a12fdd4a2d8 100644 --- a/src/ctapipe/irf/tests/test_select.py +++ b/src/ctapipe/irf/tests/test_select.py @@ -143,7 +143,6 @@ def test_events_loader(gamma_diffuse_full_reco_file): events, count, meta = loader.load_preselected_events( chunk_size=10000, obs_time=u.Quantity(50, u.h), - valid_fov=u.Quantity([0, 1], u.deg), ) columns = [ @@ -163,9 +162,12 @@ def test_events_loader(gamma_diffuse_full_reco_file): "theta", "true_source_fov_offset", "reco_source_fov_offset", - "weight", ] assert columns.sort() == events.colnames.sort() + assert isinstance(count, int) assert isinstance(meta["sim_info"], SimulatedEventsInfo) assert isinstance(meta["spectrum"], PowerLaw) + + events = loader.make_event_weights(events, meta["spectrum"], (0 * u.deg, 1 * u.deg)) + assert "weight" in events.colnames diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 870e6c676a3..0eb73572c93 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -483,19 +483,21 @@ def start(self): sel.epp.gammaness_classifier = self.opt_result.gh_cuts.meta["CLFNAME"] self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) - # TODO: This fov range is only used for the event weights for the sensitivity calculation. - # This should only be done if `do_benchmarks == True` and for each fov bin - # for which the sensitivity is calculated. - if self.do_benchmarks: - valid_fov = [self.sens.fov_offset_min, self.sens.fov_offset_max] - else: - valid_fov = [0, 5] * u.deg + evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) + # Only calculate event weights if sensitivity should be computed + if self.do_benchmarks and self.do_background: + evs["weight"] = 1.0 + for i in range(len(self.sens.fov_offset_bins) - 1): + low = self.sens.fov_offset_bins[i] + high = self.sens.fov_offset_bins[i + 1] + fov_mask = evs["true_source_fov_offset"] >= low + fov_mask &= evs["true_source_fov_offset"] < high + evs[fov_mask] = sel.make_event_weights( + evs[fov_mask], + meta["spectrum"], + (low, high), + ) - evs, cnt, meta = sel.load_preselected_events( - chunk_size=self.chunk_size, - obs_time=self.obs_time, - valid_fov=valid_fov, - ) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt reduced_events[f"{sel.kind}_meta"] = meta diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index a5bc6184a91..7da9b320d0c 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -57,7 +57,7 @@ class IrfEventSelector(Tool): ).tag(config=True) obs_time = AstroQuantity( - default_value=50.0 * u.hour, + default_value=u.Quantity(50, u.hour), physical_type=u.physical.time, help="Observation time in the form `` ``", ).tag(config=True) @@ -122,16 +122,19 @@ def setup(self): ] def start(self): - # TODO: this event loading code seems to be largely repeated between all the tools, - # try to refactor to a common solution - reduced_events = dict() for sel in self.particles: - evs, cnt, meta = sel.load_preselected_events( - self.chunk_size, - self.obs_time, - [self.optimizer.min_bkg_fov_offset, self.optimizer.max_bkg_fov_offset], - ) + evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) + if self.optimization_algorithm == "PointSourceSensitivityOptimizer": + evs = sel.make_event_weights( + evs, + meta["spectrum"], + ( + self.optimizer.min_bkg_fov_offset, + self.optimizer.max_bkg_fov_offset, + ), + ) + reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt if sel.kind == "gammas": From 10fd6435961f5bdcdc15d1e42125e134b26fa0f8 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 19 Jul 2024 18:08:03 +0200 Subject: [PATCH 124/136] Make proton and electron files optional, if PercentileCuts is used --- src/ctapipe/tools/optimize_event_selection.py | 101 +++++++++++------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 7da9b320d0c..7945ee15e3a 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -24,7 +24,13 @@ class IrfEventSelector(Tool): ).tag(config=True) proton_file = traits.Path( - default_value=None, directory_ok=False, help="Proton input filename and path" + default_value=None, + directory_ok=False, + allow_none=True, + help=( + "Proton input filename and path. " + "Not needed, if ``optimization_algorithm = 'PercentileCuts'``." + ), ).tag(config=True) proton_sim_spectrum = traits.UseEnum( @@ -34,7 +40,13 @@ class IrfEventSelector(Tool): ).tag(config=True) electron_file = traits.Path( - default_value=None, directory_ok=False, help="Electron input filename and path" + default_value=None, + directory_ok=False, + allow_none=True, + help=( + "Electron input filename and path. " + "Not needed, if ``optimization_algorithm = 'PercentileCuts'``." + ), ).tag(config=True) electron_sim_spectrum = traits.UseEnum( @@ -106,20 +118,25 @@ def setup(self): kind="gammas", file=self.gamma_file, target_spectrum=self.gamma_sim_spectrum, - ), - EventsLoader( - parent=self, - kind="protons", - file=self.proton_file, - target_spectrum=self.proton_sim_spectrum, - ), - EventsLoader( - parent=self, - kind="electrons", - file=self.electron_file, - target_spectrum=self.electron_sim_spectrum, - ), + ) ] + if self.optimization_algorithm != "PercentileCuts": + self.particles.append( + EventsLoader( + parent=self, + kind="protons", + file=self.proton_file, + target_spectrum=self.proton_sim_spectrum, + ) + ) + self.particles.append( + EventsLoader( + parent=self, + kind="electrons", + file=self.electron_file, + target_spectrum=self.electron_sim_spectrum, + ) + ) def start(self): reduced_events = dict() @@ -141,34 +158,42 @@ def start(self): self.sim_info = meta["sim_info"] self.gamma_spectrum = meta["spectrum"] - self.log.debug( - "Loaded %d gammas, %d protons, %d electrons" - % ( - reduced_events["gammas_count"], - reduced_events["protons_count"], - reduced_events["electrons_count"], + self.signal_events = reduced_events["gammas"] + + if self.optimization_algorithm == "PercentileCuts": + self.log.debug("Loaded %d gammas" % reduced_events["gammas_count"]) + self.log.debug("Keeping %d gammas" % len(reduced_events["gammas"])) + self.log.info("Optimizing cuts using %d signal" % len(self.signal_events)) + else: + self.log.debug( + "Loaded %d gammas, %d protons, %d electrons" + % ( + reduced_events["gammas_count"], + reduced_events["protons_count"], + reduced_events["electrons_count"], + ) ) - ) - self.log.debug( - "Keeping %d gammas, %d protons, %d electrons" - % ( - len(reduced_events["gammas"]), - len(reduced_events["protons"]), - len(reduced_events["electrons"]), + self.log.debug( + "Keeping %d gammas, %d protons, %d electrons" + % ( + len(reduced_events["gammas"]), + len(reduced_events["protons"]), + len(reduced_events["electrons"]), + ) + ) + self.background_events = vstack( + [reduced_events["protons"], reduced_events["electrons"]] + ) + self.log.info( + "Optimizing cuts using %d signal and %d background events" + % (len(self.signal_events), len(self.background_events)), ) - ) - self.signal_events = reduced_events["gammas"] - self.background_events = vstack( - [reduced_events["protons"], reduced_events["electrons"]] - ) - self.log.info( - "Optimizing cuts using %d signal and %d background events" - % (len(self.signal_events), len(self.background_events)), - ) result = self.optimizer.optimize_cuts( signal=self.signal_events, - background=self.background_events, + background=self.background_events + if self.optimization_algorithm != "PercentileCuts" + else None, alpha=self.alpha, precuts=self.particles[0].epp, # identical precuts for all particle types clf_prefix=self.particles[0].epp.gammaness_classifier, From 696f8f5659b538749f3f31c7a51a8fea0e33d459 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 19 Jul 2024 18:09:03 +0200 Subject: [PATCH 125/136] Move pytest fixtures to conftest.py --- src/ctapipe/conftest.py | 77 ++++++++++++++++++++++++++++ src/ctapipe/irf/tests/test_select.py | 77 +--------------------------- 2 files changed, 79 insertions(+), 75 deletions(-) diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index 4d743e5d217..615971b35ed 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -1,6 +1,7 @@ """ common pytest fixtures for tests in ctapipe """ + import shutil from copy import deepcopy @@ -684,3 +685,79 @@ def dl1_mon_pointing_file(dl1_file, dl1_tmp_path): f.remove_node("/configuration/telescope/pointing", recursive=True) return path + + +@pytest.fixture(scope="session") +def gamma_diffuse_full_reco_file( + gamma_train_clf, + particle_classifier_path, + model_tmp_path, +): + """ + Energy reconstruction and geometric origin reconstruction have already been done. + """ + from ctapipe.tools.apply_models import ApplyModels + + output_path = model_tmp_path / "gamma_diffuse_full_reco.dl2.h5" + run_tool( + ApplyModels(), + argv=[ + f"--input={gamma_train_clf}", + f"--output={output_path}", + f"--reconstructor={particle_classifier_path}", + "--no-dl1-parameters", + "--StereoMeanCombiner.weights=konrad", + ], + raises=True, + ) + return output_path + + +@pytest.fixture(scope="session") +def proton_full_reco_file( + proton_train_clf, + particle_classifier_path, + model_tmp_path, +): + """ + Energy reconstruction and geometric origin reconstruction have already been done. + """ + from ctapipe.tools.apply_models import ApplyModels + + output_path = model_tmp_path / "proton_full_reco.dl2.h5" + run_tool( + ApplyModels(), + argv=[ + f"--input={proton_train_clf}", + f"--output={output_path}", + f"--reconstructor={particle_classifier_path}", + "--no-dl1-parameters", + "--StereoMeanCombiner.weights=konrad", + ], + raises=True, + ) + return output_path + + +@pytest.fixture(scope="session") +def irf_events_loader_test_config(): + from traitlets.config import Config + + return Config( + { + "EventPreProcessor": { + "energy_reconstructor": "ExtraTreesRegressor", + "geometry_reconstructor": "HillasReconstructor", + "gammaness_classifier": "ExtraTreesClassifier", + "quality_criteria": [ + ( + "multiplicity 4", + "np.count_nonzero(tels_with_trigger,axis=1) >= 4", + ), + ("valid classifier", "ExtraTreesClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "ExtraTreesRegressor_is_valid"), + ], + } + } + ) diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_select.py index a12fdd4a2d8..89d62a12279 100644 --- a/src/ctapipe/irf/tests/test_select.py +++ b/src/ctapipe/irf/tests/test_select.py @@ -3,9 +3,6 @@ from astropy.table import Table from pyirf.simulations import SimulatedEventsInfo from pyirf.spectral import PowerLaw -from traitlets.config import Config - -from ctapipe.core.tool import run_tool @pytest.fixture(scope="module") @@ -26,58 +23,6 @@ def dummy_table(): ) -@pytest.fixture(scope="module") -def gamma_diffuse_full_reco_file( - gamma_train_clf, - particle_classifier_path, - model_tmp_path, -): - """ - Energy reconstruction and geometric origin reconstruction have already been done. - """ - from ctapipe.tools.apply_models import ApplyModels - - output_path = model_tmp_path / "gamma_diffuse_full_reco.dl2.h5" - run_tool( - ApplyModels(), - argv=[ - f"--input={gamma_train_clf}", - f"--output={output_path}", - f"--reconstructor={particle_classifier_path}", - "--no-dl1-parameters", - "--StereoMeanCombiner.weights=konrad", - ], - raises=True, - ) - return output_path - - -@pytest.fixture(scope="module") -def proton_full_reco_file( - proton_train_clf, - particle_classifier_path, - model_tmp_path, -): - """ - Energy reconstruction and geometric origin reconstruction have already been done. - """ - from ctapipe.tools.apply_models import ApplyModels - - output_path = model_tmp_path / "proton_full_reco.dl2.h5" - run_tool( - ApplyModels(), - argv=[ - f"--input={proton_train_clf}", - f"--output={output_path}", - f"--reconstructor={particle_classifier_path}", - "--no-dl1-parameters", - "--StereoMeanCombiner.weights=konrad", - ], - raises=True, - ) - return output_path - - def test_normalise_column_names(dummy_table): from ctapipe.irf import EventPreProcessor @@ -113,29 +58,11 @@ def test_normalise_column_names(dummy_table): norm_table = epp.normalise_column_names(dummy_table) -def test_events_loader(gamma_diffuse_full_reco_file): +def test_events_loader(gamma_diffuse_full_reco_file, irf_events_loader_test_config): from ctapipe.irf import EventsLoader, Spectra - config = Config( - { - "EventPreProcessor": { - "energy_reconstructor": "ExtraTreesRegressor", - "geometry_reconstructor": "HillasReconstructor", - "gammaness_classifier": "ExtraTreesClassifier", - "quality_criteria": [ - ( - "multiplicity 4", - "np.count_nonzero(tels_with_trigger,axis=1) >= 4", - ), - ("valid classifier", "ExtraTreesClassifier_is_valid"), - ("valid geom reco", "HillasReconstructor_is_valid"), - ("valid energy reco", "ExtraTreesRegressor_is_valid"), - ], - } - } - ) loader = EventsLoader( - config=config, + config=irf_events_loader_test_config, kind="gammas", file=gamma_diffuse_full_reco_file, target_spectrum=Spectra.CRAB_HEGRA, From 15df024f24396177c38aa09afc210942665943de Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 19 Jul 2024 18:09:45 +0200 Subject: [PATCH 126/136] Some tests for cut optimization --- src/ctapipe/irf/optimize.py | 2 +- src/ctapipe/irf/tests/test_optimize.py | 69 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/ctapipe/irf/tests/test_optimize.py diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 534539c8e05..99d87aaa075 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -222,7 +222,7 @@ def calculate_gh_cut(self, gammaness, reco_energy, reco_energy_bins): reco_energy, reco_energy_bins, smoothing=self.smoothing, - percentile=self.target_percentile, + percentile=100 - self.target_percentile, fill_value=gammaness.max(), min_events=self.min_counts, ) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py new file mode 100644 index 00000000000..6a405591360 --- /dev/null +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -0,0 +1,69 @@ +import astropy.units as u +import numpy as np + +from ctapipe.irf import EventsLoader, Spectra + + +def test_gh_percentile_cut_calculator(): + from ctapipe.irf import GhPercentileCutCalculator + + calc = GhPercentileCutCalculator() + calc.target_percentile = 75 + calc.min_counts = 1 + calc.smoothing = -1 + cuts = calc.calculate_gh_cut( + gammaness=np.array([0.1, 0.6, 0.45, 0.98, 0.32, 0.95, 0.25, 0.87]), + reco_energy=[0.17, 0.36, 0.47, 0.22, 1.2, 5, 4.2, 9.1] * u.TeV, + reco_energy_bins=[0, 1, 10] * u.TeV, + ) + assert len(cuts) == 2 + assert np.isclose(cuts["cut"][0], 0.3625) + assert np.isclose(cuts["cut"][1], 0.3025) + assert calc.smoothing is None + + +def test_theta_percentile_cut_calculator(): + from ctapipe.irf import ThetaPercentileCutCalculator + + calc = ThetaPercentileCutCalculator() + calc.target_percentile = 75 + calc.min_counts = 1 + calc.smoothing = -1 + cuts = calc.calculate_theta_cut( + theta=[0.1, 0.07, 0.21, 0.4, 0.03, 0.08, 0.11, 0.18] * u.deg, + reco_energy=[0.17, 0.36, 0.47, 0.22, 1.2, 5, 4.2, 9.1] * u.TeV, + reco_energy_bins=[0, 1, 10] * u.TeV, + ) + assert len(cuts) == 2 + assert np.isclose(cuts["cut"][0], 0.2575 * u.deg) + assert np.isclose(cuts["cut"][1], 0.1275 * u.deg) + assert calc.smoothing is None + + +def test_percentile_cuts(gamma_diffuse_full_reco_file, irf_events_loader_test_config): + from ctapipe.irf import OptimizationResultStore, PercentileCuts + + loader = EventsLoader( + config=irf_events_loader_test_config, + kind="gammas", + file=gamma_diffuse_full_reco_file, + target_spectrum=Spectra.CRAB_HEGRA, + ) + events, _, _ = loader.load_preselected_events( + chunk_size=10000, + obs_time=u.Quantity(50, u.h), + ) + optimizer = PercentileCuts() + result = optimizer.optimize_cuts( + signal=events, + background=None, + alpha=0.2, # Default value in the tool, not used for PercentileCuts + precuts=loader.epp, + clf_prefix="ExtraTreesClassifier", + point_like=True, + ) + assert isinstance(result, OptimizationResultStore) + assert len(result._results) == 4 + assert u.isclose(result._results[1]["energy_min"], result._results[0]["low"][0]) + assert u.isclose(result._results[1]["energy_max"], result._results[0]["high"][-1]) + assert result._results[3]["cut"].unit == u.deg From 570da7b17027ae0397ecc046938d71ef9101dd19 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 19 Jul 2024 15:53:38 +0200 Subject: [PATCH 127/136] Fixed hardcoded nonsensical offset bins for RAD_MAX --- src/ctapipe/irf/optimize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 99d87aaa075..4292f049046 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -464,7 +464,7 @@ def optimize_cuts( gh_cuts=gh_cuts, valid_energy=valid_energy, # A single set of cuts is calculated for the whole fov atm - valid_offset=[0 * u.deg, np.inf * u.deg], + valid_offset=[self.min_bkg_fov_offset, self.max_bkg_fov_offset], clf_prefix=clf_prefix, theta_cuts=theta_cuts_opt if point_like else None, ) From 2776af5e227a01a9e0c8b1f48080bc4d8c905aca Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 23 Jul 2024 11:59:07 +0200 Subject: [PATCH 128/136] Add test to ensure BackgroundRate2dMaker stays working once current regression is fixed --- src/ctapipe/irf/tests/test_irfs.py | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/ctapipe/irf/tests/test_irfs.py diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py new file mode 100644 index 00000000000..e842b42cf2b --- /dev/null +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -0,0 +1,32 @@ +import astropy.units as u +import numpy as np +import pytest +from astropy.table import QTable, vstack + +from ctapipe.irf import BackgroundRate2dMaker, EventPreProcessor + + +@pytest.fixture(scope="session") +def irf_events_table(): + N1 = 1000 + N2 = 100 + N = N1 + N2 + epp = EventPreProcessor() + tab = epp.make_empty_table() + units = {c: tab[c].unit for c in tab.columns} + + empty = np.zeros((len(tab.columns), N)) * np.nan + e_tab = QTable(data=empty.T, names=tab.colnames, units=units) + # Setting values following pyirf test in pyirf/irf/tests/test_background.py + e_tab["reco_energy"] = np.append(np.full(N1, 1), np.full(N2, 2)) * u.TeV + e_tab["reco_source_fov_offset"] = (np.zeros(N) * u.deg,) + + ev = vstack([e_tab, tab], join_type="exact", metadata_conflicts="silent") + return ev + + +def test_make_2d_bkg(irf_events_table): + bkgMkr = BackgroundRate2dMaker() + + bkg_hdu = bkgMkr.make_bkg_hdu(events=irf_events_table, obs_time=1 * u.s) + assert bkg_hdu.data["BKG"].shape == (1, 1, 20) From ff80aa17764aabb72504b8d75dc4d826943c31e1 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 23 Jul 2024 17:14:41 +0200 Subject: [PATCH 129/136] Fixed broken background generation --- src/ctapipe/irf/select.py | 3 ++- src/ctapipe/tools/make_irf.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 316e42677d1..7a1b1b6ae65 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -122,6 +122,7 @@ def make_empty_table(self) -> QTable: "theta", "true_source_fov_offset", "reco_source_fov_offset", + "weight", ] units = { "true_energy": u.TeV, @@ -257,7 +258,7 @@ def make_derived_columns(self, events: QTable, obs_conf: Table) -> QTable: reco_nominal = reco.transform_to(nominal) events["reco_fov_lon"] = -reco_nominal.fov_lon # minus for GADF events["reco_fov_lat"] = reco_nominal.fov_lat - + events["weight"] = 1.0 return events def make_event_weights( diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 0eb73572c93..f9bf35650a2 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -484,12 +484,12 @@ def start(self): self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) - # Only calculate event weights if sensitivity should be computed - if self.do_benchmarks and self.do_background: - evs["weight"] = 1.0 - for i in range(len(self.sens.fov_offset_bins) - 1): - low = self.sens.fov_offset_bins[i] - high = self.sens.fov_offset_bins[i + 1] + # Only calculate real event weights if sensitivity or background should be computed + if self.do_benchmarks or self.do_background: + fov_conf = self.bkg if self.do_background else self.sens + for i in range(fov_conf.fov_offset_n_bins): + low = fov_conf.fov_offset_bins[i] + high = fov_conf.fov_offset_bins[i + 1] fov_mask = evs["true_source_fov_offset"] >= low fov_mask &= evs["true_source_fov_offset"] < high evs[fov_mask] = sel.make_event_weights( From 0fa4477bb017eedf9ae61602095bb776d1e263c1 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 24 Jul 2024 17:32:08 +0200 Subject: [PATCH 130/136] Rework event weight calculation --- src/ctapipe/irf/select.py | 33 ++++++++++++++++++++++++--------- src/ctapipe/tools/make_irf.py | 22 ++++++++++------------ 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 7a1b1b6ae65..eeea407bffe 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -122,7 +122,6 @@ def make_empty_table(self) -> QTable: "theta", "true_source_fov_offset", "reco_source_fov_offset", - "weight", ] units = { "true_energy": u.TeV, @@ -258,14 +257,13 @@ def make_derived_columns(self, events: QTable, obs_conf: Table) -> QTable: reco_nominal = reco.transform_to(nominal) events["reco_fov_lon"] = -reco_nominal.fov_lon # minus for GADF events["reco_fov_lat"] = reco_nominal.fov_lat - events["weight"] = 1.0 return events def make_event_weights( self, events: QTable, spectrum: PowerLaw, - fov_range: tuple[u.Quantity, u.Quantity], + fov_offset_bins: u.Quantity | None = None, ) -> QTable: if ( self.kind == "gammas" @@ -273,11 +271,28 @@ def make_event_weights( spectrum.normalization.unit * u.sr ) ): - spectrum = spectrum.integrate_cone(fov_range[0], fov_range[1]) + if fov_offset_bins is None: + raise ValueError( + "gamma_target_spectrum is point-like, but no fov offset bins " + "for the integration of the simulated diffuse spectrum was given." + ) + + events["weight"] = 1.0 + + for low, high in zip(fov_offset_bins[:-1], fov_offset_bins[1:]): + fov_mask = events["true_source_fov_offset"] >= low + fov_mask &= events["true_source_fov_offset"] < high + + events[fov_mask]["weight"] = calculate_event_weights( + events[fov_mask]["true_energy"], + target_spectrum=self.target_spectrum, + simulated_spectrum=spectrum.integrate_cone(low, high), + ) + else: + events["weight"] = calculate_event_weights( + events["true_energy"], + target_spectrum=self.target_spectrum, + simulated_spectrum=spectrum, + ) - events["weight"] = calculate_event_weights( - events["true_energy"], - target_spectrum=self.target_spectrum, - simulated_spectrum=spectrum, - ) return events diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index f9bf35650a2..1e36276235c 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -484,19 +484,17 @@ def start(self): self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) - # Only calculate real event weights if sensitivity or background should be computed - if self.do_benchmarks or self.do_background: - fov_conf = self.bkg if self.do_background else self.sens - for i in range(fov_conf.fov_offset_n_bins): - low = fov_conf.fov_offset_bins[i] - high = fov_conf.fov_offset_bins[i + 1] - fov_mask = evs["true_source_fov_offset"] >= low - fov_mask &= evs["true_source_fov_offset"] < high - evs[fov_mask] = sel.make_event_weights( - evs[fov_mask], - meta["spectrum"], - (low, high), + # Only calculate event weights if background or sensitivity should be calculated. + if self.do_background: + # Sensitivity is only calculated, if do_background and do_benchmarks is true. + if self.do_benchmarks: + evs = sel.make_event_weights( + evs, meta["spectrum"], self.sens.fov_offset_bins ) + # If only background should be calculated, + # only calculate weights for protons and electrons. + elif sel.kind in ("protons", "electrons"): + evs = sel.make_event_weights(evs, meta["spectrum"]) reduced_events[sel.kind] = evs reduced_events[f"{sel.kind}_count"] = cnt From 555655d298d5d583587099572bdc8f85de10ee35 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 14 Aug 2024 19:53:47 +0200 Subject: [PATCH 131/136] Fix background2D test --- src/ctapipe/irf/tests/test_irfs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index e842b42cf2b..ee8d696bd83 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -13,6 +13,8 @@ def irf_events_table(): N = N1 + N2 epp = EventPreProcessor() tab = epp.make_empty_table() + # Add fake weight column + tab.add_column((), name="weight") units = {c: tab[c].unit for c in tab.columns} empty = np.zeros((len(tab.columns), N)) * np.nan From 0d7a114da1ed6c6cfd4f281a5abe84dea381ef58 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 14 Aug 2024 19:54:25 +0200 Subject: [PATCH 132/136] More tests for optimization components --- src/ctapipe/irf/optimize.py | 2 +- src/ctapipe/irf/tests/test_optimize.py | 86 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 4292f049046..9b8fba3debc 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -85,7 +85,7 @@ def write(self, output_name, overwrite=False): if not isinstance(self._results, list): raise ValueError( "The results of this object" - "have not been properly initialised," + " have not been properly initialised," " call `set_results` before writing." ) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 6a405591360..0576f7badba 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -1,9 +1,51 @@ import astropy.units as u import numpy as np +import pytest +from astropy.table import QTable from ctapipe.irf import EventsLoader, Spectra +def test_optimization_result_store(tmp_path, irf_events_loader_test_config): + from ctapipe.irf import ( + EventPreProcessor, + OptimizationResult, + OptimizationResultStore, + ResultValidRange, + ) + + result_path = tmp_path / "result.h5" + epp = EventPreProcessor(irf_events_loader_test_config) + store = OptimizationResultStore(epp) + + with pytest.raises( + ValueError, + match="The results of this object have not been properly initialised", + ): + store.write(result_path) + + gh_cuts = QTable( + data=[[0.2, 0.8, 1.5] * u.TeV, [0.8, 1.5, 10] * u.TeV, [0.82, 0.91, 0.88]], + names=["low", "high", "cut"], + ) + store.set_result( + gh_cuts=gh_cuts, + valid_energy=[0.2 * u.TeV, 10 * u.TeV], + valid_offset=[0 * u.deg, np.inf * u.deg], + clf_prefix="ExtraTreesClassifier", + theta_cuts=None, + ) + store.write(result_path) + assert result_path.exists() + + result = store.read(result_path) + assert isinstance(result, OptimizationResult) + assert isinstance(result.valid_energy, ResultValidRange) + assert isinstance(result.valid_offset, ResultValidRange) + assert isinstance(result.gh_cuts, QTable) + assert result.gh_cuts.meta["CLFNAME"] == "ExtraTreesClassifier" + + def test_gh_percentile_cut_calculator(): from ctapipe.irf import GhPercentileCutCalculator @@ -67,3 +109,47 @@ def test_percentile_cuts(gamma_diffuse_full_reco_file, irf_events_loader_test_co assert u.isclose(result._results[1]["energy_min"], result._results[0]["low"][0]) assert u.isclose(result._results[1]["energy_max"], result._results[0]["high"][-1]) assert result._results[3]["cut"].unit == u.deg + + +def test_point_source_sensitvity_optimizer( + gamma_diffuse_full_reco_file, proton_full_reco_file, irf_events_loader_test_config +): + from ctapipe.irf import OptimizationResultStore, PointSourceSensitivityOptimizer + + gamma_loader = EventsLoader( + config=irf_events_loader_test_config, + kind="gammas", + file=gamma_diffuse_full_reco_file, + target_spectrum=Spectra.CRAB_HEGRA, + ) + gamma_events, _, _ = gamma_loader.load_preselected_events( + chunk_size=10000, + obs_time=u.Quantity(50, u.h), + ) + proton_loader = EventsLoader( + config=irf_events_loader_test_config, + kind="protons", + file=proton_full_reco_file, + target_spectrum=Spectra.IRFDOC_PROTON_SPECTRUM, + ) + proton_events, _, _ = proton_loader.load_preselected_events( + chunk_size=10000, + obs_time=u.Quantity(50, u.h), + ) + + optimizer = PointSourceSensitivityOptimizer() + result = optimizer.optimize_cuts( + signal=gamma_events, + background=proton_events, + alpha=0.2, + precuts=gamma_loader.epp, # identical precuts for all particle types + clf_prefix="ExtraTreesClassifier", + point_like=True, + ) + assert isinstance(result, OptimizationResultStore) + assert len(result._results) == 4 + # If no significance can be calculated for any cut value in to lowest or + # highest energy bin(s), these bins are invalid. + assert result._results[1]["energy_min"] >= result._results[0]["low"][0] + assert result._results[1]["energy_max"] <= result._results[0]["high"][-1] + assert result._results[3]["cut"].unit == u.deg From e32144a1f94c4bda38ed56df7c666b2cd663c5f5 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 16 Aug 2024 16:55:04 +0200 Subject: [PATCH 133/136] Iterate over cut optimizers in optimize tests --- src/ctapipe/irf/tests/test_optimize.py | 45 ++++++-------------------- 1 file changed, 10 insertions(+), 35 deletions(-) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 0576f7badba..e554dec343b 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -3,7 +3,9 @@ import pytest from astropy.table import QTable +from ctapipe.core import non_abstract_children from ctapipe.irf import EventsLoader, Spectra +from ctapipe.irf.optimize import CutOptimizerBase def test_optimization_result_store(tmp_path, irf_events_loader_test_config): @@ -82,39 +84,14 @@ def test_theta_percentile_cut_calculator(): assert calc.smoothing is None -def test_percentile_cuts(gamma_diffuse_full_reco_file, irf_events_loader_test_config): - from ctapipe.irf import OptimizationResultStore, PercentileCuts - - loader = EventsLoader( - config=irf_events_loader_test_config, - kind="gammas", - file=gamma_diffuse_full_reco_file, - target_spectrum=Spectra.CRAB_HEGRA, - ) - events, _, _ = loader.load_preselected_events( - chunk_size=10000, - obs_time=u.Quantity(50, u.h), - ) - optimizer = PercentileCuts() - result = optimizer.optimize_cuts( - signal=events, - background=None, - alpha=0.2, # Default value in the tool, not used for PercentileCuts - precuts=loader.epp, - clf_prefix="ExtraTreesClassifier", - point_like=True, - ) - assert isinstance(result, OptimizationResultStore) - assert len(result._results) == 4 - assert u.isclose(result._results[1]["energy_min"], result._results[0]["low"][0]) - assert u.isclose(result._results[1]["energy_max"], result._results[0]["high"][-1]) - assert result._results[3]["cut"].unit == u.deg - - -def test_point_source_sensitvity_optimizer( - gamma_diffuse_full_reco_file, proton_full_reco_file, irf_events_loader_test_config +@pytest.mark.parametrize("Optimizer", non_abstract_children(CutOptimizerBase)) +def test_cut_optimizer( + Optimizer, + gamma_diffuse_full_reco_file, + proton_full_reco_file, + irf_events_loader_test_config, ): - from ctapipe.irf import OptimizationResultStore, PointSourceSensitivityOptimizer + from ctapipe.irf import OptimizationResultStore gamma_loader = EventsLoader( config=irf_events_loader_test_config, @@ -137,7 +114,7 @@ def test_point_source_sensitvity_optimizer( obs_time=u.Quantity(50, u.h), ) - optimizer = PointSourceSensitivityOptimizer() + optimizer = Optimizer() result = optimizer.optimize_cuts( signal=gamma_events, background=proton_events, @@ -148,8 +125,6 @@ def test_point_source_sensitvity_optimizer( ) assert isinstance(result, OptimizationResultStore) assert len(result._results) == 4 - # If no significance can be calculated for any cut value in to lowest or - # highest energy bin(s), these bins are invalid. assert result._results[1]["energy_min"] >= result._results[0]["low"][0] assert result._results[1]["energy_max"] <= result._results[0]["high"][-1] assert result._results[3]["cut"].unit == u.deg From df47f71c23fcd3ed4d8fb5ea8b983452396a6716 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 2 Sep 2024 18:11:38 +0200 Subject: [PATCH 134/136] Add more tests for irf makers --- src/ctapipe/irf/tests/test_irfs.py | 145 ++++++++++++++++++++++++- src/ctapipe/irf/tests/test_optimize.py | 18 +-- 2 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index ee8d696bd83..394149fb824 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -2,8 +2,9 @@ import numpy as np import pytest from astropy.table import QTable, vstack +from pyirf.simulations import SimulatedEventsInfo -from ctapipe.irf import BackgroundRate2dMaker, EventPreProcessor +from ctapipe.irf import EventPreProcessor @pytest.fixture(scope="session") @@ -21,14 +22,150 @@ def irf_events_table(): e_tab = QTable(data=empty.T, names=tab.colnames, units=units) # Setting values following pyirf test in pyirf/irf/tests/test_background.py e_tab["reco_energy"] = np.append(np.full(N1, 1), np.full(N2, 2)) * u.TeV - e_tab["reco_source_fov_offset"] = (np.zeros(N) * u.deg,) + e_tab["true_energy"] = np.append(np.full(N1, 0.9), np.full(N2, 2.1)) * u.TeV + e_tab["reco_source_fov_offset"] = ( + np.append(np.full(N1, 0.1), np.full(N2, 0.05)) * u.deg + ) + e_tab["true_source_fov_offset"] = ( + np.append(np.full(N1, 0.11), np.full(N2, 0.04)) * u.deg + ) ev = vstack([e_tab, tab], join_type="exact", metadata_conflicts="silent") return ev def test_make_2d_bkg(irf_events_table): - bkgMkr = BackgroundRate2dMaker() + from ctapipe.irf import BackgroundRate2dMaker + + bkgMkr = BackgroundRate2dMaker( + fov_offset_n_bins=3, + fov_offset_max=3 * u.deg, + reco_energy_n_bins_per_decade=7, + reco_energy_max=155 * u.TeV, + ) bkg_hdu = bkgMkr.make_bkg_hdu(events=irf_events_table, obs_time=1 * u.s) - assert bkg_hdu.data["BKG"].shape == (1, 1, 20) + # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins + assert bkg_hdu.data["BKG"].shape == (1, 3, 29) + + for col, val in zip(["THETA_LO", "ENERG_LO"], [0 * u.deg, 0.015 * u.TeV]): + assert u.isclose( + u.Quantity(bkg_hdu.data[col][0][0], bkg_hdu.columns[col].unit), val + ) + + for col, val in zip(["THETA_HI", "ENERG_HI"], [3 * u.deg, 155 * u.TeV]): + assert u.isclose( + u.Quantity(bkg_hdu.data[col][0][-1], bkg_hdu.columns[col].unit), val + ) + + +def test_make_2d_energy_migration(irf_events_table): + from ctapipe.irf import EnergyMigration2dMaker + + migMkr = EnergyMigration2dMaker( + fov_offset_n_bins=3, + fov_offset_max=3 * u.deg, + true_energy_n_bins_per_decade=7, + true_energy_max=155 * u.TeV, + energy_migration_n_bins=20, + energy_migration_min=0.1, + energy_migration_max=10, + ) + mig_hdu = migMkr.make_edisp_hdu(events=irf_events_table, point_like=False) + # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins + assert mig_hdu.data["MATRIX"].shape == (1, 3, 20, 29) + + for col, val in zip( + ["THETA_LO", "ENERG_LO", "MIGRA_LO"], [0 * u.deg, 0.015 * u.TeV, 0.1] + ): + assert u.isclose( + u.Quantity(mig_hdu.data[col][0][0], mig_hdu.columns[col].unit), val + ) + + for col, val in zip( + ["THETA_HI", "ENERG_HI", "MIGRA_HI"], [3 * u.deg, 155 * u.TeV, 10] + ): + assert u.isclose( + u.Quantity(mig_hdu.data[col][0][-1], mig_hdu.columns[col].unit), val + ) + + +def test_make_2d_eff_area(irf_events_table): + from ctapipe.irf import EffectiveArea2dMaker + + effAreaMkr = EffectiveArea2dMaker( + fov_offset_n_bins=3, + fov_offset_max=3 * u.deg, + true_energy_n_bins_per_decade=7, + true_energy_max=155 * u.TeV, + ) + sim_info = SimulatedEventsInfo( + n_showers=3000, + energy_min=0.01 * u.TeV, + energy_max=10 * u.TeV, + max_impact=1000 * u.m, + spectral_index=-1.9, + viewcone_min=0 * u.deg, + viewcone_max=10 * u.deg, + ) + eff_area_hdu = effAreaMkr.make_aeff_hdu( + events=irf_events_table, + point_like=False, + signal_is_point_like=False, + sim_info=sim_info, + ) + # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins + assert eff_area_hdu.data["EFFAREA"].shape == (1, 3, 29) + + for col, val in zip(["THETA_LO", "ENERG_LO"], [0 * u.deg, 0.015 * u.TeV]): + assert u.isclose( + u.Quantity(eff_area_hdu.data[col][0][0], eff_area_hdu.columns[col].unit), + val, + ) + + for col, val in zip(["THETA_HI", "ENERG_HI"], [3 * u.deg, 155 * u.TeV]): + assert u.isclose( + u.Quantity(eff_area_hdu.data[col][0][-1], eff_area_hdu.columns[col].unit), + val, + ) + + # point like data -> only 1 fov offset bin + eff_area_hdu = effAreaMkr.make_aeff_hdu( + events=irf_events_table, + point_like=False, + signal_is_point_like=True, + sim_info=sim_info, + ) + assert eff_area_hdu.data["EFFAREA"].shape == (1, 1, 29) + + +def test_make_3d_psf(irf_events_table): + from ctapipe.irf import Psf3dMaker + + psfMkr = Psf3dMaker( + fov_offset_n_bins=3, + fov_offset_max=3 * u.deg, + true_energy_n_bins_per_decade=7, + true_energy_max=155 * u.TeV, + source_offset_n_bins=110, + source_offset_max=2 * u.deg, + ) + psf_hdu = psfMkr.make_psf_hdu(events=irf_events_table) + # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins + assert psf_hdu.data["RPSF"].shape == (1, 110, 3, 29) + + for col, val in zip( + ["THETA_LO", "ENERG_LO", "RAD_LO"], [0 * u.deg, 0.015 * u.TeV, 0 * u.deg] + ): + assert u.isclose( + u.Quantity(psf_hdu.data[col][0][0], psf_hdu.columns[col].unit), + val, + ) + + for col, val in zip( + ["THETA_HI", "ENERG_HI", "RAD_HI"], [3 * u.deg, 155 * u.TeV, 2 * u.deg] + ): + assert u.isclose( + u.Quantity(psf_hdu.data[col][0][-1], psf_hdu.columns[col].unit), + val, + ) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index e554dec343b..ab744cf7843 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -51,10 +51,11 @@ def test_optimization_result_store(tmp_path, irf_events_loader_test_config): def test_gh_percentile_cut_calculator(): from ctapipe.irf import GhPercentileCutCalculator - calc = GhPercentileCutCalculator() - calc.target_percentile = 75 - calc.min_counts = 1 - calc.smoothing = -1 + calc = GhPercentileCutCalculator( + target_percentile=75, + min_counts=1, + smoothing=-1, + ) cuts = calc.calculate_gh_cut( gammaness=np.array([0.1, 0.6, 0.45, 0.98, 0.32, 0.95, 0.25, 0.87]), reco_energy=[0.17, 0.36, 0.47, 0.22, 1.2, 5, 4.2, 9.1] * u.TeV, @@ -69,10 +70,11 @@ def test_gh_percentile_cut_calculator(): def test_theta_percentile_cut_calculator(): from ctapipe.irf import ThetaPercentileCutCalculator - calc = ThetaPercentileCutCalculator() - calc.target_percentile = 75 - calc.min_counts = 1 - calc.smoothing = -1 + calc = ThetaPercentileCutCalculator( + target_percentile=75, + min_counts=1, + smoothing=-1, + ) cuts = calc.calculate_theta_cut( theta=[0.1, 0.07, 0.21, 0.4, 0.03, 0.08, 0.11, 0.18] * u.deg, reco_energy=[0.17, 0.36, 0.47, 0.22, 1.2, 5, 4.2, 9.1] * u.TeV, From 99e21cb2949ff65f2e9f117ff0c655675c098518 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 3 Sep 2024 15:27:54 +0200 Subject: [PATCH 135/136] Small refactoring of irf tests --- src/ctapipe/conftest.py | 31 +++++++- src/ctapipe/irf/tests/test_irfs.py | 113 +++++++++-------------------- 2 files changed, 65 insertions(+), 79 deletions(-) diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index 615971b35ed..e9f652d5c5b 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -10,7 +10,7 @@ import pytest import tables from astropy.coordinates import EarthLocation -from astropy.table import Table +from astropy.table import QTable, Table, vstack from pytest_astropy_header.display import PYTEST_HEADER_MODULES from ctapipe.core import run_tool @@ -761,3 +761,32 @@ def irf_events_loader_test_config(): } } ) + + +@pytest.fixture(scope="session") +def irf_events_table(): + from ctapipe.irf import EventPreProcessor + + N1 = 1000 + N2 = 100 + N = N1 + N2 + epp = EventPreProcessor() + tab = epp.make_empty_table() + # Add fake weight column + tab.add_column((), name="weight") + units = {c: tab[c].unit for c in tab.columns} + + empty = np.zeros((len(tab.columns), N)) * np.nan + e_tab = QTable(data=empty.T, names=tab.colnames, units=units) + # Setting values following pyirf test in pyirf/irf/tests/test_background.py + e_tab["reco_energy"] = np.append(np.full(N1, 1), np.full(N2, 2)) * u.TeV + e_tab["true_energy"] = np.append(np.full(N1, 0.9), np.full(N2, 2.1)) * u.TeV + e_tab["reco_source_fov_offset"] = ( + np.append(np.full(N1, 0.1), np.full(N2, 0.05)) * u.deg + ) + e_tab["true_source_fov_offset"] = ( + np.append(np.full(N1, 0.11), np.full(N2, 0.04)) * u.deg + ) + + ev = vstack([e_tab, tab], join_type="exact", metadata_conflicts="silent") + return ev diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index 394149fb824..bd5e212a2b9 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -1,37 +1,22 @@ import astropy.units as u -import numpy as np -import pytest -from astropy.table import QTable, vstack +from astropy.io.fits import BinTableHDU from pyirf.simulations import SimulatedEventsInfo -from ctapipe.irf import EventPreProcessor - - -@pytest.fixture(scope="session") -def irf_events_table(): - N1 = 1000 - N2 = 100 - N = N1 + N2 - epp = EventPreProcessor() - tab = epp.make_empty_table() - # Add fake weight column - tab.add_column((), name="weight") - units = {c: tab[c].unit for c in tab.columns} - - empty = np.zeros((len(tab.columns), N)) * np.nan - e_tab = QTable(data=empty.T, names=tab.colnames, units=units) - # Setting values following pyirf test in pyirf/irf/tests/test_background.py - e_tab["reco_energy"] = np.append(np.full(N1, 1), np.full(N2, 2)) * u.TeV - e_tab["true_energy"] = np.append(np.full(N1, 0.9), np.full(N2, 2.1)) * u.TeV - e_tab["reco_source_fov_offset"] = ( - np.append(np.full(N1, 0.1), np.full(N2, 0.05)) * u.deg - ) - e_tab["true_source_fov_offset"] = ( - np.append(np.full(N1, 0.11), np.full(N2, 0.04)) * u.deg - ) - ev = vstack([e_tab, tab], join_type="exact", metadata_conflicts="silent") - return ev +def _check_boundaries_in_hdu( + hdu: BinTableHDU, + lo_vals: list, + hi_vals: list, + colnames: list[str] = ["THETA", "ENERG"], +): + for col, val in zip(colnames, lo_vals): + assert u.isclose( + u.Quantity(hdu.data[f"{col}_LO"][0][0], hdu.columns[f"{col}_LO"].unit), val + ) + for col, val in zip(colnames, hi_vals): + assert u.isclose( + u.Quantity(hdu.data[f"{col}_HI"][0][-1], hdu.columns[f"{col}_HI"].unit), val + ) def test_make_2d_bkg(irf_events_table): @@ -48,15 +33,9 @@ def test_make_2d_bkg(irf_events_table): # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins assert bkg_hdu.data["BKG"].shape == (1, 3, 29) - for col, val in zip(["THETA_LO", "ENERG_LO"], [0 * u.deg, 0.015 * u.TeV]): - assert u.isclose( - u.Quantity(bkg_hdu.data[col][0][0], bkg_hdu.columns[col].unit), val - ) - - for col, val in zip(["THETA_HI", "ENERG_HI"], [3 * u.deg, 155 * u.TeV]): - assert u.isclose( - u.Quantity(bkg_hdu.data[col][0][-1], bkg_hdu.columns[col].unit), val - ) + _check_boundaries_in_hdu( + bkg_hdu, lo_vals=[0 * u.deg, 0.015 * u.TeV], hi_vals=[3 * u.deg, 155 * u.TeV] + ) def test_make_2d_energy_migration(irf_events_table): @@ -75,19 +54,12 @@ def test_make_2d_energy_migration(irf_events_table): # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins assert mig_hdu.data["MATRIX"].shape == (1, 3, 20, 29) - for col, val in zip( - ["THETA_LO", "ENERG_LO", "MIGRA_LO"], [0 * u.deg, 0.015 * u.TeV, 0.1] - ): - assert u.isclose( - u.Quantity(mig_hdu.data[col][0][0], mig_hdu.columns[col].unit), val - ) - - for col, val in zip( - ["THETA_HI", "ENERG_HI", "MIGRA_HI"], [3 * u.deg, 155 * u.TeV, 10] - ): - assert u.isclose( - u.Quantity(mig_hdu.data[col][0][-1], mig_hdu.columns[col].unit), val - ) + _check_boundaries_in_hdu( + mig_hdu, + lo_vals=[0 * u.deg, 0.015 * u.TeV, 0.1], + hi_vals=[3 * u.deg, 155 * u.TeV, 10], + colnames=["THETA", "ENERG", "MIGRA"], + ) def test_make_2d_eff_area(irf_events_table): @@ -117,17 +89,11 @@ def test_make_2d_eff_area(irf_events_table): # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins assert eff_area_hdu.data["EFFAREA"].shape == (1, 3, 29) - for col, val in zip(["THETA_LO", "ENERG_LO"], [0 * u.deg, 0.015 * u.TeV]): - assert u.isclose( - u.Quantity(eff_area_hdu.data[col][0][0], eff_area_hdu.columns[col].unit), - val, - ) - - for col, val in zip(["THETA_HI", "ENERG_HI"], [3 * u.deg, 155 * u.TeV]): - assert u.isclose( - u.Quantity(eff_area_hdu.data[col][0][-1], eff_area_hdu.columns[col].unit), - val, - ) + _check_boundaries_in_hdu( + eff_area_hdu, + lo_vals=[0 * u.deg, 0.015 * u.TeV], + hi_vals=[3 * u.deg, 155 * u.TeV], + ) # point like data -> only 1 fov offset bin eff_area_hdu = effAreaMkr.make_aeff_hdu( @@ -154,18 +120,9 @@ def test_make_3d_psf(irf_events_table): # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins assert psf_hdu.data["RPSF"].shape == (1, 110, 3, 29) - for col, val in zip( - ["THETA_LO", "ENERG_LO", "RAD_LO"], [0 * u.deg, 0.015 * u.TeV, 0 * u.deg] - ): - assert u.isclose( - u.Quantity(psf_hdu.data[col][0][0], psf_hdu.columns[col].unit), - val, - ) - - for col, val in zip( - ["THETA_HI", "ENERG_HI", "RAD_HI"], [3 * u.deg, 155 * u.TeV, 2 * u.deg] - ): - assert u.isclose( - u.Quantity(psf_hdu.data[col][0][-1], psf_hdu.columns[col].unit), - val, - ) + _check_boundaries_in_hdu( + psf_hdu, + lo_vals=[0 * u.deg, 0.015 * u.TeV, 0 * u.deg], + hi_vals=[3 * u.deg, 155 * u.TeV, 2 * u.deg], + colnames=["THETA", "ENERG", "RAD"], + ) From 9da6aeb6561fc6db1a6b812fa0a9573c6ba539c9 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 3 Sep 2024 15:28:53 +0200 Subject: [PATCH 136/136] Add tests for benchmarks; fix axis order in benchmark output --- src/ctapipe/irf/benchmarks.py | 31 +++--- src/ctapipe/irf/tests/test_benchmarks.py | 130 +++++++++++++++++++++++ 2 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 src/ctapipe/irf/tests/test_benchmarks.py diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index b1f721d6a12..a1aee0304a6 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -1,4 +1,5 @@ """Components to generate benchmarks""" + from abc import abstractmethod import astropy.units as u @@ -25,7 +26,7 @@ def _get_2d_result_table( fov_bins[np.newaxis, :].to(u.deg) ) fov_bin_index, _ = calculate_bin_indices(events["true_source_fov_offset"], fov_bins) - mat_shape = (len(e_bins) - 1, len(fov_bins) - 1) + mat_shape = (len(fov_bins) - 1, len(e_bins) - 1) return result, fov_bin_index, mat_shape @@ -85,9 +86,9 @@ def make_bias_resolution_hdu( bias_function=np.mean, energy_type="true", ) - result["N_EVENTS"][..., i] = bias_resolution["n_events"] - result["BIAS"][..., i] = bias_resolution["bias"] - result["RESOLUTI"][..., i] = bias_resolution["resolution"] + result["N_EVENTS"][:, i, :] = bias_resolution["n_events"] + result["BIAS"][:, i, :] = bias_resolution["bias"] + result["RESOLUTI"][:, i, :] = bias_resolution["resolution"] return BinTableHDU(result, name=extname) @@ -161,8 +162,8 @@ def make_angular_resolution_hdu( energy_bins=e_bins, energy_type=energy_type, ) - result["N_EVENTS"][..., i] = ang_res["n_events"] - result["ANG_RES"][..., i] = ang_res["angular_resolution"] + result["N_EVENTS"][:, i, :] = ang_res["n_events"] + result["ANG_RES"][:, i, :] = ang_res["angular_resolution"] header = Header() header["E_TYPE"] = energy_type.upper() @@ -258,15 +259,15 @@ def make_sensitivity_hdu( sens = calculate_sensitivity( signal_hist=signal_hist, background_hist=bkg_hist, alpha=self.alpha ) - result["N_SIG"][..., i] = sens["n_signal"] - result["N_SIG_W"][..., i] = sens["n_signal_weighted"] - result["N_BKG"][..., i] = sens["n_background"] - result["N_BKG_W"][..., i] = sens["n_background_weighted"] - result["SIGNIFIC"][..., i] = sens["significance"] - result["REL_SEN"][..., i] = sens["relative_sensitivity"] - result["FLUX_SEN"][..., i] = sens["relative_sensitivity"] * source_spectrum( - sens["reco_energy_center"] - ) + result["N_SIG"][:, i, :] = sens["n_signal"] + result["N_SIG_W"][:, i, :] = sens["n_signal_weighted"] + result["N_BKG"][:, i, :] = sens["n_background"] + result["N_BKG_W"][:, i, :] = sens["n_background_weighted"] + result["SIGNIFIC"][:, i, :] = sens["significance"] + result["REL_SEN"][:, i, :] = sens["relative_sensitivity"] + result["FLUX_SEN"][:, i, :] = sens[ + "relative_sensitivity" + ] * source_spectrum(sens["reco_energy_center"]) header = Header() header["ALPHA"] = self.alpha diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py new file mode 100644 index 00000000000..95298d265d7 --- /dev/null +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -0,0 +1,130 @@ +import astropy.units as u +from astropy.table import QTable + +from ctapipe.irf.tests.test_irfs import _check_boundaries_in_hdu + + +def test_make_2d_energy_bias_res(irf_events_table): + from ctapipe.irf import EnergyBiasResolution2dMaker + + biasResMkr = EnergyBiasResolution2dMaker( + fov_offset_n_bins=3, + fov_offset_max=3 * u.deg, + true_energy_n_bins_per_decade=7, + true_energy_max=155 * u.TeV, + ) + + bias_res_hdu = biasResMkr.make_bias_resolution_hdu(events=irf_events_table) + # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins + assert ( + bias_res_hdu.data["N_EVENTS"].shape + == bias_res_hdu.data["BIAS"].shape + == bias_res_hdu.data["RESOLUTI"].shape + == (1, 3, 29) + ) + _check_boundaries_in_hdu( + bias_res_hdu, + lo_vals=[0 * u.deg, 0.015 * u.TeV], + hi_vals=[3 * u.deg, 155 * u.TeV], + ) + + +def test_make_2d_ang_res(irf_events_table): + from ctapipe.irf import AngularResolution2dMaker + + angResMkr = AngularResolution2dMaker( + fov_offset_n_bins=3, + fov_offset_max=3 * u.deg, + true_energy_n_bins_per_decade=7, + true_energy_max=155 * u.TeV, + reco_energy_n_bins_per_decade=6, + reco_energy_min=0.03 * u.TeV, + ) + + ang_res_hdu = angResMkr.make_angular_resolution_hdu(events=irf_events_table) + assert ( + ang_res_hdu.data["N_EVENTS"].shape + == ang_res_hdu.data["ANG_RES"].shape + == (1, 3, 23) + ) + _check_boundaries_in_hdu( + ang_res_hdu, + lo_vals=[0 * u.deg, 0.03 * u.TeV], + hi_vals=[3 * u.deg, 150 * u.TeV], + ) + + angResMkr.use_true_energy = True + ang_res_hdu = angResMkr.make_angular_resolution_hdu(events=irf_events_table) + assert ( + ang_res_hdu.data["N_EVENTS"].shape + == ang_res_hdu.data["ANG_RES"].shape + == (1, 3, 29) + ) + _check_boundaries_in_hdu( + ang_res_hdu, + lo_vals=[0 * u.deg, 0.015 * u.TeV], + hi_vals=[3 * u.deg, 155 * u.TeV], + ) + + +def test_make_2d_sensitivity( + gamma_diffuse_full_reco_file, proton_full_reco_file, irf_events_loader_test_config +): + from ctapipe.irf import EventsLoader, Sensitivity2dMaker, Spectra + + gamma_loader = EventsLoader( + config=irf_events_loader_test_config, + kind="gammas", + file=gamma_diffuse_full_reco_file, + target_spectrum=Spectra.CRAB_HEGRA, + ) + gamma_events, _, _ = gamma_loader.load_preselected_events( + chunk_size=10000, + obs_time=u.Quantity(50, u.h), + ) + proton_loader = EventsLoader( + config=irf_events_loader_test_config, + kind="protons", + file=proton_full_reco_file, + target_spectrum=Spectra.IRFDOC_PROTON_SPECTRUM, + ) + proton_events, _, _ = proton_loader.load_preselected_events( + chunk_size=10000, + obs_time=u.Quantity(50, u.h), + ) + + sensMkr = Sensitivity2dMaker( + fov_offset_n_bins=3, + fov_offset_max=3 * u.deg, + reco_energy_n_bins_per_decade=7, + reco_energy_max=155 * u.TeV, + ) + # Create a dummy theta cut since `pyirf.sensitivity.estimate_background` + # needs a theta cut atm. + theta_cuts = QTable() + theta_cuts["center"] = 0.5 * ( + sensMkr.reco_energy_bins[:-1] + sensMkr.reco_energy_bins[1:] + ) + theta_cuts["cut"] = sensMkr.fov_offset_max + + sens_hdu = sensMkr.make_sensitivity_hdu( + signal_events=gamma_events, + background_events=proton_events, + theta_cut=theta_cuts, + gamma_spectrum=Spectra.CRAB_HEGRA, + ) + assert ( + sens_hdu.data["N_SIG"].shape + == sens_hdu.data["N_SIG_W"].shape + == sens_hdu.data["N_BKG"].shape + == sens_hdu.data["N_BKG_W"].shape + == sens_hdu.data["SIGNIFIC"].shape + == sens_hdu.data["REL_SEN"].shape + == sens_hdu.data["FLUX_SEN"].shape + == (1, 3, 29) + ) + _check_boundaries_in_hdu( + sens_hdu, + lo_vals=[0 * u.deg, 0.015 * u.TeV], + hi_vals=[3 * u.deg, 155 * u.TeV], + )