From 0b87a8fc93abf1f0a8731b06e767b57f857fa23e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 18 Apr 2023 17:28:39 +0200 Subject: [PATCH 001/195] 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 fc0fbbc4bab..32f64f44339 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "numpy >=1.23,<3.0.0a0", "packaging", "psutil", + "pyirf", "pyyaml >=5.1", "requests", "scikit-learn !=1.4.0", # 1.4.0 breaks with astropy tables, before and after works @@ -105,6 +106,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-calculate-pixel-statistics = "ctapipe.tools.calculate_pixel_stats: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 29a23053b5a66599154e463e3d665ae6a1889e0b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 19 Apr 2023 15:27:04 +0200 Subject: [PATCH 002/195] 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 32f64f44339..779ef7a43c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,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-calculate-pixel-statistics = "ctapipe.tools.calculate_pixel_stats: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 e7fae2229c1e9a0abf2488f235c2593f6ebc1d7a Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 25 Apr 2023 12:41:07 +0200 Subject: [PATCH 003/195] 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 1800dfed4ec983ede1c6eb2a0b8f711d1450c1b9 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 3 May 2023 11:03:06 +0200 Subject: [PATCH 004/195] 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 73149a38db5..87f7560fe5e 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 9d6f043d5513c75cd0c5f06d109f0e25aa3acad5 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 3 May 2023 18:35:16 +0200 Subject: [PATCH 005/195] 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 46b77a73a1eb03dde5757e74c00dcd801a631b37 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 4 May 2023 14:54:58 +0200 Subject: [PATCH 006/195] 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 330cbd13a2448619179ba6d8e692f78ada41197b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 4 May 2023 14:57:07 +0200 Subject: [PATCH 007/195] 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 46ec47de068a8f868b8a841464324eb61538584f Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 4 May 2023 16:39:15 +0200 Subject: [PATCH 008/195] 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 8921f0584b61a364f99a37bcbef3644d9b69362f Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 14:03:58 +0200 Subject: [PATCH 009/195] 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 fb1cd9db2230101359736f48af5c1e7f000f73b1 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 14:57:35 +0200 Subject: [PATCH 010/195] 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 e60f7a20a7ddd21067c6017e574db9662307dd27 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 15:09:18 +0200 Subject: [PATCH 011/195] 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 eff26fa825a086d0c79a98424cddba091d7c5bc1 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 15:38:02 +0200 Subject: [PATCH 012/195] 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 05952bccf68bec25057bed1dd058325953ae3eef Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 May 2023 15:38:45 +0200 Subject: [PATCH 013/195] 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 2f0dee8c068b2a3ff255f521e8880b0b386ee964 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 12 May 2023 16:51:13 +0200 Subject: [PATCH 014/195] 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 d0865a378eab07946a1def85913c817ef607a925 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 23 Aug 2023 18:39:37 +0200 Subject: [PATCH 015/195] 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 fb2715f4f9e656f7ddc4b668a00e8425b64e3d9a Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 23 Aug 2023 22:07:58 +0200 Subject: [PATCH 016/195] 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 1c8fd6938d07328dae69788a513b4c988eade673 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 23 Aug 2023 22:35:54 +0200 Subject: [PATCH 017/195] 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 3c844f8e773ae2b317b66e5ab7b818d3f4ab0922 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 28 Aug 2023 15:20:03 +0200 Subject: [PATCH 018/195] 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 d1e79f205cb61d6dd59b7b2808939981b22319d3 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 28 Aug 2023 15:30:02 +0200 Subject: [PATCH 019/195] 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 d04289e0d7f48f46ed8d0773d0c77a1e3e84e461 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 28 Aug 2023 15:41:39 +0200 Subject: [PATCH 020/195] 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 b31a1694fab3da4bfbaf7bd71e79943371842222 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 12:01:32 +0200 Subject: [PATCH 021/195] 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 e4066b060ca7ebd7a51cc8f1834f634065289f86 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 13:42:42 +0200 Subject: [PATCH 022/195] 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 641c7c949d38626c2a7cd537380e48fb6390d95c Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 13:45:01 +0200 Subject: [PATCH 023/195] 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 a64577f0df0b07c1bd7ab39d8e10aff8c86b50c6 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Sep 2023 14:07:08 +0200 Subject: [PATCH 024/195] 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 a1a49e2249de804ee187dfd3fed85cd7425e866e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 7 Sep 2023 14:54:48 +0200 Subject: [PATCH 025/195] 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 | 3 +- src/ctapipe/tools/make_irf.py | 31 ++-------- 4 files changed, 91 insertions(+), 61 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 70aee3529ab..20e12c6c865 100644 --- a/src/ctapipe/tools/info.py +++ b/src/ctapipe/tools/info.py @@ -1,5 +1,6 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst -""" print information about ctapipe and its command-line tools. """ +"""print information about ctapipe and its command-line tools.""" + import logging import os import sys 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 99fb702f470a09ff5edaa95dcb955a57ab21c266 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 8 Sep 2023 15:39:49 +0200 Subject: [PATCH 026/195] 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 36bb7ee8ae9e347e68366af167ba1bc9fc85c7f9 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 8 Sep 2023 15:47:48 +0200 Subject: [PATCH 027/195] 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 5644f0f4b98f37abf77e41ac0add94899b9dff58 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 11 Sep 2023 18:42:06 +0200 Subject: [PATCH 028/195] 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 8e1004ab47e57fee9acb6568013d4bc241d0caac Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 19 Sep 2023 16:33:19 +0200 Subject: [PATCH 029/195] 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 590ad493929091818bf58babc2c42201cd511f96 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 20 Sep 2023 13:39:57 +0200 Subject: [PATCH 030/195] 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 9a9d9866d81..565d5eb8b07 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -132,6 +132,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 8ebeac4d1c27cb70c6294c707468ef1529cfc016 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 26 Sep 2023 15:28:44 +0200 Subject: [PATCH 031/195] 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 453be91ac87e82a7594281a203b3b273c15d3884 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 26 Sep 2023 16:58:25 +0200 Subject: [PATCH 032/195] 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 04aa3bd4296771efed494ebcf00992c6d599af4d Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 5 Oct 2023 11:05:50 +0200 Subject: [PATCH 033/195] 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 8bc8af0d11f984cd0fe9f28dab0d24187c5c5155 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 5 Oct 2023 11:06:33 +0200 Subject: [PATCH 034/195] 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 960face01a3e7e75f3a1087123eae1f20d77edd9 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 18 Oct 2023 18:50:42 +0200 Subject: [PATCH 035/195] 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 b4d2b0c865112058b127636b5dd2f135b72c205b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 23 Oct 2023 17:04:48 +0200 Subject: [PATCH 036/195] 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 097fb95f4e8324b83cf893186bd20512a5603c6e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 16 Nov 2023 18:21:06 +0100 Subject: [PATCH 037/195] 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 7fa0a88b82d076be2a4d2b678f7fc217a80fb8ae Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 23 Nov 2023 19:28:22 +0100 Subject: [PATCH 038/195] 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 779ef7a43c2..6f6ae36cee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,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 1132a9d7553b31f2e56c9dd04003ae3aa9781208 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Sat, 25 Nov 2023 15:44:16 +0100 Subject: [PATCH 039/195] 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 dfd612f998da6f9dffa4f92a0effb7bbfb47862b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 1 Dec 2023 01:44:52 +0100 Subject: [PATCH 040/195] 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 660d956be067b15e92c87b5623526faa5ada24a2 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Dec 2023 17:02:25 +0100 Subject: [PATCH 041/195] 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 2909dc04932f7280890237342dd3096ce3ef816f Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Dec 2023 18:13:08 +0100 Subject: [PATCH 042/195] 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 83e5b0129ea2699a8471f75931c2b0e2b00cbe5d Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 7 Dec 2023 15:59:14 +0100 Subject: [PATCH 043/195] 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 39025c8dd075a6e01170e9e49a9a77c1142c5c1c Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 7 Dec 2023 18:16:39 +0100 Subject: [PATCH 044/195] 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 ba2bb61893de65fd915c5b382d2c0628546b71ca Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 8 Dec 2023 11:51:19 +0100 Subject: [PATCH 045/195] 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 e4a037dd0ebf938125d12b22dc62909cbce3e884 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 12 Dec 2023 01:58:24 +0100 Subject: [PATCH 046/195] 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 0333238d1b14af20b18ede99dde6b7147cc7f69b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 12 Dec 2023 02:00:07 +0100 Subject: [PATCH 047/195] 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 03411518018f10ef9c7f7fb071c8001544cc7574 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 12 Dec 2023 16:17:41 +0100 Subject: [PATCH 048/195] 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 073aabe6911788256cd646fb5c8ac4917a08162a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 09:44:03 +0100 Subject: [PATCH 049/195] 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 a10fb82ad2f565019d4186e866a30d3edfbfa04f Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 10:45:43 +0100 Subject: [PATCH 050/195] 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 a19dd7bf930e7390c80869c3dc02b84c1b3e0bfc Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 11:13:51 +0100 Subject: [PATCH 051/195] 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 378654376960b8dcb529020a3c032d45b0b2ceda Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 19 Dec 2023 16:18:24 +0100 Subject: [PATCH 052/195] 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/195] 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 db487c98915c153ceeeeba813132b5b316822ed0 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 20 Dec 2023 17:48:05 +0100 Subject: [PATCH 054/195] 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 668b91af6e8139e7efe29b70888f0ed640bd036a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 12 Jan 2024 18:09:23 +0100 Subject: [PATCH 055/195] 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 2dcb1d2f4c6b9d6890827f360b79133cb3168da8 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 12 Jan 2024 18:36:43 +0100 Subject: [PATCH 056/195] 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 b45ea6a82c48d8329174c155b834fce7e4580a95 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Sat, 13 Jan 2024 12:54:50 +0100 Subject: [PATCH 057/195] 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 188eb6314ac7c20253fcb39413f53bbe98971c37 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Sat, 13 Jan 2024 15:22:34 +0100 Subject: [PATCH 058/195] 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 30112ebfdb5ff9e1139ce04ab4f9e8c64b3a71cd Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 15 Jan 2024 10:51:32 +0100 Subject: [PATCH 059/195] 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 8b9a179d5d02031d21aae98013391df7dbad3d77 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 Jan 2024 17:16:41 +0100 Subject: [PATCH 060/195] 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 11e3b486eb8b02c48b67a058d108b8953d5de092 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 15 Jan 2024 10:41:19 +0100 Subject: [PATCH 061/195] 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 da187d55901650ad4426c7073c8506b98cf6ffdd Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 15 Jan 2024 12:12:06 +0100 Subject: [PATCH 062/195] 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 337144bbd59621577846fb501d03c7e36c4da8bf Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 15 Jan 2024 17:49:07 +0100 Subject: [PATCH 063/195] 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 e764763f742387076c42493e4d64da2588a04e30 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 11:31:10 +0100 Subject: [PATCH 064/195] 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 2f57ace3a56c1c65a2cb4e36fbe9cacbdb9963f1 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 14:47:44 +0100 Subject: [PATCH 065/195] 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 a2adb18655f88c5a4fb229896c9702f2a6e307d0 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 14:48:06 +0100 Subject: [PATCH 066/195] 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 f76b1c040bae0b721be04dc3f19c94913be62817 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 16 Jan 2024 15:34:07 +0100 Subject: [PATCH 067/195] 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 89ae3901f0123feb3c3cff562b769f7fb4d045e5 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 6 Feb 2024 14:08:38 +0100 Subject: [PATCH 068/195] 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 47d3a4430b2202e614a513fe6796aac7a1d17fe7 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Sun, 25 Feb 2024 23:33:00 +0100 Subject: [PATCH 069/195] 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 5f46ad19db540e5291d0ee9f2f173ee09b0d6aae Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Thu, 29 Feb 2024 14:38:08 +0100 Subject: [PATCH 070/195] 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 6df85a68c7caebeead6dc26a02ab8ee95e9f2e75 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 5 Mar 2024 13:39:31 +0100 Subject: [PATCH 071/195] 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 215dd40e03ca359e6fbd80dd48a8c4669a6b4dd8 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 10 Apr 2024 18:32:48 +0200 Subject: [PATCH 072/195] 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 2cde08a15eec4b746b756f8b25cf11c9b5a072a0 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 14:47:02 +0200 Subject: [PATCH 073/195] 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 565d5eb8b07..0594a0e156f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -119,6 +119,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"), @@ -132,40 +133,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 271b8bee2b2d1a468e5c60b33b04749e5a37c855 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 15:16:27 +0200 Subject: [PATCH 074/195] 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 77d501fa17e4bc00f399f346554f2c9af382eccb Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 15:43:07 +0200 Subject: [PATCH 075/195] 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 fea7bd4158c309b3dc7e5d2375a9af89e3c6bd63 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 29 Apr 2024 18:17:33 +0200 Subject: [PATCH 076/195] 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 4cf4e01cb830e000a8d16b873897792ecf48251a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 30 Apr 2024 14:20:58 +0200 Subject: [PATCH 077/195] 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 182e6de9cb7a053ade257fa53a308f0d057b0ec0 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 11:35:18 +0200 Subject: [PATCH 078/195] 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 0a0ebcbd9b94b618df5bdac9c06bb80ec36a5607 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 16:13:01 +0200 Subject: [PATCH 079/195] 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 c5d57ed02ddf31512134f3c302a46f54b2169fd3 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 16:22:07 +0200 Subject: [PATCH 080/195] 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 cb6a501f3a8355c2145d42a72c0a2e7b3ac70128 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 17:17:57 +0200 Subject: [PATCH 081/195] 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 7f6ed611938e290f9c48ef9ee0f1133aa83d77f9 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 2 May 2024 18:41:58 +0200 Subject: [PATCH 082/195] 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 22a9f52ddc7e6b1213433092f64a0851c701036e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 3 May 2024 19:14:05 +0200 Subject: [PATCH 083/195] 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 3a659cedf9ff99309c28a02dd38c9e4aef41e687 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 17:07:47 +0200 Subject: [PATCH 084/195] 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 48532797d95488d9e2a7e200854f22c72e0764bf Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 17:18:24 +0200 Subject: [PATCH 085/195] 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 8c2d6d1bb1c0a96a539d7de7ea0dd56d41e3bb04 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 17:32:05 +0200 Subject: [PATCH 086/195] 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 558b49b188510b4bae51c7d26109d8f6d29d76c7 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 6 May 2024 18:32:19 +0200 Subject: [PATCH 087/195] 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 6525c89d10259a418e0a88b54a9fda728ed02032 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 7 May 2024 17:34:01 +0200 Subject: [PATCH 088/195] 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 d8a16914d95993dd28f68fa2f896e169481ed4ed Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 7 May 2024 18:23:42 +0200 Subject: [PATCH 089/195] 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 b8fc9738bedc5b283909c3ce4847705a5eaff5e4 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 10 May 2024 18:18:53 +0200 Subject: [PATCH 090/195] 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 9a190b96a7f603fda7cf797882bd5172ef06ab44 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 10 May 2024 18:29:42 +0200 Subject: [PATCH 091/195] 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 0cd86ea5a6579da1c32671a9e71956298129bded Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 13 May 2024 17:29:11 +0200 Subject: [PATCH 092/195] 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 b58e78986689e1289c755bfa8d28355823d9b1b7 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 13 May 2024 17:34:03 +0200 Subject: [PATCH 093/195] 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 a966868dfb342e179f0ae5b20b91107dfa58a6d1 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 14:16:34 +0200 Subject: [PATCH 094/195] 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 99f8d8e81d3..e63f749c6ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "numpy >=1.23,<3.0.0a0", "packaging", "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 53cf92a144aa78f9b350bebcf77108408a043f0c Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 14:20:58 +0200 Subject: [PATCH 095/195] 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 b80a862d25d0523f6efc188ba3f4345d662b37b3 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 14 May 2024 16:07:38 +0200 Subject: [PATCH 096/195] 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 301fa41f1d8c44a0ccad21bfcd2789ecf61fb2ef Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 16:12:30 +0200 Subject: [PATCH 097/195] cleaning up changelogs --- docs/changes/2315.irf-maker.rst | 1 - docs/changes/2473.feature.rst | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 docs/changes/2315.irf-maker.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/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 aa7f3579413d57bd4f82ba6073bca628f7d473ba Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 16:45:17 +0200 Subject: [PATCH 098/195] 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 0d2e02d1aede2e817e7624cf56d78d348676bfc2 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 14 May 2024 17:59:26 +0200 Subject: [PATCH 099/195] 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 88ba028df624ddd8f7aafa4009785cd495259091 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 18:09:14 +0200 Subject: [PATCH 100/195] 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 1477363f35dcd3fdfa682d743fa8ff662869800d Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 18:10:16 +0200 Subject: [PATCH 101/195] 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 c8c64439dec6ec527336d5ed1e3a71767b3df74f Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 14 May 2024 18:41:29 +0200 Subject: [PATCH 102/195] 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 e5b84e2decd9dd40915e0fe9182c43c665dbffc1 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 15:13:29 +0200 Subject: [PATCH 103/195] 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 e0b979a2488cdfe8c31ffc96805bd889669af060 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 15:18:32 +0200 Subject: [PATCH 104/195] 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 7ae5518d1b2621e325ba0e6e3ec342880fb55bc2 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 17:23:40 +0200 Subject: [PATCH 105/195] 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 804173d10c964797fbb5d2411976e9a53874c57b Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 15 May 2024 17:25:30 +0200 Subject: [PATCH 106/195] 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 71129947f47598b4f329db592f1c87fca2e29696 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 16 May 2024 16:43:48 +0200 Subject: [PATCH 107/195] 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 c922f8bfb47eba5ffd97c33ff179fa09338c8ca3 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 16 May 2024 17:32:57 +0200 Subject: [PATCH 108/195] 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 b9641b98751c93a89224371a1507f68fe8f4bd6f Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 17 May 2024 19:02:50 +0200 Subject: [PATCH 109/195] 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 8cdf5d7cd52ce953e1193ca7301febbf45375f74 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 4 Jun 2024 18:04:13 +0200 Subject: [PATCH 110/195] 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 badc60098714b0b513cbb938ee5c28c5a7c28723 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 6 Jun 2024 15:29:55 +0200 Subject: [PATCH 111/195] 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 44b116ffa905b560e081d692ab3c5352207ad883 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 6 Jun 2024 18:27:19 +0200 Subject: [PATCH 112/195] 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 b8d47d00a771982a63eb977806077c0b9c0fcc6e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 7 Jun 2024 15:29:32 +0200 Subject: [PATCH 113/195] 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 efc97f4a39b2719f1b2c10d9bdc405e7607e8874 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 12:01:38 +0200 Subject: [PATCH 114/195] 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 93da1e0943a9f00813c49711d6f41fa07754d686 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 12:19:54 +0200 Subject: [PATCH 115/195] 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 80478d1a98ae9c6f6e0c2922630036d6a87ba443 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 12:32:40 +0200 Subject: [PATCH 116/195] 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 39b0b695737fb3d5c75c8dcd8a9ce6e2f9860cfa Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 13:47:25 +0200 Subject: [PATCH 117/195] 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 42f4722e8be5596531f6a1fe0e174715d70122cf Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 14:24:17 +0200 Subject: [PATCH 118/195] 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 9d08e9f222423e31855e07f33ff9bb25f7e4d97c Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 16:13:59 +0200 Subject: [PATCH 119/195] 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 a5bd3ca262ced5cdaa6aa2a818885ec46e2dad13 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 16:36:52 +0200 Subject: [PATCH 120/195] 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 51d0ac32c55d5f7fc6848c27b5363ec016843de1 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 14 Jun 2024 16:38:57 +0200 Subject: [PATCH 121/195] 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 48796dd98385b8505b716257ed526293e323dd17 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 18 Jul 2024 15:17:08 +0200 Subject: [PATCH 122/195] 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 b6874b8701a95b60518fbfbb4757948e7d7e0d83 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 18 Jul 2024 17:44:50 +0200 Subject: [PATCH 123/195] 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 888438fc376b0066bd6523c8809654d1ecca3e95 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 19 Jul 2024 18:08:03 +0200 Subject: [PATCH 124/195] 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 432e3621490e8879d3556f9e2448869f3364d986 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 19 Jul 2024 18:09:03 +0200 Subject: [PATCH 125/195] 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 d2a01331aad..0aa1eebf845 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 @@ -707,3 +708,79 @@ def provenance(monkeypatch): monkeypatch.setattr(prov, "_activities", []) monkeypatch.setattr(prov, "_finished_activities", []) return prov + + +@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 09e95579181da15f331ea91e80afaac3f4856d91 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 19 Jul 2024 18:09:45 +0200 Subject: [PATCH 126/195] 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 c3752b87ee8b398e987007b0675ec5bfcd989804 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 19 Jul 2024 15:53:38 +0200 Subject: [PATCH 127/195] 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 d4d64f08897515e7c8690f1a0936a488a4bf9dae Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 23 Jul 2024 11:59:07 +0200 Subject: [PATCH 128/195] 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 b0991f8bbefcff21f3a3dddfb99735484b31991e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Tue, 23 Jul 2024 17:14:41 +0200 Subject: [PATCH 129/195] 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 a466a1be5b4eac8908f9837b9f12416095240827 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 24 Jul 2024 17:32:08 +0200 Subject: [PATCH 130/195] 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 7e6bd5d221e3380fb0744dfc6b9e9ac12bd67bc5 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 14 Aug 2024 19:53:47 +0200 Subject: [PATCH 131/195] 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 73ffdddf65e109bdc73eb739199dfb89699522e3 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 14 Aug 2024 19:54:25 +0200 Subject: [PATCH 132/195] 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 a3cac468850d30e81ef95d834f401068cc454f57 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 16 Aug 2024 16:55:04 +0200 Subject: [PATCH 133/195] 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 0e169c46a1615d29582d7ca18f9a9f5a5af1033a Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 2 Sep 2024 18:11:38 +0200 Subject: [PATCH 134/195] 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 414d873d848194831eb337eddb19dc1b394b073d Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 3 Sep 2024 15:27:54 +0200 Subject: [PATCH 135/195] 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 0aa1eebf845..2cf941088d9 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 @@ -784,3 +784,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 ffb8969a0d5aa1662056b474fe2b4d0f12dc34d2 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 3 Sep 2024 15:28:53 +0200 Subject: [PATCH 136/195] 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], + ) From 05a35e8337ed77d7f503555048c4b48b1cf887ba Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 19 Sep 2024 18:01:48 +0200 Subject: [PATCH 137/195] Address some comments --- docs/changes/2473.feature.rst | 18 +- pyproject.toml | 2 +- src/ctapipe/conftest.py | 2 +- src/ctapipe/irf/__init__.py | 4 +- src/ctapipe/irf/benchmarks.py | 50 ++--- src/ctapipe/irf/binning.py | 13 +- src/ctapipe/irf/irfs.py | 25 +-- src/ctapipe/irf/optimize.py | 8 +- src/ctapipe/irf/select.py | 123 +++++------ src/ctapipe/irf/spectra.py | 3 + src/ctapipe/irf/tests/test_benchmarks.py | 26 +-- src/ctapipe/irf/tests/test_binning.py | 20 +- src/ctapipe/irf/tests/test_optimize.py | 6 +- src/ctapipe/irf/tests/test_select.py | 4 +- src/ctapipe/tools/make_irf.py | 200 ++++++++---------- src/ctapipe/tools/optimize_event_selection.py | 28 +-- 16 files changed, 264 insertions(+), 268 deletions(-) diff --git a/docs/changes/2473.feature.rst b/docs/changes/2473.feature.rst index 94106872294..8c21a705048 100644 --- a/docs/changes/2473.feature.rst +++ b/docs/changes/2473.feature.rst @@ -1,3 +1,17 @@ -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, +based on a gamma, and optionally a proton and an electron DL2 file. +Two components for calculating G/H and optionally theta cuts are added: +``PercentileCuts`` keeps a certain percentage of gamma events in each bin and +``PointSourceSensitivityOptimizer`` optimizes G/H cuts for maximum point source sensitivity and +optionally calculates percentile theta cuts. -Add a ``ctapipe-optimize-event-selection`` tool to produce cut-selection files. +Add a ``ctapipe-make-irf`` tool to produce irfs given a cut-selection file, a gamma, +and optionally a proton, and an electron DL2 input file. +Given only a gamma file, the energy dispersion, effective area, and point spread function are calculated. +Optionally, the bias and resolution of the energy reconstruction and the angular resolution can be calculated +and saved in a separate output file. +If a proton or a proton and an electron file is also given, a background model can be calculated, +as well as the point source sensitivity. + +Both, full enclosure and point-like irf can be calculated. +Only radially symmetric parameterizations of the irf components are implemented so far. diff --git a/pyproject.toml b/pyproject.toml index e63f749c6ff..6bea39efbc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "numpy >=1.23,<3.0.0a0", "packaging", "psutil", - "pyirf >0.10.1", + "pyirf ~=0.11.0", "pyyaml >=5.1", "requests", "scikit-learn !=1.4.0", # 1.4.0 breaks with astropy tables, before and after works diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index 2cf941088d9..fed9f8a79ea 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -775,7 +775,7 @@ def irf_events_loader_test_config(): "quality_criteria": [ ( "multiplicity 4", - "np.count_nonzero(tels_with_trigger,axis=1) >= 4", + "np.count_nonzero(HillasReconstructor_telescopes,axis=1) >= 4", ), ("valid classifier", "ExtraTreesClassifier_is_valid"), ("valid geom reco", "HillasReconstructor_is_valid"), diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 836673b31a4..a559bf0efe3 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -24,7 +24,7 @@ PointSourceSensitivityOptimizer, ThetaPercentileCutCalculator, ) -from .select import EventPreProcessor, EventsLoader +from .select import EventLoader, EventPreProcessor from .spectra import SPECTRA, Spectra __all__ = [ @@ -40,7 +40,7 @@ "OptimizationResultStore", "PointSourceSensitivityOptimizer", "PercentileCuts", - "EventsLoader", + "EventLoader", "EventPreProcessor", "Spectra", "GhPercentileCutCalculator", diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index a1aee0304a6..11190f591ae 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -11,7 +11,7 @@ from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core.traits import Bool, Float -from .binning import FoVOffsetBinsBase, RecoEnergyBinsBase, TrueEnergyBinsBase +from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins from .spectra import SPECTRA, Spectra @@ -30,7 +30,7 @@ def _get_2d_result_table( return result, fov_bin_index, mat_shape -class EnergyBiasResolutionMakerBase(TrueEnergyBinsBase): +class EnergyBiasResolutionMakerBase(DefaultTrueEnergyBins): """ Base class for calculating the bias and resolution of the energy prediciton. """ @@ -58,7 +58,7 @@ def make_bias_resolution_hdu( """ -class EnergyBiasResolution2dMaker(EnergyBiasResolutionMakerBase, FoVOffsetBinsBase): +class EnergyBiasResolution2dMaker(EnergyBiasResolutionMakerBase, DefaultFoVOffsetBins): """ Calculates the bias and the resolution of the energy prediction in bins of true energy and fov offset. @@ -77,7 +77,7 @@ def make_bias_resolution_hdu( ) 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, ...] + result["RESOLUTION"] = np.full(mat_shape, np.nan)[np.newaxis, ...] for i in range(len(self.fov_offset_bins) - 1): bias_resolution = energy_bias_resolution( @@ -88,12 +88,12 @@ def make_bias_resolution_hdu( ) result["N_EVENTS"][:, i, :] = bias_resolution["n_events"] result["BIAS"][:, i, :] = bias_resolution["bias"] - result["RESOLUTI"][:, i, :] = bias_resolution["resolution"] + result["RESOLUTION"][:, i, :] = bias_resolution["resolution"] return BinTableHDU(result, name=extname) -class AngularResolutionMakerBase(TrueEnergyBinsBase, RecoEnergyBinsBase): +class AngularResolutionMakerBase(DefaultTrueEnergyBins, DefaultRecoEnergyBins): """ Base class for calculating the angular resolution. """ @@ -127,7 +127,7 @@ def make_angular_resolution_hdu( """ -class AngularResolution2dMaker(AngularResolutionMakerBase, FoVOffsetBinsBase): +class AngularResolution2dMaker(AngularResolutionMakerBase, DefaultFoVOffsetBins): """ Calculates the angular resolution in bins of either true or reconstructed energy and fov offset. @@ -152,7 +152,7 @@ def make_angular_resolution_hdu( fov_bins=self.fov_offset_bins, ) result["N_EVENTS"] = np.zeros(mat_shape)[np.newaxis, ...] - result["ANG_RES"] = u.Quantity( + result["ANGULAR_RESOLUTION"] = u.Quantity( np.full(mat_shape, np.nan)[np.newaxis, ...], events["theta"].unit ) @@ -163,14 +163,14 @@ def make_angular_resolution_hdu( energy_type=energy_type, ) result["N_EVENTS"][:, i, :] = ang_res["n_events"] - result["ANG_RES"][:, i, :] = ang_res["angular_resolution"] + result["ANGULAR_RESOLUTION"][:, i, :] = ang_res["angular_resolution"] header = Header() header["E_TYPE"] = energy_type.upper() return BinTableHDU(result, header=header, name=extname) -class SensitivityMakerBase(RecoEnergyBinsBase): +class SensitivityMakerBase(DefaultRecoEnergyBins): """Base class for calculating the point source sensitivity.""" alpha = Float( @@ -212,7 +212,7 @@ def make_sensitivity_hdu( """ -class Sensitivity2dMaker(SensitivityMakerBase, FoVOffsetBinsBase): +class Sensitivity2dMaker(SensitivityMakerBase, DefaultFoVOffsetBins): """ Calculates the point source sensitivity in bins of reconstructed energy and fov offset. @@ -235,13 +235,13 @@ def make_sensitivity_hdu( 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( + result["N_SIGNAL"] = np.zeros(mat_shape)[np.newaxis, ...] + result["N_SIGNAL_WEIGHTED"] = np.zeros(mat_shape)[np.newaxis, ...] + result["N_BACKGROUND"] = np.zeros(mat_shape)[np.newaxis, ...] + result["N_BACKGROUND_WEIGHTED"] = np.zeros(mat_shape)[np.newaxis, ...] + result["SIGNIFICANCE"] = np.full(mat_shape, np.nan)[np.newaxis, ...] + result["RELATIVE_SENSITIVITY"] = np.full(mat_shape, np.nan)[np.newaxis, ...] + result["FLUX_SENSITIVITY"] = u.Quantity( np.full(mat_shape, np.nan)[np.newaxis, ...], 1 / (u.TeV * u.s * u.cm**2) ) for i in range(len(self.fov_offset_bins) - 1): @@ -259,13 +259,13 @@ 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[ + result["N_SIGNAL"][:, i, :] = sens["n_signal"] + result["N_SIGNAL_WEIGHTED"][:, i, :] = sens["n_signal_weighted"] + result["N_BACKGROUND"][:, i, :] = sens["n_background"] + result["N_BACKGROUND_WEIGHTED"][:, i, :] = sens["n_background_weighted"] + result["SIGNIFICANCE"][:, i, :] = sens["significance"] + result["RELATIVE_SENSITIVITY"][:, i, :] = sens["relative_sensitivity"] + result["FLUX_SENSITIVITY"][:, i, :] = sens[ "relative_sensitivity" ] * source_spectrum(sens["reco_energy_center"]) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 97b5e15d829..d87c0f05ba4 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -1,6 +1,7 @@ """Collection of binning related functionality for the irf tools""" import logging +from dataclasses import dataclass import astropy.units as u import numpy as np @@ -78,13 +79,13 @@ def make_bins_per_decade(e_min, e_max, n_bins_per_decade=5): return u.Quantity(np.logspace(log_lower, log_upper, n_bins + 1), unit, copy=False) +@dataclass class ResultValidRange: - def __init__(self, bounds_table, prefix): - self.min = bounds_table[f"{prefix}_min"][0] - self.max = bounds_table[f"{prefix}_max"][0] + min: u.Quantity + max: u.Quantity -class TrueEnergyBinsBase(Component): +class DefaultTrueEnergyBins(Component): """Base class for creating irfs or benchmarks binned in true energy.""" true_energy_min = AstroQuantity( @@ -113,7 +114,7 @@ def __init__(self, parent=None, **kwargs): ) -class RecoEnergyBinsBase(Component): +class DefaultRecoEnergyBins(Component): """Base class for creating irfs or benchmarks binned in reconstructed energy.""" reco_energy_min = AstroQuantity( @@ -142,7 +143,7 @@ def __init__(self, parent=None, **kwargs): ) -class FoVOffsetBinsBase(Component): +class DefaultFoVOffsetBins(Component): """Base class for creating radially symmetric irfs or benchmarks.""" fov_offset_min = AstroQuantity( diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index f0e3f8ddf95..119f8c8d93f 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -1,4 +1,5 @@ """Components to generate IRFs""" + from abc import abstractmethod import astropy.units as u @@ -21,10 +22,10 @@ from pyirf.simulations import SimulatedEventsInfo from ..core.traits import AstroQuantity, Float, Integer -from .binning import FoVOffsetBinsBase, RecoEnergyBinsBase, TrueEnergyBinsBase +from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins -class PsfMakerBase(TrueEnergyBinsBase): +class PsfMakerBase(DefaultTrueEnergyBins): """Base class for calculating the point spread function.""" def __init__(self, parent=None, **kwargs): @@ -48,7 +49,7 @@ def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: """ -class BackgroundRateMakerBase(RecoEnergyBinsBase): +class BackgroundRateMakerBase(DefaultRecoEnergyBins): """Base class for calculating the background rate.""" def __init__(self, parent=None, **kwargs): @@ -78,7 +79,7 @@ def make_bkg_hdu( """ -class EnergyMigrationMakerBase(TrueEnergyBinsBase): +class EnergyMigrationMakerBase(DefaultTrueEnergyBins): """Base class for calculating the energy migration.""" energy_migration_min = Float( @@ -98,7 +99,7 @@ class EnergyMigrationMakerBase(TrueEnergyBinsBase): def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self.migration_bins = np.linspace( + self.migration_bins = np.geomspace( self.energy_migration_min, self.energy_migration_max, self.energy_migration_n_bins + 1, @@ -128,7 +129,7 @@ def make_edisp_hdu( """ -class EffectiveAreaMakerBase(TrueEnergyBinsBase): +class EffectiveAreaMakerBase(DefaultTrueEnergyBins): """Base class for calculating the effective area.""" def __init__(self, parent=None, **kwargs): @@ -168,9 +169,9 @@ def make_aeff_hdu( """ -class EffectiveArea2dMaker(EffectiveAreaMakerBase, FoVOffsetBinsBase): +class EffectiveArea2dMaker(EffectiveAreaMakerBase, DefaultFoVOffsetBins): """ - Creates a radially symmetric parameterizations of the effective area in equidistant + Creates a radially symmetric parameterization of the effective area in equidistant bins of logarithmic true energy and field of view offset. """ @@ -212,9 +213,9 @@ def make_aeff_hdu( ) -class EnergyMigration2dMaker(EnergyMigrationMakerBase, FoVOffsetBinsBase): +class EnergyMigration2dMaker(EnergyMigrationMakerBase, DefaultFoVOffsetBins): """ - Creates a radially symmetric parameterizations of the energy migration in + Creates a radially symmetric parameterization of the energy migration in equidistant bins of logarithmic true energy and field of view offset. """ @@ -240,7 +241,7 @@ def make_edisp_hdu( ) -class BackgroundRate2dMaker(BackgroundRateMakerBase, FoVOffsetBinsBase): +class BackgroundRate2dMaker(BackgroundRateMakerBase, DefaultFoVOffsetBins): """ Creates a radially symmetric parameterization of the background rate in equidistant bins of logarithmic reconstructed energy and field of view offset. @@ -266,7 +267,7 @@ def make_bkg_hdu( ) -class Psf3dMaker(PsfMakerBase, FoVOffsetBinsBase): +class Psf3dMaker(PsfMakerBase, DefaultFoVOffsetBins): """ 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 9b8fba3debc..97a1f4ae24e 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -19,8 +19,12 @@ class OptimizationResult: 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.valid_energy = ResultValidRange( + min=valid_energy["energy_min"], max=valid_energy["energy_max"] + ) + self.valid_offset = ResultValidRange( + min=valid_offset["offset_min"], max=valid_offset["offset_max"] + ) self.gh_cuts = gh self.theta_cuts = theta diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index eeea407bffe..28c483160a9 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -5,9 +5,14 @@ import astropy.units as u import numpy as np from astropy.coordinates import AltAz, SkyCoord -from astropy.table import QTable, Table, vstack +from astropy.table import Column, QTable, Table, vstack from pyirf.simulations import SimulatedEventsInfo -from pyirf.spectral import PowerLaw, calculate_event_weights +from pyirf.spectral import ( + DIFFUSE_FLUX_UNIT, + POINT_SOURCE_FLUX_UNIT, + PowerLaw, + calculate_event_weights, +) from pyirf.utils import calculate_source_fov_offset, calculate_theta from ..coordinates import NominalFrame @@ -38,7 +43,10 @@ class EventPreProcessor(QualityQuery): quality_criteria = List( Tuple(Unicode(), Unicode()), default_value=[ - ("multiplicity 4", "np.count_nonzero(tels_with_trigger,axis=1) >= 4"), + ( + "multiplicity 4", + "np.count_nonzero(HillasReconstructor_telescopes,axis=1) >= 4", + ), ("valid classifier", "RandomForestClassifier_is_valid"), ("valid geom reco", "HillasReconstructor_is_valid"), ("valid energy reco", "RandomForestRegressor_is_valid"), @@ -106,61 +114,54 @@ def make_empty_table(self) -> QTable: in the event table. """ 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", + Column(name="obs_id", dtype=np.uint64, description="Observation Block ID"), + Column(name="event_id", dtype=np.uint64, description="Array Event ID"), + Column(name="true_energy", unit=u.TeV, description="Simulated Energy"), + Column(name="true_az", unit=u.deg, description="Simulated azimuth"), + Column(name="true_alt", unit=u.deg, description="Simulated altitude"), + Column(name="reco_energy", unit=u.TeV, description="Reconstructed energy"), + Column(name="reco_az", unit=u.deg, description="Reconstructed azimuth"), + Column(name="reco_alt", unit=u.deg, description="Reconstructed altitude"), + Column( + name="reco_fov_lat", + unit=u.deg, + description="Reconstructed field of view lat", + ), + Column( + name="reco_fov_lon", + unit=u.deg, + description="Reconstructed field of view lon", + ), + Column( + name="gh_score", + description=( + "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" + ), + ), + Column(name="pointing_az", unit=u.deg, description="Pointing azimuth"), + Column(name="pointing_alt", unit=u.deg, description="Pointing altitude"), + Column( + name="theta", + unit=u.deg, + description="Reconstructed angular offset from source position", + ), + Column( + name="true_source_fov_offset", + unit=u.deg, + description="Simulated angular offset from pointing direction", + ), + Column( + name="reco_source_fov_offset", + unit=u.deg, + description="Reconstructed angular offset from pointing direction", + ), ] - 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, - "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(columns) - return QTable(names=columns, units=units, descriptions=descriptions) - -class EventsLoader(Component): +class EventLoader(Component): classes = [EventPreProcessor] def __init__(self, kind: str, file: Path, target_spectrum: Spectra, **kwargs): @@ -177,7 +178,9 @@ def load_preselected_events( 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) + sim_info, spectrum, obs_conf = self.get_simulation_information( + load, obs_time + ) meta = {"sim_info": sim_info, "spectrum": spectrum} bits = [header] n_raw_events = 0 @@ -192,7 +195,7 @@ def load_preselected_events( table = vstack(bits, join_type="exact", metadata_conflicts="silent") return table, n_raw_events, meta - def get_metadata( + def get_simulation_information( self, loader: TableLoader, obs_time: u.Quantity ) -> tuple[SimulatedEventsInfo, PowerLaw, Table]: obs = loader.read_observation_information() @@ -224,7 +227,6 @@ def get_metadata( 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 events["pointing_alt"] = obs_conf["subarray_pointing_lat"][0] * u.deg events["pointing_az"] = obs_conf["subarray_pointing_lon"][0] * u.deg else: @@ -268,13 +270,14 @@ def make_event_weights( if ( self.kind == "gammas" and self.target_spectrum.normalization.unit.is_equivalent( - spectrum.normalization.unit * u.sr + POINT_SOURCE_FLUX_UNIT ) + and spectrum.normalization.unit.is_equivalent(DIFFUSE_FLUX_UNIT) ): 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." + "for the integration of the simulated diffuse spectrum were given." ) events["weight"] = 1.0 diff --git a/src/ctapipe/irf/spectra.py b/src/ctapipe/irf/spectra.py index fa726eadd10..4db3e4277cf 100644 --- a/src/ctapipe/irf/spectra.py +++ b/src/ctapipe/irf/spectra.py @@ -1,10 +1,13 @@ """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): + """Spectra for calculating event weights""" + CRAB_HEGRA = 1 IRFDOC_ELECTRON_SPECTRUM = 2 IRFDOC_PROTON_SPECTRUM = 3 diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 95298d265d7..3a122fa2a44 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -19,7 +19,7 @@ def test_make_2d_energy_bias_res(irf_events_table): assert ( bias_res_hdu.data["N_EVENTS"].shape == bias_res_hdu.data["BIAS"].shape - == bias_res_hdu.data["RESOLUTI"].shape + == bias_res_hdu.data["RESOLUTION"].shape == (1, 3, 29) ) _check_boundaries_in_hdu( @@ -44,7 +44,7 @@ def test_make_2d_ang_res(irf_events_table): 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 + == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape == (1, 3, 23) ) _check_boundaries_in_hdu( @@ -57,7 +57,7 @@ def test_make_2d_ang_res(irf_events_table): 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 + == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape == (1, 3, 29) ) _check_boundaries_in_hdu( @@ -70,9 +70,9 @@ def test_make_2d_ang_res(irf_events_table): 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 + from ctapipe.irf import EventLoader, Sensitivity2dMaker, Spectra - gamma_loader = EventsLoader( + gamma_loader = EventLoader( config=irf_events_loader_test_config, kind="gammas", file=gamma_diffuse_full_reco_file, @@ -82,7 +82,7 @@ def test_make_2d_sensitivity( chunk_size=10000, obs_time=u.Quantity(50, u.h), ) - proton_loader = EventsLoader( + proton_loader = EventLoader( config=irf_events_loader_test_config, kind="protons", file=proton_full_reco_file, @@ -114,13 +114,13 @@ def test_make_2d_sensitivity( 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 + sens_hdu.data["N_SIGNAL"].shape + == sens_hdu.data["N_SIGNAL_WEIGHTED"].shape + == sens_hdu.data["N_BACKGROUND"].shape + == sens_hdu.data["N_BACKGROUND_WEIGHTED"].shape + == sens_hdu.data["SIGNIFICANCE"].shape + == sens_hdu.data["RELATIVE_SENSITIVITY"].shape + == sens_hdu.data["FLUX_SENSITIVITY"].shape == (1, 3, 29) ) _check_boundaries_in_hdu( diff --git a/src/ctapipe/irf/tests/test_binning.py b/src/ctapipe/irf/tests/test_binning.py index e905b1fe7d1..e1040f15058 100644 --- a/src/ctapipe/irf/tests/test_binning.py +++ b/src/ctapipe/irf/tests/test_binning.py @@ -3,18 +3,12 @@ import astropy.units as u import numpy as np import pytest -from astropy.table import QTable def test_check_bins_in_range(tmp_path): 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", - ) + valid_range = ResultValidRange(min=0.03 * u.TeV, max=200 * u.TeV) # bins are in range bins = u.Quantity(np.logspace(-1, 2, 10), u.TeV) @@ -64,9 +58,9 @@ def test_make_bins_per_decade(): def test_true_energy_bins_base(): - from ctapipe.irf.binning import TrueEnergyBinsBase + from ctapipe.irf.binning import DefaultTrueEnergyBins - binning = TrueEnergyBinsBase( + binning = DefaultTrueEnergyBins( true_energy_min=0.02 * u.TeV, true_energy_max=200 * u.TeV, true_energy_n_bins_per_decade=7, @@ -81,9 +75,9 @@ def test_true_energy_bins_base(): def test_reco_energy_bins_base(): - from ctapipe.irf.binning import RecoEnergyBinsBase + from ctapipe.irf.binning import DefaultRecoEnergyBins - binning = RecoEnergyBinsBase( + binning = DefaultRecoEnergyBins( reco_energy_min=0.02 * u.TeV, reco_energy_max=200 * u.TeV, reco_energy_n_bins_per_decade=4, @@ -98,9 +92,9 @@ def test_reco_energy_bins_base(): def test_fov_offset_bins_base(): - from ctapipe.irf.binning import FoVOffsetBinsBase + from ctapipe.irf.binning import DefaultFoVOffsetBins - binning = FoVOffsetBinsBase( + binning = DefaultFoVOffsetBins( # use default for fov_offset_min fov_offset_max=3 * u.deg, fov_offset_n_bins=3, diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index ab744cf7843..8380fbda866 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -4,7 +4,7 @@ from astropy.table import QTable from ctapipe.core import non_abstract_children -from ctapipe.irf import EventsLoader, Spectra +from ctapipe.irf import EventLoader, Spectra from ctapipe.irf.optimize import CutOptimizerBase @@ -95,7 +95,7 @@ def test_cut_optimizer( ): from ctapipe.irf import OptimizationResultStore - gamma_loader = EventsLoader( + gamma_loader = EventLoader( config=irf_events_loader_test_config, kind="gammas", file=gamma_diffuse_full_reco_file, @@ -105,7 +105,7 @@ def test_cut_optimizer( chunk_size=10000, obs_time=u.Quantity(50, u.h), ) - proton_loader = EventsLoader( + proton_loader = EventLoader( config=irf_events_loader_test_config, kind="protons", file=proton_full_reco_file, diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_select.py index 89d62a12279..963dd2c64ff 100644 --- a/src/ctapipe/irf/tests/test_select.py +++ b/src/ctapipe/irf/tests/test_select.py @@ -59,9 +59,9 @@ def test_normalise_column_names(dummy_table): def test_events_loader(gamma_diffuse_full_reco_file, irf_events_loader_test_config): - from ctapipe.irf import EventsLoader, Spectra + from ctapipe.irf import EventLoader, Spectra - loader = EventsLoader( + loader = EventLoader( config=irf_events_loader_test_config, kind="gammas", file=gamma_diffuse_full_reco_file, diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 1e36276235c..de4d3eeaed0 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -13,8 +13,8 @@ from ..core import Provenance, Tool, ToolConfigurationError, traits from ..core.traits import AstroQuantity, Bool, Integer, classes_with_traits, flag from ..irf import ( + EventLoader, EventPreProcessor, - EventsLoader, OptimizationResultStore, Spectra, check_bins_in_range, @@ -34,11 +34,11 @@ class IrfTool(Tool): name = "ctapipe-make-irf" - description = "Tool to create IRF files in GAD format" + description = "Tool to create IRF files in GADF format" do_background = Bool( True, - help="Compute background rate IRF using supplied files", + help="Compute background rate using supplied files", ).tag(config=True) do_benchmarks = Bool( @@ -62,7 +62,7 @@ class IrfTool(Tool): gamma_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.CRAB_HEGRA, - help="Name of the spectra used for the simulated gamma spectrum", + help="Name of the spectrum used for weights of gamma events.", ).tag(config=True) proton_file = traits.Path( @@ -75,7 +75,7 @@ class IrfTool(Tool): proton_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_PROTON_SPECTRUM, - help="Name of the spectra used for the simulated proton spectrum", + help="Name of the spectrum used for weights of proton events.", ).tag(config=True) electron_file = traits.Path( @@ -88,7 +88,7 @@ class IrfTool(Tool): electron_target_spectrum = traits.UseEnum( Spectra, default_value=Spectra.IRFDOC_ELECTRON_SPECTRUM, - help="Name of the spectra used for the simulated electron spectrum", + help="Name of the spectrum used for weights of electron events.", ).tag(config=True) chunk_size = Integer( @@ -107,34 +107,37 @@ class IrfTool(Tool): obs_time = AstroQuantity( default_value=u.Quantity(50, u.hour), physical_type=u.physical.time, - help="Observation time in the form `` ``", + help=( + "Observation time in the form `` ``." + " This is used for flux normalization and estimating a background rate." + ), ).tag(config=True) - edisp_parameterization = traits.ComponentName( + edisp_maker = traits.ComponentName( EnergyMigrationMakerBase, default_value="EnergyMigration2dMaker", help="The parameterization of the energy migration to be used.", ).tag(config=True) - aeff_parameterization = traits.ComponentName( + aeff_maker = traits.ComponentName( EffectiveAreaMakerBase, default_value="EffectiveArea2dMaker", help="The parameterization of the effective area to be used.", ).tag(config=True) - psf_parameterization = traits.ComponentName( + psf_maker = traits.ComponentName( PsfMakerBase, default_value="Psf3dMaker", help="The parameterization of the point spread function to be used.", ).tag(config=True) - bkg_parameterization = traits.ComponentName( + bkg_maker = traits.ComponentName( BackgroundRateMakerBase, default_value="BackgroundRate2dMaker", help="The parameterization of the background rate to be used.", ).tag(config=True) - energy_bias_res_parameterization = traits.ComponentName( + energy_bias_resolution_maker = traits.ComponentName( EnergyBiasResolutionMakerBase, default_value="EnergyBiasResolution2dMaker", help=( @@ -143,13 +146,13 @@ class IrfTool(Tool): ), ).tag(config=True) - ang_res_parameterization = traits.ComponentName( + angular_resolution_maker = traits.ComponentName( AngularResolutionMakerBase, default_value="AngularResolution2dMaker", help="The parameterization of the angular resolution benchmark.", ).tag(config=True) - sens_parameterization = traits.ComponentName( + sensitivity_maker = traits.ComponentName( SensitivityMakerBase, default_value="Sensitivity2dMaker", help="The parameterization of the point source sensitivity benchmark.", @@ -195,7 +198,7 @@ class IrfTool(Tool): classes = ( [ - EventsLoader, + EventLoader, ] + classes_with_traits(BackgroundRateMakerBase) + classes_with_traits(EffectiveAreaMakerBase) @@ -220,7 +223,7 @@ def setup(self): raise_error=self.range_check_error, ) self.particles = [ - EventsLoader( + EventLoader( parent=self, kind="gammas", file=self.gamma_file, @@ -228,66 +231,60 @@ def setup(self): ), ] if self.do_background: - if self.proton_file: - self.particles.append( - EventsLoader( - parent=self, - kind="protons", - file=self.proton_file, - target_spectrum=self.proton_target_spectrum, - ) + if not self.proton_file: + raise RuntimeError( + "At least a proton file required when specifying `do_background`." ) + + self.particles.append( + EventLoader( + parent=self, + kind="protons", + file=self.proton_file, + target_spectrum=self.proton_target_spectrum, + ) + ) if self.electron_file: self.particles.append( - EventsLoader( + EventLoader( parent=self, kind="electrons", file=self.electron_file, target_spectrum=self.electron_target_spectrum, ) ) - if len(self.particles) == 1: - raise RuntimeError( - "At least one electron or proton file required when specifying `do_background`." - ) - self.bkg = BackgroundRateMakerBase.from_name( - self.bkg_parameterization, parent=self - ) + self.bkg = BackgroundRateMakerBase.from_name(self.bkg_maker, parent=self) check_e_bins( bins=self.bkg.reco_energy_bins, source="background reco energy" ) - self.edisp = EnergyMigrationMakerBase.from_name( - self.edisp_parameterization, parent=self - ) - self.aeff = EffectiveAreaMakerBase.from_name( - self.aeff_parameterization, parent=self - ) + self.edisp = EnergyMigrationMakerBase.from_name(self.edisp_maker, parent=self) + self.aeff = EffectiveAreaMakerBase.from_name(self.aeff_maker, parent=self) if self.full_enclosure: - self.psf = PsfMakerBase.from_name(self.psf_parameterization, parent=self) + self.psf = PsfMakerBase.from_name(self.psf_maker, parent=self) if self.do_benchmarks: self.b_output = self.output_path.with_name( self.output_path.name.replace(".fits", "-benchmark.fits") ) - self.ang_res = AngularResolutionMakerBase.from_name( - self.ang_res_parameterization, parent=self + self.angular_resolution = AngularResolutionMakerBase.from_name( + self.angular_resolution_maker, parent=self ) - if not self.ang_res.use_true_energy: + if not self.angular_resolution.use_true_energy: check_e_bins( - bins=self.ang_res.reco_energy_bins, + bins=self.angular_resolution.reco_energy_bins, source="Angular resolution energy", ) - self.bias_res = EnergyBiasResolutionMakerBase.from_name( - self.energy_bias_res_parameterization, parent=self + self.bias_resolution = EnergyBiasResolutionMakerBase.from_name( + self.energy_bias_resolution_maker, parent=self ) - self.sens = SensitivityMakerBase.from_name( - self.sens_parameterization, parent=self + self.sensitivity = SensitivityMakerBase.from_name( + self.sensitivity_maker, parent=self ) check_e_bins( - bins=self.sens.reco_energy_bins, source="Sensitivity reco energy" + bins=self.sensitivity.reco_energy_bins, source="Sensitivity reco energy" ) def calculate_selections(self, reduced_events: dict) -> dict: @@ -328,7 +325,8 @@ def calculate_selections(self, reduced_events: dict) -> dict: ] if self.do_background: - for bg_type in ("protons", "electrons"): + bkgs = ("protons", "electrons") if self.electron_file else ("protons") + for bg_type in bkgs: reduced_events[bg_type]["selected_gh"] = evaluate_binned_cut( reduced_events[bg_type]["gh_score"], reduced_events[bg_type]["reco_energy"], @@ -337,34 +335,21 @@ def calculate_selections(self, reduced_events: dict) -> dict: ) if self.do_background: - self.log.debug( + self.log.info( "Keeping %d signal, %d proton events, and %d electron events" % ( - sum(reduced_events["gammas"]["selected"]), - sum(reduced_events["protons"]["selected_gh"]), - sum(reduced_events["electrons"]["selected_gh"]), + np.count_nonzero(reduced_events["gammas"]["selected"]), + np.count_nonzero(reduced_events["protons"]["selected_gh"]), + np.count_nonzero(reduced_events["electrons"]["selected_gh"]), ) ) else: - self.log.debug( - "Keeping %d signal events" % (sum(reduced_events["gammas"]["selected"])) + self.log.info( + "Keeping %d signal events" + % (np.count_nonzero(reduced_events["gammas"]["selected"])) ) return reduced_events - 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 - def _make_signal_irf_hdus(self, hdus, sim_info): hdus.append( self.aeff.make_aeff_hdu( @@ -412,12 +397,12 @@ def _make_signal_irf_hdus(self, hdus, sim_info): def _make_benchmark_hdus(self, hdus): hdus.append( - self.bias_res.make_bias_resolution_hdu( + self.bias_resolution.make_bias_resolution_hdu( events=self.signal_events[self.signal_events["selected"]], ) ) hdus.append( - self.ang_res.make_angular_resolution_hdu( + self.angular_resolution.make_angular_resolution_hdu( events=self.signal_events[self.signal_events["selected_gh"]], ) ) @@ -431,14 +416,15 @@ def _make_benchmark_hdus(self, hdus): ) theta_cuts = QTable() theta_cuts["center"] = 0.5 * ( - self.sens.reco_energy_bins[:-1] + self.sens.reco_energy_bins[1:] + self.sensitivity.reco_energy_bins[:-1] + + self.sensitivity.reco_energy_bins[1:] ) - theta_cuts["cut"] = self.sens.fov_offset_max + theta_cuts["cut"] = self.sensitivity.fov_offset_max else: theta_cuts = self.opt_result.theta_cuts hdus.append( - self.sens.make_sensitivity_hdu( + self.sensitivity.make_sensitivity_hdu( signal_events=self.signal_events[self.signal_events["selected"]], background_events=self.background_events[ self.background_events["selected_gh"] @@ -471,16 +457,15 @@ def start(self): ) if sel.epp.gammaness_classifier != self.opt_result.gh_cuts.meta["CLFNAME"]: - self.log.warning( + raise RuntimeError( "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." + "Model used for g/h cuts: %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, self.obs_time) @@ -489,7 +474,7 @@ def start(self): # 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 + evs, meta["spectrum"], self.sensitivity.fov_offset_bins ) # If only background should be calculated, # only calculate weights for protons and electrons. @@ -508,45 +493,36 @@ def start(self): ).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, - ) - 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 - ) - 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 - ) + errormessage = """The gamma input file contains point-like simulations. + Therefore, the IRF can only be calculated at a single point + in the FoV, but `fov_offset_n_bins > 1`.""" + + if self.edisp.fov_offset_n_bins > 1 or self.aeff.fov_offset_n_bins > 1: + raise ToolConfigurationError(errormessage) + + if self.full_enclosure and self.psf.fov_offset_n_bins > 1: + raise ToolConfigurationError(errormessage) + + if self.do_background and self.bkg.fov_offset_n_bins > 1: + raise ToolConfigurationError(errormessage) + + if self.do_benchmarks and ( + self.angular_resolution.fov_offset_n_bins > 1 + or self.bias_resolution.fov_offset_n_bins > 1 + or self.sensitivity.fov_offset_n_bins > 1 + ): + raise ToolConfigurationError(errormessage) 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) + if self.electron_file: + self.background_events = vstack( + [reduced_events["protons"], reduced_events["electrons"]] + ) + else: + self.background_events = reduced_events["protons"] 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 7945ee15e3a..8558b6fb38a 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, classes_with_traits, flag -from ..irf import EventsLoader, Spectra +from ..irf import EventLoader, Spectra from ..irf.optimize import CutOptimizerBase @@ -17,10 +17,10 @@ class IrfEventSelector(Tool): 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 pyirf spectra used for the simulated gamma spectrum", + help="Name of the spectrum used for weights of gamma events.", ).tag(config=True) proton_file = traits.Path( @@ -33,10 +33,10 @@ class IrfEventSelector(Tool): ), ).tag(config=True) - proton_sim_spectrum = traits.UseEnum( + 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 spectrum used for weights of proton events.", ).tag(config=True) electron_file = traits.Path( @@ -49,10 +49,10 @@ class IrfEventSelector(Tool): ), ).tag(config=True) - electron_sim_spectrum = traits.UseEnum( + 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 spectrum used for weights of electron events.", ).tag(config=True) chunk_size = Integer( @@ -106,35 +106,35 @@ class IrfEventSelector(Tool): ) } - classes = [EventsLoader] + classes_with_traits(CutOptimizerBase) + classes = [EventLoader] + classes_with_traits(CutOptimizerBase) def setup(self): self.optimizer = CutOptimizerBase.from_name( self.optimization_algorithm, parent=self ) self.particles = [ - EventsLoader( + EventLoader( parent=self, kind="gammas", file=self.gamma_file, - target_spectrum=self.gamma_sim_spectrum, + target_spectrum=self.gamma_target_spectrum, ) ] if self.optimization_algorithm != "PercentileCuts": self.particles.append( - EventsLoader( + EventLoader( parent=self, kind="protons", file=self.proton_file, - target_spectrum=self.proton_sim_spectrum, + target_spectrum=self.proton_target_spectrum, ) ) self.particles.append( - EventsLoader( + EventLoader( parent=self, kind="electrons", file=self.electron_file, - target_spectrum=self.electron_sim_spectrum, + target_spectrum=self.electron_target_spectrum, ) ) From 9c4a505135e91c4869807b5d6aa9d82d32201179 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 19 Sep 2024 18:37:45 +0200 Subject: [PATCH 138/195] replace removed in numpy 2.0 --- 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 97a1f4ae24e..0ffd566948e 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -96,7 +96,7 @@ def write(self, output_name, overwrite=False): cut_expr_tab = Table( rows=self._precuts, names=["name", "cut_expr"], - dtype=[np.unicode_, np.unicode_], + dtype=[np.str_, np.str_], ) cut_expr_tab.meta["EXTNAME"] = "QUALITY_CUTS_EXPR" From d2f47e97c5b782be82878b2deb94dc6c5c2d9432 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 18 Sep 2024 11:31:10 +0200 Subject: [PATCH 139/195] Add unit definition, docstring, fix table name --- src/ctapipe/irf/irfs.py | 2 +- src/ctapipe/irf/spectra.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 119f8c8d93f..2fd1284ac4d 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -223,7 +223,7 @@ def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_edisp_hdu( - self, events: QTable, point_like: bool, extname: str = "ENERGY MIGRATION" + self, events: QTable, point_like: bool, extname: str = "ENERGY DISPERSION" ) -> BinTableHDU: edisp = energy_dispersion( selected_events=events, diff --git a/src/ctapipe/irf/spectra.py b/src/ctapipe/irf/spectra.py index 4db3e4277cf..506f5c8355d 100644 --- a/src/ctapipe/irf/spectra.py +++ b/src/ctapipe/irf/spectra.py @@ -2,8 +2,11 @@ from enum import Enum +import astropy.units as u from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM +FLUX_UNIT = (1 * u.erg / u.s / u.cm**2).unit + class Spectra(Enum): """Spectra for calculating event weights""" From 254910aaf631cdbd8e1e34c878cbf08b34c1b396 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 20 Sep 2024 13:59:41 +0200 Subject: [PATCH 140/195] Fixed bug with weights not being set properly --- src/ctapipe/irf/select.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 28c483160a9..ade2750fd34 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -280,13 +280,11 @@ def make_event_weights( "for the integration of the simulated diffuse spectrum were 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["weight"][fov_mask] = calculate_event_weights( events[fov_mask]["true_energy"], target_spectrum=self.target_spectrum, simulated_spectrum=spectrum.integrate_cone(low, high), From 2be477ebd3ace97a711cf2c836d78a78c466afd7 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 20 Sep 2024 14:01:44 +0200 Subject: [PATCH 141/195] Make header row using columns directly --- src/ctapipe/conftest.py | 2 - src/ctapipe/irf/benchmarks.py | 21 +++++++--- src/ctapipe/irf/select.py | 78 ++++++++++++++++++++++++----------- src/ctapipe/irf/spectra.py | 3 +- 4 files changed, 72 insertions(+), 32 deletions(-) diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index fed9f8a79ea..e0df646f66b 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -795,8 +795,6 @@ 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 diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index 11190f591ae..f8064003705 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -12,7 +12,7 @@ from ..core.traits import Bool, Float from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins -from .spectra import SPECTRA, Spectra +from .spectra import ENERGY_FLUX_UNIT, FLUX_UNIT, SPECTRA, Spectra def _get_2d_result_table( @@ -241,8 +241,11 @@ def make_sensitivity_hdu( result["N_BACKGROUND_WEIGHTED"] = np.zeros(mat_shape)[np.newaxis, ...] result["SIGNIFICANCE"] = np.full(mat_shape, np.nan)[np.newaxis, ...] result["RELATIVE_SENSITIVITY"] = np.full(mat_shape, np.nan)[np.newaxis, ...] - result["FLUX_SENSITIVITY"] = u.Quantity( - np.full(mat_shape, np.nan)[np.newaxis, ...], 1 / (u.TeV * u.s * u.cm**2) + result["FLUX_SENS"] = u.Quantity( + np.full(mat_shape, np.nan)[np.newaxis, ...], FLUX_UNIT + ) + result["ENERGY_FLUX_SENS"] = u.Quantity( + np.full(mat_shape, np.nan)[np.newaxis, ...], ENERGY_FLUX_UNIT ) for i in range(len(self.fov_offset_bins) - 1): signal_hist = create_histogram_table( @@ -265,9 +268,15 @@ def make_sensitivity_hdu( result["N_BACKGROUND_WEIGHTED"][:, i, :] = sens["n_background_weighted"] result["SIGNIFICANCE"][:, i, :] = sens["significance"] result["RELATIVE_SENSITIVITY"][:, i, :] = sens["relative_sensitivity"] - result["FLUX_SENSITIVITY"][:, i, :] = sens[ - "relative_sensitivity" - ] * source_spectrum(sens["reco_energy_center"]) + result["FLUX_SENS"][:, i, :] = ( + sens["relative_sensitivity"] + * source_spectrum(sens["reco_energy_center"]) + ).to(FLUX_UNIT) + result["ENERGY_FLUX_SENS"][:, i, :] = ( + sens["relative_sensitivity"] + * source_spectrum(sens["reco_energy_center"]) + * sens["reco_energy_center"] ** 2 + ).to(ENERGY_FLUX_UNIT) header = Header() header["ALPHA"] = self.alpha diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index ade2750fd34..3b2ca03c4a1 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -15,6 +15,7 @@ ) from pyirf.utils import calculate_source_fov_offset, calculate_theta +from ..containers import CoordinateFrameType from ..coordinates import NominalFrame from ..core import Component, QualityQuery from ..core.traits import List, Tuple, Unicode @@ -114,14 +115,38 @@ def make_empty_table(self) -> QTable: in the event table. """ columns = [ - Column(name="obs_id", dtype=np.uint64, description="Observation Block ID"), - Column(name="event_id", dtype=np.uint64, description="Array Event ID"), - Column(name="true_energy", unit=u.TeV, description="Simulated Energy"), - Column(name="true_az", unit=u.deg, description="Simulated azimuth"), - Column(name="true_alt", unit=u.deg, description="Simulated altitude"), - Column(name="reco_energy", unit=u.TeV, description="Reconstructed energy"), - Column(name="reco_az", unit=u.deg, description="Reconstructed azimuth"), - Column(name="reco_alt", unit=u.deg, description="Reconstructed altitude"), + Column(name="obs_id", dtype=np.uint64, description="Observation block ID"), + Column(name="event_id", dtype=np.uint64, description="Array event ID"), + Column( + name="true_energy", + unit=u.TeV, + description="Simulated energy", + ), + Column( + name="true_az", + unit=u.deg, + description="Simulated azimuth", + ), + Column( + name="true_alt", + unit=u.deg, + description="Simulated altitude", + ), + Column( + name="reco_energy", + unit=u.TeV, + description="Reconstructed energy", + ), + Column( + name="reco_az", + unit=u.deg, + description="Reconstructed azimuth", + ), + Column( + name="reco_alt", + unit=u.deg, + description="Reconstructed altitude", + ), Column( name="reco_fov_lat", unit=u.deg, @@ -133,17 +158,6 @@ def make_empty_table(self) -> QTable: description="Reconstructed field of view lon", ), Column( - name="gh_score", - description=( - "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" - ), - ), - Column(name="pointing_az", unit=u.deg, description="Pointing azimuth"), - Column(name="pointing_alt", unit=u.deg, description="Pointing altitude"), - Column( - name="theta", unit=u.deg, description="Reconstructed angular offset from source position", ), @@ -157,7 +171,20 @@ def make_empty_table(self) -> QTable: unit=u.deg, description="Reconstructed angular offset from pointing direction", ), + Column( + name="gh_score", + unit=u.dimensionless_unscaled, + description="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", + ), + Column( + name="weight", + unit=u.dimensionless_unscaled, + description="Event weight", + ), ] + return QTable(columns) @@ -226,14 +253,19 @@ def get_simulation_information( 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) + assert all( + obs_conf["subarray_pointing_frame"] == CoordinateFrameType.ALTAZ.value + ) 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["weight"] = ( + 1.0 * u.dimensionless_unscaled + ) # defer calculation of proper weights to later + events["gh_score"].unit = u.dimensionless_unscaled events["theta"] = calculate_theta( events, assumed_source_az=events["true_az"], @@ -257,8 +289,8 @@ def make_derived_columns(self, events: QTable, obs_conf: Table) -> QTable: ) nominal = NominalFrame(origin=pointing) 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["reco_fov_lon"] = u.Quantity(-reco_nominal.fov_lon) # minus for GADF + events["reco_fov_lat"] = u.Quantity(reco_nominal.fov_lat) return events def make_event_weights( diff --git a/src/ctapipe/irf/spectra.py b/src/ctapipe/irf/spectra.py index 506f5c8355d..626a65febf7 100644 --- a/src/ctapipe/irf/spectra.py +++ b/src/ctapipe/irf/spectra.py @@ -5,7 +5,8 @@ import astropy.units as u from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM -FLUX_UNIT = (1 * u.erg / u.s / u.cm**2).unit +ENERGY_FLUX_UNIT = (1 * u.erg / u.s / u.cm**2).unit +FLUX_UNIT = (1 / u.erg / u.s / u.cm**2).unit class Spectra(Enum): From 04d8c612d1c1572d7b5fb4bae4eed68e54afb715 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 20 Sep 2024 17:52:30 +0200 Subject: [PATCH 142/195] fixed conftest table generation to work around bug with dimensionless columns --- src/ctapipe/conftest.py | 32 ++++++++++++++++++++++++-------- src/ctapipe/irf/select.py | 3 +++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index e0df646f66b..fd53cd2b952 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 QTable, Table, vstack +from astropy.table import Column, QTable, Table, hstack, vstack from pytest_astropy_header.display import PYTEST_HEADER_MODULES from ctapipe.core import run_tool @@ -795,19 +795,35 @@ def irf_events_table(): 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) + ids, bulk, unitless = tab.colnames[:2], tab.colnames[2:-2], tab.colnames[-2:] + + id_tab = QTable( + data=np.zeros((N, len(ids)), dtype=np.uint64), + names=ids, + units={c: tab[c].unit for c in ids}, + ) + bulk_tab = QTable( + data=np.zeros((N, len(bulk))) * np.nan, + names=bulk, + units={c: tab[c].unit for c in bulk}, + ) + # 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"] = ( + bulk_tab["reco_energy"] = np.append(np.full(N1, 1), np.full(N2, 2)) * u.TeV + bulk_tab["true_energy"] = np.append(np.full(N1, 0.9), np.full(N2, 2.1)) * u.TeV + bulk_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"] = ( + bulk_tab["true_source_fov_offset"] = ( np.append(np.full(N1, 0.11), np.full(N2, 0.04)) * u.deg ) + for name in unitless: + bulk_tab.add_column( + Column(name=name, unit=tab[name].unit, data=np.zeros(N) * np.nan) + ) + + e_tab = hstack([id_tab, bulk_tab]) ev = vstack([e_tab, tab], join_type="exact", metadata_conflicts="silent") return ev diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 3b2ca03c4a1..0df5d005d96 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -157,7 +157,10 @@ def make_empty_table(self) -> QTable: unit=u.deg, description="Reconstructed field of view lon", ), + Column(name="pointing_az", unit=u.deg, description="Pointing azimuth"), + Column(name="pointing_alt", unit=u.deg, description="Pointing altitude"), Column( + name="theta", unit=u.deg, description="Reconstructed angular offset from source position", ), From 05965d1630716d722440c933a0ae3ee69856b40e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Fri, 20 Sep 2024 17:53:40 +0200 Subject: [PATCH 143/195] Linspace to match bins from official IRFs --- 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 2fd1284ac4d..540e054717f 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -99,7 +99,7 @@ class EnergyMigrationMakerBase(DefaultTrueEnergyBins): def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self.migration_bins = np.geomspace( + self.migration_bins = np.linspace( self.energy_migration_min, self.energy_migration_max, self.energy_migration_n_bins + 1, From 962b0cb9cf81d4674ccc90f7aa4c66a364cbd15a Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 23 Sep 2024 11:55:59 +0200 Subject: [PATCH 144/195] harmonised column names --- src/ctapipe/irf/benchmarks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index f8064003705..86a682ec878 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -241,10 +241,10 @@ def make_sensitivity_hdu( result["N_BACKGROUND_WEIGHTED"] = np.zeros(mat_shape)[np.newaxis, ...] result["SIGNIFICANCE"] = np.full(mat_shape, np.nan)[np.newaxis, ...] result["RELATIVE_SENSITIVITY"] = np.full(mat_shape, np.nan)[np.newaxis, ...] - result["FLUX_SENS"] = u.Quantity( + result["FLUX_SENSITIVITY"] = u.Quantity( np.full(mat_shape, np.nan)[np.newaxis, ...], FLUX_UNIT ) - result["ENERGY_FLUX_SENS"] = u.Quantity( + result["ENERGY_FLUX_SENSITIVITY"] = u.Quantity( np.full(mat_shape, np.nan)[np.newaxis, ...], ENERGY_FLUX_UNIT ) for i in range(len(self.fov_offset_bins) - 1): @@ -268,11 +268,11 @@ def make_sensitivity_hdu( result["N_BACKGROUND_WEIGHTED"][:, i, :] = sens["n_background_weighted"] result["SIGNIFICANCE"][:, i, :] = sens["significance"] result["RELATIVE_SENSITIVITY"][:, i, :] = sens["relative_sensitivity"] - result["FLUX_SENS"][:, i, :] = ( + result["FLUX_SENSITIVITY"][:, i, :] = ( sens["relative_sensitivity"] * source_spectrum(sens["reco_energy_center"]) ).to(FLUX_UNIT) - result["ENERGY_FLUX_SENS"][:, i, :] = ( + result["ENERGY_FLUX_SENSITIVITY"][:, i, :] = ( sens["relative_sensitivity"] * source_spectrum(sens["reco_energy_center"]) * sens["reco_energy_center"] ** 2 From 92dda444ed470644891235bfb2d02c81dc6f6cb4 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 23 Sep 2024 14:26:54 +0200 Subject: [PATCH 145/195] Made linear bins optional, adjusted wording of help strings --- src/ctapipe/irf/irfs.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 540e054717f..60ec3645041 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -21,7 +21,7 @@ ) from pyirf.simulations import SimulatedEventsInfo -from ..core.traits import AstroQuantity, Float, Integer +from ..core.traits import AstroQuantity, Bool, Float, Integer from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins @@ -83,23 +83,31 @@ class EnergyMigrationMakerBase(DefaultTrueEnergyBins): """Base class for calculating the energy migration.""" energy_migration_min = Float( - help="Minimum value of Energy Migration matrix", + help="Minimum value of energy migration ratio", default_value=0.2, ).tag(config=True) energy_migration_max = Float( - help="Maximum value of Energy Migration matrix", + help="Maximum value of energy migration ratio", default_value=5, ).tag(config=True) energy_migration_n_bins = Integer( - help="Number of bins in log scale for Energy Migration matrix", + help="Number of bins in log scale for energy migration ratio", default_value=30, ).tag(config=True) + energy_migration_linear_bins = Bool( + help="Bin energy migration ratio using linear bins", + default_value=False, + ).tag(config=True) + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self.migration_bins = np.linspace( + bin_func = np.geomspace + if self.energy_migration_linear_bins: + bin_func = np.linspace + self.migration_bins = bin_func( self.energy_migration_min, self.energy_migration_max, self.energy_migration_n_bins + 1, From a9432d42e14029cb5a4d12f5ea9d65f921c4d1ed Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 23 Sep 2024 14:40:12 +0200 Subject: [PATCH 146/195] Trying to work around pyIrf errors --- src/ctapipe/irf/tests/test_benchmarks.py | 4 ++++ src/ctapipe/irf/tests/test_optimize.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 3a122fa2a44..012f920d9fb 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -1,4 +1,7 @@ +import sys + import astropy.units as u +import pytest from astropy.table import QTable from ctapipe.irf.tests.test_irfs import _check_boundaries_in_hdu @@ -67,6 +70,7 @@ def test_make_2d_ang_res(irf_events_table): ) +@pytest.mark.skipif(sys.version_info.minor > 11, reason="Pyirf+numpy 2.0 errors out") def test_make_2d_sensitivity( gamma_diffuse_full_reco_file, proton_full_reco_file, irf_events_loader_test_config ): diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 8380fbda866..12dcc013720 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -1,3 +1,5 @@ +import sys + import astropy.units as u import numpy as np import pytest @@ -86,6 +88,7 @@ def test_theta_percentile_cut_calculator(): assert calc.smoothing is None +@pytest.mark.skipif(sys.version_info.minor > 11, reason="Pyirf+numpy 2.0 errors out") @pytest.mark.parametrize("Optimizer", non_abstract_children(CutOptimizerBase)) def test_cut_optimizer( Optimizer, From 7a28dac1272b24e04fb773909228d5a998e337b9 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 26 Sep 2024 13:59:15 +0200 Subject: [PATCH 147/195] Rename EnergyMigrationMaker to EnergyDispersionMaker to avoid confusion --- src/ctapipe/irf/__init__.py | 4 ++-- src/ctapipe/irf/irfs.py | 8 ++++---- src/ctapipe/irf/tests/test_irfs.py | 4 ++-- src/ctapipe/tools/make_irf.py | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index a559bf0efe3..7c6dfac1750 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -13,7 +13,7 @@ from .irfs import ( BackgroundRate2dMaker, EffectiveArea2dMaker, - EnergyMigration2dMaker, + EnergyDispersion2dMaker, Psf3dMaker, ) from .optimize import ( @@ -33,7 +33,7 @@ "Sensitivity2dMaker", "Psf3dMaker", "BackgroundRate2dMaker", - "EnergyMigration2dMaker", + "EnergyDispersion2dMaker", "EffectiveArea2dMaker", "ResultValidRange", "OptimizationResult", diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 60ec3645041..d0f3d2b8bea 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -79,8 +79,8 @@ def make_bkg_hdu( """ -class EnergyMigrationMakerBase(DefaultTrueEnergyBins): - """Base class for calculating the energy migration.""" +class EnergyDispersionMakerBase(DefaultTrueEnergyBins): + """Base class for calculating the energy dispersion.""" energy_migration_min = Float( help="Minimum value of energy migration ratio", @@ -221,9 +221,9 @@ def make_aeff_hdu( ) -class EnergyMigration2dMaker(EnergyMigrationMakerBase, DefaultFoVOffsetBins): +class EnergyDispersion2dMaker(EnergyDispersionMakerBase, DefaultFoVOffsetBins): """ - Creates a radially symmetric parameterization of the energy migration in + Creates a radially symmetric parameterization of the energy dispersion in equidistant bins of logarithmic true energy and field of view offset. """ diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index bd5e212a2b9..db6179f98e1 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -39,9 +39,9 @@ def test_make_2d_bkg(irf_events_table): def test_make_2d_energy_migration(irf_events_table): - from ctapipe.irf import EnergyMigration2dMaker + from ctapipe.irf import EnergyDispersion2dMaker - migMkr = EnergyMigration2dMaker( + migMkr = EnergyDispersion2dMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, true_energy_n_bins_per_decade=7, diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index de4d3eeaed0..3a83ef5e4f7 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -27,7 +27,7 @@ from ..irf.irfs import ( BackgroundRateMakerBase, EffectiveAreaMakerBase, - EnergyMigrationMakerBase, + EnergyDispersionMakerBase, PsfMakerBase, ) @@ -114,9 +114,9 @@ class IrfTool(Tool): ).tag(config=True) edisp_maker = traits.ComponentName( - EnergyMigrationMakerBase, - default_value="EnergyMigration2dMaker", - help="The parameterization of the energy migration to be used.", + EnergyDispersionMakerBase, + default_value="EnergyDispersion2dMaker", + help="The parameterization of the energy dispersion to be used.", ).tag(config=True) aeff_maker = traits.ComponentName( @@ -202,7 +202,7 @@ class IrfTool(Tool): ] + classes_with_traits(BackgroundRateMakerBase) + classes_with_traits(EffectiveAreaMakerBase) - + classes_with_traits(EnergyMigrationMakerBase) + + classes_with_traits(EnergyDispersionMakerBase) + classes_with_traits(PsfMakerBase) + classes_with_traits(AngularResolutionMakerBase) + classes_with_traits(EnergyBiasResolutionMakerBase) @@ -259,7 +259,7 @@ def setup(self): bins=self.bkg.reco_energy_bins, source="background reco energy" ) - self.edisp = EnergyMigrationMakerBase.from_name(self.edisp_maker, parent=self) + self.edisp = EnergyDispersionMakerBase.from_name(self.edisp_maker, parent=self) self.aeff = EffectiveAreaMakerBase.from_name(self.aeff_maker, parent=self) if self.full_enclosure: From 3cda9f693c0242932c700e4ff0dd121572f13864 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 26 Sep 2024 14:31:07 +0200 Subject: [PATCH 148/195] Replace do_benchmarks with optional benchmark_output_path --- src/ctapipe/tools/make_irf.py | 58 +++++++++++++++++------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 3a83ef5e4f7..4d95840b2e1 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -38,25 +38,22 @@ class IrfTool(Tool): do_background = Bool( True, - help="Compute background rate using supplied files", - ).tag(config=True) - - do_benchmarks = Bool( - False, - help="Produce IRF related benchmarks", + help="Compute background rate using supplied files.", ).tag(config=True) range_check_error = Bool( False, - help="Raise error if asking for IRFs outside range where cut optimisation is valid", + 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" + default_value=None, + directory_ok=False, + help="Path to optimized cuts input file.", ).tag(config=True) gamma_file = traits.Path( - default_value=None, directory_ok=False, help="Gamma input filename and path" + default_value=None, directory_ok=False, help="Gamma input filename and path." ).tag(config=True) gamma_target_spectrum = traits.UseEnum( @@ -69,7 +66,7 @@ class IrfTool(Tool): default_value=None, allow_none=True, directory_ok=False, - help="Proton input filename and path", + help="Proton input filename and path.", ).tag(config=True) proton_target_spectrum = traits.UseEnum( @@ -82,7 +79,7 @@ class IrfTool(Tool): default_value=None, allow_none=True, directory_ok=False, - help="Electron input filename and path", + help="Electron input filename and path.", ).tag(config=True) electron_target_spectrum = traits.UseEnum( @@ -104,6 +101,13 @@ class IrfTool(Tool): help="Output file", ).tag(config=True) + benchmarks_output_path = traits.Path( + default_value=None, + allow_none=True, + directory_ok=False, + help="Optional second output file for benchmarks.", + ).tag(config=True) + obs_time = AstroQuantity( default_value=u.Quantity(50, u.hour), physical_type=u.physical.time, @@ -172,6 +176,7 @@ class IrfTool(Tool): "proton-file": "IrfTool.proton_file", "electron-file": "IrfTool.electron_file", "output": "IrfTool.output_path", + "benchmark-output": "IrfTool.benchmarks_output_path", "chunk_size": "IrfTool.chunk_size", } @@ -182,12 +187,6 @@ class IrfTool(Tool): "Compute background rate.", "Do not compute background rate.", ), - **flag( - "do-benchmarks", - "IrfTool.do_benchmarks", - "Produce IRF related benchmarks.", - "Do not produce IRF related benchmarks.", - ), **flag( "full-enclosure", "IrfTool.full_enclosure", @@ -265,10 +264,7 @@ def setup(self): if self.full_enclosure: self.psf = PsfMakerBase.from_name(self.psf_maker, parent=self) - if self.do_benchmarks: - self.b_output = self.output_path.with_name( - self.output_path.name.replace(".fits", "-benchmark.fits") - ) + if self.benchmarks_output_path is not None: self.angular_resolution = AngularResolutionMakerBase.from_name( self.angular_resolution_maker, parent=self ) @@ -277,6 +273,7 @@ def setup(self): bins=self.angular_resolution.reco_energy_bins, source="Angular resolution energy", ) + self.bias_resolution = EnergyBiasResolutionMakerBase.from_name( self.energy_bias_resolution_maker, parent=self ) @@ -471,8 +468,9 @@ def start(self): evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) # 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: + # Sensitivity is only calculated, if do_background is true + # and benchmarks_output_path is given. + if self.benchmarks_output_path is not None: evs = sel.make_event_weights( evs, meta["spectrum"], self.sensitivity.fov_offset_bins ) @@ -506,7 +504,7 @@ def start(self): if self.do_background and self.bkg.fov_offset_n_bins > 1: raise ToolConfigurationError(errormessage) - if self.do_benchmarks and ( + if self.benchmarks_output_path is not None and ( self.angular_resolution.fov_offset_n_bins > 1 or self.bias_resolution.fov_offset_n_bins > 1 or self.sensitivity.fov_offset_n_bins > 1 @@ -561,7 +559,7 @@ def start(self): ) self.hdus = hdus - if self.do_benchmarks: + if self.benchmarks_output_path is not None: b_hdus = [fits.PrimaryHDU()] b_hdus = self._make_benchmark_hdus(b_hdus) self.b_hdus = b_hdus @@ -573,13 +571,15 @@ 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) + if self.benchmarks_output_path is not None: + self.log.info( + "Writing benchmark file to '%s'" % self.benchmarks_output_path + ) fits.HDUList(self.b_hdus).writeto( - self.b_output, + self.benchmarks_output_path, overwrite=self.overwrite, ) - Provenance().add_output_file(self.b_output, role="Benchmark") + Provenance().add_output_file(self.benchmarks_output_path, role="Benchmark") def main(): From 78a7bcab7eb378bffa464b17b265c8049b410079 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 26 Sep 2024 15:01:16 +0200 Subject: [PATCH 149/195] Fix some code smells --- src/ctapipe/irf/tests/test_benchmarks.py | 20 ++++++++++---------- src/ctapipe/irf/tests/test_binning.py | 7 ++++--- src/ctapipe/irf/tests/test_irfs.py | 22 +++++++++++----------- src/ctapipe/irf/tests/test_select.py | 4 ++-- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 012f920d9fb..511a4015645 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -10,14 +10,14 @@ def test_make_2d_energy_bias_res(irf_events_table): from ctapipe.irf import EnergyBiasResolution2dMaker - biasResMkr = EnergyBiasResolution2dMaker( + bias_res_maker = 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) + bias_res_hdu = bias_res_maker.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 @@ -35,7 +35,7 @@ def test_make_2d_energy_bias_res(irf_events_table): def test_make_2d_ang_res(irf_events_table): from ctapipe.irf import AngularResolution2dMaker - angResMkr = AngularResolution2dMaker( + ang_res_maker = AngularResolution2dMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, true_energy_n_bins_per_decade=7, @@ -44,7 +44,7 @@ def test_make_2d_ang_res(irf_events_table): reco_energy_min=0.03 * u.TeV, ) - ang_res_hdu = angResMkr.make_angular_resolution_hdu(events=irf_events_table) + ang_res_hdu = ang_res_maker.make_angular_resolution_hdu(events=irf_events_table) assert ( ang_res_hdu.data["N_EVENTS"].shape == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape @@ -56,8 +56,8 @@ def test_make_2d_ang_res(irf_events_table): 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) + ang_res_maker.use_true_energy = True + ang_res_hdu = ang_res_maker.make_angular_resolution_hdu(events=irf_events_table) assert ( ang_res_hdu.data["N_EVENTS"].shape == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape @@ -97,7 +97,7 @@ def test_make_2d_sensitivity( obs_time=u.Quantity(50, u.h), ) - sensMkr = Sensitivity2dMaker( + sens_maker = Sensitivity2dMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, reco_energy_n_bins_per_decade=7, @@ -107,11 +107,11 @@ def test_make_2d_sensitivity( # needs a theta cut atm. theta_cuts = QTable() theta_cuts["center"] = 0.5 * ( - sensMkr.reco_energy_bins[:-1] + sensMkr.reco_energy_bins[1:] + sens_maker.reco_energy_bins[:-1] + sens_maker.reco_energy_bins[1:] ) - theta_cuts["cut"] = sensMkr.fov_offset_max + theta_cuts["cut"] = sens_maker.fov_offset_max - sens_hdu = sensMkr.make_sensitivity_hdu( + sens_hdu = sens_maker.make_sensitivity_hdu( signal_events=gamma_events, background_events=proton_events, theta_cut=theta_cuts, diff --git a/src/ctapipe/irf/tests/test_binning.py b/src/ctapipe/irf/tests/test_binning.py index e1040f15058..4925f8855ca 100644 --- a/src/ctapipe/irf/tests/test_binning.py +++ b/src/ctapipe/irf/tests/test_binning.py @@ -9,6 +9,7 @@ def test_check_bins_in_range(tmp_path): from ctapipe.irf import ResultValidRange, check_bins_in_range valid_range = ResultValidRange(min=0.03 * u.TeV, max=200 * u.TeV) + errormessage = "Valid range for result is 0.03 to 200., got" # bins are in range bins = u.Quantity(np.logspace(-1, 2, 10), u.TeV) @@ -16,17 +17,17 @@ def test_check_bins_in_range(tmp_path): # bins are too small bins = u.Quantity(np.logspace(-2, 2, 10), u.TeV) - with pytest.raises(ValueError, match="Valid range for"): + with pytest.raises(ValueError, match=errormessage): 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"): + with pytest.raises(ValueError, match=errormessage): 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"): + with pytest.raises(ValueError, match=errormessage): check_bins_in_range(bins, valid_range) logger = logging.getLogger("ctapipe.irf.binning") diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index db6179f98e1..8da1f2dac2e 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -22,14 +22,14 @@ def _check_boundaries_in_hdu( def test_make_2d_bkg(irf_events_table): from ctapipe.irf import BackgroundRate2dMaker - bkgMkr = BackgroundRate2dMaker( + bkg_maker = 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) + bkg_hdu = bkg_maker.make_bkg_hdu(events=irf_events_table, obs_time=1 * u.s) # 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) @@ -41,7 +41,7 @@ def test_make_2d_bkg(irf_events_table): def test_make_2d_energy_migration(irf_events_table): from ctapipe.irf import EnergyDispersion2dMaker - migMkr = EnergyDispersion2dMaker( + edisp_maker = EnergyDispersion2dMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, true_energy_n_bins_per_decade=7, @@ -50,12 +50,12 @@ def test_make_2d_energy_migration(irf_events_table): energy_migration_min=0.1, energy_migration_max=10, ) - mig_hdu = migMkr.make_edisp_hdu(events=irf_events_table, point_like=False) + edisp_hdu = edisp_maker.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) + assert edisp_hdu.data["MATRIX"].shape == (1, 3, 20, 29) _check_boundaries_in_hdu( - mig_hdu, + edisp_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"], @@ -65,7 +65,7 @@ def test_make_2d_energy_migration(irf_events_table): def test_make_2d_eff_area(irf_events_table): from ctapipe.irf import EffectiveArea2dMaker - effAreaMkr = EffectiveArea2dMaker( + eff_area_maker = EffectiveArea2dMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, true_energy_n_bins_per_decade=7, @@ -80,7 +80,7 @@ def test_make_2d_eff_area(irf_events_table): viewcone_min=0 * u.deg, viewcone_max=10 * u.deg, ) - eff_area_hdu = effAreaMkr.make_aeff_hdu( + eff_area_hdu = eff_area_maker.make_aeff_hdu( events=irf_events_table, point_like=False, signal_is_point_like=False, @@ -96,7 +96,7 @@ def test_make_2d_eff_area(irf_events_table): ) # point like data -> only 1 fov offset bin - eff_area_hdu = effAreaMkr.make_aeff_hdu( + eff_area_hdu = eff_area_maker.make_aeff_hdu( events=irf_events_table, point_like=False, signal_is_point_like=True, @@ -108,7 +108,7 @@ def test_make_2d_eff_area(irf_events_table): def test_make_3d_psf(irf_events_table): from ctapipe.irf import Psf3dMaker - psfMkr = Psf3dMaker( + psf_maker = Psf3dMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, true_energy_n_bins_per_decade=7, @@ -116,7 +116,7 @@ def test_make_3d_psf(irf_events_table): source_offset_n_bins=110, source_offset_max=2 * u.deg, ) - psf_hdu = psfMkr.make_psf_hdu(events=irf_events_table) + psf_hdu = psf_maker.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) diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_select.py index 963dd2c64ff..87aa35195a7 100644 --- a/src/ctapipe/irf/tests/test_select.py +++ b/src/ctapipe/irf/tests/test_select.py @@ -48,14 +48,14 @@ def test_normalise_column_names(dummy_table): for c in needed_cols: assert c in norm_table.colnames - # error if reco_{alt,az} is missing because of no-standard name + # error if reco_{alt,az} is missing because of non-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) + _ = epp.normalise_column_names(dummy_table) def test_events_loader(gamma_diffuse_full_reco_file, irf_events_loader_test_config): From a3eeb32996e8bc4dbc957c7f9b6e4d7349407e08 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 30 Sep 2024 16:02:38 +0200 Subject: [PATCH 150/195] Use ctapipe.compat.COPY_IF_NEEDED --- src/ctapipe/irf/binning.py | 5 ++++- src/ctapipe/irf/select.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index d87c0f05ba4..0fe6c2ae4fd 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -6,6 +6,7 @@ import astropy.units as u import numpy as np +from ..compat import COPY_IF_NEEDED from ..core import Component from ..core.traits import AstroQuantity, Integer @@ -76,7 +77,9 @@ 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 + 1), unit, copy=False) + return u.Quantity( + np.logspace(log_lower, log_upper, n_bins + 1), unit, copy=COPY_IF_NEEDED + ) @dataclass diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index 0df5d005d96..d3f0c320c6c 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -15,6 +15,7 @@ ) from pyirf.utils import calculate_source_fov_offset, calculate_theta +from ..compat import COPY_IF_NEEDED from ..containers import CoordinateFrameType from ..coordinates import NominalFrame from ..core import Component, QualityQuery @@ -105,7 +106,7 @@ def normalise_column_names(self, events: Table) -> QTable: ) keep_columns.extend(rename_from) - events = QTable(events[keep_columns], copy=False) + events = QTable(events[keep_columns], copy=COPY_IF_NEEDED) events.rename_columns(rename_from, rename_to) return events From 5962b2268ad5428a3f8caa7799b7ca3023a1de5f Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 1 Oct 2024 17:12:58 +0200 Subject: [PATCH 151/195] Add test for cut optimization tool --- .../tests/test_optimize_event_selection.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/ctapipe/tools/tests/test_optimize_event_selection.py diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py new file mode 100644 index 00000000000..d8f42c506c0 --- /dev/null +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -0,0 +1,63 @@ +import json + +import astropy.units as u +import pytest +from astropy.table import QTable + +from ctapipe.core import run_tool + + +@pytest.mark.parametrize("point_like", (True, False)) +def test_cuts_optimization( + gamma_diffuse_full_reco_file, + proton_full_reco_file, + irf_events_loader_test_config, + tmp_path, + point_like, +): + from ctapipe.irf import ( + OptimizationResult, + OptimizationResultStore, + ResultValidRange, + ) + from ctapipe.tools.optimize_event_selection import IrfEventSelector + + output_path = tmp_path / "cuts.fits" + config_path = tmp_path / "config.json" + with config_path.open("w") as f: + json.dump(irf_events_loader_test_config, f) + + argv = [ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + # Use diffuse gammas weighted to electron spectrum as stand-in + f"--electron-file={gamma_diffuse_full_reco_file}", + f"--output={output_path}", + f"--config={config_path}", + ] + if not point_like: + argv.append("--full-enclosure") + + ret = run_tool( + IrfEventSelector(), + argv=argv, + ) + assert ret == 0 + + result = OptimizationResultStore().read(output_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" + assert "cut" in result.gh_cuts.colnames + if point_like: + assert isinstance(result.theta_cuts, QTable) + assert "cut" in result.theta_cuts.colnames + + for c in ["low", "center", "high"]: + assert c in result.gh_cuts.colnames + assert result.gh_cuts[c].unit == u.TeV + if point_like: + assert c in result.theta_cuts.colnames + assert result.theta_cuts[c].unit == u.TeV From 8ac3fa57e3663f81e472f18f2b0210c9af126a0d Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 11 Oct 2024 18:26:46 +0200 Subject: [PATCH 152/195] Add test for irf tool --- src/ctapipe/conftest.py | 15 ++- src/ctapipe/irf/tests/test_benchmarks.py | 6 +- src/ctapipe/irf/tests/test_optimize.py | 10 +- src/ctapipe/irf/tests/test_select.py | 4 +- src/ctapipe/tools/tests/test_make_irf.py | 109 ++++++++++++++++++ .../tests/test_optimize_event_selection.py | 9 +- 6 files changed, 132 insertions(+), 21 deletions(-) create mode 100644 src/ctapipe/tools/tests/test_make_irf.py diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index fd53cd2b952..6743143b410 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -710,18 +710,23 @@ def provenance(monkeypatch): return prov +@pytest.fixture(scope="session") +def irf_tmp_path(tmp_path_factory): + return tmp_path_factory.mktemp("irf") + + @pytest.fixture(scope="session") def gamma_diffuse_full_reco_file( gamma_train_clf, particle_classifier_path, - model_tmp_path, + irf_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" + output_path = irf_tmp_path / "gamma_diffuse_full_reco.dl2.h5" run_tool( ApplyModels(), argv=[ @@ -740,14 +745,14 @@ def gamma_diffuse_full_reco_file( def proton_full_reco_file( proton_train_clf, particle_classifier_path, - model_tmp_path, + irf_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" + output_path = irf_tmp_path / "proton_full_reco.dl2.h5" run_tool( ApplyModels(), argv=[ @@ -763,7 +768,7 @@ def proton_full_reco_file( @pytest.fixture(scope="session") -def irf_events_loader_test_config(): +def irf_event_loader_test_config(): from traitlets.config import Config return Config( diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 511a4015645..4f964081709 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -72,12 +72,12 @@ def test_make_2d_ang_res(irf_events_table): @pytest.mark.skipif(sys.version_info.minor > 11, reason="Pyirf+numpy 2.0 errors out") def test_make_2d_sensitivity( - gamma_diffuse_full_reco_file, proton_full_reco_file, irf_events_loader_test_config + gamma_diffuse_full_reco_file, proton_full_reco_file, irf_event_loader_test_config ): from ctapipe.irf import EventLoader, Sensitivity2dMaker, Spectra gamma_loader = EventLoader( - config=irf_events_loader_test_config, + config=irf_event_loader_test_config, kind="gammas", file=gamma_diffuse_full_reco_file, target_spectrum=Spectra.CRAB_HEGRA, @@ -87,7 +87,7 @@ def test_make_2d_sensitivity( obs_time=u.Quantity(50, u.h), ) proton_loader = EventLoader( - config=irf_events_loader_test_config, + config=irf_event_loader_test_config, kind="protons", file=proton_full_reco_file, target_spectrum=Spectra.IRFDOC_PROTON_SPECTRUM, diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 12dcc013720..282ce82a596 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -10,7 +10,7 @@ from ctapipe.irf.optimize import CutOptimizerBase -def test_optimization_result_store(tmp_path, irf_events_loader_test_config): +def test_optimization_result_store(tmp_path, irf_event_loader_test_config): from ctapipe.irf import ( EventPreProcessor, OptimizationResult, @@ -19,7 +19,7 @@ def test_optimization_result_store(tmp_path, irf_events_loader_test_config): ) result_path = tmp_path / "result.h5" - epp = EventPreProcessor(irf_events_loader_test_config) + epp = EventPreProcessor(irf_event_loader_test_config) store = OptimizationResultStore(epp) with pytest.raises( @@ -94,12 +94,12 @@ def test_cut_optimizer( Optimizer, gamma_diffuse_full_reco_file, proton_full_reco_file, - irf_events_loader_test_config, + irf_event_loader_test_config, ): from ctapipe.irf import OptimizationResultStore gamma_loader = EventLoader( - config=irf_events_loader_test_config, + config=irf_event_loader_test_config, kind="gammas", file=gamma_diffuse_full_reco_file, target_spectrum=Spectra.CRAB_HEGRA, @@ -109,7 +109,7 @@ def test_cut_optimizer( obs_time=u.Quantity(50, u.h), ) proton_loader = EventLoader( - config=irf_events_loader_test_config, + config=irf_event_loader_test_config, kind="protons", file=proton_full_reco_file, target_spectrum=Spectra.IRFDOC_PROTON_SPECTRUM, diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_select.py index 87aa35195a7..16e2f849bd8 100644 --- a/src/ctapipe/irf/tests/test_select.py +++ b/src/ctapipe/irf/tests/test_select.py @@ -58,11 +58,11 @@ def test_normalise_column_names(dummy_table): _ = epp.normalise_column_names(dummy_table) -def test_events_loader(gamma_diffuse_full_reco_file, irf_events_loader_test_config): +def test_event_loader(gamma_diffuse_full_reco_file, irf_event_loader_test_config): from ctapipe.irf import EventLoader, Spectra loader = EventLoader( - config=irf_events_loader_test_config, + config=irf_event_loader_test_config, kind="gammas", file=gamma_diffuse_full_reco_file, target_spectrum=Spectra.CRAB_HEGRA, diff --git a/src/ctapipe/tools/tests/test_make_irf.py b/src/ctapipe/tools/tests/test_make_irf.py new file mode 100644 index 00000000000..36a424cf7ca --- /dev/null +++ b/src/ctapipe/tools/tests/test_make_irf.py @@ -0,0 +1,109 @@ +import json +import os + +import pytest +from astropy.io import fits + +from ctapipe.core import run_tool + + +@pytest.fixture(scope="module") +def event_loader_config_path(irf_event_loader_test_config, irf_tmp_path): + config_path = irf_tmp_path / "event_loader_config.json" + with config_path.open("w") as f: + json.dump(irf_event_loader_test_config, f) + + return config_path + + +@pytest.fixture(scope="module") +def dummy_cuts_file( + gamma_diffuse_full_reco_file, + proton_full_reco_file, + event_loader_config_path, + irf_tmp_path, +): + from ctapipe.tools.optimize_event_selection import IrfEventSelector + + # Do "point-like" cuts to have both g/h and theta cuts in the file + output_path = irf_tmp_path / "dummy_cuts.fits" + run_tool( + IrfEventSelector(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + # Use diffuse gammas weighted to electron spectrum as stand-in + f"--electron-file={gamma_diffuse_full_reco_file}", + f"--output={output_path}", + f"--config={event_loader_config_path}", + ], + ) + return output_path + + +@pytest.mark.parametrize("include_bkg", (False, True)) +@pytest.mark.parametrize("point_like", (True, False)) +def test_irf_tool( + gamma_diffuse_full_reco_file, + proton_full_reco_file, + event_loader_config_path, + dummy_cuts_file, + tmp_path, + include_bkg, + point_like, +): + from ctapipe.tools.make_irf import IrfTool + + output_path = tmp_path / "irf.fits.gz" + output_benchmarks_path = tmp_path / "benchmarks.fits.gz" + + argv = [ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--cuts={dummy_cuts_file}", + f"--output={output_path}", + f"--config={event_loader_config_path}", + ] + if not point_like: + argv.append("--full-enclosure") + + if include_bkg: + argv.append(f"--proton-file={proton_full_reco_file}") + # Use diffuse gammas weighted to electron spectrum as stand-in + argv.append(f"--electron-file={gamma_diffuse_full_reco_file}") + else: + argv.append("--no-do-background") + + ret = run_tool(IrfTool(), argv=argv) + assert ret == 0 + + assert output_path.exists() + assert not output_benchmarks_path.exists() + # Readability by gammapy is tested by pyirf tests, so not repeated here + with fits.open(output_path) as hdul: + assert isinstance(hdul["ENERGY DISPERSION"], fits.BinTableHDU) + assert isinstance(hdul["EFFECTIVE AREA"], fits.BinTableHDU) + if point_like: + assert isinstance(hdul["RAD_MAX"], fits.BinTableHDU) + else: + assert isinstance(hdul["PSF"], fits.BinTableHDU) + + if include_bkg: + assert isinstance(hdul["BACKGROUND"], fits.BinTableHDU) + + os.remove(output_path) # Delete output file + + # Include benchmarks + argv.append(f"--benchmark-output={output_benchmarks_path}") + ret = run_tool(IrfTool(), argv=argv) + assert ret == 0 + + assert output_path.exists() + assert output_benchmarks_path.exists() + with fits.open(output_benchmarks_path) as hdul: + assert isinstance(hdul["ENERGY BIAS RESOLUTION"], fits.BinTableHDU) + assert isinstance(hdul["ANGULAR RESOLUTION"], fits.BinTableHDU) + if include_bkg: + assert isinstance(hdul["SENSITIVITY"], fits.BinTableHDU) + + +# TODO: Add test using point-like gammas diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index d8f42c506c0..cf76e34973f 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -11,7 +11,7 @@ def test_cuts_optimization( gamma_diffuse_full_reco_file, proton_full_reco_file, - irf_events_loader_test_config, + irf_event_loader_test_config, tmp_path, point_like, ): @@ -25,7 +25,7 @@ def test_cuts_optimization( output_path = tmp_path / "cuts.fits" config_path = tmp_path / "config.json" with config_path.open("w") as f: - json.dump(irf_events_loader_test_config, f) + json.dump(irf_event_loader_test_config, f) argv = [ f"--gamma-file={gamma_diffuse_full_reco_file}", @@ -38,10 +38,7 @@ def test_cuts_optimization( if not point_like: argv.append("--full-enclosure") - ret = run_tool( - IrfEventSelector(), - argv=argv, - ) + ret = run_tool(IrfEventSelector(), argv=argv) assert ret == 0 result = OptimizationResultStore().read(output_path) From 96e2cf0b665d8f71494862af6f7a892dfe76dcc7 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 18 Oct 2024 16:01:50 +0200 Subject: [PATCH 153/195] Calculate full-enclosure irf by default with optional point-like flag --- src/ctapipe/tools/make_irf.py | 32 +++++++++---------- src/ctapipe/tools/optimize_event_selection.py | 17 ++++++---- src/ctapipe/tools/tests/test_make_irf.py | 5 +-- .../tests/test_optimize_event_selection.py | 4 +-- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 4d95840b2e1..290d62bf8c5 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -162,11 +162,11 @@ class IrfTool(Tool): help="The parameterization of the point source sensitivity benchmark.", ).tag(config=True) - full_enclosure = Bool( + point_like = Bool( False, help=( - "Compute a full enclosure IRF by not applying a theta cut and only use" - " the G/H separation cut." + "Compute a point-like IRF by applying a theta cut (``RAD_MAX``) " + "which makes calculating a point spread function unnecessary." ), ).tag(config=True) @@ -188,10 +188,10 @@ class IrfTool(Tool): "Do not compute background rate.", ), **flag( - "full-enclosure", - "IrfTool.full_enclosure", - "Compute a full-enclosure IRF.", + "point-like", + "IrfTool.point_like", "Compute a point-like IRF.", + "Compute a full-enclosure IRF.", ), } @@ -211,7 +211,7 @@ class IrfTool(Tool): def setup(self): self.opt_result = OptimizationResultStore().read(self.cuts_file) - if not self.full_enclosure and self.opt_result.theta_cuts is None: + if self.point_like and self.opt_result.theta_cuts is None: raise ToolConfigurationError( "Computing a point-like IRF requires an (optimized) theta cut." ) @@ -261,7 +261,7 @@ def setup(self): self.edisp = EnergyDispersionMakerBase.from_name(self.edisp_maker, parent=self) self.aeff = EffectiveAreaMakerBase.from_name(self.aeff_maker, parent=self) - if self.full_enclosure: + if not self.point_like: self.psf = PsfMakerBase.from_name(self.psf_maker, parent=self) if self.benchmarks_output_path is not None: @@ -305,7 +305,7 @@ def calculate_selections(self, reduced_events: dict) -> dict: self.opt_result.gh_cuts, operator.ge, ) - if not self.full_enclosure: + if self.point_like: reduced_events["gammas"]["selected_theta"] = evaluate_binned_cut( reduced_events["gammas"]["theta"], reduced_events["gammas"]["reco_energy"], @@ -351,7 +351,7 @@ 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, + point_like=self.point_like, signal_is_point_like=self.signal_is_point_like, sim_info=sim_info, ) @@ -359,10 +359,10 @@ def _make_signal_irf_hdus(self, hdus, sim_info): hdus.append( self.edisp.make_edisp_hdu( events=self.signal_events[self.signal_events["selected"]], - point_like=not self.full_enclosure, + point_like=self.point_like, ) ) - if self.full_enclosure: + if not self.point_like: hdus.append( self.psf.make_psf_hdu( events=self.signal_events[self.signal_events["selected"]] @@ -404,7 +404,7 @@ def _make_benchmark_hdus(self, hdus): ) ) if self.do_background: - if self.full_enclosure: + if not self.point_like: # Create a dummy theta cut since `pyirf.sensitivity.estimate_background` # needs a theta cut atm. self.log.info( @@ -498,7 +498,7 @@ def start(self): if self.edisp.fov_offset_n_bins > 1 or self.aeff.fov_offset_n_bins > 1: raise ToolConfigurationError(errormessage) - if self.full_enclosure and self.psf.fov_offset_n_bins > 1: + if not self.point_like and self.psf.fov_offset_n_bins > 1: raise ToolConfigurationError(errormessage) if self.do_background and self.bkg.fov_offset_n_bins > 1: @@ -539,7 +539,7 @@ def start(self): events=reduced_events["protons"][ reduced_events["protons"]["selected_gh"] ], - point_like=not self.full_enclosure, + point_like=self.point_like, signal_is_point_like=False, sim_info=reduced_events["protons_meta"]["sim_info"], extname="EFFECTIVE AREA PROTONS", @@ -551,7 +551,7 @@ def start(self): events=reduced_events["electrons"][ reduced_events["electrons"]["selected_gh"] ], - point_like=not self.full_enclosure, + point_like=self.point_like, signal_is_point_like=False, sim_info=reduced_events["electrons_meta"]["sim_info"], extname="EFFECTIVE AREA ELECTRONS", diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 8558b6fb38a..d69430e9052 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -84,9 +84,12 @@ class IrfEventSelector(Tool): help="The cut optimization algorithm to be used.", ).tag(config=True) - full_enclosure = Bool( + point_like = Bool( False, - help="Compute only the G/H separation cut needed for full enclosure IRF.", + help=( + "Compute a theta cut in addition to the G/H separation cut " + "for a point-like IRF." + ), ).tag(config=True) aliases = { @@ -99,10 +102,10 @@ class IrfEventSelector(Tool): flags = { **flag( - "full-enclosure", - "IrfEventSelector.full_enclosure", - "Compute only the G/H separation cut.", - "Compute the G/H separation cut and the theta cut.", + "point-like", + "IrfEventSelector.point_like", + "Compute a theta cut and a G/H separation cut.", + "Compute only a G/H separation cut.", ) } @@ -197,7 +200,7 @@ def start(self): alpha=self.alpha, 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, + point_like=self.point_like, ) self.result = result diff --git a/src/ctapipe/tools/tests/test_make_irf.py b/src/ctapipe/tools/tests/test_make_irf.py index 36a424cf7ca..36f514b867e 100644 --- a/src/ctapipe/tools/tests/test_make_irf.py +++ b/src/ctapipe/tools/tests/test_make_irf.py @@ -36,6 +36,7 @@ def dummy_cuts_file( f"--electron-file={gamma_diffuse_full_reco_file}", f"--output={output_path}", f"--config={event_loader_config_path}", + "--point-like", ], ) return output_path @@ -63,8 +64,8 @@ def test_irf_tool( f"--output={output_path}", f"--config={event_loader_config_path}", ] - if not point_like: - argv.append("--full-enclosure") + if point_like: + argv.append("--point-like") if include_bkg: argv.append(f"--proton-file={proton_full_reco_file}") diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index cf76e34973f..8cc6cc501df 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -35,8 +35,8 @@ def test_cuts_optimization( f"--output={output_path}", f"--config={config_path}", ] - if not point_like: - argv.append("--full-enclosure") + if point_like: + argv.append("--point-like") ret = run_tool(IrfEventSelector(), argv=argv) assert ret == 0 From b1b7073662808241bcca874feef8ebb209ab2eb4 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 18 Oct 2024 16:02:22 +0200 Subject: [PATCH 154/195] Remove old todo --- src/ctapipe/tools/make_irf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 290d62bf8c5..8958b57d1cd 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -435,7 +435,6 @@ def _make_benchmark_hdus(self, hdus): def start(self): reduced_events = dict() for sel in self.particles: - # TODO: not very elegant to pass them this way, refactor later if sel.epp.quality_criteria != self.opt_result.precuts.quality_criteria: self.log.warning( "Precuts are different from precuts used for calculating " From e787a4fd6adc8aa8e00a8445f823a89519e6f0f5 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 18 Oct 2024 18:45:47 +0200 Subject: [PATCH 155/195] Merge OptimizationResult and OptimizationResultStore --- src/ctapipe/irf/__init__.py | 2 - src/ctapipe/irf/optimize.py | 174 ++++++++++-------- src/ctapipe/irf/tests/test_optimize.py | 49 +++-- src/ctapipe/tools/make_irf.py | 8 +- .../tests/test_optimize_event_selection.py | 8 +- 5 files changed, 123 insertions(+), 118 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 7c6dfac1750..b5af4f756c4 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -19,7 +19,6 @@ from .optimize import ( GhPercentileCutCalculator, OptimizationResult, - OptimizationResultStore, PercentileCuts, PointSourceSensitivityOptimizer, ThetaPercentileCutCalculator, @@ -37,7 +36,6 @@ "EffectiveArea2dMaker", "ResultValidRange", "OptimizationResult", - "OptimizationResultStore", "PointSourceSensitivityOptimizer", "PercentileCuts", "EventLoader", diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 0ffd566948e..0c0419c3509 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -2,6 +2,7 @@ import operator from abc import abstractmethod +from collections.abc import Sequence import astropy.units as u import numpy as np @@ -11,22 +12,45 @@ from pyirf.cuts import calculate_percentile_cut, evaluate_binned_cut from ..core import Component, QualityQuery -from ..core.traits import AstroQuantity, Float, Integer +from ..core.traits import AstroQuantity, Float, Integer, Path from .binning import ResultValidRange, make_bins_per_decade from .select import EventPreProcessor class OptimizationResult: - def __init__(self, precuts, valid_energy, valid_offset, gh, theta): - self.precuts = precuts - self.valid_energy = ResultValidRange( - min=valid_energy["energy_min"], max=valid_energy["energy_max"] - ) - self.valid_offset = ResultValidRange( - min=valid_offset["offset_min"], max=valid_offset["offset_max"] - ) - self.gh_cuts = gh - self.theta_cuts = theta + """Result of an optimization of G/H and theta cuts or only G/H cuts.""" + + def __init__( + self, + valid_energy_min: u.Quantity, + valid_energy_max: u.Quantity, + valid_offset_min: u.Quantity, + valid_offset_max: u.Quantity, + gh_cuts: QTable, + clf_prefix: str, + theta_cuts: QTable | None = None, + precuts: QualityQuery | Sequence | None = None, + ) -> None: + if precuts: + if isinstance(precuts, QualityQuery): + if len(precuts.quality_criteria) == 0: + precuts.quality_criteria = [ + (" ", " ") + ] # Ensures table serialises properly + + self.precuts = precuts + elif isinstance(precuts, list): + self.precuts = QualityQuery(quality_criteria=precuts) + else: + self.precuts = QualityQuery(quality_criteria=list(precuts)) + else: + self.precuts = QualityQuery(quality_criteria=[(" ", " ")]) + + self.valid_energy = ResultValidRange(min=valid_energy_min, max=valid_energy_max) + self.valid_offset = ResultValidRange(min=valid_offset_min, max=valid_offset_max) + self.gh_cuts = gh_cuts + self.clf_prefix = clf_prefix + self.theta_cuts = theta_cuts def __repr__(self): if self.theta_cuts is not None: @@ -45,75 +69,52 @@ def __repr__(self): f"with {len(self.precuts.quality_criteria)} precuts>" ) + def write(self, output_name: Path | str, overwrite: bool = False) -> None: + """Write an ``OptimizationResult`` to a file in FITS format.""" -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 - 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._precuts = None - - 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") + cut_expr_tab = Table( + rows=self.precuts.quality_criteria, + names=["name", "cut_expr"], + dtype=[np.str_, np.str_], + ) + cut_expr_tab.meta["EXTNAME"] = "QUALITY_CUTS_EXPR" - gh_cuts.meta["EXTNAME"] = "GH_CUTS" - gh_cuts.meta["CLFNAME"] = clf_prefix + self.gh_cuts.meta["EXTNAME"] = "GH_CUTS" + self.gh_cuts.meta["CLFNAME"] = self.clf_prefix - energy_lim_tab = QTable(rows=[valid_energy], names=["energy_min", "energy_max"]) + energy_lim_tab = QTable( + rows=[[self.valid_energy.min, self.valid_energy.max]], + 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 = QTable( + rows=[[self.valid_offset.min, self.valid_offset.max]], + names=["offset_min", "offset_max"], + ) offset_lim_tab.meta["EXTNAME"] = "VALID_OFFSET" - 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] + results = [cut_expr_tab, self.gh_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." - ) - - cut_expr_tab = Table( - rows=self._precuts, - names=["name", "cut_expr"], - dtype=[np.str_, np.str_], - ) - cut_expr_tab.meta["EXTNAME"] = "QUALITY_CUTS_EXPR" + if self.theta_cuts is not None: + self.theta_cuts.meta["EXTNAME"] = "RAD_MAX" + results.append(self.theta_cuts) - cut_expr_tab.write(output_name, format="fits", overwrite=overwrite) + # Overwrite if needed and allowed + results[0].write(output_name, format="fits", overwrite=overwrite) - for table in self._results: + for table in results[1:]: table.write(output_name, format="fits", append=True) - def read(self, file_name): + @classmethod + def read(cls, file_name): + """Read an ``OptimizationResult`` from a file in FITS format.""" + 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: + if (" ", " ") in cut_expr_lst: cut_expr_lst.remove((" ", " ")) - except ValueError: - pass precuts = QualityQuery(quality_criteria=cut_expr_lst) gh_cuts = QTable.read(hdul[2]) @@ -121,8 +122,15 @@ def read(self, file_name): 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 + return cls( + precuts=precuts, + valid_energy_min=valid_energy["energy_min"], + valid_energy_max=valid_energy["energy_max"], + valid_offset_min=valid_offset["offset_min"], + valid_offset_max=valid_offset["offset_max"], + gh_cuts=gh_cuts, + clf_prefix=gh_cuts.meta["CLFNAME"], + theta_cuts=theta_cuts, ) @@ -173,7 +181,7 @@ def optimize_cuts( precuts: EventPreProcessor, clf_prefix: str, point_like: bool, - ) -> OptimizationResultStore: + ) -> OptimizationResult: """ Optimize G/H (and optionally theta) cuts and fill them in an ``OptimizationResult``. @@ -319,7 +327,7 @@ def optimize_cuts( precuts: EventPreProcessor, clf_prefix: str, point_like: bool, - ) -> OptimizationResultStore: + ) -> OptimizationResult: reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), @@ -343,16 +351,18 @@ def optimize_cuts( reco_energy_bins, ) - result_saver = OptimizationResultStore(precuts) - result_saver.set_result( + result = OptimizationResult( + precuts=precuts, gh_cuts=gh_cuts, - valid_energy=[self.reco_energy_min, self.reco_energy_max], - # 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, + valid_energy_min=self.reco_energy_min, + valid_energy_max=self.reco_energy_max, + # A single set of cuts is calculated for the whole fov atm + valid_offset_min=0 * u.deg, + valid_offset_max=np.inf * u.deg, theta_cuts=theta_cuts if point_like else None, ) - return result_saver + return result class PointSourceSensitivityOptimizer(CutOptimizerBase): @@ -388,7 +398,7 @@ def optimize_cuts( precuts: EventPreProcessor, clf_prefix: str, point_like: bool, - ) -> OptimizationResultStore: + ) -> OptimizationResult: reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), self.reco_energy_max.to(u.TeV), @@ -463,16 +473,18 @@ def optimize_cuts( reco_energy_bins, ) - result_saver = OptimizationResultStore(precuts) - result_saver.set_result( + result = OptimizationResult( + precuts=precuts, gh_cuts=gh_cuts, - valid_energy=valid_energy, - # A single set of cuts is calculated for the whole fov atm - valid_offset=[self.min_bkg_fov_offset, self.max_bkg_fov_offset], clf_prefix=clf_prefix, + valid_energy_min=valid_energy[0], + valid_energy_max=valid_energy[1], + # A single set of cuts is calculated for the whole fov atm + valid_offset_min=self.min_bkg_fov_offset, + valid_offset_max=self.max_bkg_fov_offset, theta_cuts=theta_cuts_opt if point_like else None, ) - return result_saver + return result def _get_valid_energy_range(self, opt_sens): keep_mask = np.isfinite(opt_sens["significance"]) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 282ce82a596..3c93c6ad8f9 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -5,49 +5,44 @@ import pytest from astropy.table import QTable -from ctapipe.core import non_abstract_children +from ctapipe.core import QualityQuery, non_abstract_children from ctapipe.irf import EventLoader, Spectra from ctapipe.irf.optimize import CutOptimizerBase -def test_optimization_result_store(tmp_path, irf_event_loader_test_config): +def test_optimization_result(tmp_path, irf_event_loader_test_config): from ctapipe.irf import ( EventPreProcessor, OptimizationResult, - OptimizationResultStore, ResultValidRange, ) result_path = tmp_path / "result.h5" epp = EventPreProcessor(irf_event_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( + result = OptimizationResult( + precuts=epp, 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", + valid_energy_min=0.2 * u.TeV, + valid_energy_max=10 * u.TeV, + valid_offset_min=0 * u.deg, + valid_offset_max=np.inf * u.deg, theta_cuts=None, ) - store.write(result_path) + result.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" + loaded = OptimizationResult.read(result_path) + assert isinstance(loaded, OptimizationResult) + assert isinstance(loaded.precuts, QualityQuery) + assert isinstance(loaded.valid_energy, ResultValidRange) + assert isinstance(loaded.valid_offset, ResultValidRange) + assert isinstance(loaded.gh_cuts, QTable) + assert loaded.clf_prefix == "ExtraTreesClassifier" def test_gh_percentile_cut_calculator(): @@ -96,7 +91,7 @@ def test_cut_optimizer( proton_full_reco_file, irf_event_loader_test_config, ): - from ctapipe.irf import OptimizationResultStore + from ctapipe.irf import OptimizationResult gamma_loader = EventLoader( config=irf_event_loader_test_config, @@ -128,8 +123,8 @@ def test_cut_optimizer( clf_prefix="ExtraTreesClassifier", point_like=True, ) - assert isinstance(result, OptimizationResultStore) - assert len(result._results) == 4 - 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 + assert isinstance(result, OptimizationResult) + assert result.clf_prefix == "ExtraTreesClassifier" + assert result.valid_energy.min >= result.gh_cuts["low"][0] + assert result.valid_energy.max <= result.gh_cuts["high"][-1] + assert result.theta_cuts["cut"].unit == u.deg diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 8958b57d1cd..d21cfeb4c7b 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -15,7 +15,7 @@ from ..irf import ( EventLoader, EventPreProcessor, - OptimizationResultStore, + OptimizationResult, Spectra, check_bins_in_range, ) @@ -209,7 +209,7 @@ class IrfTool(Tool): ) def setup(self): - self.opt_result = OptimizationResultStore().read(self.cuts_file) + self.opt_result = OptimizationResult.read(self.cuts_file) if self.point_like and self.opt_result.theta_cuts is None: raise ToolConfigurationError( @@ -452,14 +452,14 @@ def start(self): quality_criteria=self.opt_result.precuts.quality_criteria, ) - if sel.epp.gammaness_classifier != self.opt_result.gh_cuts.meta["CLFNAME"]: + if sel.epp.gammaness_classifier != self.opt_result.clf_prefix: raise RuntimeError( "G/H cuts are only valid for gammaness scores predicted by " "the same classifier model. Requested model: %s. " "Model used for g/h cuts: %s." % ( sel.epp.gammaness_classifier, - self.opt_result.gh_cuts.meta["CLFNAME"], + self.opt_result.clf_prefix, ) ) diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index 8cc6cc501df..728cfb2b7f1 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -4,7 +4,7 @@ import pytest from astropy.table import QTable -from ctapipe.core import run_tool +from ctapipe.core import QualityQuery, run_tool @pytest.mark.parametrize("point_like", (True, False)) @@ -17,7 +17,6 @@ def test_cuts_optimization( ): from ctapipe.irf import ( OptimizationResult, - OptimizationResultStore, ResultValidRange, ) from ctapipe.tools.optimize_event_selection import IrfEventSelector @@ -41,12 +40,13 @@ def test_cuts_optimization( ret = run_tool(IrfEventSelector(), argv=argv) assert ret == 0 - result = OptimizationResultStore().read(output_path) + result = OptimizationResult.read(output_path) assert isinstance(result, OptimizationResult) + assert isinstance(result.precuts, QualityQuery) 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" + assert result.clf_prefix == "ExtraTreesClassifier" assert "cut" in result.gh_cuts.colnames if point_like: assert isinstance(result.theta_cuts, QTable) From 8cb46e67ba1be11086f998573e98e5cd5eb1bc50 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 24 Oct 2024 11:31:12 +0200 Subject: [PATCH 156/195] Fix some docstrings --- src/ctapipe/irf/irfs.py | 8 ++++---- src/ctapipe/tools/optimize_event_selection.py | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index d0f3d2b8bea..00b2d9bdd0f 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -34,7 +34,7 @@ def __init__(self, parent=None, **kwargs): @abstractmethod def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: """ - Calculate the psf and create a fits binary table HDU in GAD format. + Calculate the psf and create a fits binary table HDU in GADF format. Parameters ---------- @@ -61,7 +61,7 @@ def make_bkg_hdu( ) -> BinTableHDU: """ Calculate the background rate and create a fits binary table HDU - in GAD format. + in GADF format. Parameters ---------- @@ -119,7 +119,7 @@ def make_edisp_hdu( ) -> BinTableHDU: """ Calculate the energy dispersion and create a fits binary table HDU - in GAD format. + in GADF format. Parameters ---------- @@ -154,7 +154,7 @@ def make_aeff_hdu( ) -> BinTableHDU: """ Calculate the effective area and create a fits binary table HDU - in GAD format. + in GADF format. Parameters ---------- diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index d69430e9052..a368aeaf14f 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -71,11 +71,15 @@ class IrfEventSelector(Tool): obs_time = AstroQuantity( default_value=u.Quantity(50, u.hour), physical_type=u.physical.time, - help="Observation time in the form `` ``", + help=( + "Observation time in the form `` ``." + " This is used for flux normalization when calculating sensitivities." + ), ).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 when calculating sensitivities.", ).tag(config=True) optimization_algorithm = traits.ComponentName( From d9e65468613802fee732f10a671223755d80c74d Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 24 Oct 2024 13:33:21 +0200 Subject: [PATCH 157/195] Remove support for non-standart column names --- src/ctapipe/irf/select.py | 51 +++++++--------------------- src/ctapipe/irf/tests/test_select.py | 9 +++-- 2 files changed, 16 insertions(+), 44 deletions(-) diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/select.py index d3f0c320c6c..7e063de0e8d 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/select.py @@ -56,13 +56,6 @@ class EventPreProcessor(QualityQuery): 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: [('alt_from_new_algorithm','reco_alt')]", - default_value=[], - ).tag(config=True) - def normalise_column_names(self, events: Table) -> QTable: keep_columns = [ "obs_id", @@ -71,41 +64,21 @@ def normalise_column_names(self, events: Table) -> QTable: "true_az", "true_alt", ] - 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 old, new 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: + 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"] + keep_columns.extend(rename_from) + for c in keep_columns: + if c not in events.colnames: 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." + "Input files must conform to the ctapipe DL2 data model. " + f"Required column {c} is missing." ) - keep_columns.extend(rename_from) events = QTable(events[keep_columns], copy=COPY_IF_NEEDED) events.rename_columns(rename_from, rename_to) return events diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_select.py index 16e2f849bd8..5d6abdbc216 100644 --- a/src/ctapipe/irf/tests/test_select.py +++ b/src/ctapipe/irf/tests/test_select.py @@ -16,9 +16,9 @@ def dummy_table(): "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, + "geom_alt": [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, + "geom_az": [12.5, 13, 11.8, 15.1, 14.7, 12.8] * u.deg, } ) @@ -30,7 +30,6 @@ def test_normalise_column_names(dummy_table): 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) @@ -48,8 +47,8 @@ def test_normalise_column_names(dummy_table): for c in needed_cols: assert c in norm_table.colnames - # error if reco_{alt,az} is missing because of non-standard name - with pytest.raises(ValueError, match="No column corresponding"): + with pytest.raises(ValueError, match="Required column geom_alt is missing."): + dummy_table.rename_column("geom_alt", "alt_geom") epp = EventPreProcessor( energy_reconstructor="dummy", geometry_reconstructor="geom", From cf5b33bb40c61b273b0022571faf7f0a07351c5d Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 24 Oct 2024 14:28:30 +0200 Subject: [PATCH 158/195] Move on/off ratio config to PointSourceSensitivityOptimizer --- src/ctapipe/irf/benchmarks.py | 3 ++- src/ctapipe/irf/optimize.py | 12 ++++++------ src/ctapipe/irf/tests/test_optimize.py | 1 - src/ctapipe/tools/optimize_event_selection.py | 8 +------- 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index 86a682ec878..56d7c99c03e 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -174,7 +174,8 @@ class SensitivityMakerBase(DefaultRecoEnergyBins): """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." + default_value=0.2, + help="Size ratio of on region / off region.", ).tag(config=True) def __init__(self, parent=None, **kwargs): diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 0c0419c3509..5d25239f550 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -177,7 +177,6 @@ def optimize_cuts( self, signal: QTable, background: QTable, - alpha: float, precuts: EventPreProcessor, clf_prefix: str, point_like: bool, @@ -192,8 +191,6 @@ def optimize_cuts( Table containing signal events background: astropy.table.QTable Table containing background events - alpha: float - Size ratio of on region / off region precuts: ctapipe.irf.EventPreProcessor ``ctapipe.core.QualityQuery`` subclass containing preselection criteria for events @@ -323,7 +320,6 @@ def optimize_cuts( self, signal: QTable, background: QTable, - alpha: float, precuts: EventPreProcessor, clf_prefix: str, point_like: bool, @@ -386,6 +382,11 @@ class PointSourceSensitivityOptimizer(CutOptimizerBase): help="Stepsize used for scanning after optimal gammaness cut", ).tag(config=True) + alpha = Float( + default_value=0.2, + help="Size ratio of on region / off region.", + ).tag(config=True) + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.theta = ThetaPercentileCutCalculator(parent=self) @@ -394,7 +395,6 @@ def optimize_cuts( self, signal: QTable, background: QTable, - alpha: float, precuts: EventPreProcessor, clf_prefix: str, point_like: bool, @@ -453,7 +453,7 @@ def optimize_cuts( gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, theta_cuts=theta_cuts, - alpha=alpha, + alpha=self.alpha, fov_offset_max=self.max_bkg_fov_offset, fov_offset_min=self.min_bkg_fov_offset, ) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 3c93c6ad8f9..5861a454d32 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -118,7 +118,6 @@ def test_cut_optimizer( 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, diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index a368aeaf14f..66420bd90cd 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -4,7 +4,7 @@ from astropy.table import vstack from ..core import Provenance, Tool, 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 EventLoader, Spectra from ..irf.optimize import CutOptimizerBase @@ -77,11 +77,6 @@ class IrfEventSelector(Tool): ), ).tag(config=True) - alpha = Float( - default_value=0.2, - help="Ratio between size of on and off regions when calculating sensitivities.", - ).tag(config=True) - optimization_algorithm = traits.ComponentName( CutOptimizerBase, default_value="PointSourceSensitivityOptimizer", @@ -201,7 +196,6 @@ def start(self): 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, point_like=self.point_like, From 703f54a2a8002ad14016a8c994f2de017ffaabbc Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 15 Nov 2024 13:24:18 +0100 Subject: [PATCH 159/195] Rename ctapipe.irf.select to ctapipe.irf.preprocessing --- src/ctapipe/irf/__init__.py | 2 +- src/ctapipe/irf/optimize.py | 3 ++- src/ctapipe/irf/{select.py => preprocessing.py} | 2 +- .../irf/tests/{test_select.py => test_preprocessing.py} | 0 4 files changed, 4 insertions(+), 3 deletions(-) rename src/ctapipe/irf/{select.py => preprocessing.py} (99%) rename src/ctapipe/irf/tests/{test_select.py => test_preprocessing.py} (100%) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index b5af4f756c4..8780a54f7a1 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -23,7 +23,7 @@ PointSourceSensitivityOptimizer, ThetaPercentileCutCalculator, ) -from .select import EventLoader, EventPreProcessor +from .preprocessing import EventLoader, EventPreProcessor from .spectra import SPECTRA, Spectra __all__ = [ diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 5d25239f550..e9658b5feb9 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -14,7 +14,7 @@ from ..core import Component, QualityQuery from ..core.traits import AstroQuantity, Float, Integer, Path from .binning import ResultValidRange, make_bins_per_decade -from .select import EventPreProcessor +from .preprocessing import EventPreProcessor class OptimizationResult: @@ -31,6 +31,7 @@ def __init__( theta_cuts: QTable | None = None, precuts: QualityQuery | Sequence | None = None, ) -> None: + """""" if precuts: if isinstance(precuts, QualityQuery): if len(precuts.quality_criteria) == 0: diff --git a/src/ctapipe/irf/select.py b/src/ctapipe/irf/preprocessing.py similarity index 99% rename from src/ctapipe/irf/select.py rename to src/ctapipe/irf/preprocessing.py index 7e063de0e8d..ab4f92bf7b4 100644 --- a/src/ctapipe/irf/select.py +++ b/src/ctapipe/irf/preprocessing.py @@ -1,4 +1,4 @@ -"""Module containing classes related to event preprocessing and selection""" +"""Module containing classes related to event loading and preprocessing""" from pathlib import Path diff --git a/src/ctapipe/irf/tests/test_select.py b/src/ctapipe/irf/tests/test_preprocessing.py similarity index 100% rename from src/ctapipe/irf/tests/test_select.py rename to src/ctapipe/irf/tests/test_preprocessing.py From 54d582c8b261b196578b6be333f6cfc1732a24a2 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 15 Nov 2024 19:36:49 +0100 Subject: [PATCH 160/195] Add doc pages; fix docstrings; add __all__ --- docs/api-reference/irf/benchmarks.rst | 12 +++++ docs/api-reference/irf/binning.rst | 12 +++++ docs/api-reference/irf/index.rst | 49 +++++++++++++++++++ docs/api-reference/irf/irfs.rst | 12 +++++ docs/api-reference/irf/optimize.rst | 12 +++++ docs/api-reference/irf/preprocessing.rst | 12 +++++ docs/api-reference/irf/spectra.rst | 12 +++++ docs/api-reference/irf/vis_utils.rst | 12 +++++ docs/api-reference/irf/visualization.rst | 12 +++++ docs/api-reference/tools/index.rst | 8 ++- docs/conf.py | 1 + src/ctapipe/irf/__init__.py | 4 +- src/ctapipe/irf/benchmarks.py | 9 ++++ src/ctapipe/irf/binning.py | 9 ++++ src/ctapipe/irf/irfs.py | 11 +++++ src/ctapipe/irf/optimize.py | 10 +++- src/ctapipe/irf/preprocessing.py | 9 +++- src/ctapipe/irf/spectra.py | 2 + src/ctapipe/irf/vis_utils.py | 8 +++ src/ctapipe/irf/visualisation.py | 27 +++++++--- src/ctapipe/tools/make_irf.py | 26 +++++++++- src/ctapipe/tools/optimize_event_selection.py | 22 ++++++++- 22 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 docs/api-reference/irf/benchmarks.rst create mode 100644 docs/api-reference/irf/binning.rst create mode 100644 docs/api-reference/irf/index.rst create mode 100644 docs/api-reference/irf/irfs.rst create mode 100644 docs/api-reference/irf/optimize.rst create mode 100644 docs/api-reference/irf/preprocessing.rst create mode 100644 docs/api-reference/irf/spectra.rst create mode 100644 docs/api-reference/irf/vis_utils.rst create mode 100644 docs/api-reference/irf/visualization.rst diff --git a/docs/api-reference/irf/benchmarks.rst b/docs/api-reference/irf/benchmarks.rst new file mode 100644 index 00000000000..c63701ef4dd --- /dev/null +++ b/docs/api-reference/irf/benchmarks.rst @@ -0,0 +1,12 @@ +.. _benchmarks: + +********** +Benchmarks +********** + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.benchmarks + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/binning.rst b/docs/api-reference/irf/binning.rst new file mode 100644 index 00000000000..cff1d01ebf3 --- /dev/null +++ b/docs/api-reference/irf/binning.rst @@ -0,0 +1,12 @@ +.. _binning: + +******* +Binning +******* + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.binning + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/index.rst b/docs/api-reference/irf/index.rst new file mode 100644 index 00000000000..f9090c4108c --- /dev/null +++ b/docs/api-reference/irf/index.rst @@ -0,0 +1,49 @@ +.. _irf: + +********************************************* +Instrument Response Functions (`~ctapipe.irf`) +********************************************* + +.. currentmodule:: ctapipe.irf + +This module contains functionalities for generating instrument response functions. +The simulated events used for this have to be selected based on their associated "gammaness" +value and (optionally) their reconstructed angular offset from their point of origin. +The code for doing this can found in :ref:`cut_optimization` and is intended for use via the +`~ctapipe.tools.optimize_event_selection.IrfEventSelector` tool. + +The generation of the irf components themselves is implemented in :ref:`irfs` and is intended for +use via the `~ctapipe.tools.make_irf.IrfTool` tool. +This tool can optionally also compute some common benchmarks, which are implemented in :ref:`benchmarks`. + +The cut optimization as well as the calculations of the irf components and the benchmarks +are done using the `pyirf `_ package. + +:ref:`binning`, :ref:`preprocessing`, and :ref:`spectra` contain helper functions and classes used by many of the +other components in this module. + +:ref:`irf_visualization` and :ref:`vis_utils` implement some functionalities for visualizing +the irf components. + + +Submodules +========== + +.. toctree:: + :maxdepth: 1 + + optimize + irfs + benchmarks + binning + preprocessing + spectra + visualization + vis_utils + + +Reference/API +============= + +.. automodapi:: ctapipe.irf + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/irfs.rst b/docs/api-reference/irf/irfs.rst new file mode 100644 index 00000000000..9755f91ec70 --- /dev/null +++ b/docs/api-reference/irf/irfs.rst @@ -0,0 +1,12 @@ +.. _irfs: + +************** +IRF components +************** + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.irfs + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/optimize.rst b/docs/api-reference/irf/optimize.rst new file mode 100644 index 00000000000..ad47192f8eb --- /dev/null +++ b/docs/api-reference/irf/optimize.rst @@ -0,0 +1,12 @@ +.. _cut_optimization: + +******************************** +G/H (and Theta) Cut Optimization +******************************** + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.optimize + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/preprocessing.rst b/docs/api-reference/irf/preprocessing.rst new file mode 100644 index 00000000000..9d57445cbe3 --- /dev/null +++ b/docs/api-reference/irf/preprocessing.rst @@ -0,0 +1,12 @@ +.. _preprocessing: + +******************************* +Event Loading and Preprocessing +******************************* + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.preprocessing + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/spectra.rst b/docs/api-reference/irf/spectra.rst new file mode 100644 index 00000000000..08165e9acd5 --- /dev/null +++ b/docs/api-reference/irf/spectra.rst @@ -0,0 +1,12 @@ +.. _spectra: + +************************************* +Spectra definitions for event weights +************************************* + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.spectra + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/vis_utils.rst b/docs/api-reference/irf/vis_utils.rst new file mode 100644 index 00000000000..1e3aac584bd --- /dev/null +++ b/docs/api-reference/irf/vis_utils.rst @@ -0,0 +1,12 @@ +.. _vis_utils: + +************************************** +Helper functions for IRF visualization +************************************** + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.vis_utils + :no-inheritance-diagram: diff --git a/docs/api-reference/irf/visualization.rst b/docs/api-reference/irf/visualization.rst new file mode 100644 index 00000000000..089bcc85a80 --- /dev/null +++ b/docs/api-reference/irf/visualization.rst @@ -0,0 +1,12 @@ +.. _irf_visualization: + +***************** +IRF Visualization +***************** + + +Reference/ API +============== + +.. automodapi:: ctapipe.irf.visualisation + :no-inheritance-diagram: diff --git a/docs/api-reference/tools/index.rst b/docs/api-reference/tools/index.rst index 25efbb905e8..c0073ec0bd3 100644 --- a/docs/api-reference/tools/index.rst +++ b/docs/api-reference/tools/index.rst @@ -94,7 +94,13 @@ Reference/API :no-inheritance-diagram: .. automodapi:: ctapipe.tools.train_disp_reconstructor - :no-inheritance-diagram: + :no-inheritance-diagram: .. automodapi:: ctapipe.tools.apply_models :no-inheritance-diagram: + +.. automodapi:: ctapipe.tools.optimize_event_selection + :no-inheritance-diagram: + +.. automodapi:: ctapipe.tools.make_irf + :no-inheritance-diagram: diff --git a/docs/conf.py b/docs/conf.py index 0594a0e156f..a9cc582516f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -405,6 +405,7 @@ def setup(app): "numpy": ("https://numpy.org/doc/stable", None), "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), "psutil": ("https://psutil.readthedocs.io/en/stable", None), + "pyirf": ("https://pyirf.readthedocs.io/en/stable/", None), "pytables": ("https://www.pytables.org", None), "pytest": ("https://docs.pytest.org/en/stable", None), "python": ("https://docs.python.org/3", None), diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index 8780a54f7a1..be6e1f5c8a2 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -24,7 +24,7 @@ ThetaPercentileCutCalculator, ) from .preprocessing import EventLoader, EventPreProcessor -from .spectra import SPECTRA, Spectra +from .spectra import ENERGY_FLUX_UNIT, FLUX_UNIT, SPECTRA, Spectra __all__ = [ "AngularResolution2dMaker", @@ -44,6 +44,8 @@ "GhPercentileCutCalculator", "ThetaPercentileCutCalculator", "SPECTRA", + "ENERGY_FLUX_UNIT", + "FLUX_UNIT", "check_bins_in_range", "make_bins_per_decade", ] diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index 56d7c99c03e..c78d5991023 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -14,6 +14,15 @@ from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins from .spectra import ENERGY_FLUX_UNIT, FLUX_UNIT, SPECTRA, Spectra +__all__ = [ + "EnergyBiasResolutionMakerBase", + "EnergyBiasResolution2dMaker", + "AngularResolutionMakerBase", + "AngularResolution2dMaker", + "SensitivityMakerBase", + "Sensitivity2dMaker", +] + def _get_2d_result_table( events: QTable, e_bins: u.Quantity, fov_bins: u.Quantity diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index 0fe6c2ae4fd..b0c28cfedb0 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -10,6 +10,15 @@ from ..core import Component from ..core.traits import AstroQuantity, Integer +__all__ = [ + "ResultValidRange", + "check_bins_in_range", + "make_bins_per_decade", + "DefaultTrueEnergyBins", + "DefaultRecoEnergyBins", + "DefaultFoVOffsetBins", +] + logger = logging.getLogger(__name__) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 00b2d9bdd0f..23def128aca 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -24,6 +24,17 @@ from ..core.traits import AstroQuantity, Bool, Float, Integer from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins +__all__ = [ + "BackgroundRateMakerBase", + "BackgroundRate2dMaker", + "EffectiveAreaMakerBase", + "EffectiveArea2dMaker", + "EnergyDispersionMakerBase", + "EnergyDispersion2dMaker", + "PsfMakerBase", + "Psf3dMaker", +] + class PsfMakerBase(DefaultTrueEnergyBins): """Base class for calculating the point spread function.""" diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index e9658b5feb9..474805b8f43 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -16,6 +16,15 @@ from .binning import ResultValidRange, make_bins_per_decade from .preprocessing import EventPreProcessor +__all__ = [ + "CutOptimizerBase", + "GhPercentileCutCalculator", + "OptimizationResult", + "PercentileCuts", + "PointSourceSensitivityOptimizer", + "ThetaPercentileCutCalculator", +] + class OptimizationResult: """Result of an optimization of G/H and theta cuts or only G/H cuts.""" @@ -31,7 +40,6 @@ def __init__( theta_cuts: QTable | None = None, precuts: QualityQuery | Sequence | None = None, ) -> None: - """""" if precuts: if isinstance(precuts, QualityQuery): if len(precuts.quality_criteria) == 0: diff --git a/src/ctapipe/irf/preprocessing.py b/src/ctapipe/irf/preprocessing.py index ab4f92bf7b4..50869659682 100644 --- a/src/ctapipe/irf/preprocessing.py +++ b/src/ctapipe/irf/preprocessing.py @@ -23,9 +23,11 @@ from ..io import TableLoader from .spectra import SPECTRA, Spectra +__all__ = ["EventLoader", "EventPreProcessor"] + class EventPreProcessor(QualityQuery): - """Defines preselection cuts and the necessary renaming of columns""" + """Defines preselection cuts and the necessary renaming of columns.""" energy_reconstructor = Unicode( default_value="RandomForestRegressor", @@ -166,6 +168,11 @@ def make_empty_table(self) -> QTable: class EventLoader(Component): + """ + Contains functions to load events and simulation information from a file + and derive some additional columns needed for irf calculation. + """ + classes = [EventPreProcessor] def __init__(self, kind: str, file: Path, target_spectrum: Spectra, **kwargs): diff --git a/src/ctapipe/irf/spectra.py b/src/ctapipe/irf/spectra.py index 626a65febf7..75106112b97 100644 --- a/src/ctapipe/irf/spectra.py +++ b/src/ctapipe/irf/spectra.py @@ -5,6 +5,8 @@ import astropy.units as u from pyirf.spectral import CRAB_HEGRA, IRFDOC_ELECTRON_SPECTRUM, IRFDOC_PROTON_SPECTRUM +__all__ = ["ENERGY_FLUX_UNIT", "FLUX_UNIT", "SPECTRA", "Spectra"] + ENERGY_FLUX_UNIT = (1 * u.erg / u.s / u.cm**2).unit FLUX_UNIT = (1 / u.erg / u.s / u.cm**2).unit diff --git a/src/ctapipe/irf/vis_utils.py b/src/ctapipe/irf/vis_utils.py index e05812e1c84..e09df19ecd4 100644 --- a/src/ctapipe/irf/vis_utils.py +++ b/src/ctapipe/irf/vis_utils.py @@ -1,6 +1,14 @@ import numpy as np import scipy.stats as st +__all__ = [ + "find_columnwise_stats", + "rebin_x_2d_hist", + "get_2d_hist_from_table", + "get_x_bin_values_with_rebinning", + "get_bin_centers", +] + def find_columnwise_stats(table, col_bins, percentiles, density=False): tab = np.squeeze(table) diff --git a/src/ctapipe/irf/visualisation.py b/src/ctapipe/irf/visualisation.py index 8880d0f3a53..46b74648980 100644 --- a/src/ctapipe/irf/visualisation.py +++ b/src/ctapipe/irf/visualisation.py @@ -12,6 +12,15 @@ get_x_bin_values_with_rebinning, ) +__all__ = [ + "plot_2d_irf_table", + "plot_2d_table_with_col_stats", + "plot_2d_table_col_stats", + "plot_hist2d", + "plot_hist2d_as_contour", + "plot_irf_table", +] + quantity_support() @@ -48,11 +57,12 @@ def plot_2d_table_with_col_stats( "stats": {"color": "firebrick"}, }, ): - """Function to draw 2d histogram along with columnwise statistics + """ + Function to draw 2d histogram along with columnwise statistics 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) + 0 -> mean + standard deviation + 1 -> median + standard deviation + 2 -> median + user specified quantiles around median (default 0.1 to 0.9) """ mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) @@ -105,11 +115,12 @@ 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) + 0 -> mean + standard deviation + 1 -> median + standard deviation + 2 -> median + user specified quantiles around median (default 0.1 to 0.9) """ mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index d21cfeb4c7b..b60358e48d7 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -31,10 +31,23 @@ PsfMakerBase, ) +__all__ = ["IrfTool"] + class IrfTool(Tool): + "Tool to create IRF files in GADF format" + name = "ctapipe-make-irf" - description = "Tool to create IRF files in GADF format" + description = __doc__ + examples = """ + ctapipe-make-irf \\ + --cuts cuts.fits \\ + --gamma-file gamma.dl2.h5 \\ + --proton-file proton.dl2.h5 \\ + --electron-file electron.dl2.h5 \\ + --output irf.fits.gz \\ + --benchmark-output benchmarks.fits.gz + """ do_background = Bool( True, @@ -95,7 +108,7 @@ class IrfTool(Tool): ).tag(config=True) output_path = traits.Path( - default_value="./IRF.fits.gz", + default_value=None, allow_none=False, directory_ok=False, help="Output file", @@ -209,6 +222,9 @@ class IrfTool(Tool): ) def setup(self): + """ + Initialize components from config and load g/h (and theta) cuts. + """ self.opt_result = OptimizationResult.read(self.cuts_file) if self.point_like and self.opt_result.theta_cuts is None: @@ -433,6 +449,9 @@ def _make_benchmark_hdus(self, hdus): return hdus def start(self): + """ + Load events and calculate the irf (and the benchmarks). + """ reduced_events = dict() for sel in self.particles: if sel.epp.quality_criteria != self.opt_result.precuts.quality_criteria: @@ -564,6 +583,9 @@ def start(self): self.b_hdus = b_hdus def finish(self): + """ + Write the irf (and the benchmarks) to the (respective) output file(s). + """ self.log.info("Writing outputfile '%s'" % self.output_path) fits.HDUList(self.hdus).writeto( self.output_path, diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 66420bd90cd..d62e26b8fa1 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -8,10 +8,21 @@ from ..irf import EventLoader, Spectra from ..irf.optimize import CutOptimizerBase +__all__ = ["IrfEventSelector"] + class IrfEventSelector(Tool): + "Tool to create optimized cuts for IRF generation" + name = "ctapipe-optimize-event-selection" - description = "Tool to create optimized cuts for IRF generation" + description = __doc__ + examples = """ + ctapipe-optimize-event-selection \\ + --gamma-file gamma.dl2.h5 \\ + --proton-file proton.dl2.h5 \\ + --electron-file electron.dl2.h5 \\ + --output cuts.fits + """ gamma_file = traits.Path( default_value=None, directory_ok=False, help="Gamma input filename and path" @@ -111,6 +122,9 @@ class IrfEventSelector(Tool): classes = [EventLoader] + classes_with_traits(CutOptimizerBase) def setup(self): + """ + Initialize components from config. + """ self.optimizer = CutOptimizerBase.from_name( self.optimization_algorithm, parent=self ) @@ -141,6 +155,9 @@ def setup(self): ) def start(self): + """ + Load events and optimize g/h (and theta) cuts. + """ reduced_events = dict() for sel in self.particles: evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) @@ -203,6 +220,9 @@ def start(self): self.result = result def finish(self): + """ + Write optimized cuts to the output file. + """ self.log.info("Writing results to %s" % self.output_path) Provenance().add_output_file(self.output_path, role="Optimization Result") self.result.write(self.output_path, self.overwrite) From 806afd226b9ebaf6336dd77a0d96685f5fbc74a9 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 15 Nov 2024 20:01:24 +0100 Subject: [PATCH 161/195] Update pyirf dependency and make it optional; remove numpy 2.0 hotfixes --- pyproject.toml | 2 +- src/ctapipe/irf/benchmarks.py | 2 +- src/ctapipe/irf/tests/test_benchmarks.py | 4 ---- src/ctapipe/irf/tests/test_optimize.py | 3 --- 4 files changed, 2 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6bea39efbc7..5006db8c20e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "numpy >=1.23,<3.0.0a0", "packaging", "psutil", - "pyirf ~=0.11.0", "pyyaml >=5.1", "requests", "scikit-learn !=1.4.0", # 1.4.0 breaks with astropy tables, before and after works @@ -56,6 +55,7 @@ all = [ "eventio >=1.9.1,<2.0.0a0", "iminuit >=2", "matplotlib ~=3.0", + "pyirf ~=0.12.0" ] tests = [ diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index c78d5991023..d336fe8d508 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -172,7 +172,7 @@ def make_angular_resolution_hdu( energy_type=energy_type, ) result["N_EVENTS"][:, i, :] = ang_res["n_events"] - result["ANGULAR_RESOLUTION"][:, i, :] = ang_res["angular_resolution"] + result["ANGULAR_RESOLUTION"][:, i, :] = ang_res["angular_resolution_68"] header = Header() header["E_TYPE"] = energy_type.upper() diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 4f964081709..0f8262b9be5 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -1,7 +1,4 @@ -import sys - import astropy.units as u -import pytest from astropy.table import QTable from ctapipe.irf.tests.test_irfs import _check_boundaries_in_hdu @@ -70,7 +67,6 @@ def test_make_2d_ang_res(irf_events_table): ) -@pytest.mark.skipif(sys.version_info.minor > 11, reason="Pyirf+numpy 2.0 errors out") def test_make_2d_sensitivity( gamma_diffuse_full_reco_file, proton_full_reco_file, irf_event_loader_test_config ): diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 5861a454d32..7e44bebddee 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -1,5 +1,3 @@ -import sys - import astropy.units as u import numpy as np import pytest @@ -83,7 +81,6 @@ def test_theta_percentile_cut_calculator(): assert calc.smoothing is None -@pytest.mark.skipif(sys.version_info.minor > 11, reason="Pyirf+numpy 2.0 errors out") @pytest.mark.parametrize("Optimizer", non_abstract_children(CutOptimizerBase)) def test_cut_optimizer( Optimizer, From b245b662138f9493cfc322325fdf7ddc201dcb32 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 18 Nov 2024 15:22:45 +0100 Subject: [PATCH 162/195] Fix heading on doc page --- docs/api-reference/irf/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-reference/irf/index.rst b/docs/api-reference/irf/index.rst index f9090c4108c..471e53077af 100644 --- a/docs/api-reference/irf/index.rst +++ b/docs/api-reference/irf/index.rst @@ -1,8 +1,8 @@ .. _irf: -********************************************* +********************************************** Instrument Response Functions (`~ctapipe.irf`) -********************************************* +********************************************** .. currentmodule:: ctapipe.irf From 9501c75b964f04edad66be3c0041b9951aff831a Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 20 Nov 2024 15:29:06 +0100 Subject: [PATCH 163/195] Add support for older files --- src/ctapipe/irf/preprocessing.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ctapipe/irf/preprocessing.py b/src/ctapipe/irf/preprocessing.py index 50869659682..d46502a0aac 100644 --- a/src/ctapipe/irf/preprocessing.py +++ b/src/ctapipe/irf/preprocessing.py @@ -14,6 +14,7 @@ calculate_event_weights, ) from pyirf.utils import calculate_source_fov_offset, calculate_theta +from tables import NoSuchNodeError from ..compat import COPY_IF_NEEDED from ..containers import CoordinateFrameType @@ -211,7 +212,11 @@ def get_simulation_information( ) -> tuple[SimulatedEventsInfo, PowerLaw, Table]: obs = loader.read_observation_information() sim = loader.read_simulation_configuration() - show = loader.read_shower_distribution() + try: + show = loader.read_shower_distribution() + except NoSuchNodeError: + # Fall back to using the run header + show = Table([sim["n_showers"]], names=["n_entries"], dtype=[np.int64]) for itm in ["spectral_index", "energy_range_min", "energy_range_max"]: if len(np.unique(sim[itm])) > 1: From fd7ebbe0adee6bbe45b12f324feabb3f6afe6ff8 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 20 Nov 2024 15:30:05 +0100 Subject: [PATCH 164/195] Support for only using proton and gamma files --- src/ctapipe/tools/make_irf.py | 10 ++++++--- src/ctapipe/tools/optimize_event_selection.py | 22 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index b60358e48d7..c776f70a1ed 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -338,7 +338,8 @@ def calculate_selections(self, reduced_events: dict) -> dict: ] if self.do_background: - bkgs = ("protons", "electrons") if self.electron_file else ("protons") + bkgs = ["protons", "electrons"] if self.electron_file else ["protons"] + n_sel = {"protons": 0, "electrons": 0} for bg_type in bkgs: reduced_events[bg_type]["selected_gh"] = evaluate_binned_cut( reduced_events[bg_type]["gh_score"], @@ -346,14 +347,17 @@ def calculate_selections(self, reduced_events: dict) -> dict: self.opt_result.gh_cuts, operator.ge, ) + n_sel["bg_type"] = np.count_nonzero( + reduced_events[bg_type]["selected_gh"] + ) if self.do_background: self.log.info( "Keeping %d signal, %d proton events, and %d electron events" % ( np.count_nonzero(reduced_events["gammas"]["selected"]), - np.count_nonzero(reduced_events["protons"]["selected_gh"]), - np.count_nonzero(reduced_events["electrons"]["selected_gh"]), + n_sel["protons"], + n_sel["electrons"], ) ) else: diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index d62e26b8fa1..4c180e36bb3 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -137,6 +137,8 @@ def setup(self): ) ] if self.optimization_algorithm != "PercentileCuts": + if self.proton_file and not self.proton_file.exists(): + raise ValueError("Need a proton file to proceed") self.particles.append( EventLoader( parent=self, @@ -145,14 +147,17 @@ def setup(self): target_spectrum=self.proton_target_spectrum, ) ) - self.particles.append( - EventLoader( - parent=self, - kind="electrons", - file=self.electron_file, - target_spectrum=self.electron_target_spectrum, + if self.electron_file and self.electron_file.exists(): + self.particles.append( + EventLoader( + parent=self, + kind="electrons", + file=self.electron_file, + target_spectrum=self.electron_target_spectrum, + ) ) - ) + else: + self.log.warning("Optimizing without electron file.") def start(self): """ @@ -184,6 +189,9 @@ def start(self): self.log.debug("Keeping %d gammas" % len(reduced_events["gammas"])) self.log.info("Optimizing cuts using %d signal" % len(self.signal_events)) else: + if "electrons" not in reduced_events.keys(): + reduced_events["electrons"] = [] + reduced_events["electrons_count"] = 0 self.log.debug( "Loaded %d gammas, %d protons, %d electrons" % ( From b0058da7165d08d0b32761c30e0c362629f9866f Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 20 Nov 2024 15:41:25 +0100 Subject: [PATCH 165/195] Remove unused visualisation conde --- src/ctapipe/irf/vis_utils.py | 72 ---------- src/ctapipe/irf/visualisation.py | 235 ------------------------------- 2 files changed, 307 deletions(-) delete mode 100644 src/ctapipe/irf/vis_utils.py delete mode 100644 src/ctapipe/irf/visualisation.py diff --git a/src/ctapipe/irf/vis_utils.py b/src/ctapipe/irf/vis_utils.py deleted file mode 100644 index e09df19ecd4..00000000000 --- a/src/ctapipe/irf/vis_utils.py +++ /dev/null @@ -1,72 +0,0 @@ -import numpy as np -import scipy.stats as st - -__all__ = [ - "find_columnwise_stats", - "rebin_x_2d_hist", - "get_2d_hist_from_table", - "get_x_bin_values_with_rebinning", - "get_bin_centers", -] - - -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, [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 deleted file mode 100644 index 46b74648980..00000000000 --- a/src/ctapipe/irf/visualisation.py +++ /dev/null @@ -1,235 +0,0 @@ -import astropy.units as u -import matplotlib.pyplot as plt -import numpy as np -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, -) - -__all__ = [ - "plot_2d_irf_table", - "plot_2d_table_with_col_stats", - "plot_2d_table_col_stats", - "plot_hist2d", - "plot_hist2d_as_contour", - "plot_irf_table", -] - -quantity_support() - - -def plot_2d_irf_table( - ax, table, column, x_prefix, y_prefix, x_label=None, y_label=None, **mpl_args -): - mat_vals, xbins, ybins = get_2d_hist_from_table(x_prefix, y_prefix, table, column) - - if not x_label: - x_label = x_prefix - if not y_label: - y_label = y_prefix - plot = plot_hist2d( - ax, mat_vals, xbins, ybins, xlabel=x_label, ylabel=y_label, **mpl_args - ) - plt.colorbar(plot) - return ax - - -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, - density=False, - mpl_args={ - "histo": {"xscale": "log"}, - "stats": {"color": "firebrick"}, - }, -): - """ - Function to draw 2d histogram along with columnwise statistics - 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) - """ - - 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, density = get_x_bin_values_with_rebinning( - num_rebin, xbins, xcent, mat_vals, density - ) - - plot = plot_hist2d( - ax, - rebin_hist, - rebin_x, - ybins, - xlabel=x_label, - ylabel=y_label, - **mpl_args["histo"], - ) - plt.colorbar(plot) - - 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=mpl_args, - lbl_prefix="", - ) - 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) - """ - - 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, density = get_x_bin_values_with_rebinning( - num_rebin, xbins, xcent, mat_vals, density - ) - - 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 -): - 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", - colorbar=False, -): - 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, norm=norm, cmap=cmap) - ax.set(xscale=xscale, xlabel=xlabel, yscale=yscale, ylabel=ylabel) - if colorbar: - plt.colorbar(out) - return out From 8e57ac64de4dc28af7a57982c391275bf9d4831e Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Wed, 20 Nov 2024 15:42:21 +0100 Subject: [PATCH 166/195] Remove unused visualisation conde --- docs/api-reference/irf/vis_utils.rst | 12 ------------ docs/api-reference/irf/visualization.rst | 12 ------------ 2 files changed, 24 deletions(-) delete mode 100644 docs/api-reference/irf/vis_utils.rst delete mode 100644 docs/api-reference/irf/visualization.rst diff --git a/docs/api-reference/irf/vis_utils.rst b/docs/api-reference/irf/vis_utils.rst deleted file mode 100644 index 1e3aac584bd..00000000000 --- a/docs/api-reference/irf/vis_utils.rst +++ /dev/null @@ -1,12 +0,0 @@ -.. _vis_utils: - -************************************** -Helper functions for IRF visualization -************************************** - - -Reference/ API -============== - -.. automodapi:: ctapipe.irf.vis_utils - :no-inheritance-diagram: diff --git a/docs/api-reference/irf/visualization.rst b/docs/api-reference/irf/visualization.rst deleted file mode 100644 index 089bcc85a80..00000000000 --- a/docs/api-reference/irf/visualization.rst +++ /dev/null @@ -1,12 +0,0 @@ -.. _irf_visualization: - -***************** -IRF Visualization -***************** - - -Reference/ API -============== - -.. automodapi:: ctapipe.irf.visualisation - :no-inheritance-diagram: From ba082a59a246f542fa87b1adff829ae338ca703d Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 21 Nov 2024 12:30:05 +0100 Subject: [PATCH 167/195] Fix tests for pyirf as optional dependency; update ctapipe-info --- src/ctapipe/conftest.py | 5 +++++ src/ctapipe/irf/__init__.py | 8 ++++++++ src/ctapipe/irf/tests/test_benchmarks.py | 5 +++-- src/ctapipe/irf/tests/test_optimize.py | 3 +-- src/ctapipe/tools/info.py | 8 ++------ src/ctapipe/tools/make_irf.py | 7 +++++++ src/ctapipe/tools/tests/test_make_irf.py | 2 ++ .../tests/test_optimize_event_selection.py | 2 ++ src/ctapipe/tools/utils.py | 20 +++++++++---------- 9 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index 6743143b410..8db4114caaa 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -2,6 +2,7 @@ common pytest fixtures for tests in ctapipe """ +import importlib import shutil from copy import deepcopy @@ -43,6 +44,10 @@ "VERITAS", "Whipple490", ] +collect_ignore = [] + +if importlib.util.find_spec("pyirf") is None: + collect_ignore.append("irf") @pytest.fixture(scope="function", params=camera_names) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index be6e1f5c8a2..ad2a7d74ea5 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -1,5 +1,13 @@ """Top level module for the irf functionality""" +from importlib.util import find_spec + +if find_spec("pyirf") is None: + from ..exceptions import OptionalDependencyMissing + + raise OptionalDependencyMissing("pyirf") from None + + from .benchmarks import ( AngularResolution2dMaker, EnergyBiasResolution2dMaker, diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 0f8262b9be5..19d731a9fc0 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -1,11 +1,10 @@ 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 + from ctapipe.irf.tests.test_irfs import _check_boundaries_in_hdu bias_res_maker = EnergyBiasResolution2dMaker( fov_offset_n_bins=3, @@ -31,6 +30,7 @@ def test_make_2d_energy_bias_res(irf_events_table): def test_make_2d_ang_res(irf_events_table): from ctapipe.irf import AngularResolution2dMaker + from ctapipe.irf.tests.test_irfs import _check_boundaries_in_hdu ang_res_maker = AngularResolution2dMaker( fov_offset_n_bins=3, @@ -71,6 +71,7 @@ def test_make_2d_sensitivity( gamma_diffuse_full_reco_file, proton_full_reco_file, irf_event_loader_test_config ): from ctapipe.irf import EventLoader, Sensitivity2dMaker, Spectra + from ctapipe.irf.tests.test_irfs import _check_boundaries_in_hdu gamma_loader = EventLoader( config=irf_event_loader_test_config, diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 7e44bebddee..fd6126d1eec 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -4,7 +4,6 @@ from astropy.table import QTable from ctapipe.core import QualityQuery, non_abstract_children -from ctapipe.irf import EventLoader, Spectra from ctapipe.irf.optimize import CutOptimizerBase @@ -88,7 +87,7 @@ def test_cut_optimizer( proton_full_reco_file, irf_event_loader_test_config, ): - from ctapipe.irf import OptimizationResult + from ctapipe.irf import EventLoader, OptimizationResult, Spectra gamma_loader = EventLoader( config=irf_event_loader_test_config, diff --git a/src/ctapipe/tools/info.py b/src/ctapipe/tools/info.py index 20e12c6c865..da0f65f2305 100644 --- a/src/ctapipe/tools/info.py +++ b/src/ctapipe/tools/info.py @@ -124,19 +124,15 @@ def _info_tools(): print("the following can be executed by typing ctapipe-:") print("") - # TODO: how to get a one-line description or - # full help text from the docstring or ArgumentParser? - # This is the function names, we want the command-line names - # that are defined in setup.py !??? from textwrap import TextWrapper from ctapipe.tools.utils import get_all_descriptions - wrapper = TextWrapper(width=80, subsequent_indent=" " * 35) + wrapper = TextWrapper(width=80, subsequent_indent=" " * 37) scripts = get_all_descriptions() for name, desc in sorted(scripts.items()): - text = f"{name:<30s} - {desc}" + text = f"{name:<33s} - {desc}" print(wrapper.fill(text)) print("") print("") diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index c776f70a1ed..7500977aba2 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -1,5 +1,12 @@ """Tool to generate IRFs""" +from importlib.util import find_spec + +if find_spec("pyirf") is None: + from ..exceptions import OptionalDependencyMissing + + raise OptionalDependencyMissing("pyirf") from None + import operator from functools import partial diff --git a/src/ctapipe/tools/tests/test_make_irf.py b/src/ctapipe/tools/tests/test_make_irf.py index 36f514b867e..a02a050ea7e 100644 --- a/src/ctapipe/tools/tests/test_make_irf.py +++ b/src/ctapipe/tools/tests/test_make_irf.py @@ -6,6 +6,8 @@ from ctapipe.core import run_tool +pytest.importorskip("pyirf") + @pytest.fixture(scope="module") def event_loader_config_path(irf_event_loader_test_config, irf_tmp_path): diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index 728cfb2b7f1..286a8435159 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -6,6 +6,8 @@ from ctapipe.core import QualityQuery, run_tool +pytest.importorskip("pyirf") + @pytest.mark.parametrize("point_like", (True, False)) def test_cuts_optimization( diff --git a/src/ctapipe/tools/utils.py b/src/ctapipe/tools/utils.py index 5b1e468db93..69cf6f04326 100644 --- a/src/ctapipe/tools/utils.py +++ b/src/ctapipe/tools/utils.py @@ -1,10 +1,13 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst """Utils to create scripts and command-line tools""" + import argparse -import importlib +import ast import logging from collections import OrderedDict from importlib.metadata import distribution +from importlib.util import find_spec +from pathlib import Path import numpy as np from astropy.table import vstack @@ -67,15 +70,12 @@ def get_all_descriptions(): descriptions = OrderedDict() for name, value in tools.items(): - module_name, attr = value.split(":") - module = importlib.import_module(module_name) - if hasattr(module, "__doc__") and module.__doc__ is not None: - try: - descrip = module.__doc__ - descrip.replace("\n", "") - descriptions[name] = descrip - except Exception as err: - descriptions[name] = f"[Couldn't parse docstring: {err}]" + module_name, _ = value.split(":") + descrip = ast.get_docstring( + ast.parse(Path(find_spec(module_name).origin).read_text()) + ) + if descrip is not None: + descriptions[name] = descrip.replace("\n", " ") else: descriptions[name] = "[no documentation. Please add a docstring]" From ef48d87064bb9cae5311361da6116a637300ae55 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 21 Nov 2024 12:33:12 +0100 Subject: [PATCH 168/195] Fix irf tool for only proton file; remove refs to visualization code in docs --- docs/api-reference/irf/index.rst | 5 ----- src/ctapipe/tools/make_irf.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/api-reference/irf/index.rst b/docs/api-reference/irf/index.rst index 471e53077af..c50d705fc5a 100644 --- a/docs/api-reference/irf/index.rst +++ b/docs/api-reference/irf/index.rst @@ -22,9 +22,6 @@ are done using the `pyirf `_ package. :ref:`binning`, :ref:`preprocessing`, and :ref:`spectra` contain helper functions and classes used by many of the other components in this module. -:ref:`irf_visualization` and :ref:`vis_utils` implement some functionalities for visualizing -the irf components. - Submodules ========== @@ -38,8 +35,6 @@ Submodules binning preprocessing spectra - visualization - vis_utils Reference/API diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index 7500977aba2..ec039cfa0a6 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -354,7 +354,7 @@ def calculate_selections(self, reduced_events: dict) -> dict: self.opt_result.gh_cuts, operator.ge, ) - n_sel["bg_type"] = np.count_nonzero( + n_sel[bg_type] = np.count_nonzero( reduced_events[bg_type]["selected_gh"] ) From a782cd24462e3512b5922c85297b6b20a2af7888 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 21 Nov 2024 15:27:43 +0100 Subject: [PATCH 169/195] Improve handling of only protons as bkg and add tests --- src/ctapipe/conftest.py | 10 ++ src/ctapipe/tools/make_irf.py | 11 +- src/ctapipe/tools/optimize_event_selection.py | 12 +- src/ctapipe/tools/tests/test_make_irf.py | 133 ++++++++++++++++-- .../tests/test_optimize_event_selection.py | 73 +++++++++- 5 files changed, 215 insertions(+), 24 deletions(-) diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index 8db4114caaa..ef133d771ea 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -3,6 +3,7 @@ """ import importlib +import json import shutil from copy import deepcopy @@ -796,6 +797,15 @@ def irf_event_loader_test_config(): ) +@pytest.fixture(scope="session") +def event_loader_config_path(irf_event_loader_test_config, irf_tmp_path): + config_path = irf_tmp_path / "event_loader_config.json" + with config_path.open("w") as f: + json.dump(irf_event_loader_test_config, f) + + return config_path + + @pytest.fixture(scope="session") def irf_events_table(): from ctapipe.irf import EventPreProcessor diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/make_irf.py index ec039cfa0a6..cfeb532a17a 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/make_irf.py @@ -253,8 +253,10 @@ def setup(self): ), ] if self.do_background: - if not self.proton_file: - raise RuntimeError( + if not self.proton_file or ( + self.proton_file and not self.proton_file.exists() + ): + raise ValueError( "At least a proton file required when specifying `do_background`." ) @@ -266,7 +268,7 @@ def setup(self): target_spectrum=self.proton_target_spectrum, ) ) - if self.electron_file: + if self.electron_file and self.electron_file.exists(): self.particles.append( EventLoader( parent=self, @@ -275,6 +277,8 @@ def setup(self): target_spectrum=self.electron_target_spectrum, ) ) + else: + self.log.warning("Estimating background without electron file.") self.bkg = BackgroundRateMakerBase.from_name(self.bkg_maker, parent=self) check_e_bins( @@ -358,7 +362,6 @@ def calculate_selections(self, reduced_events: dict) -> dict: reduced_events[bg_type]["selected_gh"] ) - if self.do_background: self.log.info( "Keeping %d signal, %d proton events, and %d electron events" % ( diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 4c180e36bb3..b2415ab9209 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -137,8 +137,14 @@ def setup(self): ) ] if self.optimization_algorithm != "PercentileCuts": - if self.proton_file and not self.proton_file.exists(): - raise ValueError("Need a proton file to proceed") + if not self.proton_file or ( + self.proton_file and not self.proton_file.exists() + ): + raise ValueError( + "Need a proton file for cut optimization " + f"using {self.optimization_algorithm}." + ) + self.particles.append( EventLoader( parent=self, @@ -157,7 +163,7 @@ def setup(self): ) ) else: - self.log.warning("Optimizing without electron file.") + self.log.warning("Optimizing cuts without electron file.") def start(self): """ diff --git a/src/ctapipe/tools/tests/test_make_irf.py b/src/ctapipe/tools/tests/test_make_irf.py index a02a050ea7e..d54ad2bf302 100644 --- a/src/ctapipe/tools/tests/test_make_irf.py +++ b/src/ctapipe/tools/tests/test_make_irf.py @@ -1,23 +1,14 @@ -import json +import logging import os import pytest from astropy.io import fits -from ctapipe.core import run_tool +from ctapipe.core import ToolConfigurationError, run_tool pytest.importorskip("pyirf") -@pytest.fixture(scope="module") -def event_loader_config_path(irf_event_loader_test_config, irf_tmp_path): - config_path = irf_tmp_path / "event_loader_config.json" - with config_path.open("w") as f: - json.dump(irf_event_loader_test_config, f) - - return config_path - - @pytest.fixture(scope="module") def dummy_cuts_file( gamma_diffuse_full_reco_file, @@ -109,4 +100,124 @@ def test_irf_tool( assert isinstance(hdul["SENSITIVITY"], fits.BinTableHDU) +def test_irf_tool_no_electrons( + gamma_diffuse_full_reco_file, + proton_full_reco_file, + event_loader_config_path, + dummy_cuts_file, + tmp_path, +): + from ctapipe.tools.make_irf import IrfTool + + output_path = tmp_path / "irf.fits.gz" + output_benchmarks_path = tmp_path / "benchmarks.fits.gz" + logpath = tmp_path / "test_irf_tool_no_electrons.log" + logger = logging.getLogger("ctapipe.tools.make_irf") + logger.addHandler(logging.FileHandler(logpath)) + + ret = run_tool( + IrfTool(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + f"--cuts={dummy_cuts_file}", + f"--output={output_path}", + f"--benchmark-output={output_benchmarks_path}", + f"--config={event_loader_config_path}", + f"--log-file={logpath}", + ], + ) + assert ret == 0 + assert output_path.exists() + assert output_benchmarks_path.exists() + assert "Estimating background without electron file." in logpath.read_text() + + +def test_irf_tool_only_gammas( + gamma_diffuse_full_reco_file, event_loader_config_path, dummy_cuts_file, tmp_path +): + from ctapipe.tools.make_irf import IrfTool + + output_path = tmp_path / "irf.fits.gz" + output_benchmarks_path = tmp_path / "benchmarks.fits.gz" + + with pytest.raises( + ValueError, + match="At least a proton file required when specifying `do_background`.", + ): + run_tool( + IrfTool(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--cuts={dummy_cuts_file}", + f"--output={output_path}", + f"--benchmark-output={output_benchmarks_path}", + f"--config={event_loader_config_path}", + ], + raises=True, + ) + + ret = run_tool( + IrfTool(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--cuts={dummy_cuts_file}", + f"--output={output_path}", + f"--benchmark-output={output_benchmarks_path}", + f"--config={event_loader_config_path}", + "--no-do-background", + ], + ) + assert ret == 0 + assert output_path.exists() + assert output_benchmarks_path.exists() + + # TODO: Add test using point-like gammas + + +def test_point_like_irf_no_theta_cut( + gamma_diffuse_full_reco_file, + proton_full_reco_file, + event_loader_config_path, + tmp_path, +): + from ctapipe.tools.make_irf import IrfTool + from ctapipe.tools.optimize_event_selection import IrfEventSelector + + gh_cuts_path = tmp_path / "gh_cuts.fits" + # Without the "--point-like" flag only G/H cuts are produced. + run_tool( + IrfEventSelector(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + # Use diffuse gammas weighted to electron spectrum as stand-in + f"--electron-file={gamma_diffuse_full_reco_file}", + f"--output={gh_cuts_path}", + f"--config={event_loader_config_path}", + ], + ) + assert gh_cuts_path.exists() + + output_path = tmp_path / "irf.fits.gz" + output_benchmarks_path = tmp_path / "benchmarks.fits.gz" + + with pytest.raises( + ToolConfigurationError, + match=r"Computing a point-like IRF requires an \(optimized\) theta cut.", + ): + run_tool( + IrfTool(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + f"--electron-file={gamma_diffuse_full_reco_file}", + f"--cuts={gh_cuts_path}", + f"--output={output_path}", + f"--benchmark-output={output_benchmarks_path}", + f"--config={event_loader_config_path}", + "--point-like", + ], + raises=True, + ) diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index 286a8435159..dda9001fab9 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -1,4 +1,4 @@ -import json +import logging import astropy.units as u import pytest @@ -13,7 +13,7 @@ def test_cuts_optimization( gamma_diffuse_full_reco_file, proton_full_reco_file, - irf_event_loader_test_config, + event_loader_config_path, tmp_path, point_like, ): @@ -24,9 +24,6 @@ def test_cuts_optimization( from ctapipe.tools.optimize_event_selection import IrfEventSelector output_path = tmp_path / "cuts.fits" - config_path = tmp_path / "config.json" - with config_path.open("w") as f: - json.dump(irf_event_loader_test_config, f) argv = [ f"--gamma-file={gamma_diffuse_full_reco_file}", @@ -34,7 +31,7 @@ def test_cuts_optimization( # Use diffuse gammas weighted to electron spectrum as stand-in f"--electron-file={gamma_diffuse_full_reco_file}", f"--output={output_path}", - f"--config={config_path}", + f"--config={event_loader_config_path}", ] if point_like: argv.append("--point-like") @@ -60,3 +57,67 @@ def test_cuts_optimization( if point_like: assert c in result.theta_cuts.colnames assert result.theta_cuts[c].unit == u.TeV + + +def test_cuts_opt_no_electrons( + gamma_diffuse_full_reco_file, + proton_full_reco_file, + event_loader_config_path, + tmp_path, +): + from ctapipe.tools.optimize_event_selection import IrfEventSelector + + output_path = tmp_path / "cuts.fits" + logpath = tmp_path / "test_cuts_opt_no_electrons.log" + logger = logging.getLogger("ctapipe.tools.optimize_event_selection") + logger.addHandler(logging.FileHandler(logpath)) + + ret = run_tool( + IrfEventSelector(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + f"--output={output_path}", + f"--config={event_loader_config_path}", + f"--log-file={logpath}", + ], + ) + assert ret == 0 + assert "Optimizing cuts without electron file." in logpath.read_text() + + +def test_cuts_opt_only_gammas( + gamma_diffuse_full_reco_file, event_loader_config_path, tmp_path +): + from ctapipe.tools.optimize_event_selection import IrfEventSelector + + output_path = tmp_path / "cuts.fits" + + with pytest.raises( + ValueError, + match=( + "Need a proton file for cut optimization using " + "PointSourceSensitivityOptimizer" + ), + ): + run_tool( + IrfEventSelector(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--output={output_path}", + f"--config={event_loader_config_path}", + ], + raises=True, + ) + + ret = run_tool( + IrfEventSelector(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--output={output_path}", + f"--config={event_loader_config_path}", + "--IrfEventSelector.optimization_algorithm=PercentileCuts", + ], + ) + assert ret == 0 + assert output_path.exists() From 1ea0e2ebf1fba46515a32fbec8c3425b072c2787 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 21 Nov 2024 16:06:51 +0100 Subject: [PATCH 170/195] ctapipe-make-irf -> ctapipe-compute-irf; IrfEventSelector -> EventSelectionOptimizer --- docs/api-reference/irf/index.rst | 4 ++-- docs/api-reference/tools/index.rst | 2 +- docs/changes/2473.feature.rst | 2 +- pyproject.toml | 2 +- .../tools/{make_irf.py => compute_irf.py} | 4 ++-- src/ctapipe/tools/optimize_event_selection.py | 18 +++++++++--------- .../{test_make_irf.py => test_compute_irf.py} | 18 +++++++++--------- .../tests/test_optimize_event_selection.py | 16 ++++++++-------- 8 files changed, 33 insertions(+), 33 deletions(-) rename src/ctapipe/tools/{make_irf.py => compute_irf.py} (99%) rename src/ctapipe/tools/tests/{test_make_irf.py => test_compute_irf.py} (93%) diff --git a/docs/api-reference/irf/index.rst b/docs/api-reference/irf/index.rst index c50d705fc5a..a8f2cc41f3b 100644 --- a/docs/api-reference/irf/index.rst +++ b/docs/api-reference/irf/index.rst @@ -10,10 +10,10 @@ This module contains functionalities for generating instrument response function The simulated events used for this have to be selected based on their associated "gammaness" value and (optionally) their reconstructed angular offset from their point of origin. The code for doing this can found in :ref:`cut_optimization` and is intended for use via the -`~ctapipe.tools.optimize_event_selection.IrfEventSelector` tool. +`~ctapipe.tools.optimize_event_selection.EventSelectionOptimizer` tool. The generation of the irf components themselves is implemented in :ref:`irfs` and is intended for -use via the `~ctapipe.tools.make_irf.IrfTool` tool. +use via the `~ctapipe.tools.compute_irf.IrfTool` tool. This tool can optionally also compute some common benchmarks, which are implemented in :ref:`benchmarks`. The cut optimization as well as the calculations of the irf components and the benchmarks diff --git a/docs/api-reference/tools/index.rst b/docs/api-reference/tools/index.rst index c0073ec0bd3..ad122d76dc1 100644 --- a/docs/api-reference/tools/index.rst +++ b/docs/api-reference/tools/index.rst @@ -102,5 +102,5 @@ Reference/API .. automodapi:: ctapipe.tools.optimize_event_selection :no-inheritance-diagram: -.. automodapi:: ctapipe.tools.make_irf +.. automodapi:: ctapipe.tools.compute_irf :no-inheritance-diagram: diff --git a/docs/changes/2473.feature.rst b/docs/changes/2473.feature.rst index 8c21a705048..cfcfd923862 100644 --- a/docs/changes/2473.feature.rst +++ b/docs/changes/2473.feature.rst @@ -5,7 +5,7 @@ Two components for calculating G/H and optionally theta cuts are added: ``PointSourceSensitivityOptimizer`` optimizes G/H cuts for maximum point source sensitivity and optionally calculates percentile theta cuts. -Add a ``ctapipe-make-irf`` tool to produce irfs given a cut-selection file, a gamma, +Add a ``ctapipe-compute-irf`` tool to produce irfs given a cut-selection file, a gamma, and optionally a proton, and an electron DL2 input file. Given only a gamma file, the energy dispersion, effective area, and point spread function are calculated. Optionally, the bias and resolution of the energy reconstruction and the angular resolution can be calculated diff --git a/pyproject.toml b/pyproject.toml index 5006db8c20e..42f7ea9d9c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,7 +107,7 @@ ctapipe-display-dl1 = "ctapipe.tools.display_dl1:main" ctapipe-process = "ctapipe.tools.process:main" ctapipe-merge = "ctapipe.tools.merge:main" ctapipe-optimize-event-selection = "ctapipe.tools.optimize_event_selection:main" -ctapipe-make-irf = "ctapipe.tools.make_irf:main" +ctapipe-compute-irf = "ctapipe.tools.compute_irf:main" ctapipe-fileinfo = "ctapipe.tools.fileinfo:main" ctapipe-quickstart = "ctapipe.tools.quickstart:main" ctapipe-calculate-pixel-statistics = "ctapipe.tools.calculate_pixel_stats:main" diff --git a/src/ctapipe/tools/make_irf.py b/src/ctapipe/tools/compute_irf.py similarity index 99% rename from src/ctapipe/tools/make_irf.py rename to src/ctapipe/tools/compute_irf.py index cfeb532a17a..cd4def51593 100644 --- a/src/ctapipe/tools/make_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -44,10 +44,10 @@ class IrfTool(Tool): "Tool to create IRF files in GADF format" - name = "ctapipe-make-irf" + name = "ctapipe-compute-irf" description = __doc__ examples = """ - ctapipe-make-irf \\ + ctapipe-compute-irf \\ --cuts cuts.fits \\ --gamma-file gamma.dl2.h5 \\ --proton-file proton.dl2.h5 \\ diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index b2415ab9209..d3935da34f8 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -8,10 +8,10 @@ from ..irf import EventLoader, Spectra from ..irf.optimize import CutOptimizerBase -__all__ = ["IrfEventSelector"] +__all__ = ["EventSelectionOptimizer"] -class IrfEventSelector(Tool): +class EventSelectionOptimizer(Tool): "Tool to create optimized cuts for IRF generation" name = "ctapipe-optimize-event-selection" @@ -103,17 +103,17 @@ class IrfEventSelector(Tool): ).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", + "gamma-file": "EventSelectionOptimizer.gamma_file", + "proton-file": "EventSelectionOptimizer.proton_file", + "electron-file": "EventSelectionOptimizer.electron_file", + "output": "EventSelectionOptimizer.output_path", + "chunk_size": "EventSelectionOptimizer.chunk_size", } flags = { **flag( "point-like", - "IrfEventSelector.point_like", + "EventSelectionOptimizer.point_like", "Compute a theta cut and a G/H separation cut.", "Compute only a G/H separation cut.", ) @@ -243,7 +243,7 @@ def finish(self): def main(): - tool = IrfEventSelector() + tool = EventSelectionOptimizer() tool.run() diff --git a/src/ctapipe/tools/tests/test_make_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py similarity index 93% rename from src/ctapipe/tools/tests/test_make_irf.py rename to src/ctapipe/tools/tests/test_compute_irf.py index d54ad2bf302..691f4e742c0 100644 --- a/src/ctapipe/tools/tests/test_make_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -16,12 +16,12 @@ def dummy_cuts_file( event_loader_config_path, irf_tmp_path, ): - from ctapipe.tools.optimize_event_selection import IrfEventSelector + from ctapipe.tools.optimize_event_selection import EventSelectionOptimizer # Do "point-like" cuts to have both g/h and theta cuts in the file output_path = irf_tmp_path / "dummy_cuts.fits" run_tool( - IrfEventSelector(), + EventSelectionOptimizer(), argv=[ f"--gamma-file={gamma_diffuse_full_reco_file}", f"--proton-file={proton_full_reco_file}", @@ -46,7 +46,7 @@ def test_irf_tool( include_bkg, point_like, ): - from ctapipe.tools.make_irf import IrfTool + from ctapipe.tools.compute_irf import IrfTool output_path = tmp_path / "irf.fits.gz" output_benchmarks_path = tmp_path / "benchmarks.fits.gz" @@ -107,12 +107,12 @@ def test_irf_tool_no_electrons( dummy_cuts_file, tmp_path, ): - from ctapipe.tools.make_irf import IrfTool + from ctapipe.tools.compute_irf import IrfTool output_path = tmp_path / "irf.fits.gz" output_benchmarks_path = tmp_path / "benchmarks.fits.gz" logpath = tmp_path / "test_irf_tool_no_electrons.log" - logger = logging.getLogger("ctapipe.tools.make_irf") + logger = logging.getLogger("ctapipe.tools.compute_irf") logger.addHandler(logging.FileHandler(logpath)) ret = run_tool( @@ -136,7 +136,7 @@ def test_irf_tool_no_electrons( def test_irf_tool_only_gammas( gamma_diffuse_full_reco_file, event_loader_config_path, dummy_cuts_file, tmp_path ): - from ctapipe.tools.make_irf import IrfTool + from ctapipe.tools.compute_irf import IrfTool output_path = tmp_path / "irf.fits.gz" output_benchmarks_path = tmp_path / "benchmarks.fits.gz" @@ -182,13 +182,13 @@ def test_point_like_irf_no_theta_cut( event_loader_config_path, tmp_path, ): - from ctapipe.tools.make_irf import IrfTool - from ctapipe.tools.optimize_event_selection import IrfEventSelector + from ctapipe.tools.compute_irf import IrfTool + from ctapipe.tools.optimize_event_selection import EventSelectionOptimizer gh_cuts_path = tmp_path / "gh_cuts.fits" # Without the "--point-like" flag only G/H cuts are produced. run_tool( - IrfEventSelector(), + EventSelectionOptimizer(), argv=[ f"--gamma-file={gamma_diffuse_full_reco_file}", f"--proton-file={proton_full_reco_file}", diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index dda9001fab9..7695d6c3717 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -21,7 +21,7 @@ def test_cuts_optimization( OptimizationResult, ResultValidRange, ) - from ctapipe.tools.optimize_event_selection import IrfEventSelector + from ctapipe.tools.optimize_event_selection import EventSelectionOptimizer output_path = tmp_path / "cuts.fits" @@ -36,7 +36,7 @@ def test_cuts_optimization( if point_like: argv.append("--point-like") - ret = run_tool(IrfEventSelector(), argv=argv) + ret = run_tool(EventSelectionOptimizer(), argv=argv) assert ret == 0 result = OptimizationResult.read(output_path) @@ -65,7 +65,7 @@ def test_cuts_opt_no_electrons( event_loader_config_path, tmp_path, ): - from ctapipe.tools.optimize_event_selection import IrfEventSelector + from ctapipe.tools.optimize_event_selection import EventSelectionOptimizer output_path = tmp_path / "cuts.fits" logpath = tmp_path / "test_cuts_opt_no_electrons.log" @@ -73,7 +73,7 @@ def test_cuts_opt_no_electrons( logger.addHandler(logging.FileHandler(logpath)) ret = run_tool( - IrfEventSelector(), + EventSelectionOptimizer(), argv=[ f"--gamma-file={gamma_diffuse_full_reco_file}", f"--proton-file={proton_full_reco_file}", @@ -89,7 +89,7 @@ def test_cuts_opt_no_electrons( def test_cuts_opt_only_gammas( gamma_diffuse_full_reco_file, event_loader_config_path, tmp_path ): - from ctapipe.tools.optimize_event_selection import IrfEventSelector + from ctapipe.tools.optimize_event_selection import EventSelectionOptimizer output_path = tmp_path / "cuts.fits" @@ -101,7 +101,7 @@ def test_cuts_opt_only_gammas( ), ): run_tool( - IrfEventSelector(), + EventSelectionOptimizer(), argv=[ f"--gamma-file={gamma_diffuse_full_reco_file}", f"--output={output_path}", @@ -111,12 +111,12 @@ def test_cuts_opt_only_gammas( ) ret = run_tool( - IrfEventSelector(), + EventSelectionOptimizer(), argv=[ f"--gamma-file={gamma_diffuse_full_reco_file}", f"--output={output_path}", f"--config={event_loader_config_path}", - "--IrfEventSelector.optimization_algorithm=PercentileCuts", + "--EventSelectionOptimizer.optimization_algorithm=PercentileCuts", ], ) assert ret == 0 From ea5e736a97fbcb8a1ef0c4f12849c6f5db7cc388 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 21 Nov 2024 16:07:31 +0100 Subject: [PATCH 171/195] Fix docs test for minimal dependencies --- src/ctapipe/tools/conftest.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/ctapipe/tools/conftest.py diff --git a/src/ctapipe/tools/conftest.py b/src/ctapipe/tools/conftest.py new file mode 100644 index 00000000000..39effc92055 --- /dev/null +++ b/src/ctapipe/tools/conftest.py @@ -0,0 +1,4 @@ +from importlib.util import find_spec + +if find_spec("pyirf") is None: + collect_ignore = ["compute_irf.py", "optimize_event_selection.py"] From 22b1a822563dea9c7468dd788f1d4d00e3d3c28d Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 21 Nov 2024 17:02:23 +0100 Subject: [PATCH 172/195] Add test for using incompatible cuts for irf computation --- src/ctapipe/tools/compute_irf.py | 22 +++---- src/ctapipe/tools/tests/test_compute_irf.py | 71 +++++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index cd4def51593..26126c5b726 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -468,6 +468,17 @@ def start(self): """ reduced_events = dict() for sel in self.particles: + if sel.epp.gammaness_classifier != self.opt_result.clf_prefix: + raise RuntimeError( + "G/H cuts are only valid for gammaness scores predicted by " + "the same classifier model. Requested model: %s. " + "Model used for g/h cuts: %s." + % ( + sel.epp.gammaness_classifier, + self.opt_result.clf_prefix, + ) + ) + if sel.epp.quality_criteria != self.opt_result.precuts.quality_criteria: self.log.warning( "Precuts are different from precuts used for calculating " @@ -485,17 +496,6 @@ def start(self): quality_criteria=self.opt_result.precuts.quality_criteria, ) - if sel.epp.gammaness_classifier != self.opt_result.clf_prefix: - raise RuntimeError( - "G/H cuts are only valid for gammaness scores predicted by " - "the same classifier model. Requested model: %s. " - "Model used for g/h cuts: %s." - % ( - sel.epp.gammaness_classifier, - self.opt_result.clf_prefix, - ) - ) - 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 background or sensitivity should be calculated. diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index 691f4e742c0..fa502423a51 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -1,3 +1,4 @@ +import json import logging import os @@ -212,6 +213,7 @@ def test_point_like_irf_no_theta_cut( argv=[ f"--gamma-file={gamma_diffuse_full_reco_file}", f"--proton-file={proton_full_reco_file}", + # Use diffuse gammas weighted to electron spectrum as stand-in f"--electron-file={gamma_diffuse_full_reco_file}", f"--cuts={gh_cuts_path}", f"--output={output_path}", @@ -221,3 +223,72 @@ def test_point_like_irf_no_theta_cut( ], raises=True, ) + + +def test_irf_tool_wrong_cuts( + gamma_diffuse_full_reco_file, proton_full_reco_file, dummy_cuts_file, tmp_path +): + from ctapipe.tools.compute_irf import IrfTool + + output_path = tmp_path / "irf.fits.gz" + output_benchmarks_path = tmp_path / "benchmarks.fits.gz" + + with pytest.raises(RuntimeError): + run_tool( + IrfTool(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + # Use diffuse gammas weighted to electron spectrum as stand-in + f"--electron-file={gamma_diffuse_full_reco_file}", + f"--cuts={dummy_cuts_file}", + f"--output={output_path}", + f"--benchmark-output={output_benchmarks_path}", + ], + raises=True, + ) + + config_path = tmp_path / "config.json" + with config_path.open("w") as f: + json.dump( + { + "EventPreProcessor": { + "energy_reconstructor": "ExtraTreesRegressor", + "geometry_reconstructor": "HillasReconstructor", + "gammaness_classifier": "ExtraTreesClassifier", + "quality_criteria": [ + # No criteria for minimum event multiplicity + ("valid classifier", "ExtraTreesClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "ExtraTreesRegressor_is_valid"), + ], + } + }, + f, + ) + + logpath = tmp_path / "test_irf_tool_wrong_cuts.log" + logger = logging.getLogger("ctapipe.tools.compute_irf") + logger.addHandler(logging.FileHandler(logpath)) + + ret = run_tool( + IrfTool(), + argv=[ + f"--gamma-file={gamma_diffuse_full_reco_file}", + f"--proton-file={proton_full_reco_file}", + # Use diffuse gammas weighted to electron spectrum as stand-in + f"--electron-file={gamma_diffuse_full_reco_file}", + f"--cuts={dummy_cuts_file}", + f"--output={output_path}", + f"--benchmark-output={output_benchmarks_path}", + f"--config={config_path}", + f"--log-file={logpath}", + ], + ) + assert ret == 0 + assert output_path.exists() + assert output_benchmarks_path.exists() + assert ( + "Precuts are different from precuts used for calculating g/h / theta cuts." + in logpath.read_text() + ) From 98994cea5a39e9f2501528da66901d775183a9ba Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 22 Nov 2024 14:19:29 +0100 Subject: [PATCH 173/195] Use TableLoader to load observation info --- src/ctapipe/irf/preprocessing.py | 49 +++++++++++---------- src/ctapipe/irf/tests/test_preprocessing.py | 6 +++ 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/ctapipe/irf/preprocessing.py b/src/ctapipe/irf/preprocessing.py index d46502a0aac..7c59e2cc27d 100644 --- a/src/ctapipe/irf/preprocessing.py +++ b/src/ctapipe/irf/preprocessing.py @@ -60,6 +60,15 @@ class EventPreProcessor(QualityQuery): ).tag(config=True) def normalise_column_names(self, events: Table) -> QTable: + if events["subarray_pointing_lat"].std() > 1e-3: + raise NotImplementedError( + "No support for making irfs from varying pointings yet" + ) + if any(events["subarray_pointing_frame"] != CoordinateFrameType.ALTAZ.value): + raise NotImplementedError( + "At the moment only pointing in altaz is supported." + ) + keep_columns = [ "obs_id", "event_id", @@ -72,8 +81,17 @@ def normalise_column_names(self, events: Table) -> QTable: f"{self.geometry_reconstructor}_az", f"{self.geometry_reconstructor}_alt", f"{self.gammaness_classifier}_prediction", + "subarray_pointing_lat", + "subarray_pointing_lon", + ] + rename_to = [ + "reco_energy", + "reco_az", + "reco_alt", + "gh_score", + "pointing_alt", + "pointing_az", ] - rename_to = ["reco_energy", "reco_az", "reco_alt", "gh_score"] keep_columns.extend(rename_from) for c in keep_columns: if c not in events.colnames: @@ -187,19 +205,17 @@ def __init__(self, kind: str, file: Path, target_spectrum: Spectra, **kwargs): def load_preselected_events( self, chunk_size: int, obs_time: u.Quantity ) -> tuple[QTable, int, dict]: - opts = dict(dl2=True, simulated=True) + opts = dict(dl2=True, simulated=True, observation_info=True) with TableLoader(self.file, parent=self, **opts) as load: header = self.epp.make_empty_table() - sim_info, spectrum, obs_conf = self.get_simulation_information( - load, obs_time - ) + sim_info, spectrum = self.get_simulation_information(load, obs_time) meta = {"sim_info": sim_info, "spectrum": spectrum} bits = [header] n_raw_events = 0 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, obs_conf) + selected = self.make_derived_columns(selected) bits.append(selected) n_raw_events += len(events) @@ -209,8 +225,7 @@ def load_preselected_events( def get_simulation_information( self, loader: TableLoader, obs_time: u.Quantity - ) -> tuple[SimulatedEventsInfo, PowerLaw, Table]: - obs = loader.read_observation_information() + ) -> tuple[SimulatedEventsInfo, PowerLaw]: sim = loader.read_simulation_configuration() try: show = loader.read_shower_distribution() @@ -234,23 +249,9 @@ def get_simulation_information( viewcone_min=sim["min_viewcone_radius"].quantity[0], ) - return ( - sim_info, - PowerLaw.from_simulation(sim_info, obstime=obs_time), - obs, - ) + return sim_info, PowerLaw.from_simulation(sim_info, obstime=obs_time) - 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"] == CoordinateFrameType.ALTAZ.value - ) - 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" - ) + def make_derived_columns(self, events: QTable) -> QTable: events["weight"] = ( 1.0 * u.dimensionless_unscaled ) # defer calculation of proper weights to later diff --git a/src/ctapipe/irf/tests/test_preprocessing.py b/src/ctapipe/irf/tests/test_preprocessing.py index 5d6abdbc216..53e964a86d5 100644 --- a/src/ctapipe/irf/tests/test_preprocessing.py +++ b/src/ctapipe/irf/tests/test_preprocessing.py @@ -1,4 +1,5 @@ import astropy.units as u +import numpy as np import pytest from astropy.table import Table from pyirf.simulations import SimulatedEventsInfo @@ -19,6 +20,9 @@ def dummy_table(): "geom_alt": [58.5, 61.2, 59, 71.6, 60, 62] * u.deg, "true_az": [13, 13, 13, 13, 13, 13] * u.deg, "geom_az": [12.5, 13, 11.8, 15.1, 14.7, 12.8] * u.deg, + "subarray_pointing_frame": np.zeros(6), + "subarray_pointing_lat": np.full(6, 20) * u.deg, + "subarray_pointing_lon": np.full(6, 0) * u.deg, } ) @@ -43,6 +47,8 @@ def test_normalise_column_names(dummy_table): "reco_alt", "reco_az", "gh_score", + "pointing_alt", + "pointing_az", ] for c in needed_cols: assert c in norm_table.colnames From 5acf966bf1c3d9baa63331ac358e9b4999086204 Mon Sep 17 00:00:00 2001 From: Tomas Bylund Date: Mon, 25 Nov 2024 19:24:46 +0100 Subject: [PATCH 174/195] Restore ability to generate correct pointlike irf files --- src/ctapipe/tools/compute_irf.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 26126c5b726..777afcaa805 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -233,7 +233,6 @@ def setup(self): Initialize components from config and load g/h (and theta) cuts. """ self.opt_result = OptimizationResult.read(self.cuts_file) - if self.point_like and self.opt_result.theta_cuts is None: raise ToolConfigurationError( "Computing a point-like IRF requires an (optimized) theta cut." @@ -413,11 +412,10 @@ def _make_signal_irf_hdus(self, hdus, sim_info): ), fov_offset_bins=u.Quantity( [ - self.opt_result.valid_offset.min.to_value(u.deg), - self.opt_result.valid_offset.max.to_value(u.deg), - ], - u.deg, - ), + self.opt_result.valid_offset.min, + self.opt_result.valid_offset.max, + ] + ).reshape(-1), ) ) return hdus From a2a3f25d0c57ce862f9c8b4b631c5b891946d7d9 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 10 Jan 2025 19:38:21 +0100 Subject: [PATCH 175/195] Remove option of only calculating gh cuts with cut-opt tool, but keep possibility to save an OptimizationResult with only gh cuts for now. --- src/ctapipe/irf/optimize.py | 111 +++++++----------- src/ctapipe/irf/tests/test_optimize.py | 1 - src/ctapipe/tools/compute_irf.py | 22 +--- src/ctapipe/tools/optimize_event_selection.py | 20 +--- src/ctapipe/tools/tests/test_compute_irf.py | 20 +--- .../tests/test_optimize_event_selection.py | 15 +-- 6 files changed, 61 insertions(+), 128 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 474805b8f43..218a2d159d4 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -188,7 +188,6 @@ def optimize_cuts( background: QTable, precuts: EventPreProcessor, clf_prefix: str, - point_like: bool, ) -> OptimizationResult: """ Optimize G/H (and optionally theta) cuts @@ -206,9 +205,6 @@ def optimize_cuts( 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) """ @@ -331,7 +327,6 @@ def optimize_cuts( background: QTable, precuts: EventPreProcessor, clf_prefix: str, - point_like: bool, ) -> OptimizationResult: reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), @@ -343,18 +338,17 @@ def optimize_cuts( 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, - ) + 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 = OptimizationResult( precuts=precuts, @@ -365,7 +359,7 @@ def optimize_cuts( # A single set of cuts is calculated for the whole fov atm valid_offset_min=0 * u.deg, valid_offset_max=np.inf * u.deg, - theta_cuts=theta_cuts if point_like else None, + theta_cuts=theta_cuts, ) return result @@ -406,7 +400,6 @@ def optimize_cuts( background: QTable, precuts: EventPreProcessor, clf_prefix: str, - point_like: bool, ) -> OptimizationResult: reco_energy_bins = make_bins_per_decade( self.reco_energy_min.to(u.TeV), @@ -414,41 +407,28 @@ def optimize_cuts( self.reco_energy_n_bins_per_decade, ) - 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=10, - smoothing=1, - ) - initial_gh_mask = evaluate_binned_cut( - signal["gh_score"], - signal["reco_energy"], - initial_gh_cuts, - op=operator.gt, - ) + 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=10, + smoothing=1, + ) + initial_gh_mask = evaluate_binned_cut( + signal["gh_score"], + signal["reco_energy"], + initial_gh_cuts, + op=operator.gt, + ) - theta_cuts = self.theta.calculate_theta_cut( - signal["theta"][initial_gh_mask], - signal["reco_energy"][initial_gh_mask], - reco_energy_bins, - ) - self.log.info("Optimizing G/H separation cut for best sensitivity") - else: - # 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"] = self.max_bkg_fov_offset - self.log.info( - "Optimizing G/H separation cut for best sensitivity " - "with `max_bkg_fov_offset` as theta cut." - ) + theta_cuts = self.theta.calculate_theta_cut( + signal["theta"][initial_gh_mask], + signal["reco_energy"][initial_gh_mask], + reco_energy_bins, + ) + self.log.info("Optimizing G/H separation cut for best sensitivity") gh_cut_efficiencies = np.arange( self.gh_cut_efficiency_step, @@ -469,18 +449,17 @@ def optimize_cuts( 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 = self.theta.calculate_theta_cut( - signal[signal["selected_gh"]]["theta"], - signal[signal["selected_gh"]]["reco_energy"], - reco_energy_bins, - ) + signal["selected_gh"] = evaluate_binned_cut( + signal["gh_score"], + signal["reco_energy"], + gh_cuts, + operator.ge, + ) + theta_cuts_opt = self.theta.calculate_theta_cut( + signal[signal["selected_gh"]]["theta"], + signal[signal["selected_gh"]]["reco_energy"], + reco_energy_bins, + ) result = OptimizationResult( precuts=precuts, @@ -491,7 +470,7 @@ def optimize_cuts( # A single set of cuts is calculated for the whole fov atm valid_offset_min=self.min_bkg_fov_offset, valid_offset_max=self.max_bkg_fov_offset, - theta_cuts=theta_cuts_opt if point_like else None, + theta_cuts=theta_cuts_opt, ) return result diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index fd6126d1eec..5299d57eb4c 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -116,7 +116,6 @@ def test_cut_optimizer( background=proton_events, precuts=gamma_loader.epp, # identical precuts for all particle types clf_prefix="ExtraTreesClassifier", - point_like=True, ) assert isinstance(result, OptimizationResult) assert result.clf_prefix == "ExtraTreesClassifier" diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 777afcaa805..41ec291e642 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -13,7 +13,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.cuts import evaluate_binned_cut from pyirf.io import create_rad_max_hdu @@ -432,21 +432,11 @@ def _make_benchmark_hdus(self, hdus): ) ) if self.do_background: - if not self.point_like: - # 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.sensitivity.reco_energy_bins[:-1] - + self.sensitivity.reco_energy_bins[1:] + if self.opt_result.theta_cuts is None: + raise ValueError( + "Calculating the point-source sensitivity requires " + f"theta cuts, but {self.cuts_file} does not contain any." ) - theta_cuts["cut"] = self.sensitivity.fov_offset_max - else: - theta_cuts = self.opt_result.theta_cuts hdus.append( self.sensitivity.make_sensitivity_hdu( @@ -454,7 +444,7 @@ def _make_benchmark_hdus(self, hdus): background_events=self.background_events[ self.background_events["selected_gh"] ], - theta_cut=theta_cuts, + theta_cut=self.opt_result.theta_cuts, gamma_spectrum=self.gamma_target_spectrum, ) ) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index d3935da34f8..f82165bf852 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -4,7 +4,7 @@ from astropy.table import vstack from ..core import Provenance, Tool, traits -from ..core.traits import AstroQuantity, Bool, Integer, classes_with_traits, flag +from ..core.traits import AstroQuantity, Integer, classes_with_traits from ..irf import EventLoader, Spectra from ..irf.optimize import CutOptimizerBase @@ -94,14 +94,6 @@ class EventSelectionOptimizer(Tool): help="The cut optimization algorithm to be used.", ).tag(config=True) - point_like = Bool( - False, - help=( - "Compute a theta cut in addition to the G/H separation cut " - "for a point-like IRF." - ), - ).tag(config=True) - aliases = { "gamma-file": "EventSelectionOptimizer.gamma_file", "proton-file": "EventSelectionOptimizer.proton_file", @@ -110,15 +102,6 @@ class EventSelectionOptimizer(Tool): "chunk_size": "EventSelectionOptimizer.chunk_size", } - flags = { - **flag( - "point-like", - "EventSelectionOptimizer.point_like", - "Compute a theta cut and a G/H separation cut.", - "Compute only a G/H separation cut.", - ) - } - classes = [EventLoader] + classes_with_traits(CutOptimizerBase) def setup(self): @@ -229,7 +212,6 @@ def start(self): else None, 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.result = result diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index fa502423a51..da169f8769d 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -19,7 +19,6 @@ def dummy_cuts_file( ): from ctapipe.tools.optimize_event_selection import EventSelectionOptimizer - # Do "point-like" cuts to have both g/h and theta cuts in the file output_path = irf_tmp_path / "dummy_cuts.fits" run_tool( EventSelectionOptimizer(), @@ -30,7 +29,6 @@ def dummy_cuts_file( f"--electron-file={gamma_diffuse_full_reco_file}", f"--output={output_path}", f"--config={event_loader_config_path}", - "--point-like", ], ) return output_path @@ -181,24 +179,16 @@ def test_point_like_irf_no_theta_cut( gamma_diffuse_full_reco_file, proton_full_reco_file, event_loader_config_path, + dummy_cuts_file, tmp_path, ): + from ctapipe.irf import OptimizationResult from ctapipe.tools.compute_irf import IrfTool - from ctapipe.tools.optimize_event_selection import EventSelectionOptimizer gh_cuts_path = tmp_path / "gh_cuts.fits" - # Without the "--point-like" flag only G/H cuts are produced. - run_tool( - EventSelectionOptimizer(), - argv=[ - f"--gamma-file={gamma_diffuse_full_reco_file}", - f"--proton-file={proton_full_reco_file}", - # Use diffuse gammas weighted to electron spectrum as stand-in - f"--electron-file={gamma_diffuse_full_reco_file}", - f"--output={gh_cuts_path}", - f"--config={event_loader_config_path}", - ], - ) + cuts = OptimizationResult.read(dummy_cuts_file) + cuts.theta_cuts = None + cuts.write(gh_cuts_path) assert gh_cuts_path.exists() output_path = tmp_path / "irf.fits.gz" diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index 7695d6c3717..226043a66c5 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -9,13 +9,11 @@ pytest.importorskip("pyirf") -@pytest.mark.parametrize("point_like", (True, False)) def test_cuts_optimization( gamma_diffuse_full_reco_file, proton_full_reco_file, event_loader_config_path, tmp_path, - point_like, ): from ctapipe.irf import ( OptimizationResult, @@ -33,9 +31,6 @@ def test_cuts_optimization( f"--output={output_path}", f"--config={event_loader_config_path}", ] - if point_like: - argv.append("--point-like") - ret = run_tool(EventSelectionOptimizer(), argv=argv) assert ret == 0 @@ -47,16 +42,14 @@ def test_cuts_optimization( assert isinstance(result.gh_cuts, QTable) assert result.clf_prefix == "ExtraTreesClassifier" assert "cut" in result.gh_cuts.colnames - if point_like: - assert isinstance(result.theta_cuts, QTable) - assert "cut" in result.theta_cuts.colnames + assert isinstance(result.theta_cuts, QTable) + assert "cut" in result.theta_cuts.colnames for c in ["low", "center", "high"]: assert c in result.gh_cuts.colnames assert result.gh_cuts[c].unit == u.TeV - if point_like: - assert c in result.theta_cuts.colnames - assert result.theta_cuts[c].unit == u.TeV + assert c in result.theta_cuts.colnames + assert result.theta_cuts[c].unit == u.TeV def test_cuts_opt_no_electrons( From 0ab95be05526e5ed8da59b206db175903cfcf6a4 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 15 Jan 2025 15:13:30 +0100 Subject: [PATCH 176/195] energy_migration_linear_bins -> energy_migration_binning --- src/ctapipe/irf/irfs.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 23def128aca..a3e3ea0b29a 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -21,7 +21,7 @@ ) from pyirf.simulations import SimulatedEventsInfo -from ..core.traits import AstroQuantity, Bool, Float, Integer +from ..core.traits import AstroQuantity, CaselessStrEnum, Float, Integer from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins __all__ = [ @@ -104,19 +104,23 @@ class EnergyDispersionMakerBase(DefaultTrueEnergyBins): ).tag(config=True) energy_migration_n_bins = Integer( - help="Number of bins in log scale for energy migration ratio", + help="Number of bins for energy migration ratio", default_value=30, ).tag(config=True) - energy_migration_linear_bins = Bool( - help="Bin energy migration ratio using linear bins", - default_value=False, + energy_migration_binning = CaselessStrEnum( + ["linear", "logarithmic"], + help=( + "How energy bins are distributed between energy_migration_min" + " and energy_migration_max." + ), + default_value="logarithmic", ).tag(config=True) def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) bin_func = np.geomspace - if self.energy_migration_linear_bins: + if self.energy_migration_binning == "linear": bin_func = np.linspace self.migration_bins = bin_func( self.energy_migration_min, From e0da126d627bb6a12770e3eba18ad532ab11724c Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 15 Jan 2025 16:12:02 +0100 Subject: [PATCH 177/195] point-like -> spatial-selection-applied --- docs/changes/2473.feature.rst | 2 +- src/ctapipe/irf/irfs.py | 28 +++++++++-------- src/ctapipe/irf/tests/test_irfs.py | 8 +++-- src/ctapipe/tools/compute_irf.py | 33 +++++++++++---------- src/ctapipe/tools/tests/test_compute_irf.py | 14 ++++----- 5 files changed, 46 insertions(+), 39 deletions(-) diff --git a/docs/changes/2473.feature.rst b/docs/changes/2473.feature.rst index cfcfd923862..d01d8902529 100644 --- a/docs/changes/2473.feature.rst +++ b/docs/changes/2473.feature.rst @@ -13,5 +13,5 @@ and saved in a separate output file. If a proton or a proton and an electron file is also given, a background model can be calculated, as well as the point source sensitivity. -Both, full enclosure and point-like irf can be calculated. +Irfs can be calculated with and without applying a direction cut. Only radially symmetric parameterizations of the irf components are implemented so far. diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index a3e3ea0b29a..dc31cbca0b4 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -130,7 +130,10 @@ def __init__(self, parent=None, **kwargs): @abstractmethod def make_edisp_hdu( - self, events: QTable, point_like: bool, extname: str = "ENERGY MIGRATION" + self, + events: QTable, + spatial_selection_applied: bool, + extname: str = "ENERGY MIGRATION", ) -> BinTableHDU: """ Calculate the energy dispersion and create a fits binary table HDU @@ -140,9 +143,8 @@ def make_edisp_hdu( ---------- 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. + spatial_selection_applied: bool + If a direction cut was applied on ``events``, pass ``True``, else ``False``. extname: str Name for the BinTableHDU. @@ -162,7 +164,7 @@ def __init__(self, parent=None, **kwargs): def make_aeff_hdu( self, events: QTable, - point_like: bool, + spatial_selection_applied: bool, signal_is_point_like: bool, sim_info: SimulatedEventsInfo, extname: str = "EFFECTIVE AREA", @@ -175,9 +177,8 @@ def make_aeff_hdu( ---------- 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. + spatial_selection_applied: bool + If a direction cut was applied on ``events``, pass ``True``, else ``False``. signal_is_point_like: bool If ``events`` were simulated only at a single point in the field of view, pass ``True``, else ``False``. @@ -204,7 +205,7 @@ def __init__(self, parent=None, **kwargs): def make_aeff_hdu( self, events: QTable, - point_like: bool, + spatial_selection_applied: bool, signal_is_point_like: bool, sim_info: SimulatedEventsInfo, extname: str = "EFFECTIVE AREA", @@ -231,7 +232,7 @@ def make_aeff_hdu( effective_area=aeff, true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, - point_like=point_like, + point_like=spatial_selection_applied, extname=extname, ) @@ -246,7 +247,10 @@ def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) def make_edisp_hdu( - self, events: QTable, point_like: bool, extname: str = "ENERGY DISPERSION" + self, + events: QTable, + spatial_selection_applied: bool, + extname: str = "ENERGY DISPERSION", ) -> BinTableHDU: edisp = energy_dispersion( selected_events=events, @@ -259,7 +263,7 @@ def make_edisp_hdu( true_energy_bins=self.true_energy_bins, migration_bins=self.migration_bins, fov_offset_bins=self.fov_offset_bins, - point_like=point_like, + point_like=spatial_selection_applied, extname=extname, ) diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index 8da1f2dac2e..2eb90870885 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -50,7 +50,9 @@ def test_make_2d_energy_migration(irf_events_table): energy_migration_min=0.1, energy_migration_max=10, ) - edisp_hdu = edisp_maker.make_edisp_hdu(events=irf_events_table, point_like=False) + edisp_hdu = edisp_maker.make_edisp_hdu( + events=irf_events_table, spatial_selection_applied=False + ) # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins assert edisp_hdu.data["MATRIX"].shape == (1, 3, 20, 29) @@ -82,7 +84,7 @@ def test_make_2d_eff_area(irf_events_table): ) eff_area_hdu = eff_area_maker.make_aeff_hdu( events=irf_events_table, - point_like=False, + spatial_selection_applied=False, signal_is_point_like=False, sim_info=sim_info, ) @@ -98,7 +100,7 @@ def test_make_2d_eff_area(irf_events_table): # point like data -> only 1 fov offset bin eff_area_hdu = eff_area_maker.make_aeff_hdu( events=irf_events_table, - point_like=False, + spatial_selection_applied=False, signal_is_point_like=True, sim_info=sim_info, ) diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 41ec291e642..43530010b4e 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -182,10 +182,10 @@ class IrfTool(Tool): help="The parameterization of the point source sensitivity benchmark.", ).tag(config=True) - point_like = Bool( + spatial_selection_applied = Bool( False, help=( - "Compute a point-like IRF by applying a theta cut (``RAD_MAX``) " + "Compute an IRF after applying a direction cut (``SpatialSelection=RAD_MAX``) " "which makes calculating a point spread function unnecessary." ), ).tag(config=True) @@ -208,10 +208,10 @@ class IrfTool(Tool): "Do not compute background rate.", ), **flag( - "point-like", - "IrfTool.point_like", - "Compute a point-like IRF.", - "Compute a full-enclosure IRF.", + "spatial-selection-applied", + "IrfTool.spatial_selection_applied", + "Compute an IRF after applying a direction cut (``SpatialSelection=RAD_MAX``).", + "Compute an IRF without any direction cut (``SpatialSelection=None``).", ), } @@ -233,9 +233,10 @@ def setup(self): Initialize components from config and load g/h (and theta) cuts. """ self.opt_result = OptimizationResult.read(self.cuts_file) - if self.point_like and self.opt_result.theta_cuts is None: + if self.spatial_selection_applied and self.opt_result.theta_cuts is None: raise ToolConfigurationError( - "Computing a point-like IRF requires an (optimized) theta cut." + f"{self.cuts_file} does not contain any direction cut, " + "but --spatial-selection-applied was given." ) check_e_bins = partial( @@ -287,7 +288,7 @@ def setup(self): self.edisp = EnergyDispersionMakerBase.from_name(self.edisp_maker, parent=self) self.aeff = EffectiveAreaMakerBase.from_name(self.aeff_maker, parent=self) - if not self.point_like: + if not self.spatial_selection_applied: self.psf = PsfMakerBase.from_name(self.psf_maker, parent=self) if self.benchmarks_output_path is not None: @@ -331,7 +332,7 @@ def calculate_selections(self, reduced_events: dict) -> dict: self.opt_result.gh_cuts, operator.ge, ) - if self.point_like: + if self.spatial_selection_applied: reduced_events["gammas"]["selected_theta"] = evaluate_binned_cut( reduced_events["gammas"]["theta"], reduced_events["gammas"]["reco_energy"], @@ -380,7 +381,7 @@ 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=self.point_like, + spatial_selection_applied=self.spatial_selection_applied, signal_is_point_like=self.signal_is_point_like, sim_info=sim_info, ) @@ -388,10 +389,10 @@ def _make_signal_irf_hdus(self, hdus, sim_info): hdus.append( self.edisp.make_edisp_hdu( events=self.signal_events[self.signal_events["selected"]], - point_like=self.point_like, + spatial_selection_applied=self.spatial_selection_applied, ) ) - if not self.point_like: + if not self.spatial_selection_applied: hdus.append( self.psf.make_psf_hdu( events=self.signal_events[self.signal_events["selected"]] @@ -518,7 +519,7 @@ def start(self): if self.edisp.fov_offset_n_bins > 1 or self.aeff.fov_offset_n_bins > 1: raise ToolConfigurationError(errormessage) - if not self.point_like and self.psf.fov_offset_n_bins > 1: + if not self.spatial_selection_applied and self.psf.fov_offset_n_bins > 1: raise ToolConfigurationError(errormessage) if self.do_background and self.bkg.fov_offset_n_bins > 1: @@ -559,7 +560,7 @@ def start(self): events=reduced_events["protons"][ reduced_events["protons"]["selected_gh"] ], - point_like=self.point_like, + spatial_selection_applied=self.spatial_selection_applied, signal_is_point_like=False, sim_info=reduced_events["protons_meta"]["sim_info"], extname="EFFECTIVE AREA PROTONS", @@ -571,7 +572,7 @@ def start(self): events=reduced_events["electrons"][ reduced_events["electrons"]["selected_gh"] ], - point_like=self.point_like, + spatial_selection_applied=self.spatial_selection_applied, signal_is_point_like=False, sim_info=reduced_events["electrons_meta"]["sim_info"], extname="EFFECTIVE AREA ELECTRONS", diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index da169f8769d..b1b734f6eeb 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -35,7 +35,7 @@ def dummy_cuts_file( @pytest.mark.parametrize("include_bkg", (False, True)) -@pytest.mark.parametrize("point_like", (True, False)) +@pytest.mark.parametrize("spatial_selection_applied", (True, False)) def test_irf_tool( gamma_diffuse_full_reco_file, proton_full_reco_file, @@ -43,7 +43,7 @@ def test_irf_tool( dummy_cuts_file, tmp_path, include_bkg, - point_like, + spatial_selection_applied, ): from ctapipe.tools.compute_irf import IrfTool @@ -56,8 +56,8 @@ def test_irf_tool( f"--output={output_path}", f"--config={event_loader_config_path}", ] - if point_like: - argv.append("--point-like") + if spatial_selection_applied: + argv.append("--spatial-selection-applied") if include_bkg: argv.append(f"--proton-file={proton_full_reco_file}") @@ -75,7 +75,7 @@ def test_irf_tool( with fits.open(output_path) as hdul: assert isinstance(hdul["ENERGY DISPERSION"], fits.BinTableHDU) assert isinstance(hdul["EFFECTIVE AREA"], fits.BinTableHDU) - if point_like: + if spatial_selection_applied: assert isinstance(hdul["RAD_MAX"], fits.BinTableHDU) else: assert isinstance(hdul["PSF"], fits.BinTableHDU) @@ -196,7 +196,7 @@ def test_point_like_irf_no_theta_cut( with pytest.raises( ToolConfigurationError, - match=r"Computing a point-like IRF requires an \(optimized\) theta cut.", + match=rf"{gh_cuts_path} does not contain any direction cut", ): run_tool( IrfTool(), @@ -209,7 +209,7 @@ def test_point_like_irf_no_theta_cut( f"--output={output_path}", f"--benchmark-output={output_benchmarks_path}", f"--config={event_loader_config_path}", - "--point-like", + "--spatial-selection-applied", ], raises=True, ) From 5bc7f3d2c44e1e00a5b1eb6d08c111ee295dd226 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 15 Jan 2025 17:40:27 +0100 Subject: [PATCH 178/195] Fix __init__ signatures; use __call__ in small Components; improve attribute names --- src/ctapipe/irf/benchmarks.py | 36 ++++---- src/ctapipe/irf/binning.py | 12 +-- src/ctapipe/irf/irfs.py | 48 +++++----- src/ctapipe/irf/optimize.py | 32 +++---- src/ctapipe/irf/tests/test_benchmarks.py | 8 +- src/ctapipe/irf/tests/test_irfs.py | 12 ++- src/ctapipe/irf/tests/test_optimize.py | 6 +- src/ctapipe/tools/compute_irf.py | 87 +++++++++++-------- src/ctapipe/tools/optimize_event_selection.py | 2 +- 9 files changed, 127 insertions(+), 116 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index d336fe8d508..31e594ae715 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -44,11 +44,11 @@ class EnergyBiasResolutionMakerBase(DefaultTrueEnergyBins): Base class for calculating the bias and resolution of the energy prediciton. """ - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def make_bias_resolution_hdu( + def __call__( self, events: QTable, extname: str = "ENERGY BIAS RESOLUTION" ) -> BinTableHDU: """ @@ -73,10 +73,10 @@ class EnergyBiasResolution2dMaker(EnergyBiasResolutionMakerBase, DefaultFoVOffse true energy and fov offset. """ - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) - def make_bias_resolution_hdu( + def __call__( self, events: QTable, extname: str = "ENERGY BIAS RESOLUTION" ) -> BinTableHDU: result, fov_bin_idx, mat_shape = _get_2d_result_table( @@ -113,11 +113,11 @@ class AngularResolutionMakerBase(DefaultTrueEnergyBins, DefaultRecoEnergyBins): help="Use true energy instead of reconstructed energy for energy binning.", ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def make_angular_resolution_hdu( + def __call__( self, events: QTable, extname: str = "ANGULAR RESOLUTION" ) -> BinTableHDU: """ @@ -142,10 +142,10 @@ class AngularResolution2dMaker(AngularResolutionMakerBase, DefaultFoVOffsetBins) and fov offset. """ - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) - def make_angular_resolution_hdu( + def __call__( self, events: QTable, extname: str = "ANGULAR RESOLUTION" ) -> BinTableHDU: if self.use_true_energy: @@ -187,11 +187,11 @@ class SensitivityMakerBase(DefaultRecoEnergyBins): help="Size ratio of on region / off region.", ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def make_sensitivity_hdu( + def __call__( self, signal_events: QTable, background_events: QTable, @@ -228,10 +228,10 @@ class Sensitivity2dMaker(SensitivityMakerBase, DefaultFoVOffsetBins): and fov offset. """ - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) - def make_sensitivity_hdu( + def __call__( self, signal_events: QTable, background_events: QTable, diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index b0c28cfedb0..a9344fca67f 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -117,8 +117,8 @@ class DefaultTrueEnergyBins(Component): default_value=10, ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, 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), @@ -146,8 +146,8 @@ class DefaultRecoEnergyBins(Component): default_value=5, ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, 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), @@ -175,8 +175,8 @@ class DefaultFoVOffsetBins(Component): default_value=1, ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) self.fov_offset_bins = u.Quantity( np.linspace( self.fov_offset_min.to_value(u.deg), diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index dc31cbca0b4..2c235144bbd 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -39,11 +39,11 @@ class PsfMakerBase(DefaultTrueEnergyBins): """Base class for calculating the point spread function.""" - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: + def __call__(self, events: QTable, extname: str = "PSF") -> BinTableHDU: """ Calculate the psf and create a fits binary table HDU in GADF format. @@ -63,11 +63,11 @@ def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: class BackgroundRateMakerBase(DefaultRecoEnergyBins): """Base class for calculating the background rate.""" - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def make_bkg_hdu( + def __call__( self, events: QTable, obs_time: u.Quantity, extname: str = "BACKGROUND" ) -> BinTableHDU: """ @@ -117,8 +117,8 @@ class EnergyDispersionMakerBase(DefaultTrueEnergyBins): default_value="logarithmic", ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) bin_func = np.geomspace if self.energy_migration_binning == "linear": bin_func = np.linspace @@ -129,7 +129,7 @@ def __init__(self, parent=None, **kwargs): ) @abstractmethod - def make_edisp_hdu( + def __call__( self, events: QTable, spatial_selection_applied: bool, @@ -157,11 +157,11 @@ def make_edisp_hdu( class EffectiveAreaMakerBase(DefaultTrueEnergyBins): """Base class for calculating the effective area.""" - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def make_aeff_hdu( + def __call__( self, events: QTable, spatial_selection_applied: bool, @@ -199,10 +199,10 @@ class EffectiveArea2dMaker(EffectiveAreaMakerBase, DefaultFoVOffsetBins): bins of logarithmic true energy and field of view offset. """ - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) - def make_aeff_hdu( + def __call__( self, events: QTable, spatial_selection_applied: bool, @@ -243,10 +243,10 @@ class EnergyDispersion2dMaker(EnergyDispersionMakerBase, DefaultFoVOffsetBins): equidistant bins of logarithmic true energy and field of view offset. """ - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) - def make_edisp_hdu( + def __call__( self, events: QTable, spatial_selection_applied: bool, @@ -274,10 +274,10 @@ class BackgroundRate2dMaker(BackgroundRateMakerBase, DefaultFoVOffsetBins): bins of logarithmic reconstructed energy and field of view offset. """ - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) - def make_bkg_hdu( + def __call__( self, events: QTable, obs_time: u.Quantity, extname: str = "BACKGROUND" ) -> BinTableHDU: background_rate = background_2d( @@ -317,8 +317,8 @@ class Psf3dMaker(PsfMakerBase, DefaultFoVOffsetBins): default_value=100, ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) self.source_offset_bins = u.Quantity( np.linspace( self.source_offset_min.to_value(u.deg), @@ -328,7 +328,7 @@ def __init__(self, parent=None, **kwargs): u.deg, ) - def make_psf_hdu(self, events: QTable, extname: str = "PSF") -> BinTableHDU: + def __call__(self, events: QTable, extname: str = "PSF") -> BinTableHDU: psf = psf_table( events=events, true_energy_bins=self.true_energy_bins, diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 218a2d159d4..be7aed8ddd3 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -182,7 +182,7 @@ class CutOptimizerBase(Component): ).tag(config=True) @abstractmethod - def optimize_cuts( + def __call__( self, signal: QTable, background: QTable, @@ -227,7 +227,7 @@ class GhPercentileCutCalculator(Component): 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): + def __call__(self, gammaness, reco_energy, reco_energy_bins): if self.smoothing and self.smoothing < 0: self.smoothing = None @@ -279,7 +279,7 @@ class ThetaPercentileCutCalculator(Component): 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): + def __call__(self, theta, reco_energy, reco_energy_bins): if self.theta_min_angle < 0 * u.deg: theta_min_angle = None else: @@ -316,12 +316,12 @@ class PercentileCuts(CutOptimizerBase): classes = [GhPercentileCutCalculator, ThetaPercentileCutCalculator] - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) - self.gh = GhPercentileCutCalculator(parent=self) - self.theta = ThetaPercentileCutCalculator(parent=self) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) + self.gh_cut_calculator = GhPercentileCutCalculator(parent=self) + self.theta_cut_calculator = ThetaPercentileCutCalculator(parent=self) - def optimize_cuts( + def __call__( self, signal: QTable, background: QTable, @@ -333,7 +333,7 @@ def optimize_cuts( self.reco_energy_max.to(u.TeV), self.reco_energy_n_bins_per_decade, ) - gh_cuts = self.gh.calculate_gh_cut( + gh_cuts = self.gh_cut_calculator( signal["gh_score"], signal["reco_energy"], reco_energy_bins, @@ -344,7 +344,7 @@ def optimize_cuts( gh_cuts, op=operator.ge, ) - theta_cuts = self.theta.calculate_theta_cut( + theta_cuts = self.theta_cut_calculator( signal["theta"][gh_mask], signal["reco_energy"][gh_mask], reco_energy_bins, @@ -390,11 +390,11 @@ class PointSourceSensitivityOptimizer(CutOptimizerBase): help="Size ratio of on region / off region.", ).tag(config=True) - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) - self.theta = ThetaPercentileCutCalculator(parent=self) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) + self.theta_cut_calculator = ThetaPercentileCutCalculator(parent=self) - def optimize_cuts( + def __call__( self, signal: QTable, background: QTable, @@ -423,7 +423,7 @@ def optimize_cuts( op=operator.gt, ) - theta_cuts = self.theta.calculate_theta_cut( + theta_cuts = self.theta_cut_calculator( signal["theta"][initial_gh_mask], signal["reco_energy"][initial_gh_mask], reco_energy_bins, @@ -455,7 +455,7 @@ def optimize_cuts( gh_cuts, operator.ge, ) - theta_cuts_opt = self.theta.calculate_theta_cut( + theta_cuts_opt = self.theta_cut_calculator( signal[signal["selected_gh"]]["theta"], signal[signal["selected_gh"]]["reco_energy"], reco_energy_bins, diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 19d731a9fc0..24a7a52980d 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -13,7 +13,7 @@ def test_make_2d_energy_bias_res(irf_events_table): true_energy_max=155 * u.TeV, ) - bias_res_hdu = bias_res_maker.make_bias_resolution_hdu(events=irf_events_table) + bias_res_hdu = bias_res_maker(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 @@ -41,7 +41,7 @@ def test_make_2d_ang_res(irf_events_table): reco_energy_min=0.03 * u.TeV, ) - ang_res_hdu = ang_res_maker.make_angular_resolution_hdu(events=irf_events_table) + ang_res_hdu = ang_res_maker(events=irf_events_table) assert ( ang_res_hdu.data["N_EVENTS"].shape == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape @@ -54,7 +54,7 @@ def test_make_2d_ang_res(irf_events_table): ) ang_res_maker.use_true_energy = True - ang_res_hdu = ang_res_maker.make_angular_resolution_hdu(events=irf_events_table) + ang_res_hdu = ang_res_maker(events=irf_events_table) assert ( ang_res_hdu.data["N_EVENTS"].shape == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape @@ -108,7 +108,7 @@ def test_make_2d_sensitivity( ) theta_cuts["cut"] = sens_maker.fov_offset_max - sens_hdu = sens_maker.make_sensitivity_hdu( + sens_hdu = sens_maker( signal_events=gamma_events, background_events=proton_events, theta_cut=theta_cuts, diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index 2eb90870885..d8c8165ed95 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -29,7 +29,7 @@ def test_make_2d_bkg(irf_events_table): reco_energy_max=155 * u.TeV, ) - bkg_hdu = bkg_maker.make_bkg_hdu(events=irf_events_table, obs_time=1 * u.s) + bkg_hdu = bkg_maker(events=irf_events_table, obs_time=1 * u.s) # 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) @@ -50,9 +50,7 @@ def test_make_2d_energy_migration(irf_events_table): energy_migration_min=0.1, energy_migration_max=10, ) - edisp_hdu = edisp_maker.make_edisp_hdu( - events=irf_events_table, spatial_selection_applied=False - ) + edisp_hdu = edisp_maker(events=irf_events_table, spatial_selection_applied=False) # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins assert edisp_hdu.data["MATRIX"].shape == (1, 3, 20, 29) @@ -82,7 +80,7 @@ def test_make_2d_eff_area(irf_events_table): viewcone_min=0 * u.deg, viewcone_max=10 * u.deg, ) - eff_area_hdu = eff_area_maker.make_aeff_hdu( + eff_area_hdu = eff_area_maker( events=irf_events_table, spatial_selection_applied=False, signal_is_point_like=False, @@ -98,7 +96,7 @@ def test_make_2d_eff_area(irf_events_table): ) # point like data -> only 1 fov offset bin - eff_area_hdu = eff_area_maker.make_aeff_hdu( + eff_area_hdu = eff_area_maker( events=irf_events_table, spatial_selection_applied=False, signal_is_point_like=True, @@ -118,7 +116,7 @@ def test_make_3d_psf(irf_events_table): source_offset_n_bins=110, source_offset_max=2 * u.deg, ) - psf_hdu = psf_maker.make_psf_hdu(events=irf_events_table) + psf_hdu = psf_maker(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) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 5299d57eb4c..e93d10e9b2d 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -50,7 +50,7 @@ def test_gh_percentile_cut_calculator(): min_counts=1, smoothing=-1, ) - cuts = calc.calculate_gh_cut( + cuts = calc( 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, @@ -69,7 +69,7 @@ def test_theta_percentile_cut_calculator(): min_counts=1, smoothing=-1, ) - cuts = calc.calculate_theta_cut( + cuts = calc( 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, @@ -111,7 +111,7 @@ def test_cut_optimizer( ) optimizer = Optimizer() - result = optimizer.optimize_cuts( + result = optimizer( signal=gamma_events, background=proton_events, precuts=gamma_loader.epp, # identical precuts for all particle types diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 43530010b4e..087414af61d 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -137,31 +137,31 @@ class IrfTool(Tool): ), ).tag(config=True) - edisp_maker = traits.ComponentName( + edisp_maker_name = traits.ComponentName( EnergyDispersionMakerBase, default_value="EnergyDispersion2dMaker", help="The parameterization of the energy dispersion to be used.", ).tag(config=True) - aeff_maker = traits.ComponentName( + aeff_maker_name = traits.ComponentName( EffectiveAreaMakerBase, default_value="EffectiveArea2dMaker", help="The parameterization of the effective area to be used.", ).tag(config=True) - psf_maker = traits.ComponentName( + psf_maker_name = traits.ComponentName( PsfMakerBase, default_value="Psf3dMaker", help="The parameterization of the point spread function to be used.", ).tag(config=True) - bkg_maker = traits.ComponentName( + bkg_maker_name = traits.ComponentName( BackgroundRateMakerBase, default_value="BackgroundRate2dMaker", help="The parameterization of the background rate to be used.", ).tag(config=True) - energy_bias_resolution_maker = traits.ComponentName( + energy_bias_resolution_maker_name = traits.ComponentName( EnergyBiasResolutionMakerBase, default_value="EnergyBiasResolution2dMaker", help=( @@ -170,13 +170,13 @@ class IrfTool(Tool): ), ).tag(config=True) - angular_resolution_maker = traits.ComponentName( + angular_resolution_maker_name = traits.ComponentName( AngularResolutionMakerBase, default_value="AngularResolution2dMaker", help="The parameterization of the angular resolution benchmark.", ).tag(config=True) - sensitivity_maker = traits.ComponentName( + sensitivity_maker_name = traits.ComponentName( SensitivityMakerBase, default_value="Sensitivity2dMaker", help="The parameterization of the point source sensitivity benchmark.", @@ -280,35 +280,42 @@ def setup(self): else: self.log.warning("Estimating background without electron file.") - self.bkg = BackgroundRateMakerBase.from_name(self.bkg_maker, parent=self) + self.bkg_maker = BackgroundRateMakerBase.from_name( + self.bkg_maker_name, parent=self + ) check_e_bins( - bins=self.bkg.reco_energy_bins, source="background reco energy" + bins=self.bkg_maker.reco_energy_bins, source="background reco energy" ) - self.edisp = EnergyDispersionMakerBase.from_name(self.edisp_maker, parent=self) - self.aeff = EffectiveAreaMakerBase.from_name(self.aeff_maker, parent=self) + self.edisp_maker = EnergyDispersionMakerBase.from_name( + self.edisp_maker_name, parent=self + ) + self.aeff_maker = EffectiveAreaMakerBase.from_name( + self.aeff_maker_name, parent=self + ) if not self.spatial_selection_applied: - self.psf = PsfMakerBase.from_name(self.psf_maker, parent=self) + self.psf_maker = PsfMakerBase.from_name(self.psf_maker_name, parent=self) if self.benchmarks_output_path is not None: - self.angular_resolution = AngularResolutionMakerBase.from_name( - self.angular_resolution_maker, parent=self + self.angular_resolution_maker = AngularResolutionMakerBase.from_name( + self.angular_resolution_maker_name, parent=self ) - if not self.angular_resolution.use_true_energy: + if not self.angular_resolution_maker.use_true_energy: check_e_bins( - bins=self.angular_resolution.reco_energy_bins, + bins=self.angular_resolution_maker.reco_energy_bins, source="Angular resolution energy", ) - self.bias_resolution = EnergyBiasResolutionMakerBase.from_name( - self.energy_bias_resolution_maker, parent=self + self.bias_resolution_maker = EnergyBiasResolutionMakerBase.from_name( + self.energy_bias_resolution_maker_name, parent=self ) - self.sensitivity = SensitivityMakerBase.from_name( - self.sensitivity_maker, parent=self + self.sensitivity_maker = SensitivityMakerBase.from_name( + self.sensitivity_maker_name, parent=self ) check_e_bins( - bins=self.sensitivity.reco_energy_bins, source="Sensitivity reco energy" + bins=self.sensitivity_maker.reco_energy_bins, + source="Sensitivity reco energy", ) def calculate_selections(self, reduced_events: dict) -> dict: @@ -379,7 +386,7 @@ def calculate_selections(self, reduced_events: dict) -> dict: def _make_signal_irf_hdus(self, hdus, sim_info): hdus.append( - self.aeff.make_aeff_hdu( + self.aeff_maker( events=self.signal_events[self.signal_events["selected"]], spatial_selection_applied=self.spatial_selection_applied, signal_is_point_like=self.signal_is_point_like, @@ -387,14 +394,14 @@ def _make_signal_irf_hdus(self, hdus, sim_info): ) ) hdus.append( - self.edisp.make_edisp_hdu( + self.edisp_maker( events=self.signal_events[self.signal_events["selected"]], spatial_selection_applied=self.spatial_selection_applied, ) ) if not self.spatial_selection_applied: hdus.append( - self.psf.make_psf_hdu( + self.psf_maker( events=self.signal_events[self.signal_events["selected"]] ) ) @@ -423,12 +430,12 @@ def _make_signal_irf_hdus(self, hdus, sim_info): def _make_benchmark_hdus(self, hdus): hdus.append( - self.bias_resolution.make_bias_resolution_hdu( + self.bias_resolution_maker( events=self.signal_events[self.signal_events["selected"]], ) ) hdus.append( - self.angular_resolution.make_angular_resolution_hdu( + self.angular_resolution_maker( events=self.signal_events[self.signal_events["selected_gh"]], ) ) @@ -440,7 +447,7 @@ def _make_benchmark_hdus(self, hdus): ) hdus.append( - self.sensitivity.make_sensitivity_hdu( + self.sensitivity_maker( signal_events=self.signal_events[self.signal_events["selected"]], background_events=self.background_events[ self.background_events["selected_gh"] @@ -493,7 +500,7 @@ def start(self): # and benchmarks_output_path is given. if self.benchmarks_output_path is not None: evs = sel.make_event_weights( - evs, meta["spectrum"], self.sensitivity.fov_offset_bins + evs, meta["spectrum"], self.sensitivity_maker.fov_offset_bins ) # If only background should be calculated, # only calculate weights for protons and electrons. @@ -516,19 +523,25 @@ def start(self): Therefore, the IRF can only be calculated at a single point in the FoV, but `fov_offset_n_bins > 1`.""" - if self.edisp.fov_offset_n_bins > 1 or self.aeff.fov_offset_n_bins > 1: + if ( + self.edisp_maker.fov_offset_n_bins > 1 + or self.aeff_maker.fov_offset_n_bins > 1 + ): raise ToolConfigurationError(errormessage) - if not self.spatial_selection_applied and self.psf.fov_offset_n_bins > 1: + if ( + not self.spatial_selection_applied + and self.psf_maker.fov_offset_n_bins > 1 + ): raise ToolConfigurationError(errormessage) - if self.do_background and self.bkg.fov_offset_n_bins > 1: + if self.do_background and self.bkg_maker.fov_offset_n_bins > 1: raise ToolConfigurationError(errormessage) if self.benchmarks_output_path is not None and ( - self.angular_resolution.fov_offset_n_bins > 1 - or self.bias_resolution.fov_offset_n_bins > 1 - or self.sensitivity.fov_offset_n_bins > 1 + self.angular_resolution_maker.fov_offset_n_bins > 1 + or self.bias_resolution_maker.fov_offset_n_bins > 1 + or self.sensitivity_maker.fov_offset_n_bins > 1 ): raise ToolConfigurationError(errormessage) @@ -549,14 +562,14 @@ def start(self): ) if self.do_background: hdus.append( - self.bkg.make_bkg_hdu( + self.bkg_maker( self.background_events[self.background_events["selected_gh"]], self.obs_time, ) ) if "protons" in reduced_events.keys(): hdus.append( - self.aeff.make_aeff_hdu( + self.aeff_maker( events=reduced_events["protons"][ reduced_events["protons"]["selected_gh"] ], @@ -568,7 +581,7 @@ def start(self): ) if "electrons" in reduced_events.keys(): hdus.append( - self.aeff.make_aeff_hdu( + self.aeff_maker( events=reduced_events["electrons"][ reduced_events["electrons"]["selected_gh"] ], diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index f82165bf852..155ddda7c67 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -205,7 +205,7 @@ def start(self): % (len(self.signal_events), len(self.background_events)), ) - result = self.optimizer.optimize_cuts( + result = self.optimizer( signal=self.signal_events, background=self.background_events if self.optimization_algorithm != "PercentileCuts" From d6d15060010177d9ac68206a4787083fdd7c085f Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 15 Jan 2025 17:56:33 +0100 Subject: [PATCH 179/195] Adress minor comments --- src/ctapipe/tools/optimize_event_selection.py | 8 ++++---- src/ctapipe/tools/tests/test_compute_irf.py | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 155ddda7c67..de61b9e563d 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, Integer, classes_with_traits -from ..irf import EventLoader, Spectra +from ..irf import EventLoader, PercentileCuts, PointSourceSensitivityOptimizer, Spectra from ..irf.optimize import CutOptimizerBase __all__ = ["EventSelectionOptimizer"] @@ -119,7 +119,7 @@ def setup(self): target_spectrum=self.gamma_target_spectrum, ) ] - if self.optimization_algorithm != "PercentileCuts": + if not isinstance(self.optimizer, PercentileCuts): if not self.proton_file or ( self.proton_file and not self.proton_file.exists() ): @@ -155,7 +155,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) - if self.optimization_algorithm == "PointSourceSensitivityOptimizer": + if isinstance(self.optimizer, PointSourceSensitivityOptimizer): evs = sel.make_event_weights( evs, meta["spectrum"], @@ -173,7 +173,7 @@ def start(self): self.signal_events = reduced_events["gammas"] - if self.optimization_algorithm == "PercentileCuts": + if isinstance(self.optimizer, 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)) diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index b1b734f6eeb..01adf636d39 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -1,6 +1,5 @@ import json import logging -import os import pytest from astropy.io import fits @@ -83,7 +82,7 @@ def test_irf_tool( if include_bkg: assert isinstance(hdul["BACKGROUND"], fits.BinTableHDU) - os.remove(output_path) # Delete output file + output_path.unlink() # Delete output file # Include benchmarks argv.append(f"--benchmark-output={output_benchmarks_path}") From b48461fe7b95132a2dffd0a4041080d58f0d055e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 20 Jan 2025 12:15:27 +0100 Subject: [PATCH 180/195] EventPreProcessor -> EventPreprocessor and add EventQualityQuery --- src/ctapipe/conftest.py | 26 +++++----- src/ctapipe/irf/__init__.py | 4 +- src/ctapipe/irf/optimize.py | 10 ++-- src/ctapipe/irf/preprocessing.py | 50 ++++++++++++------- src/ctapipe/irf/tests/test_optimize.py | 8 +-- src/ctapipe/irf/tests/test_preprocessing.py | 6 +-- src/ctapipe/tools/compute_irf.py | 17 +++++-- src/ctapipe/tools/optimize_event_selection.py | 6 +-- src/ctapipe/tools/tests/test_compute_irf.py | 16 +++--- 9 files changed, 83 insertions(+), 60 deletions(-) diff --git a/src/ctapipe/conftest.py b/src/ctapipe/conftest.py index ef133d771ea..f4555044f4d 100644 --- a/src/ctapipe/conftest.py +++ b/src/ctapipe/conftest.py @@ -779,19 +779,21 @@ def irf_event_loader_test_config(): return Config( { - "EventPreProcessor": { + "EventPreprocessor": { "energy_reconstructor": "ExtraTreesRegressor", "geometry_reconstructor": "HillasReconstructor", "gammaness_classifier": "ExtraTreesClassifier", - "quality_criteria": [ - ( - "multiplicity 4", - "np.count_nonzero(HillasReconstructor_telescopes,axis=1) >= 4", - ), - ("valid classifier", "ExtraTreesClassifier_is_valid"), - ("valid geom reco", "HillasReconstructor_is_valid"), - ("valid energy reco", "ExtraTreesRegressor_is_valid"), - ], + "EventQualityQuery": { + "quality_criteria": [ + ( + "multiplicity 4", + "np.count_nonzero(HillasReconstructor_telescopes,axis=1) >= 4", + ), + ("valid classifier", "ExtraTreesClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "ExtraTreesRegressor_is_valid"), + ], + }, } } ) @@ -808,12 +810,12 @@ def event_loader_config_path(irf_event_loader_test_config, irf_tmp_path): @pytest.fixture(scope="session") def irf_events_table(): - from ctapipe.irf import EventPreProcessor + from ctapipe.irf import EventPreprocessor N1 = 1000 N2 = 100 N = N1 + N2 - epp = EventPreProcessor() + epp = EventPreprocessor() tab = epp.make_empty_table() ids, bulk, unitless = tab.colnames[:2], tab.colnames[2:-2], tab.colnames[-2:] diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index ad2a7d74ea5..e507f7ec07e 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -31,7 +31,7 @@ PointSourceSensitivityOptimizer, ThetaPercentileCutCalculator, ) -from .preprocessing import EventLoader, EventPreProcessor +from .preprocessing import EventLoader, EventPreprocessor from .spectra import ENERGY_FLUX_UNIT, FLUX_UNIT, SPECTRA, Spectra __all__ = [ @@ -47,7 +47,7 @@ "PointSourceSensitivityOptimizer", "PercentileCuts", "EventLoader", - "EventPreProcessor", + "EventPreprocessor", "Spectra", "GhPercentileCutCalculator", "ThetaPercentileCutCalculator", diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index be7aed8ddd3..24a3e9abe4b 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -14,7 +14,7 @@ from ..core import Component, QualityQuery from ..core.traits import AstroQuantity, Float, Integer, Path from .binning import ResultValidRange, make_bins_per_decade -from .preprocessing import EventPreProcessor +from .preprocessing import EventQualityQuery __all__ = [ "CutOptimizerBase", @@ -186,7 +186,7 @@ def __call__( self, signal: QTable, background: QTable, - precuts: EventPreProcessor, + precuts: EventQualityQuery, clf_prefix: str, ) -> OptimizationResult: """ @@ -199,7 +199,7 @@ def __call__( Table containing signal events background: astropy.table.QTable Table containing background events - precuts: ctapipe.irf.EventPreProcessor + precuts: ctapipe.irf.EventPreprocessor ``ctapipe.core.QualityQuery`` subclass containing preselection criteria for events clf_prefix: str @@ -325,7 +325,7 @@ def __call__( self, signal: QTable, background: QTable, - precuts: EventPreProcessor, + precuts: EventQualityQuery, clf_prefix: str, ) -> OptimizationResult: reco_energy_bins = make_bins_per_decade( @@ -398,7 +398,7 @@ def __call__( self, signal: QTable, background: QTable, - precuts: EventPreProcessor, + precuts: EventQualityQuery, clf_prefix: str, ) -> OptimizationResult: reco_energy_bins = make_bins_per_decade( diff --git a/src/ctapipe/irf/preprocessing.py b/src/ctapipe/irf/preprocessing.py index 7c59e2cc27d..44706f413de 100644 --- a/src/ctapipe/irf/preprocessing.py +++ b/src/ctapipe/irf/preprocessing.py @@ -24,11 +24,33 @@ from ..io import TableLoader from .spectra import SPECTRA, Spectra -__all__ = ["EventLoader", "EventPreProcessor"] +__all__ = ["EventLoader", "EventPreprocessor"] -class EventPreProcessor(QualityQuery): - """Defines preselection cuts and the necessary renaming of columns.""" +class EventQualityQuery(QualityQuery): + """ + Event pre-selection quality criteria for IRF computation with different defaults. + """ + + quality_criteria = List( + Tuple(Unicode(), Unicode()), + default_value=[ + ( + "multiplicity 4", + "np.count_nonzero(HillasReconstructor_telescopes,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) + + +class EventPreprocessor(Component): + """Defines pre-selection cuts and the necessary renaming of columns.""" + + classes = [EventQualityQuery] energy_reconstructor = Unicode( default_value="RandomForestRegressor", @@ -45,19 +67,9 @@ class EventPreProcessor(QualityQuery): help="Prefix of the classifier `_prediction` column", ).tag(config=True) - quality_criteria = List( - Tuple(Unicode(), Unicode()), - default_value=[ - ( - "multiplicity 4", - "np.count_nonzero(HillasReconstructor_telescopes,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) + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) + self.quality_query = EventQualityQuery(parent=self) def normalise_column_names(self, events: Table) -> QTable: if events["subarray_pointing_lat"].std() > 1e-3: @@ -192,12 +204,12 @@ class EventLoader(Component): and derive some additional columns needed for irf calculation. """ - classes = [EventPreProcessor] + classes = [EventPreprocessor] def __init__(self, kind: str, file: Path, target_spectrum: Spectra, **kwargs): super().__init__(**kwargs) - self.epp = EventPreProcessor(parent=self) + self.epp = EventPreprocessor(parent=self) self.target_spectrum = SPECTRA[target_spectrum] self.kind = kind self.file = file @@ -213,7 +225,7 @@ def load_preselected_events( bits = [header] n_raw_events = 0 for _, _, events in load.read_subarray_events_chunked(chunk_size, **opts): - selected = events[self.epp.get_table_mask(events)] + selected = events[self.epp.quality_query.get_table_mask(events)] selected = self.epp.normalise_column_names(selected) selected = self.make_derived_columns(selected) bits.append(selected) diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index e93d10e9b2d..bdb907abb43 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -9,19 +9,19 @@ def test_optimization_result(tmp_path, irf_event_loader_test_config): from ctapipe.irf import ( - EventPreProcessor, + EventPreprocessor, OptimizationResult, ResultValidRange, ) result_path = tmp_path / "result.h5" - epp = EventPreProcessor(irf_event_loader_test_config) + epp = EventPreprocessor(irf_event_loader_test_config) 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"], ) result = OptimizationResult( - precuts=epp, + precuts=epp.quality_query, gh_cuts=gh_cuts, clf_prefix="ExtraTreesClassifier", valid_energy_min=0.2 * u.TeV, @@ -114,7 +114,7 @@ def test_cut_optimizer( result = optimizer( signal=gamma_events, background=proton_events, - precuts=gamma_loader.epp, # identical precuts for all particle types + precuts=gamma_loader.epp.quality_query, # identical precuts for all particle types clf_prefix="ExtraTreesClassifier", ) assert isinstance(result, OptimizationResult) diff --git a/src/ctapipe/irf/tests/test_preprocessing.py b/src/ctapipe/irf/tests/test_preprocessing.py index 53e964a86d5..b2a33c9319a 100644 --- a/src/ctapipe/irf/tests/test_preprocessing.py +++ b/src/ctapipe/irf/tests/test_preprocessing.py @@ -28,9 +28,9 @@ def dummy_table(): def test_normalise_column_names(dummy_table): - from ctapipe.irf import EventPreProcessor + from ctapipe.irf import EventPreprocessor - epp = EventPreProcessor( + epp = EventPreprocessor( energy_reconstructor="dummy", geometry_reconstructor="geom", gammaness_classifier="classifier", @@ -55,7 +55,7 @@ def test_normalise_column_names(dummy_table): with pytest.raises(ValueError, match="Required column geom_alt is missing."): dummy_table.rename_column("geom_alt", "alt_geom") - epp = EventPreProcessor( + epp = EventPreprocessor( energy_reconstructor="dummy", geometry_reconstructor="geom", gammaness_classifier="classifier", diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 087414af61d..f0e24d75c08 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -21,7 +21,6 @@ from ..core.traits import AstroQuantity, Bool, Integer, classes_with_traits, flag from ..irf import ( EventLoader, - EventPreProcessor, OptimizationResult, Spectra, check_bins_in_range, @@ -37,6 +36,7 @@ EnergyDispersionMakerBase, PsfMakerBase, ) +from ..irf.preprocessing import EventQualityQuery __all__ = ["IrfTool"] @@ -475,24 +475,31 @@ def start(self): ) ) - if sel.epp.quality_criteria != self.opt_result.precuts.quality_criteria: + if ( + sel.epp.quality_query.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"], + sel.epp.quality_query.to_table(functions=True)[ + "criteria", "func" + ], self.opt_result.precuts.to_table(functions=True)[ "criteria", "func" ], ) ) - sel.epp = EventPreProcessor( + sel.epp.quality_query = EventQualityQuery( parent=sel, quality_criteria=self.opt_result.precuts.quality_criteria, ) - self.log.debug("%s Precuts: %s" % (sel.kind, sel.epp.quality_criteria)) + self.log.debug( + "%s Precuts: %s" % (sel.kind, sel.epp.quality_query.quality_criteria) + ) evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) # Only calculate event weights if background or sensitivity should be calculated. if self.do_background: diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index de61b9e563d..402d258f2f3 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -205,15 +205,15 @@ def start(self): % (len(self.signal_events), len(self.background_events)), ) - result = self.optimizer( + self.result = self.optimizer( signal=self.signal_events, background=self.background_events if self.optimization_algorithm != "PercentileCuts" else None, - precuts=self.particles[0].epp, # identical precuts for all particle types + # identical precuts for all particle types + precuts=self.particles[0].epp.quality_query, clf_prefix=self.particles[0].epp.gammaness_classifier, ) - self.result = result def finish(self): """ diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index 01adf636d39..d1fcd43aebc 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -241,16 +241,18 @@ def test_irf_tool_wrong_cuts( with config_path.open("w") as f: json.dump( { - "EventPreProcessor": { + "EventPreprocessor": { "energy_reconstructor": "ExtraTreesRegressor", "geometry_reconstructor": "HillasReconstructor", "gammaness_classifier": "ExtraTreesClassifier", - "quality_criteria": [ - # No criteria for minimum event multiplicity - ("valid classifier", "ExtraTreesClassifier_is_valid"), - ("valid geom reco", "HillasReconstructor_is_valid"), - ("valid energy reco", "ExtraTreesRegressor_is_valid"), - ], + "EventQualityQuery": { + "quality_criteria": [ + # No criteria for minimum event multiplicity + ("valid classifier", "ExtraTreesClassifier_is_valid"), + ("valid geom reco", "HillasReconstructor_is_valid"), + ("valid energy reco", "ExtraTreesRegressor_is_valid"), + ], + }, } }, f, From 1a5dbe8ed11740135a2750093af6cf18a2a942ef Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Mon, 20 Jan 2025 13:44:38 +0100 Subject: [PATCH 181/195] theta_cuts -> spatial_selection_table --- src/ctapipe/irf/benchmarks.py | 8 ++--- src/ctapipe/irf/optimize.py | 30 +++++++++---------- src/ctapipe/irf/tests/test_benchmarks.py | 10 +++---- src/ctapipe/irf/tests/test_optimize.py | 4 +-- src/ctapipe/tools/compute_irf.py | 19 +++++++----- src/ctapipe/tools/tests/test_compute_irf.py | 2 +- .../tests/test_optimize_event_selection.py | 8 ++--- 7 files changed, 43 insertions(+), 38 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index 31e594ae715..2b295cdabe6 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -195,7 +195,7 @@ def __call__( self, signal_events: QTable, background_events: QTable, - theta_cut: QTable, + spatial_selection_table: QTable, gamma_spectrum: Spectra, extname: str = "SENSITIVITY", ) -> BinTableHDU: @@ -209,7 +209,7 @@ def __call__( Reconstructed signal events to be used. background_events: astropy.table.QTable Reconstructed background events to be used. - theta_cut: QTable + spatial_selection_table: QTable Direction cut that was applied on ``signal_events``. gamma_spectrum: ctapipe.irf.Spectra Spectra by which to scale the relative sensitivity to get the flux sensitivity. @@ -235,7 +235,7 @@ def __call__( self, signal_events: QTable, background_events: QTable, - theta_cut: QTable, + spatial_selection_table: QTable, gamma_spectrum: Spectra, extname: str = "SENSITIVITY", ) -> BinTableHDU: @@ -264,7 +264,7 @@ def __call__( bkg_hist = estimate_background( events=background_events, reco_energy_bins=self.reco_energy_bins, - theta_cuts=theta_cut, + theta_cuts=spatial_selection_table, alpha=self.alpha, fov_offset_min=self.fov_offset_bins[i], fov_offset_max=self.fov_offset_bins[i + 1], diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 24a3e9abe4b..e6093934b17 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -37,7 +37,7 @@ def __init__( valid_offset_max: u.Quantity, gh_cuts: QTable, clf_prefix: str, - theta_cuts: QTable | None = None, + spatial_selection_table: QTable | None = None, precuts: QualityQuery | Sequence | None = None, ) -> None: if precuts: @@ -59,13 +59,13 @@ def __init__( self.valid_offset = ResultValidRange(min=valid_offset_min, max=valid_offset_max) self.gh_cuts = gh_cuts self.clf_prefix = clf_prefix - self.theta_cuts = theta_cuts + self.spatial_selection_table = spatial_selection_table def __repr__(self): - if self.theta_cuts is not None: + if self.spatial_selection_table is not None: return ( f"" @@ -105,9 +105,9 @@ def write(self, output_name: Path | str, overwrite: bool = False) -> None: results = [cut_expr_tab, self.gh_cuts, energy_lim_tab, offset_lim_tab] - if self.theta_cuts is not None: - self.theta_cuts.meta["EXTNAME"] = "RAD_MAX" - results.append(self.theta_cuts) + if self.spatial_selection_table is not None: + self.spatial_selection_table.meta["EXTNAME"] = "RAD_MAX" + results.append(self.spatial_selection_table) # Overwrite if needed and allowed results[0].write(output_name, format="fits", overwrite=overwrite) @@ -129,7 +129,7 @@ def read(cls, file_name): 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 + spatial_selection_table = QTable.read(hdul[5]) if len(hdul) > 5 else None return cls( precuts=precuts, @@ -139,7 +139,7 @@ def read(cls, file_name): valid_offset_max=valid_offset["offset_max"], gh_cuts=gh_cuts, clf_prefix=gh_cuts.meta["CLFNAME"], - theta_cuts=theta_cuts, + spatial_selection_table=spatial_selection_table, ) @@ -344,7 +344,7 @@ def __call__( gh_cuts, op=operator.ge, ) - theta_cuts = self.theta_cut_calculator( + spatial_selection_table = self.theta_cut_calculator( signal["theta"][gh_mask], signal["reco_energy"][gh_mask], reco_energy_bins, @@ -359,7 +359,7 @@ def __call__( # A single set of cuts is calculated for the whole fov atm valid_offset_min=0 * u.deg, valid_offset_max=np.inf * u.deg, - theta_cuts=theta_cuts, + spatial_selection_table=spatial_selection_table, ) return result @@ -423,7 +423,7 @@ def __call__( op=operator.gt, ) - theta_cuts = self.theta_cut_calculator( + spatial_selection_table = self.theta_cut_calculator( signal["theta"][initial_gh_mask], signal["reco_energy"][initial_gh_mask], reco_energy_bins, @@ -441,7 +441,7 @@ def __call__( reco_energy_bins=reco_energy_bins, gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, - theta_cuts=theta_cuts, + theta_cuts=spatial_selection_table, alpha=self.alpha, fov_offset_max=self.max_bkg_fov_offset, fov_offset_min=self.min_bkg_fov_offset, @@ -455,7 +455,7 @@ def __call__( gh_cuts, operator.ge, ) - theta_cuts_opt = self.theta_cut_calculator( + spatial_selection_table_opt = self.theta_cut_calculator( signal[signal["selected_gh"]]["theta"], signal[signal["selected_gh"]]["reco_energy"], reco_energy_bins, @@ -470,7 +470,7 @@ def __call__( # A single set of cuts is calculated for the whole fov atm valid_offset_min=self.min_bkg_fov_offset, valid_offset_max=self.max_bkg_fov_offset, - theta_cuts=theta_cuts_opt, + spatial_selection_table=spatial_selection_table_opt, ) return result diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 24a7a52980d..21534f555a7 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -101,17 +101,17 @@ def test_make_2d_sensitivity( 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 * ( + # needs a "theta cut" atm. + spatial_selection_table = QTable() + spatial_selection_table["center"] = 0.5 * ( sens_maker.reco_energy_bins[:-1] + sens_maker.reco_energy_bins[1:] ) - theta_cuts["cut"] = sens_maker.fov_offset_max + spatial_selection_table["cut"] = sens_maker.fov_offset_max sens_hdu = sens_maker( signal_events=gamma_events, background_events=proton_events, - theta_cut=theta_cuts, + spatial_selection_table=spatial_selection_table, gamma_spectrum=Spectra.CRAB_HEGRA, ) assert ( diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index bdb907abb43..0d5d91730c5 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -28,7 +28,7 @@ def test_optimization_result(tmp_path, irf_event_loader_test_config): valid_energy_max=10 * u.TeV, valid_offset_min=0 * u.deg, valid_offset_max=np.inf * u.deg, - theta_cuts=None, + spatial_selection_table=None, ) result.write(result_path) assert result_path.exists() @@ -121,4 +121,4 @@ def test_cut_optimizer( assert result.clf_prefix == "ExtraTreesClassifier" assert result.valid_energy.min >= result.gh_cuts["low"][0] assert result.valid_energy.max <= result.gh_cuts["high"][-1] - assert result.theta_cuts["cut"].unit == u.deg + assert result.spatial_selection_table["cut"].unit == u.deg diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index f0e24d75c08..430bea25b55 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -233,7 +233,10 @@ def setup(self): Initialize components from config and load g/h (and theta) cuts. """ self.opt_result = OptimizationResult.read(self.cuts_file) - if self.spatial_selection_applied and self.opt_result.theta_cuts is None: + if ( + self.spatial_selection_applied + and self.opt_result.spatial_selection_table is None + ): raise ToolConfigurationError( f"{self.cuts_file} does not contain any direction cut, " "but --spatial-selection-applied was given." @@ -343,7 +346,7 @@ def calculate_selections(self, reduced_events: dict) -> dict: reduced_events["gammas"]["selected_theta"] = evaluate_binned_cut( reduced_events["gammas"]["theta"], reduced_events["gammas"]["reco_energy"], - self.opt_result.theta_cuts, + self.opt_result.spatial_selection_table, operator.le, ) reduced_events["gammas"]["selected"] = ( @@ -413,10 +416,12 @@ def _make_signal_irf_hdus(self, hdus, sim_info): ) hdus.append( create_rad_max_hdu( - rad_max=self.opt_result.theta_cuts["cut"].reshape(-1, 1), + rad_max=self.opt_result.spatial_selection_table["cut"].reshape( + -1, 1 + ), reco_energy_bins=np.append( - self.opt_result.theta_cuts["low"], - self.opt_result.theta_cuts["high"][-1], + self.opt_result.spatial_selection_table["low"], + self.opt_result.spatial_selection_table["high"][-1], ), fov_offset_bins=u.Quantity( [ @@ -440,7 +445,7 @@ def _make_benchmark_hdus(self, hdus): ) ) if self.do_background: - if self.opt_result.theta_cuts is None: + if self.opt_result.spatial_selection_table is None: raise ValueError( "Calculating the point-source sensitivity requires " f"theta cuts, but {self.cuts_file} does not contain any." @@ -452,7 +457,7 @@ def _make_benchmark_hdus(self, hdus): background_events=self.background_events[ self.background_events["selected_gh"] ], - theta_cut=self.opt_result.theta_cuts, + spatial_selection_table=self.opt_result.spatial_selection_table, gamma_spectrum=self.gamma_target_spectrum, ) ) diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index d1fcd43aebc..4b234853265 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -186,7 +186,7 @@ def test_point_like_irf_no_theta_cut( gh_cuts_path = tmp_path / "gh_cuts.fits" cuts = OptimizationResult.read(dummy_cuts_file) - cuts.theta_cuts = None + cuts.spatial_selection_table = None cuts.write(gh_cuts_path) assert gh_cuts_path.exists() diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index 226043a66c5..67be107b3df 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -42,14 +42,14 @@ def test_cuts_optimization( assert isinstance(result.gh_cuts, QTable) assert result.clf_prefix == "ExtraTreesClassifier" assert "cut" in result.gh_cuts.colnames - assert isinstance(result.theta_cuts, QTable) - assert "cut" in result.theta_cuts.colnames + assert isinstance(result.spatial_selection_table, QTable) + assert "cut" in result.spatial_selection_table.colnames for c in ["low", "center", "high"]: assert c in result.gh_cuts.colnames assert result.gh_cuts[c].unit == u.TeV - assert c in result.theta_cuts.colnames - assert result.theta_cuts[c].unit == u.TeV + assert c in result.spatial_selection_table.colnames + assert result.spatial_selection_table[c].unit == u.TeV def test_cuts_opt_no_electrons( From dfa76e8c9e3fde0e4a3b7a733fc8a18ab9c2be9c Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 21 Jan 2025 15:48:46 +0100 Subject: [PATCH 182/195] Make multiple angular resolution quantiles configurable --- src/ctapipe/irf/benchmarks.py | 21 ++++++++++++---- src/ctapipe/irf/tests/test_benchmarks.py | 31 ++++++++++++++++-------- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index 2b295cdabe6..1476631ecc6 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -10,7 +10,7 @@ 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 ..core.traits import Bool, Float, List from .binning import DefaultFoVOffsetBins, DefaultRecoEnergyBins, DefaultTrueEnergyBins from .spectra import ENERGY_FLUX_UNIT, FLUX_UNIT, SPECTRA, Spectra @@ -113,6 +113,12 @@ class AngularResolutionMakerBase(DefaultTrueEnergyBins, DefaultRecoEnergyBins): help="Use true energy instead of reconstructed energy for energy binning.", ).tag(config=True) + quantiles = List( + Float(), + default_value=[0.25, 0.5, 0.68, 0.95], + help="Quantiles for which the containment radius should be calculated.", + ).tag(config=True) + def __init__(self, config=None, parent=None, **kwargs): super().__init__(config=config, parent=parent, **kwargs) @@ -161,18 +167,23 @@ def __call__( fov_bins=self.fov_offset_bins, ) result["N_EVENTS"] = np.zeros(mat_shape)[np.newaxis, ...] - result["ANGULAR_RESOLUTION"] = u.Quantity( - np.full(mat_shape, np.nan)[np.newaxis, ...], events["theta"].unit - ) + for q in self.quantiles: + result[f"ANGULAR_RESOLUTION_{q * 100:.0f}"] = 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, + quantile=self.quantiles, ) result["N_EVENTS"][:, i, :] = ang_res["n_events"] - result["ANGULAR_RESOLUTION"][:, i, :] = ang_res["angular_resolution_68"] + for q in self.quantiles: + result[f"ANGULAR_RESOLUTION_{q * 100:.0f}"][:, i, :] = ang_res[ + f"angular_resolution_{q * 100:.0f}" + ] header = Header() header["E_TYPE"] = energy_type.upper() diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 21534f555a7..921bab35d84 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -42,11 +42,17 @@ def test_make_2d_ang_res(irf_events_table): ) ang_res_hdu = ang_res_maker(events=irf_events_table) - assert ( - ang_res_hdu.data["N_EVENTS"].shape - == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape - == (1, 3, 23) - ) + cols = [ + "N_EVENTS", + "ANGULAR_RESOLUTION_25", + "ANGULAR_RESOLUTION_50", + "ANGULAR_RESOLUTION_68", + "ANGULAR_RESOLUTION_95", + ] + for c in cols: + assert c in ang_res_hdu.data.names + assert ang_res_hdu.data[c].shape == (1, 3, 23) + _check_boundaries_in_hdu( ang_res_hdu, lo_vals=[0 * u.deg, 0.03 * u.TeV], @@ -54,12 +60,17 @@ def test_make_2d_ang_res(irf_events_table): ) ang_res_maker.use_true_energy = True + ang_res_maker.quantiles = [0.4, 0.7] ang_res_hdu = ang_res_maker(events=irf_events_table) - assert ( - ang_res_hdu.data["N_EVENTS"].shape - == ang_res_hdu.data["ANGULAR_RESOLUTION"].shape - == (1, 3, 29) - ) + cols = [ + "N_EVENTS", + "ANGULAR_RESOLUTION_40", + "ANGULAR_RESOLUTION_70", + ] + for c in cols: + assert c in ang_res_hdu.data.names + assert ang_res_hdu.data[c].shape == (1, 3, 29) + _check_boundaries_in_hdu( ang_res_hdu, lo_vals=[0 * u.deg, 0.015 * u.TeV], From d304c608c7533182dcbefbdc26c8fca04eb0a7a1 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 21 Jan 2025 16:10:06 +0100 Subject: [PATCH 183/195] Also calculate psf for point-like IRF --- src/ctapipe/irf/irfs.py | 17 ++++++++++++++--- src/ctapipe/tools/compute_irf.py | 17 +++++++---------- src/ctapipe/tools/tests/test_compute_irf.py | 3 +-- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 2c235144bbd..b015707808c 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -43,7 +43,9 @@ def __init__(self, config=None, parent=None, **kwargs): super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def __call__(self, events: QTable, extname: str = "PSF") -> BinTableHDU: + def __call__( + self, events: QTable, spatial_selection_applied: bool, extname: str = "PSF" + ) -> BinTableHDU: """ Calculate the psf and create a fits binary table HDU in GADF format. @@ -328,17 +330,26 @@ def __init__(self, config=None, parent=None, **kwargs): u.deg, ) - def __call__(self, events: QTable, extname: str = "PSF") -> BinTableHDU: + def __call__( + self, events: QTable, spatial_selection_applied: bool, extname: str = "PSF" + ) -> BinTableHDU: psf = psf_table( events=events, true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, source_offset_bins=self.source_offset_bins, ) - return create_psf_table_hdu( + hdu = 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=extname, ) + # We also calculate a psf for IRFs including a spatial selection + # ("point-like IRF") to enable RAD_MAX cross-checks downstream. + # In that case, we have to change the header accordingly. + if spatial_selection_applied: + hdu.header["HDUCLAS3"] = "POINT-LIKE" + + return hdu diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 430bea25b55..99a5bb231da 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -186,7 +186,6 @@ class IrfTool(Tool): False, help=( "Compute an IRF after applying a direction cut (``SpatialSelection=RAD_MAX``) " - "which makes calculating a point spread function unnecessary." ), ).tag(config=True) @@ -296,9 +295,7 @@ def setup(self): self.aeff_maker = EffectiveAreaMakerBase.from_name( self.aeff_maker_name, parent=self ) - - if not self.spatial_selection_applied: - self.psf_maker = PsfMakerBase.from_name(self.psf_maker_name, parent=self) + self.psf_maker = PsfMakerBase.from_name(self.psf_maker_name, parent=self) if self.benchmarks_output_path is not None: self.angular_resolution_maker = AngularResolutionMakerBase.from_name( @@ -402,13 +399,13 @@ def _make_signal_irf_hdus(self, hdus, sim_info): spatial_selection_applied=self.spatial_selection_applied, ) ) - if not self.spatial_selection_applied: - hdus.append( - self.psf_maker( - events=self.signal_events[self.signal_events["selected"]] - ) + hdus.append( + self.psf_maker( + events=self.signal_events[self.signal_events["selected"]], + spatial_selection_applied=self.spatial_selection_applied, ) - else: + ) + if self.spatial_selection_applied: # TODO: Support fov binning self.log.debug( "Currently multiple fov bins is not supported for RAD_MAX. " diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index 4b234853265..07ac4bf91c6 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -74,10 +74,9 @@ def test_irf_tool( with fits.open(output_path) as hdul: assert isinstance(hdul["ENERGY DISPERSION"], fits.BinTableHDU) assert isinstance(hdul["EFFECTIVE AREA"], fits.BinTableHDU) + assert isinstance(hdul["PSF"], fits.BinTableHDU) if spatial_selection_applied: assert isinstance(hdul["RAD_MAX"], fits.BinTableHDU) - else: - assert isinstance(hdul["PSF"], fits.BinTableHDU) if include_bkg: assert isinstance(hdul["BACKGROUND"], fits.BinTableHDU) From f423375ce7f642c1ef6787d0e15b3c3bee547382 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 21 Jan 2025 16:38:00 +0100 Subject: [PATCH 184/195] Fix psf test --- src/ctapipe/irf/irfs.py | 2 ++ src/ctapipe/irf/tests/test_irfs.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index b015707808c..9814736f402 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -53,6 +53,8 @@ def __call__( ---------- events: astropy.table.QTable Reconstructed events to be used. + spatial_selection_applied: bool + If a direction cut was applied on ``events``, pass ``True``, else ``False``. extname: str Name for the BinTableHDU. diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index d8c8165ed95..5cbbe18cfba 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -116,7 +116,7 @@ def test_make_3d_psf(irf_events_table): source_offset_n_bins=110, source_offset_max=2 * u.deg, ) - psf_hdu = psf_maker(events=irf_events_table) + psf_hdu = psf_maker(events=irf_events_table, spatial_selection_applied=False) # 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) From e7ea330561896a4a9027ce987404a080c7398fea Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 21 Jan 2025 16:47:43 +0100 Subject: [PATCH 185/195] Make CutOptimizers use DefaultRecoEnergyBins --- src/ctapipe/irf/optimize.py | 44 +++++++------------------------------ 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index e6093934b17..65e10e857ff 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -13,7 +13,7 @@ from ..core import Component, QualityQuery from ..core.traits import AstroQuantity, Float, Integer, Path -from .binning import ResultValidRange, make_bins_per_decade +from .binning import DefaultRecoEnergyBins, ResultValidRange from .preprocessing import EventQualityQuery __all__ = [ @@ -143,26 +143,9 @@ def read(cls, file_name): ) -class CutOptimizerBase(Component): +class CutOptimizerBase(DefaultRecoEnergyBins): """Base class for cut optimization algorithms.""" - 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) - min_bkg_fov_offset = AstroQuantity( help=( "Minimum distance from the fov center for background events " @@ -328,15 +311,10 @@ def __call__( precuts: EventQualityQuery, clf_prefix: str, ) -> OptimizationResult: - 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, - ) gh_cuts = self.gh_cut_calculator( signal["gh_score"], signal["reco_energy"], - reco_energy_bins, + self.reco_energy_bins, ) gh_mask = evaluate_binned_cut( signal["gh_score"], @@ -347,7 +325,7 @@ def __call__( spatial_selection_table = self.theta_cut_calculator( signal["theta"][gh_mask], signal["reco_energy"][gh_mask], - reco_energy_bins, + self.reco_energy_bins, ) result = OptimizationResult( @@ -401,16 +379,10 @@ def __call__( precuts: EventQualityQuery, clf_prefix: str, ) -> OptimizationResult: - 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, - ) - initial_gh_cuts = calculate_percentile_cut( signal["gh_score"], signal["reco_energy"], - bins=reco_energy_bins, + bins=self.reco_energy_bins, fill_value=0.0, percentile=100 * (1 - self.initial_gh_cut_efficency), min_events=10, @@ -426,7 +398,7 @@ def __call__( spatial_selection_table = self.theta_cut_calculator( signal["theta"][initial_gh_mask], signal["reco_energy"][initial_gh_mask], - reco_energy_bins, + self.reco_energy_bins, ) self.log.info("Optimizing G/H separation cut for best sensitivity") @@ -438,7 +410,7 @@ def __call__( opt_sens, gh_cuts = optimize_gh_cut( signal, background, - reco_energy_bins=reco_energy_bins, + reco_energy_bins=self.reco_energy_bins, gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, theta_cuts=spatial_selection_table, @@ -458,7 +430,7 @@ def __call__( spatial_selection_table_opt = self.theta_cut_calculator( signal[signal["selected_gh"]]["theta"], signal[signal["selected_gh"]]["reco_energy"], - reco_energy_bins, + self.reco_energy_bins, ) result = OptimizationResult( From 2da20695b750a41ad5a74193a55f9a8d9485ab4e Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 21 Jan 2025 17:04:03 +0100 Subject: [PATCH 186/195] Do not requiere background events for percentile cuts --- src/ctapipe/irf/optimize.py | 58 ++++++++++--------- src/ctapipe/tools/optimize_event_selection.py | 1 + 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index 65e10e857ff..d2a71ba3e02 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -146,31 +146,13 @@ def read(cls, file_name): class CutOptimizerBase(DefaultRecoEnergyBins): """Base class for cut optimization algorithms.""" - min_bkg_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_bkg_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 __call__( self, signal: QTable, - background: QTable, precuts: EventQualityQuery, clf_prefix: str, + background: QTable | None = None, ) -> OptimizationResult: """ Optimize G/H (and optionally theta) cuts @@ -179,15 +161,15 @@ def __call__( Parameters ---------- signal: astropy.table.QTable - Table containing signal events - background: astropy.table.QTable - Table containing background events + Table containing signal events. precuts: ctapipe.irf.EventPreprocessor ``ctapipe.core.QualityQuery`` subclass containing preselection - criteria for events + criteria for events. clf_prefix: str Prefix of the output from the G/H classifier for which the - cut will be optimized + cut will be optimized. + background: astropy.table.QTable | None + Table containing background events (Not needed for percentile cuts). """ @@ -307,9 +289,9 @@ def __init__(self, config=None, parent=None, **kwargs): def __call__( self, signal: QTable, - background: QTable, precuts: EventQualityQuery, clf_prefix: str, + background: QTable | None = None, ) -> OptimizationResult: gh_cuts = self.gh_cut_calculator( signal["gh_score"], @@ -368,6 +350,24 @@ class PointSourceSensitivityOptimizer(CutOptimizerBase): help="Size ratio of on region / off region.", ).tag(config=True) + min_bkg_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_bkg_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) + def __init__(self, config=None, parent=None, **kwargs): super().__init__(config=config, parent=parent, **kwargs) self.theta_cut_calculator = ThetaPercentileCutCalculator(parent=self) @@ -375,10 +375,16 @@ def __init__(self, config=None, parent=None, **kwargs): def __call__( self, signal: QTable, - background: QTable, precuts: EventQualityQuery, clf_prefix: str, + background: QTable | None = None, ) -> OptimizationResult: + if background is None: + raise ValueError( + "Optimizing G/H cuts for maximum point-source sensitivity " + "requires background events, but none were given." + ) + initial_gh_cuts = calculate_percentile_cut( signal["gh_score"], signal["reco_energy"], diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 402d258f2f3..a83b26f5079 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -177,6 +177,7 @@ def start(self): 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)) + self.background_events = None else: if "electrons" not in reduced_events.keys(): reduced_events["electrons"] = [] From 2b3430a8ccd3909630cf252101b4182d9f1d6421 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 21 Jan 2025 17:31:32 +0100 Subject: [PATCH 187/195] Add default config files for both tools to ctapipe-quickstart --- src/ctapipe/resources/compute_irf.yaml | 120 +++++++++++++++++++++++ src/ctapipe/resources/optimize_cuts.yaml | 51 ++++++++++ src/ctapipe/tools/quickstart.py | 13 +++ 3 files changed, 184 insertions(+) create mode 100644 src/ctapipe/resources/compute_irf.yaml create mode 100644 src/ctapipe/resources/optimize_cuts.yaml diff --git a/src/ctapipe/resources/compute_irf.yaml b/src/ctapipe/resources/compute_irf.yaml new file mode 100644 index 00000000000..a80edec262c --- /dev/null +++ b/src/ctapipe/resources/compute_irf.yaml @@ -0,0 +1,120 @@ +# ============================================================================== +# ctapipe-compute-irf config file +# version: VERSION +# +# Configuration for calculating IRF with or without applying a spatial selection +# ============================================================================== + +IrfTool: + gamma_target_spectrum: CRAB_HEGRA + proton_target_spectrum: IRFDOC_PROTON_SPECTRUM + electron_target_spectrum: IRFDOC_ELECTRON_SPECTRUM + obs_time: 50 hour + edisp_maker_name: "EnergyDispersion2dMaker" + aeff_maker_name: "EffectiveArea2dMaker" + psf_maker_name: "Psf3dMaker" + bkg_maker_name: "BackgroundRate2dMaker" + angular_resolution_maker_name: "AngularResolution2dMaker" + energy_bias_resolution_maker_name: "EnergyBiasResolution2dMaker" + sensitivity_maker_name: "Sensitivity2dMaker" + +EventPreprocessor: + energy_reconstructor: "RandomForestRegressor" + geometry_reconstructor: "HillasReconstructor" + gammaness_classifier: "RandomForestClassifier" + + EventQualityQuery: + quality_criteria: + - ["multiplicity 4", "np.count_nonzero(HillasReconstructor_telescopes,axis=1) >= 4"] + - ["valid classifier", "RandomForestClassifier_is_valid"] + - ["valid geom reco", "HillasReconstructor_is_valid"] + - ["valid energy reco", "RandomForestRegressor_is_valid"] + + +# Using the following three components, default values for the reconstruced/true energy +# and fov bins can be configured for all irf components and benchmarks. +# These values are used, if the corresponding configuration options are not overwritten +# in the configuration of the individual components/benchmarks themselves. + +DefaultRecoEnergyBins: + reco_energy_min: 0.015 TeV + reco_energy_max: 150 TeV + reco_energy_n_bins_per_decade: 5 + +DefaultTrueEnergyBins: + true_energy_min: 0.015 TeV + true_energy_max: 150 TeV + true_energy_n_bins_per_decade: 10 + +DefaultFoVOffsetBins: + fov_offset_min: 0 deg + fov_offset_max: 5 deg + fov_offset_n_bins: 3 + +EnergyDispersion2dMaker: + # true_energy_min: 0.015 TeV + # true_energy_max: 150 TeV + # true_energy_n_bins_per_decade: 10 + energy_migration_min: 0.2 + energy_migration_max: 5 + energy_migration_n_bins: 30 + # fov_offset_min: 0 deg + # fov_offset_max: 5 deg + # fov_offset_n_bins: 3 + +Psf3dMaker: + # true_energy_min: 0.015 TeV + # true_energy_max: 150 TeV + # true_energy_n_bins_per_decade: 10 + source_offset_min: 0.0 deg + source_offset_max: 1.0 deg + source_offset_n_bins: 100 + # fov_offset_min: 0 deg + # fov_offset_max: 5 deg + # fov_offset_n_bins: 3 + +# EffectiveArea2dMaker: +# true_energy_min: 0.015 TeV +# true_energy_max: 150 TeV +# true_energy_n_bins_per_decade: 10 +# fov_offset_min: 0 deg +# fov_offset_max: 5 deg +# fov_offset_n_bins: 3 + +# BackgroundRate2dMaker: +# reco_energy_min: 0.015 TeV +# reco_energy_max: 150 TeV +# reco_energy_n_bins_per_decade: 5 +# fov_offset_min: 0 deg +# fov_offset_max: 5 deg +# fov_offset_n_bins: 3 + +AngularResolution2dMaker: + use_true_energy: False + quantiles: [0.25, 0.5, 0.68, 0.95] + # reco_energy_min: 0.015 TeV + # reco_energy_max: 150 TeV + # reco_energy_n_bins_per_decade: 5 + # true_energy_min: 0.015 TeV + # true_energy_max: 150 TeV + # true_energy_n_bins_per_decade: 10 + # fov_offset_min: 0 deg + # fov_offset_max: 5 deg + # fov_offset_n_bins: 3 + +# EnergyBiasResolution2dMaker: +# true_energy_min: 0.015 TeV +# true_energy_max: 150 TeV +# true_energy_n_bins_per_decade: 10 +# fov_offset_min: 0 deg +# fov_offset_max: 5 deg +# fov_offset_n_bins: 3 + +Sensitivity2dMaker: + alpha: 0.2 + # reco_energy_min: 0.015 TeV + # reco_energy_max: 150 TeV + # reco_energy_n_bins_per_decade: 5 + # fov_offset_min: 0 deg + # fov_offset_max: 5 deg + # fov_offset_n_bins: 3 diff --git a/src/ctapipe/resources/optimize_cuts.yaml b/src/ctapipe/resources/optimize_cuts.yaml new file mode 100644 index 00000000000..83a65742479 --- /dev/null +++ b/src/ctapipe/resources/optimize_cuts.yaml @@ -0,0 +1,51 @@ +# ====================================================================== +# ctapipe-optimize-event-selection config file +# version: VERSION +# +# Configuration for calculating G/H and spatial selection ("theta") cuts +# ====================================================================== + +IrfEventSelector: + gamma_target_spectrum: CRAB_HEGRA + proton_target_spectrum: IRFDOC_PROTON_SPECTRUM + electron_target_spectrum: IRFDOC_ELECTRON_SPECTRUM + obs_time: 50 hour + optimization_algorithm: "PointSourceSensitivityOptimizer" # Alternative: "PercentileCuts" + +EventPreprocessor: + energy_reconstructor: "RandomForestRegressor" + geometry_reconstructor: "HillasReconstructor" + gammaness_classifier: "RandomForestClassifier" + + EventQualityQuery: + quality_criteria: + - ["multiplicity 4", "np.count_nonzero(HillasReconstructor_telescopes,axis=1) >= 4"] + - ["valid classifier", "RandomForestClassifier_is_valid"] + - ["valid geom reco", "HillasReconstructor_is_valid"] + - ["valid energy reco", "RandomForestRegressor_is_valid"] + +DefaultRecoEnergyBins: + reco_energy_min: 0.015 TeV + reco_energy_max: 150 TeV + reco_energy_n_bins_per_decade: 5 + +ThetaPercentileCutCalculator: + theta_min_angle: -1 deg + theta_max_angle: 0.32 deg + theta_fill_value: 0.32 deg + smoothing: + target_percentile: 68 + min_counts: 10 + +GhPercentileCutCalculator: + target_percentile: 68 + min_counts: 10 + smoothing: + +PointSourceSensitivityOptimizer: + min_bkg_fov_offset: 0.0 deg + max_bkg_fov_offset: 5.0 deg + initial_gh_cut_efficency: 0.4 + max_gh_cut_efficiency: 0.8 + gh_cut_efficiency_step: 0.1 + alpha: 0.2 diff --git a/src/ctapipe/tools/quickstart.py b/src/ctapipe/tools/quickstart.py index f8dfaff0d3c..63970e7cccb 100644 --- a/src/ctapipe/tools/quickstart.py +++ b/src/ctapipe/tools/quickstart.py @@ -2,6 +2,7 @@ Create a working directory for ctapipe-process containing standard configuration files. """ + from importlib.resources import files from pathlib import Path @@ -19,6 +20,8 @@ "train_energy_regressor.yaml", "train_particle_classifier.yaml", "train_disp_reconstructor.yaml", + "optimize_cuts.yaml", + "compute_irf.yaml", ] README_TEXT = f""" @@ -68,6 +71,16 @@ - `train_particle_classifier.yaml`: configuration of particle classification model - `train_disp_reconstructor.yaml`: configuration of disp reconstruction models +## ctapipe-optimize-event-selection / ctapipe-compute-irf configs + +There are also configuration files for the calculation of G/H and direction ('theta') cuts and +the calculation of IRF. + +- `optimize_cuts.yaml`: configuration for cut calculation +- `compute_irf.yaml`: configuration for IRF calculation + +These files contain the default values for all configuration options and are meant to make it easier +for you to create your own configuration files for these tools. This file was generated using ctapipe version {VERSION} """ From 5676a3c6919a2855c1e98dec038ef82de5263187 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Tue, 21 Jan 2025 18:04:10 +0100 Subject: [PATCH 188/195] Fix docs --- src/ctapipe/irf/preprocessing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctapipe/irf/preprocessing.py b/src/ctapipe/irf/preprocessing.py index 44706f413de..06507cc45cc 100644 --- a/src/ctapipe/irf/preprocessing.py +++ b/src/ctapipe/irf/preprocessing.py @@ -24,7 +24,7 @@ from ..io import TableLoader from .spectra import SPECTRA, Spectra -__all__ = ["EventLoader", "EventPreprocessor"] +__all__ = ["EventLoader", "EventPreprocessor", "EventQualityQuery"] class EventQualityQuery(QualityQuery): From ce57838a4faadf85754f9f541574600a1d02dc81 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 23 Jan 2025 17:47:59 +0100 Subject: [PATCH 189/195] Add new tools to tools doc page --- docs/user-guide/tools.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/tools.rst b/docs/user-guide/tools.rst index 1a0b2320d9b..0dfda42fdcb 100644 --- a/docs/user-guide/tools.rst +++ b/docs/user-guide/tools.rst @@ -14,13 +14,15 @@ You can get a list of all available command-line tools by typing Data Processing Tools ===================== -* ``ctapipe-quickstart``: create some default analysis configurations and a working directory +* ``ctapipe-quickstart``: Create some default analysis configurations and a working directory. * ``ctapipe-process``: Process event data in any supported format from R0/R1/DL0 to DL1 or DL2 HDF5 files. * ``ctapipe-apply-models``: Tool to apply machine learning models in bulk (as opposed to event by event). * ``ctapipe-calculate-pixel-statistics``: Tool to aggregate statistics and detect outliers from pixel-wise image data. * ``ctapipe-train-disp-reconstructor`` : Train the ML models for the `ctapipe.reco.DispReconstructor` (monoscopic reconstruction) * ``ctapipe-train-energy-regressor``: Train the ML models for the `ctapipe.reco.EnergyRegressor` (energy estimation) * ``ctapipe-train-particle-classifier``: Train the ML models for the `ctapipe.reco.ParticleClassifier` (gamma-hadron separation) +* ``ctapipe-optimize-event-selection``: Calculate gamma/hadron and direction cuts (e.g. for IRF calculation). +* ``ctapipe-compute-irf``: Calculate an IRF with or without applying a direction cut and optionally benchmarks. File Management Tools: ====================== From cf72c723a04282a050b4c2bd6b6218aa97b29afa Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Wed, 29 Jan 2025 17:37:17 +0100 Subject: [PATCH 190/195] Address some comments, rest later --- src/ctapipe/irf/__init__.py | 4 +- src/ctapipe/irf/irfs.py | 8 +- src/ctapipe/irf/optimize.py | 42 ++++---- src/ctapipe/irf/preprocessing.py | 6 +- src/ctapipe/irf/tests/test_benchmarks.py | 2 - src/ctapipe/irf/tests/test_irfs.py | 4 +- src/ctapipe/irf/tests/test_optimize.py | 8 +- src/ctapipe/irf/tests/test_preprocessing.py | 5 +- src/ctapipe/resources/compute_irf.yaml | 4 +- src/ctapipe/tools/compute_irf.py | 96 ++++++++++--------- src/ctapipe/tools/optimize_event_selection.py | 50 +++++----- src/ctapipe/tools/tests/test_compute_irf.py | 4 +- .../tests/test_optimize_event_selection.py | 2 +- 13 files changed, 115 insertions(+), 120 deletions(-) diff --git a/src/ctapipe/irf/__init__.py b/src/ctapipe/irf/__init__.py index e507f7ec07e..dbbd812e1c1 100644 --- a/src/ctapipe/irf/__init__.py +++ b/src/ctapipe/irf/__init__.py @@ -22,7 +22,7 @@ BackgroundRate2dMaker, EffectiveArea2dMaker, EnergyDispersion2dMaker, - Psf3dMaker, + PSF3DMaker, ) from .optimize import ( GhPercentileCutCalculator, @@ -38,7 +38,7 @@ "AngularResolution2dMaker", "EnergyBiasResolution2dMaker", "Sensitivity2dMaker", - "Psf3dMaker", + "PSF3DMaker", "BackgroundRate2dMaker", "EnergyDispersion2dMaker", "EffectiveArea2dMaker", diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 9814736f402..6df1f960f2a 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -31,12 +31,12 @@ "EffectiveArea2dMaker", "EnergyDispersionMakerBase", "EnergyDispersion2dMaker", - "PsfMakerBase", - "Psf3dMaker", + "PSFMakerBase", + "PSF3DMaker", ] -class PsfMakerBase(DefaultTrueEnergyBins): +class PSFMakerBase(DefaultTrueEnergyBins): """Base class for calculating the point spread function.""" def __init__(self, config=None, parent=None, **kwargs): @@ -298,7 +298,7 @@ def __call__( ) -class Psf3dMaker(PsfMakerBase, DefaultFoVOffsetBins): +class PSF3DMaker(PSFMakerBase, DefaultFoVOffsetBins): """ 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 d2a71ba3e02..acf3a288ce9 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -38,22 +38,22 @@ def __init__( gh_cuts: QTable, clf_prefix: str, spatial_selection_table: QTable | None = None, - precuts: QualityQuery | Sequence | None = None, + quality_query: QualityQuery | Sequence | None = None, ) -> None: - if precuts: - if isinstance(precuts, QualityQuery): - if len(precuts.quality_criteria) == 0: - precuts.quality_criteria = [ + if quality_query: + if isinstance(quality_query, QualityQuery): + if len(quality_query.quality_criteria) == 0: + quality_query.quality_criteria = [ (" ", " ") ] # Ensures table serialises properly - self.precuts = precuts - elif isinstance(precuts, list): - self.precuts = QualityQuery(quality_criteria=precuts) + self.quality_query = quality_query + elif isinstance(quality_query, list): + self.quality_query = QualityQuery(quality_criteria=quality_query) else: - self.precuts = QualityQuery(quality_criteria=list(precuts)) + self.quality_query = QualityQuery(quality_criteria=list(quality_query)) else: - self.precuts = QualityQuery(quality_criteria=[(" ", " ")]) + self.quality_query = QualityQuery(quality_criteria=[(" ", " ")]) self.valid_energy = ResultValidRange(min=valid_energy_min, max=valid_energy_max) self.valid_offset = ResultValidRange(min=valid_offset_min, max=valid_offset_max) @@ -68,21 +68,21 @@ def __repr__(self): f"and {len(self.spatial_selection_table)} theta bins valid " f"between {self.valid_offset.min} to {self.valid_offset.max} " f"and {self.valid_energy.min} to {self.valid_energy.max} " - f"with {len(self.precuts.quality_criteria)} precuts>" + f"with {len(self.quality_query.quality_criteria)} quality criteria>" ) else: return ( f"" + f"with {len(self.quality_query.quality_criteria)} quality criteria>" ) def write(self, output_name: Path | str, overwrite: bool = False) -> None: """Write an ``OptimizationResult`` to a file in FITS format.""" cut_expr_tab = Table( - rows=self.precuts.quality_criteria, + rows=self.quality_query.quality_criteria, names=["name", "cut_expr"], dtype=[np.str_, np.str_], ) @@ -125,14 +125,14 @@ def read(cls, file_name): if (" ", " ") in cut_expr_lst: cut_expr_lst.remove((" ", " ")) - precuts = QualityQuery(quality_criteria=cut_expr_lst) + quality_query = QualityQuery(quality_criteria=cut_expr_lst) gh_cuts = QTable.read(hdul[2]) valid_energy = QTable.read(hdul[3]) valid_offset = QTable.read(hdul[4]) spatial_selection_table = QTable.read(hdul[5]) if len(hdul) > 5 else None return cls( - precuts=precuts, + quality_query=quality_query, valid_energy_min=valid_energy["energy_min"], valid_energy_max=valid_energy["energy_max"], valid_offset_min=valid_offset["offset_min"], @@ -150,7 +150,7 @@ class CutOptimizerBase(DefaultRecoEnergyBins): def __call__( self, signal: QTable, - precuts: EventQualityQuery, + quality_query: EventQualityQuery, clf_prefix: str, background: QTable | None = None, ) -> OptimizationResult: @@ -162,7 +162,7 @@ def __call__( ---------- signal: astropy.table.QTable Table containing signal events. - precuts: ctapipe.irf.EventPreprocessor + quality_query: ctapipe.irf.EventPreprocessor ``ctapipe.core.QualityQuery`` subclass containing preselection criteria for events. clf_prefix: str @@ -289,7 +289,7 @@ def __init__(self, config=None, parent=None, **kwargs): def __call__( self, signal: QTable, - precuts: EventQualityQuery, + quality_query: EventQualityQuery, clf_prefix: str, background: QTable | None = None, ) -> OptimizationResult: @@ -311,7 +311,7 @@ def __call__( ) result = OptimizationResult( - precuts=precuts, + quality_query=quality_query, gh_cuts=gh_cuts, clf_prefix=clf_prefix, valid_energy_min=self.reco_energy_min, @@ -375,7 +375,7 @@ def __init__(self, config=None, parent=None, **kwargs): def __call__( self, signal: QTable, - precuts: EventQualityQuery, + quality_query: EventQualityQuery, clf_prefix: str, background: QTable | None = None, ) -> OptimizationResult: @@ -440,7 +440,7 @@ def __call__( ) result = OptimizationResult( - precuts=precuts, + quality_query=quality_query, gh_cuts=gh_cuts, clf_prefix=clf_prefix, valid_energy_min=valid_energy[0], diff --git a/src/ctapipe/irf/preprocessing.py b/src/ctapipe/irf/preprocessing.py index 06507cc45cc..21653693793 100644 --- a/src/ctapipe/irf/preprocessing.py +++ b/src/ctapipe/irf/preprocessing.py @@ -206,12 +206,11 @@ class EventLoader(Component): classes = [EventPreprocessor] - def __init__(self, kind: str, file: Path, target_spectrum: Spectra, **kwargs): + def __init__(self, file: Path, target_spectrum: Spectra, **kwargs): super().__init__(**kwargs) self.epp = EventPreprocessor(parent=self) self.target_spectrum = SPECTRA[target_spectrum] - self.kind = kind self.file = file def load_preselected_events( @@ -299,10 +298,11 @@ def make_event_weights( self, events: QTable, spectrum: PowerLaw, + kind: str, fov_offset_bins: u.Quantity | None = None, ) -> QTable: if ( - self.kind == "gammas" + kind == "gammas" and self.target_spectrum.normalization.unit.is_equivalent( POINT_SOURCE_FLUX_UNIT ) diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 921bab35d84..bedf3152472 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -86,7 +86,6 @@ def test_make_2d_sensitivity( gamma_loader = EventLoader( config=irf_event_loader_test_config, - kind="gammas", file=gamma_diffuse_full_reco_file, target_spectrum=Spectra.CRAB_HEGRA, ) @@ -96,7 +95,6 @@ def test_make_2d_sensitivity( ) proton_loader = EventLoader( config=irf_event_loader_test_config, - kind="protons", file=proton_full_reco_file, target_spectrum=Spectra.IRFDOC_PROTON_SPECTRUM, ) diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index 5cbbe18cfba..7280a3752f0 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -106,9 +106,9 @@ def test_make_2d_eff_area(irf_events_table): def test_make_3d_psf(irf_events_table): - from ctapipe.irf import Psf3dMaker + from ctapipe.irf import PSF3DMaker - psf_maker = Psf3dMaker( + psf_maker = PSF3DMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, true_energy_n_bins_per_decade=7, diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index 0d5d91730c5..a59e9e10d7b 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -21,7 +21,7 @@ def test_optimization_result(tmp_path, irf_event_loader_test_config): names=["low", "high", "cut"], ) result = OptimizationResult( - precuts=epp.quality_query, + quality_query=epp.quality_query, gh_cuts=gh_cuts, clf_prefix="ExtraTreesClassifier", valid_energy_min=0.2 * u.TeV, @@ -35,7 +35,7 @@ def test_optimization_result(tmp_path, irf_event_loader_test_config): loaded = OptimizationResult.read(result_path) assert isinstance(loaded, OptimizationResult) - assert isinstance(loaded.precuts, QualityQuery) + assert isinstance(loaded.quality_query, QualityQuery) assert isinstance(loaded.valid_energy, ResultValidRange) assert isinstance(loaded.valid_offset, ResultValidRange) assert isinstance(loaded.gh_cuts, QTable) @@ -91,7 +91,6 @@ def test_cut_optimizer( gamma_loader = EventLoader( config=irf_event_loader_test_config, - kind="gammas", file=gamma_diffuse_full_reco_file, target_spectrum=Spectra.CRAB_HEGRA, ) @@ -101,7 +100,6 @@ def test_cut_optimizer( ) proton_loader = EventLoader( config=irf_event_loader_test_config, - kind="protons", file=proton_full_reco_file, target_spectrum=Spectra.IRFDOC_PROTON_SPECTRUM, ) @@ -114,7 +112,7 @@ def test_cut_optimizer( result = optimizer( signal=gamma_events, background=proton_events, - precuts=gamma_loader.epp.quality_query, # identical precuts for all particle types + quality_query=gamma_loader.epp.quality_query, # identical qualityquery for all particle types clf_prefix="ExtraTreesClassifier", ) assert isinstance(result, OptimizationResult) diff --git a/src/ctapipe/irf/tests/test_preprocessing.py b/src/ctapipe/irf/tests/test_preprocessing.py index b2a33c9319a..71cedc22ade 100644 --- a/src/ctapipe/irf/tests/test_preprocessing.py +++ b/src/ctapipe/irf/tests/test_preprocessing.py @@ -68,7 +68,6 @@ def test_event_loader(gamma_diffuse_full_reco_file, irf_event_loader_test_config loader = EventLoader( config=irf_event_loader_test_config, - kind="gammas", file=gamma_diffuse_full_reco_file, target_spectrum=Spectra.CRAB_HEGRA, ) @@ -101,5 +100,7 @@ def test_event_loader(gamma_diffuse_full_reco_file, irf_event_loader_test_config 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)) + events = loader.make_event_weights( + events, meta["spectrum"], "gammas", (0 * u.deg, 1 * u.deg) + ) assert "weight" in events.colnames diff --git a/src/ctapipe/resources/compute_irf.yaml b/src/ctapipe/resources/compute_irf.yaml index a80edec262c..65089a30719 100644 --- a/src/ctapipe/resources/compute_irf.yaml +++ b/src/ctapipe/resources/compute_irf.yaml @@ -12,7 +12,7 @@ IrfTool: obs_time: 50 hour edisp_maker_name: "EnergyDispersion2dMaker" aeff_maker_name: "EffectiveArea2dMaker" - psf_maker_name: "Psf3dMaker" + psf_maker_name: "PSF3DMaker" bkg_maker_name: "BackgroundRate2dMaker" angular_resolution_maker_name: "AngularResolution2dMaker" energy_bias_resolution_maker_name: "EnergyBiasResolution2dMaker" @@ -62,7 +62,7 @@ EnergyDispersion2dMaker: # fov_offset_max: 5 deg # fov_offset_n_bins: 3 -Psf3dMaker: +PSF3DMaker: # true_energy_min: 0.015 TeV # true_energy_max: 150 TeV # true_energy_n_bins_per_decade: 10 diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 99a5bb231da..7b02ed9a227 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -34,7 +34,7 @@ BackgroundRateMakerBase, EffectiveAreaMakerBase, EnergyDispersionMakerBase, - PsfMakerBase, + PSFMakerBase, ) from ..irf.preprocessing import EventQualityQuery @@ -150,8 +150,8 @@ class IrfTool(Tool): ).tag(config=True) psf_maker_name = traits.ComponentName( - PsfMakerBase, - default_value="Psf3dMaker", + PSFMakerBase, + default_value="PSF3DMaker", help="The parameterization of the point spread function to be used.", ).tag(config=True) @@ -221,7 +221,7 @@ class IrfTool(Tool): + classes_with_traits(BackgroundRateMakerBase) + classes_with_traits(EffectiveAreaMakerBase) + classes_with_traits(EnergyDispersionMakerBase) - + classes_with_traits(PsfMakerBase) + + classes_with_traits(PSFMakerBase) + classes_with_traits(AngularResolutionMakerBase) + classes_with_traits(EnergyBiasResolutionMakerBase) + classes_with_traits(SensitivityMakerBase) @@ -246,14 +246,13 @@ def setup(self): valid_range=self.opt_result.valid_energy, raise_error=self.range_check_error, ) - self.particles = [ - EventLoader( + self.event_loaders = { + "gammas": EventLoader( parent=self, - kind="gammas", file=self.gamma_file, target_spectrum=self.gamma_target_spectrum, ), - ] + } if self.do_background: if not self.proton_file or ( self.proton_file and not self.proton_file.exists() @@ -262,22 +261,16 @@ def setup(self): "At least a proton file required when specifying `do_background`." ) - self.particles.append( - EventLoader( - parent=self, - kind="protons", - file=self.proton_file, - target_spectrum=self.proton_target_spectrum, - ) + self.event_loaders["protons"] = EventLoader( + parent=self, + file=self.proton_file, + target_spectrum=self.proton_target_spectrum, ) if self.electron_file and self.electron_file.exists(): - self.particles.append( - EventLoader( - parent=self, - kind="electrons", - file=self.electron_file, - target_spectrum=self.electron_target_spectrum, - ) + self.event_loaders["electrons"] = EventLoader( + parent=self, + file=self.electron_file, + target_spectrum=self.electron_target_spectrum, ) else: self.log.warning("Estimating background without electron file.") @@ -295,7 +288,7 @@ def setup(self): self.aeff_maker = EffectiveAreaMakerBase.from_name( self.aeff_maker_name, parent=self ) - self.psf_maker = PsfMakerBase.from_name(self.psf_maker_name, parent=self) + self.psf_maker = PSFMakerBase.from_name(self.psf_maker_name, parent=self) if self.benchmarks_output_path is not None: self.angular_resolution_maker = AngularResolutionMakerBase.from_name( @@ -465,64 +458,73 @@ def start(self): Load events and calculate the irf (and the benchmarks). """ reduced_events = dict() - for sel in self.particles: - if sel.epp.gammaness_classifier != self.opt_result.clf_prefix: + for particle_type, loader in self.event_loaders.items(): + if loader.epp.gammaness_classifier != self.opt_result.clf_prefix: raise RuntimeError( "G/H cuts are only valid for gammaness scores predicted by " "the same classifier model. Requested model: %s. " "Model used for g/h cuts: %s." % ( - sel.epp.gammaness_classifier, + loader.epp.gammaness_classifier, self.opt_result.clf_prefix, ) ) if ( - sel.epp.quality_query.quality_criteria - != self.opt_result.precuts.quality_criteria + loader.epp.quality_query.quality_criteria + != self.opt_result.quality_query.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. " + "Quality criteria are different from quality criteria used for " + "calculating g/h / theta cuts. Provided quality criteria:\n%s. " + "\nUsing the same quality criteria as g/h / theta cuts:\n%s. " % ( - sel.epp.quality_query.to_table(functions=True)[ + loader.epp.quality_query.to_table(functions=True)[ "criteria", "func" ], - self.opt_result.precuts.to_table(functions=True)[ + self.opt_result.quality_query.to_table(functions=True)[ "criteria", "func" ], ) ) - sel.epp.quality_query = EventQualityQuery( - parent=sel, - quality_criteria=self.opt_result.precuts.quality_criteria, + loader.epp.quality_query = EventQualityQuery( + parent=loader, + quality_criteria=self.opt_result.quality_query.quality_criteria, ) self.log.debug( - "%s Precuts: %s" % (sel.kind, sel.epp.quality_query.quality_criteria) + "%s Quality criteria: %s" + % (particle_type, loader.epp.quality_query.quality_criteria) + ) + evs, cnt, meta = loader.load_preselected_events( + self.chunk_size, self.obs_time ) - evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) # Only calculate event weights if background or sensitivity should be calculated. if self.do_background: # Sensitivity is only calculated, if do_background is true # and benchmarks_output_path is given. if self.benchmarks_output_path is not None: - evs = sel.make_event_weights( - evs, meta["spectrum"], self.sensitivity_maker.fov_offset_bins + evs = loader.make_event_weights( + evs, + meta["spectrum"], + particle_type, + self.sensitivity_maker.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"]) + elif particle_type in ("protons", "electrons"): + evs = loader.make_event_weights( + evs, meta["spectrum"], particle_type + ) - reduced_events[sel.kind] = evs - reduced_events[f"{sel.kind}_count"] = cnt - reduced_events[f"{sel.kind}_meta"] = meta + reduced_events[particle_type] = evs + reduced_events[f"{particle_type}_count"] = cnt + reduced_events[f"{particle_type}_meta"] = meta self.log.debug( - "Loaded %d %s events" % (reduced_events[f"{sel.kind}_count"], sel.kind) + "Loaded %d %s events" + % (reduced_events[f"{particle_type}_count"], particle_type) ) - if sel.kind == "gammas": + if particle_type == "gammas": self.signal_is_point_like = ( meta["sim_info"].viewcone_max - meta["sim_info"].viewcone_min ).value == 0 diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index a83b26f5079..d4c01777ea0 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -111,14 +111,13 @@ def setup(self): self.optimizer = CutOptimizerBase.from_name( self.optimization_algorithm, parent=self ) - self.particles = [ - EventLoader( + self.event_loaders = { + "gammas": EventLoader( parent=self, - kind="gammas", file=self.gamma_file, target_spectrum=self.gamma_target_spectrum, ) - ] + } if not isinstance(self.optimizer, PercentileCuts): if not self.proton_file or ( self.proton_file and not self.proton_file.exists() @@ -128,22 +127,16 @@ def setup(self): f"using {self.optimization_algorithm}." ) - self.particles.append( - EventLoader( - parent=self, - kind="protons", - file=self.proton_file, - target_spectrum=self.proton_target_spectrum, - ) + self.event_loaders["protons"] = EventLoader( + parent=self, + file=self.proton_file, + target_spectrum=self.proton_target_spectrum, ) if self.electron_file and self.electron_file.exists(): - self.particles.append( - EventLoader( - parent=self, - kind="electrons", - file=self.electron_file, - target_spectrum=self.electron_target_spectrum, - ) + self.event_loaders["electrons"] = EventLoader( + parent=self, + file=self.electron_file, + target_spectrum=self.electron_target_spectrum, ) else: self.log.warning("Optimizing cuts without electron file.") @@ -153,21 +146,24 @@ def start(self): Load events and optimize g/h (and theta) cuts. """ reduced_events = dict() - for sel in self.particles: - evs, cnt, meta = sel.load_preselected_events(self.chunk_size, self.obs_time) + for particle_type, loader in self.event_loaders.items(): + evs, cnt, meta = loader.load_preselected_events( + self.chunk_size, self.obs_time + ) if isinstance(self.optimizer, PointSourceSensitivityOptimizer): - evs = sel.make_event_weights( + evs = loader.make_event_weights( evs, meta["spectrum"], + particle_type, ( 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": + reduced_events[particle_type] = evs + reduced_events[f"{particle_type}_count"] = cnt + if particle_type == "gammas": self.sim_info = meta["sim_info"] self.gamma_spectrum = meta["spectrum"] @@ -211,9 +207,9 @@ def start(self): background=self.background_events if self.optimization_algorithm != "PercentileCuts" else None, - # identical precuts for all particle types - precuts=self.particles[0].epp.quality_query, - clf_prefix=self.particles[0].epp.gammaness_classifier, + # identical quality_query for all particle types + quality_query=self.event_loaders["gammas"].epp.quality_query, + clf_prefix=self.event_loaders["gammas"].epp.gammaness_classifier, ) def finish(self): diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index 07ac4bf91c6..684cb393618 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -279,6 +279,6 @@ def test_irf_tool_wrong_cuts( assert output_path.exists() assert output_benchmarks_path.exists() assert ( - "Precuts are different from precuts used for calculating g/h / theta cuts." - in logpath.read_text() + "Quality criteria are different from quality criteria " + "used for calculating g/h / theta cuts." in logpath.read_text() ) diff --git a/src/ctapipe/tools/tests/test_optimize_event_selection.py b/src/ctapipe/tools/tests/test_optimize_event_selection.py index 67be107b3df..cb787653a0a 100644 --- a/src/ctapipe/tools/tests/test_optimize_event_selection.py +++ b/src/ctapipe/tools/tests/test_optimize_event_selection.py @@ -36,7 +36,7 @@ def test_cuts_optimization( result = OptimizationResult.read(output_path) assert isinstance(result, OptimizationResult) - assert isinstance(result.precuts, QualityQuery) + assert isinstance(result.quality_query, QualityQuery) assert isinstance(result.valid_energy, ResultValidRange) assert isinstance(result.valid_offset, ResultValidRange) assert isinstance(result.gh_cuts, QTable) From 39540b837328e5ae7de50942f5271f560f185dc6 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 30 Jan 2025 15:47:09 +0100 Subject: [PATCH 191/195] Remove unnecessary abbreviations --- src/ctapipe/irf/benchmarks.py | 6 +- src/ctapipe/irf/irfs.py | 8 +-- src/ctapipe/irf/optimize.py | 12 ++-- src/ctapipe/irf/tests/test_irfs.py | 22 +++--- src/ctapipe/resources/compute_irf.yaml | 6 +- src/ctapipe/resources/optimize_cuts.yaml | 4 +- src/ctapipe/tools/compute_irf.py | 67 ++++++++++--------- src/ctapipe/tools/optimize_event_selection.py | 14 ++-- src/ctapipe/tools/tests/test_compute_irf.py | 10 +-- 9 files changed, 79 insertions(+), 70 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index 1476631ecc6..a9737abe6c6 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -272,7 +272,7 @@ def __call__( signal_hist = create_histogram_table( events=signal_events[fov_bin_idx == i], bins=self.reco_energy_bins ) - bkg_hist = estimate_background( + background_hist = estimate_background( events=background_events, reco_energy_bins=self.reco_energy_bins, theta_cuts=spatial_selection_table, @@ -281,7 +281,9 @@ def __call__( fov_offset_max=self.fov_offset_bins[i + 1], ) sens = calculate_sensitivity( - signal_hist=signal_hist, background_hist=bkg_hist, alpha=self.alpha + signal_hist=signal_hist, + background_hist=background_hist, + alpha=self.alpha, ) result["N_SIGNAL"][:, i, :] = sens["n_signal"] result["N_SIGNAL_WEIGHTED"][:, i, :] = sens["n_signal_weighted"] diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 6df1f960f2a..4b818a0277d 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -217,15 +217,15 @@ def __call__( # 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( + effective_area = effective_area_per_energy( selected_events=events, simulation_info=sim_info, true_energy_bins=self.true_energy_bins, ) # +1 dimension for FOV offset - aeff = aeff[..., np.newaxis] + effective_area = effective_area[..., np.newaxis] else: - aeff = effective_area_per_energy_and_fov( + effective_area = effective_area_per_energy_and_fov( selected_events=events, simulation_info=sim_info, true_energy_bins=self.true_energy_bins, @@ -233,7 +233,7 @@ def __call__( ) return create_aeff2d_hdu( - effective_area=aeff, + effective_area=effective_area, true_energy_bins=self.true_energy_bins, fov_offset_bins=self.fov_offset_bins, point_like=spatial_selection_applied, diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index acf3a288ce9..dab5975f38a 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -350,7 +350,7 @@ class PointSourceSensitivityOptimizer(CutOptimizerBase): help="Size ratio of on region / off region.", ).tag(config=True) - min_bkg_fov_offset = AstroQuantity( + min_background_fov_offset = AstroQuantity( help=( "Minimum distance from the fov center for background events " "to be taken into account" @@ -359,7 +359,7 @@ class PointSourceSensitivityOptimizer(CutOptimizerBase): physical_type=u.physical.angle, ).tag(config=True) - max_bkg_fov_offset = AstroQuantity( + max_background_fov_offset = AstroQuantity( help=( "Maximum distance from the fov center for background events " "to be taken into account" @@ -421,8 +421,8 @@ def __call__( op=operator.ge, theta_cuts=spatial_selection_table, alpha=self.alpha, - fov_offset_max=self.max_bkg_fov_offset, - fov_offset_min=self.min_bkg_fov_offset, + fov_offset_max=self.max_background_fov_offset, + fov_offset_min=self.min_background_fov_offset, ) valid_energy = self._get_valid_energy_range(opt_sens) @@ -446,8 +446,8 @@ def __call__( valid_energy_min=valid_energy[0], valid_energy_max=valid_energy[1], # A single set of cuts is calculated for the whole fov atm - valid_offset_min=self.min_bkg_fov_offset, - valid_offset_max=self.max_bkg_fov_offset, + valid_offset_min=self.min_background_fov_offset, + valid_offset_max=self.max_background_fov_offset, spatial_selection_table=spatial_selection_table_opt, ) return result diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index 7280a3752f0..2f8387bb020 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -19,29 +19,31 @@ def _check_boundaries_in_hdu( ) -def test_make_2d_bkg(irf_events_table): +def test_make_2d_background(irf_events_table): from ctapipe.irf import BackgroundRate2dMaker - bkg_maker = BackgroundRate2dMaker( + background_maker = 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 = bkg_maker(events=irf_events_table, obs_time=1 * u.s) + background_hdu = background_maker(events=irf_events_table, obs_time=1 * u.s) # 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) + assert background_hdu.data["BKG"].shape == (1, 3, 29) _check_boundaries_in_hdu( - bkg_hdu, lo_vals=[0 * u.deg, 0.015 * u.TeV], hi_vals=[3 * u.deg, 155 * u.TeV] + background_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): from ctapipe.irf import EnergyDispersion2dMaker - edisp_maker = EnergyDispersion2dMaker( + energy_dispersion_maker = EnergyDispersion2dMaker( fov_offset_n_bins=3, fov_offset_max=3 * u.deg, true_energy_n_bins_per_decade=7, @@ -50,12 +52,14 @@ def test_make_2d_energy_migration(irf_events_table): energy_migration_min=0.1, energy_migration_max=10, ) - edisp_hdu = edisp_maker(events=irf_events_table, spatial_selection_applied=False) + energy_dispersion_hdu = energy_dispersion_maker( + events=irf_events_table, spatial_selection_applied=False + ) # min 7 bins per decade between 0.015 TeV and 155 TeV -> 7 * 4 + 1 = 29 bins - assert edisp_hdu.data["MATRIX"].shape == (1, 3, 20, 29) + assert energy_dispersion_hdu.data["MATRIX"].shape == (1, 3, 20, 29) _check_boundaries_in_hdu( - edisp_hdu, + energy_dispersion_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"], diff --git a/src/ctapipe/resources/compute_irf.yaml b/src/ctapipe/resources/compute_irf.yaml index 65089a30719..c73b98acea9 100644 --- a/src/ctapipe/resources/compute_irf.yaml +++ b/src/ctapipe/resources/compute_irf.yaml @@ -10,10 +10,10 @@ IrfTool: proton_target_spectrum: IRFDOC_PROTON_SPECTRUM electron_target_spectrum: IRFDOC_ELECTRON_SPECTRUM obs_time: 50 hour - edisp_maker_name: "EnergyDispersion2dMaker" - aeff_maker_name: "EffectiveArea2dMaker" + energy_dispersion_maker_name: "EnergyDispersion2dMaker" + effective_area_maker_name: "EffectiveArea2dMaker" psf_maker_name: "PSF3DMaker" - bkg_maker_name: "BackgroundRate2dMaker" + background_maker_name: "BackgroundRate2dMaker" angular_resolution_maker_name: "AngularResolution2dMaker" energy_bias_resolution_maker_name: "EnergyBiasResolution2dMaker" sensitivity_maker_name: "Sensitivity2dMaker" diff --git a/src/ctapipe/resources/optimize_cuts.yaml b/src/ctapipe/resources/optimize_cuts.yaml index 83a65742479..d0f656f9dd2 100644 --- a/src/ctapipe/resources/optimize_cuts.yaml +++ b/src/ctapipe/resources/optimize_cuts.yaml @@ -43,8 +43,8 @@ GhPercentileCutCalculator: smoothing: PointSourceSensitivityOptimizer: - min_bkg_fov_offset: 0.0 deg - max_bkg_fov_offset: 5.0 deg + min_background_fov_offset: 0.0 deg + max_background_fov_offset: 5.0 deg initial_gh_cut_efficency: 0.4 max_gh_cut_efficiency: 0.8 gh_cut_efficiency_step: 0.1 diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 7b02ed9a227..e61666a0b1b 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -137,13 +137,13 @@ class IrfTool(Tool): ), ).tag(config=True) - edisp_maker_name = traits.ComponentName( + energy_dispersion_maker_name = traits.ComponentName( EnergyDispersionMakerBase, default_value="EnergyDispersion2dMaker", help="The parameterization of the energy dispersion to be used.", ).tag(config=True) - aeff_maker_name = traits.ComponentName( + effective_area_maker_name = traits.ComponentName( EffectiveAreaMakerBase, default_value="EffectiveArea2dMaker", help="The parameterization of the effective area to be used.", @@ -155,7 +155,7 @@ class IrfTool(Tool): help="The parameterization of the point spread function to be used.", ).tag(config=True) - bkg_maker_name = traits.ComponentName( + background_maker_name = traits.ComponentName( BackgroundRateMakerBase, default_value="BackgroundRate2dMaker", help="The parameterization of the background rate to be used.", @@ -275,18 +275,19 @@ def setup(self): else: self.log.warning("Estimating background without electron file.") - self.bkg_maker = BackgroundRateMakerBase.from_name( - self.bkg_maker_name, parent=self + self.background_maker = BackgroundRateMakerBase.from_name( + self.background_maker_name, parent=self ) check_e_bins( - bins=self.bkg_maker.reco_energy_bins, source="background reco energy" + bins=self.background_maker.reco_energy_bins, + source="background reco energy", ) - self.edisp_maker = EnergyDispersionMakerBase.from_name( - self.edisp_maker_name, parent=self + self.energy_dispersion_maker = EnergyDispersionMakerBase.from_name( + self.energy_dispersion_maker_name, parent=self ) - self.aeff_maker = EffectiveAreaMakerBase.from_name( - self.aeff_maker_name, parent=self + self.effective_area_maker = EffectiveAreaMakerBase.from_name( + self.effective_area_maker_name, parent=self ) self.psf_maker = PSFMakerBase.from_name(self.psf_maker_name, parent=self) @@ -349,17 +350,19 @@ def calculate_selections(self, reduced_events: dict) -> dict: ] if self.do_background: - bkgs = ["protons", "electrons"] if self.electron_file else ["protons"] + backgrounds = ( + ["protons", "electrons"] if self.electron_file else ["protons"] + ) n_sel = {"protons": 0, "electrons": 0} - for bg_type in bkgs: - reduced_events[bg_type]["selected_gh"] = evaluate_binned_cut( - reduced_events[bg_type]["gh_score"], - reduced_events[bg_type]["reco_energy"], + for bkg_type in backgrounds: + reduced_events[bkg_type]["selected_gh"] = evaluate_binned_cut( + reduced_events[bkg_type]["gh_score"], + reduced_events[bkg_type]["reco_energy"], self.opt_result.gh_cuts, operator.ge, ) - n_sel[bg_type] = np.count_nonzero( - reduced_events[bg_type]["selected_gh"] + n_sel[bkg_type] = np.count_nonzero( + reduced_events[bkg_type]["selected_gh"] ) self.log.info( @@ -379,7 +382,7 @@ def calculate_selections(self, reduced_events: dict) -> dict: def _make_signal_irf_hdus(self, hdus, sim_info): hdus.append( - self.aeff_maker( + self.effective_area_maker( events=self.signal_events[self.signal_events["selected"]], spatial_selection_applied=self.spatial_selection_applied, signal_is_point_like=self.signal_is_point_like, @@ -387,7 +390,7 @@ def _make_signal_irf_hdus(self, hdus, sim_info): ) ) hdus.append( - self.edisp_maker( + self.energy_dispersion_maker( events=self.signal_events[self.signal_events["selected"]], spatial_selection_applied=self.spatial_selection_applied, ) @@ -496,7 +499,7 @@ def start(self): "%s Quality criteria: %s" % (particle_type, loader.epp.quality_query.quality_criteria) ) - evs, cnt, meta = loader.load_preselected_events( + events, count, meta = loader.load_preselected_events( self.chunk_size, self.obs_time ) # Only calculate event weights if background or sensitivity should be calculated. @@ -504,8 +507,8 @@ def start(self): # Sensitivity is only calculated, if do_background is true # and benchmarks_output_path is given. if self.benchmarks_output_path is not None: - evs = loader.make_event_weights( - evs, + events = loader.make_event_weights( + events, meta["spectrum"], particle_type, self.sensitivity_maker.fov_offset_bins, @@ -513,12 +516,12 @@ def start(self): # If only background should be calculated, # only calculate weights for protons and electrons. elif particle_type in ("protons", "electrons"): - evs = loader.make_event_weights( - evs, meta["spectrum"], particle_type + events = loader.make_event_weights( + events, meta["spectrum"], particle_type ) - reduced_events[particle_type] = evs - reduced_events[f"{particle_type}_count"] = cnt + reduced_events[particle_type] = events + reduced_events[f"{particle_type}_count"] = count reduced_events[f"{particle_type}_meta"] = meta self.log.debug( "Loaded %d %s events" @@ -535,8 +538,8 @@ def start(self): in the FoV, but `fov_offset_n_bins > 1`.""" if ( - self.edisp_maker.fov_offset_n_bins > 1 - or self.aeff_maker.fov_offset_n_bins > 1 + self.energy_dispersion_maker.fov_offset_n_bins > 1 + or self.effective_area_maker.fov_offset_n_bins > 1 ): raise ToolConfigurationError(errormessage) @@ -546,7 +549,7 @@ def start(self): ): raise ToolConfigurationError(errormessage) - if self.do_background and self.bkg_maker.fov_offset_n_bins > 1: + if self.do_background and self.background_maker.fov_offset_n_bins > 1: raise ToolConfigurationError(errormessage) if self.benchmarks_output_path is not None and ( @@ -573,14 +576,14 @@ def start(self): ) if self.do_background: hdus.append( - self.bkg_maker( + self.background_maker( self.background_events[self.background_events["selected_gh"]], self.obs_time, ) ) if "protons" in reduced_events.keys(): hdus.append( - self.aeff_maker( + self.effective_area_maker( events=reduced_events["protons"][ reduced_events["protons"]["selected_gh"] ], @@ -592,7 +595,7 @@ def start(self): ) if "electrons" in reduced_events.keys(): hdus.append( - self.aeff_maker( + self.effective_area_maker( events=reduced_events["electrons"][ reduced_events["electrons"]["selected_gh"] ], diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index d4c01777ea0..3bf91acf23a 100644 --- a/src/ctapipe/tools/optimize_event_selection.py +++ b/src/ctapipe/tools/optimize_event_selection.py @@ -147,22 +147,22 @@ def start(self): """ reduced_events = dict() for particle_type, loader in self.event_loaders.items(): - evs, cnt, meta = loader.load_preselected_events( + events, count, meta = loader.load_preselected_events( self.chunk_size, self.obs_time ) if isinstance(self.optimizer, PointSourceSensitivityOptimizer): - evs = loader.make_event_weights( - evs, + events = loader.make_event_weights( + events, meta["spectrum"], particle_type, ( - self.optimizer.min_bkg_fov_offset, - self.optimizer.max_bkg_fov_offset, + self.optimizer.min_background_fov_offset, + self.optimizer.max_background_fov_offset, ), ) - reduced_events[particle_type] = evs - reduced_events[f"{particle_type}_count"] = cnt + reduced_events[particle_type] = events + reduced_events[f"{particle_type}_count"] = count if particle_type == "gammas": self.sim_info = meta["sim_info"] self.gamma_spectrum = meta["spectrum"] diff --git a/src/ctapipe/tools/tests/test_compute_irf.py b/src/ctapipe/tools/tests/test_compute_irf.py index 684cb393618..acd35a50aaf 100644 --- a/src/ctapipe/tools/tests/test_compute_irf.py +++ b/src/ctapipe/tools/tests/test_compute_irf.py @@ -33,7 +33,7 @@ def dummy_cuts_file( return output_path -@pytest.mark.parametrize("include_bkg", (False, True)) +@pytest.mark.parametrize("include_background", (False, True)) @pytest.mark.parametrize("spatial_selection_applied", (True, False)) def test_irf_tool( gamma_diffuse_full_reco_file, @@ -41,7 +41,7 @@ def test_irf_tool( event_loader_config_path, dummy_cuts_file, tmp_path, - include_bkg, + include_background, spatial_selection_applied, ): from ctapipe.tools.compute_irf import IrfTool @@ -58,7 +58,7 @@ def test_irf_tool( if spatial_selection_applied: argv.append("--spatial-selection-applied") - if include_bkg: + if include_background: argv.append(f"--proton-file={proton_full_reco_file}") # Use diffuse gammas weighted to electron spectrum as stand-in argv.append(f"--electron-file={gamma_diffuse_full_reco_file}") @@ -78,7 +78,7 @@ def test_irf_tool( if spatial_selection_applied: assert isinstance(hdul["RAD_MAX"], fits.BinTableHDU) - if include_bkg: + if include_background: assert isinstance(hdul["BACKGROUND"], fits.BinTableHDU) output_path.unlink() # Delete output file @@ -93,7 +93,7 @@ def test_irf_tool( with fits.open(output_benchmarks_path) as hdul: assert isinstance(hdul["ENERGY BIAS RESOLUTION"], fits.BinTableHDU) assert isinstance(hdul["ANGULAR RESOLUTION"], fits.BinTableHDU) - if include_bkg: + if include_background: assert isinstance(hdul["SENSITIVITY"], fits.BinTableHDU) From ffdbbfcf42d39d3eaab802c3397b43555c0b1314 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Thu, 30 Jan 2025 16:37:24 +0100 Subject: [PATCH 192/195] Slight rework of CutOptimizer API --- src/ctapipe/irf/optimize.py | 96 +++++++++++-------- src/ctapipe/irf/tests/test_optimize.py | 3 +- src/ctapipe/tools/optimize_event_selection.py | 13 +-- 3 files changed, 61 insertions(+), 51 deletions(-) diff --git a/src/ctapipe/irf/optimize.py b/src/ctapipe/irf/optimize.py index dab5975f38a..fd8f9e9ad52 100644 --- a/src/ctapipe/irf/optimize.py +++ b/src/ctapipe/irf/optimize.py @@ -146,30 +146,46 @@ def read(cls, file_name): class CutOptimizerBase(DefaultRecoEnergyBins): """Base class for cut optimization algorithms.""" + needs_background = False + + def __init__(self, config=None, parent=None, **kwargs): + super().__init__(config=config, parent=parent, **kwargs) + + def _check_events(self, events: dict[str, QTable]): + if "signal" not in events.keys(): + raise ValueError( + "Calculating G/H and/or spatial selection cuts requires 'signal' " + f"events, but none are given. Provided events: {events.keys()}" + ) + if self.needs_background and "background" not in events.keys(): + raise ValueError( + "Optimizing G/H cuts for maximum point-source sensitivity " + "requires 'background' events, but none were given. " + f"Provided events: {events.keys()}" + ) + @abstractmethod def __call__( self, - signal: QTable, + events: dict[str, QTable], quality_query: EventQualityQuery, clf_prefix: str, - background: QTable | None = None, ) -> OptimizationResult: """ - Optimize G/H (and optionally theta) cuts + Optimize G/H (and optionally spatial selection) cuts and fill them in an ``OptimizationResult``. Parameters ---------- - signal: astropy.table.QTable - Table containing signal events. + events: dict[str, astropy.table.QTable] + Dictionary containing tables of events used for calculating cuts. + This has to include "signal" events and can include "background" events. quality_query: 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. - background: astropy.table.QTable | None - Table containing background events (Not needed for percentile cuts). """ @@ -288,25 +304,26 @@ def __init__(self, config=None, parent=None, **kwargs): def __call__( self, - signal: QTable, + events: dict[str, QTable], quality_query: EventQualityQuery, clf_prefix: str, - background: QTable | None = None, ) -> OptimizationResult: + self._check_events(events) + gh_cuts = self.gh_cut_calculator( - signal["gh_score"], - signal["reco_energy"], + events["signal"]["gh_score"], + events["signal"]["reco_energy"], self.reco_energy_bins, ) gh_mask = evaluate_binned_cut( - signal["gh_score"], - signal["reco_energy"], + events["signal"]["gh_score"], + events["signal"]["reco_energy"], gh_cuts, op=operator.ge, ) spatial_selection_table = self.theta_cut_calculator( - signal["theta"][gh_mask], - signal["reco_energy"][gh_mask], + events["signal"]["theta"][gh_mask], + events["signal"]["reco_energy"][gh_mask], self.reco_energy_bins, ) @@ -330,6 +347,7 @@ class PointSourceSensitivityOptimizer(CutOptimizerBase): calculates a percentile cut on theta. """ + needs_background = True classes = [ThetaPercentileCutCalculator] initial_gh_cut_efficency = Float( @@ -374,20 +392,15 @@ def __init__(self, config=None, parent=None, **kwargs): def __call__( self, - signal: QTable, + events: dict[str, QTable], quality_query: EventQualityQuery, clf_prefix: str, - background: QTable | None = None, ) -> OptimizationResult: - if background is None: - raise ValueError( - "Optimizing G/H cuts for maximum point-source sensitivity " - "requires background events, but none were given." - ) + self._check_events(events) initial_gh_cuts = calculate_percentile_cut( - signal["gh_score"], - signal["reco_energy"], + events["signal"]["gh_score"], + events["signal"]["reco_energy"], bins=self.reco_energy_bins, fill_value=0.0, percentile=100 * (1 - self.initial_gh_cut_efficency), @@ -395,15 +408,15 @@ def __call__( smoothing=1, ) initial_gh_mask = evaluate_binned_cut( - signal["gh_score"], - signal["reco_energy"], + events["signal"]["gh_score"], + events["signal"]["reco_energy"], initial_gh_cuts, op=operator.gt, ) spatial_selection_table = self.theta_cut_calculator( - signal["theta"][initial_gh_mask], - signal["reco_energy"][initial_gh_mask], + events["signal"]["theta"][initial_gh_mask], + events["signal"]["reco_energy"][initial_gh_mask], self.reco_energy_bins, ) self.log.info("Optimizing G/H separation cut for best sensitivity") @@ -413,9 +426,9 @@ def __call__( 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, + opt_sensitivity, gh_cuts = optimize_gh_cut( + events["signal"], + events["background"], reco_energy_bins=self.reco_energy_bins, gh_cut_efficiencies=gh_cut_efficiencies, op=operator.ge, @@ -424,18 +437,19 @@ def __call__( fov_offset_max=self.max_background_fov_offset, fov_offset_min=self.min_background_fov_offset, ) - valid_energy = self._get_valid_energy_range(opt_sens) + valid_energy = self._get_valid_energy_range(opt_sensitivity) # Re-calculate theta cut with optimized g/h cut - signal["selected_gh"] = evaluate_binned_cut( - signal["gh_score"], - signal["reco_energy"], + gh_mask = evaluate_binned_cut( + events["signal"]["gh_score"], + events["signal"]["reco_energy"], gh_cuts, operator.ge, ) + events["signal"]["selected_gh"] = gh_mask spatial_selection_table_opt = self.theta_cut_calculator( - signal[signal["selected_gh"]]["theta"], - signal[signal["selected_gh"]]["reco_energy"], + events["signal"][gh_mask]["theta"], + events["signal"][gh_mask]["reco_energy"], self.reco_energy_bins, ) @@ -452,14 +466,14 @@ def __call__( ) return result - def _get_valid_energy_range(self, opt_sens): - keep_mask = np.isfinite(opt_sens["significance"]) + def _get_valid_energy_range(self, opt_sensitivity): + keep_mask = np.isfinite(opt_sensitivity["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], + opt_sensitivity["reco_energy_low"][keep_mask][0], + opt_sensitivity["reco_energy_high"][keep_mask][-1], ] else: raise ValueError("Optimal significance curve has internal NaN bins") diff --git a/src/ctapipe/irf/tests/test_optimize.py b/src/ctapipe/irf/tests/test_optimize.py index a59e9e10d7b..366c7ca334a 100644 --- a/src/ctapipe/irf/tests/test_optimize.py +++ b/src/ctapipe/irf/tests/test_optimize.py @@ -110,8 +110,7 @@ def test_cut_optimizer( optimizer = Optimizer() result = optimizer( - signal=gamma_events, - background=proton_events, + events={"signal": gamma_events, "background": proton_events}, quality_query=gamma_loader.epp.quality_query, # identical qualityquery for all particle types clf_prefix="ExtraTreesClassifier", ) diff --git a/src/ctapipe/tools/optimize_event_selection.py b/src/ctapipe/tools/optimize_event_selection.py index 3bf91acf23a..e2066fa77e1 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, Integer, classes_with_traits -from ..irf import EventLoader, PercentileCuts, PointSourceSensitivityOptimizer, Spectra +from ..irf import EventLoader, Spectra from ..irf.optimize import CutOptimizerBase __all__ = ["EventSelectionOptimizer"] @@ -118,7 +118,7 @@ def setup(self): target_spectrum=self.gamma_target_spectrum, ) } - if not isinstance(self.optimizer, PercentileCuts): + if self.optimizer.needs_background: if not self.proton_file or ( self.proton_file and not self.proton_file.exists() ): @@ -150,7 +150,7 @@ def start(self): events, count, meta = loader.load_preselected_events( self.chunk_size, self.obs_time ) - if isinstance(self.optimizer, PointSourceSensitivityOptimizer): + if self.optimizer.needs_background: events = loader.make_event_weights( events, meta["spectrum"], @@ -169,7 +169,7 @@ def start(self): self.signal_events = reduced_events["gammas"] - if isinstance(self.optimizer, PercentileCuts): + if not self.optimizer.needs_background: 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)) @@ -203,10 +203,7 @@ def start(self): ) self.result = self.optimizer( - signal=self.signal_events, - background=self.background_events - if self.optimization_algorithm != "PercentileCuts" - else None, + events={"signal": self.signal_events, "background": self.background_events}, # identical quality_query for all particle types quality_query=self.event_loaders["gammas"].epp.quality_query, clf_prefix=self.event_loaders["gammas"].epp.gammaness_classifier, From 5ff65cad46ace68aad15d7aa95d91d33d1b9a17b Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 31 Jan 2025 14:56:11 +0100 Subject: [PATCH 193/195] Only apply gh cuts for psf --- src/ctapipe/irf/irfs.py | 15 ++------------- src/ctapipe/irf/tests/test_irfs.py | 2 +- src/ctapipe/tools/compute_irf.py | 5 +---- 3 files changed, 4 insertions(+), 18 deletions(-) diff --git a/src/ctapipe/irf/irfs.py b/src/ctapipe/irf/irfs.py index 4b818a0277d..cc4ec4adb50 100644 --- a/src/ctapipe/irf/irfs.py +++ b/src/ctapipe/irf/irfs.py @@ -43,9 +43,7 @@ def __init__(self, config=None, parent=None, **kwargs): super().__init__(config=config, parent=parent, **kwargs) @abstractmethod - def __call__( - self, events: QTable, spatial_selection_applied: bool, extname: str = "PSF" - ) -> BinTableHDU: + def __call__(self, events: QTable, extname: str = "PSF") -> BinTableHDU: """ Calculate the psf and create a fits binary table HDU in GADF format. @@ -53,8 +51,6 @@ def __call__( ---------- events: astropy.table.QTable Reconstructed events to be used. - spatial_selection_applied: bool - If a direction cut was applied on ``events``, pass ``True``, else ``False``. extname: str Name for the BinTableHDU. @@ -332,9 +328,7 @@ def __init__(self, config=None, parent=None, **kwargs): u.deg, ) - def __call__( - self, events: QTable, spatial_selection_applied: bool, extname: str = "PSF" - ) -> BinTableHDU: + def __call__(self, events: QTable, extname: str = "PSF") -> BinTableHDU: psf = psf_table( events=events, true_energy_bins=self.true_energy_bins, @@ -348,10 +342,5 @@ def __call__( source_offset_bins=self.source_offset_bins, extname=extname, ) - # We also calculate a psf for IRFs including a spatial selection - # ("point-like IRF") to enable RAD_MAX cross-checks downstream. - # In that case, we have to change the header accordingly. - if spatial_selection_applied: - hdu.header["HDUCLAS3"] = "POINT-LIKE" return hdu diff --git a/src/ctapipe/irf/tests/test_irfs.py b/src/ctapipe/irf/tests/test_irfs.py index 2f8387bb020..670ba424c4e 100644 --- a/src/ctapipe/irf/tests/test_irfs.py +++ b/src/ctapipe/irf/tests/test_irfs.py @@ -120,7 +120,7 @@ def test_make_3d_psf(irf_events_table): source_offset_n_bins=110, source_offset_max=2 * u.deg, ) - psf_hdu = psf_maker(events=irf_events_table, spatial_selection_applied=False) + psf_hdu = psf_maker(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) diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index e61666a0b1b..680e570d7eb 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -396,10 +396,7 @@ def _make_signal_irf_hdus(self, hdus, sim_info): ) ) hdus.append( - self.psf_maker( - events=self.signal_events[self.signal_events["selected"]], - spatial_selection_applied=self.spatial_selection_applied, - ) + self.psf_maker(events=self.signal_events[self.signal_events["selected_gh"]]) ) if self.spatial_selection_applied: # TODO: Support fov binning From c30781509b34bbf8a7eba416941a781309d5e770 Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 31 Jan 2025 15:07:17 +0100 Subject: [PATCH 194/195] use_true_energy -> use_reco_energy --- src/ctapipe/irf/benchmarks.py | 7 +++---- src/ctapipe/irf/tests/test_benchmarks.py | 14 +++++++------- src/ctapipe/resources/compute_irf.yaml | 2 +- src/ctapipe/tools/compute_irf.py | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index a9737abe6c6..b2913c82bbb 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -107,10 +107,9 @@ class AngularResolutionMakerBase(DefaultTrueEnergyBins, DefaultRecoEnergyBins): Base class for calculating the angular resolution. """ - # Use reconstructed energy by default for the sake of current pipeline comparisons - use_true_energy = Bool( + use_reco_energy = Bool( False, - help="Use true energy instead of reconstructed energy for energy binning.", + help="Use reconstructed energy instead of true energy for energy binning.", ).tag(config=True) quantiles = List( @@ -154,7 +153,7 @@ def __init__(self, config=None, parent=None, **kwargs): def __call__( self, events: QTable, extname: str = "ANGULAR RESOLUTION" ) -> BinTableHDU: - if self.use_true_energy: + if not self.use_reco_energy: e_bins = self.true_energy_bins energy_type = "true" else: diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index bedf3152472..28538ff9a07 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -51,15 +51,15 @@ def test_make_2d_ang_res(irf_events_table): ] for c in cols: assert c in ang_res_hdu.data.names - assert ang_res_hdu.data[c].shape == (1, 3, 23) + assert ang_res_hdu.data[c].shape == (1, 3, 29) _check_boundaries_in_hdu( ang_res_hdu, - lo_vals=[0 * u.deg, 0.03 * u.TeV], - hi_vals=[3 * u.deg, 150 * u.TeV], + lo_vals=[0 * u.deg, 0.015 * u.TeV], + hi_vals=[3 * u.deg, 155 * u.TeV], ) - ang_res_maker.use_true_energy = True + ang_res_maker.use_reco_energy = True ang_res_maker.quantiles = [0.4, 0.7] ang_res_hdu = ang_res_maker(events=irf_events_table) cols = [ @@ -69,12 +69,12 @@ def test_make_2d_ang_res(irf_events_table): ] for c in cols: assert c in ang_res_hdu.data.names - assert ang_res_hdu.data[c].shape == (1, 3, 29) + assert ang_res_hdu.data[c].shape == (1, 3, 23) _check_boundaries_in_hdu( ang_res_hdu, - lo_vals=[0 * u.deg, 0.015 * u.TeV], - hi_vals=[3 * u.deg, 155 * u.TeV], + lo_vals=[0 * u.deg, 0.03 * u.TeV], + hi_vals=[3 * u.deg, 150 * u.TeV], ) diff --git a/src/ctapipe/resources/compute_irf.yaml b/src/ctapipe/resources/compute_irf.yaml index c73b98acea9..806f73d160b 100644 --- a/src/ctapipe/resources/compute_irf.yaml +++ b/src/ctapipe/resources/compute_irf.yaml @@ -90,7 +90,7 @@ PSF3DMaker: # fov_offset_n_bins: 3 AngularResolution2dMaker: - use_true_energy: False + use_reco_energy: False quantiles: [0.25, 0.5, 0.68, 0.95] # reco_energy_min: 0.015 TeV # reco_energy_max: 150 TeV diff --git a/src/ctapipe/tools/compute_irf.py b/src/ctapipe/tools/compute_irf.py index 680e570d7eb..f9465293ec7 100644 --- a/src/ctapipe/tools/compute_irf.py +++ b/src/ctapipe/tools/compute_irf.py @@ -295,7 +295,7 @@ def setup(self): self.angular_resolution_maker = AngularResolutionMakerBase.from_name( self.angular_resolution_maker_name, parent=self ) - if not self.angular_resolution_maker.use_true_energy: + if self.angular_resolution_maker.use_reco_energy: check_e_bins( bins=self.angular_resolution_maker.reco_energy_bins, source="Angular resolution energy", From 1ad6c54ff05f6cdbca4f13550419ca7837bbd5ec Mon Sep 17 00:00:00 2001 From: Lukas Beiske Date: Fri, 31 Jan 2025 16:08:01 +0100 Subject: [PATCH 195/195] Fix binning for sensitivity --- src/ctapipe/irf/benchmarks.py | 28 ++++++++++++++++++++++-- src/ctapipe/irf/binning.py | 6 ++++- src/ctapipe/irf/tests/test_benchmarks.py | 16 ++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/ctapipe/irf/benchmarks.py b/src/ctapipe/irf/benchmarks.py index b2913c82bbb..8f4f521a0b8 100644 --- a/src/ctapipe/irf/benchmarks.py +++ b/src/ctapipe/irf/benchmarks.py @@ -7,7 +7,12 @@ 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 calculate_bin_indices, create_histogram_table, split_bin_lo_hi +from pyirf.binning import ( + calculate_bin_indices, + create_bins_per_decade, + create_histogram_table, + split_bin_lo_hi, +) from pyirf.sensitivity import calculate_sensitivity, estimate_background from ..core.traits import Bool, Float, List @@ -190,7 +195,16 @@ def __call__( class SensitivityMakerBase(DefaultRecoEnergyBins): - """Base class for calculating the point source sensitivity.""" + """ + Base class for calculating the point source sensitivity. + + This uses `pyirf.binning.create_bins_per_decade` to create an energy binning + with exactly ``reco_energy_n_bins_per_decade`` bins per decade, to comply + with CTAO requirements. + All other benchmarks/ IRF components prioritize respecting the lower and upper + bounds for the energy binning over creating exactly ``n_bins_per_decade`` bins + per decade. + """ alpha = Float( default_value=0.2, @@ -199,6 +213,16 @@ class SensitivityMakerBase(DefaultRecoEnergyBins): def __init__(self, config=None, parent=None, **kwargs): super().__init__(config=config, parent=parent, **kwargs) + # We overwrite reco_energy_bins here to conform with CTAO requirements. + # This class still inherits from DefaultRecoEnergyBins to be able to use + # the config values set for DefaultRecoEnergyBins and not force an individual + # configuration of the bounds for only the sensitivity, while all other + # benchmarks/ IRF components can use the values set for DefaultRecoEnergyBins. + self.reco_energy_bins = create_bins_per_decade( + self.reco_energy_min, + self.reco_energy_max, + self.reco_energy_n_bins_per_decade, + ) @abstractmethod def __call__( diff --git a/src/ctapipe/irf/binning.py b/src/ctapipe/irf/binning.py index a9344fca67f..172e159b3d2 100644 --- a/src/ctapipe/irf/binning.py +++ b/src/ctapipe/irf/binning.py @@ -62,7 +62,11 @@ 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 ``n_bins_per_decade`` bins per decade. + Create energy bins with at least ``n_bins_per_decade`` bins per decade, + while using the exact lower (``e_min``) and upper (``e_max``) limits. + If you want to get exactly ``n_bins_per_decade`` use + `pyirf.binning.create_bins_per_decade`. + The number of bins is calculated as ``n_bins = ceil((log10(e_max) - log10(e_min)) * n_bins_per_decade)``. diff --git a/src/ctapipe/irf/tests/test_benchmarks.py b/src/ctapipe/irf/tests/test_benchmarks.py index 28538ff9a07..8264003c9ee 100644 --- a/src/ctapipe/irf/tests/test_benchmarks.py +++ b/src/ctapipe/irf/tests/test_benchmarks.py @@ -1,5 +1,7 @@ import astropy.units as u +import numpy as np from astropy.table import QTable +from pyirf.binning import join_bin_lo_hi def test_make_2d_energy_bias_res(irf_events_table): @@ -131,10 +133,20 @@ def test_make_2d_sensitivity( == sens_hdu.data["SIGNIFICANCE"].shape == sens_hdu.data["RELATIVE_SENSITIVITY"].shape == sens_hdu.data["FLUX_SENSITIVITY"].shape - == (1, 3, 29) + == (1, 3, 28) ) _check_boundaries_in_hdu( sens_hdu, lo_vals=[0 * u.deg, 0.015 * u.TeV], - hi_vals=[3 * u.deg, 155 * u.TeV], + hi_vals=[3 * u.deg, (155 * 1.00002) * u.TeV], + colnames=["THETA"], + ) + print(sens_hdu.data["ENERG_LO"].flatten().shape) + np.testing.assert_allclose( + np.diff( + np.log10( + join_bin_lo_hi(sens_hdu.data["ENERG_LO"], sens_hdu.data["ENERG_HI"]) + ) + ), + 1 / 7, )