Source code for corsika.corsika_histograms

"""Extract Cherenkov photons from a CORSIKA IACT file and fill histograms."""

import logging
from pathlib import Path
from types import SimpleNamespace

import numpy as np
from astropy import units as u
from eventio import IACTFile

from simtools.utils.geometry import rotate


class _WeightedHistogram:
    """Accumulate weighted bin contents and variances, including flow bins."""

    def __init__(self, edges, scales=None):
        self.axes = tuple(SimpleNamespace(edges=edge) for edge in edges)
        self._scales = scales or ["linear"] * len(edges)
        self._values = np.zeros(
            tuple(len(edge) + 1 for edge in edges), dtype=[("value", "f8"), ("variance", "f8")]
        )

    def fill(self, *coordinates, weight):
        """Add weighted observations to the histogram."""
        samples = []
        bins = []
        for coordinate, axis, scale in zip(coordinates, self.axes, self._scales):
            edges = axis.edges
            values = np.asarray(coordinate)
            start, stop = edges[0], edges[-1]
            if scale == "log":
                with np.errstate(divide="ignore", invalid="ignore"):
                    values = np.log(values)
                start, stop = np.log(start), np.log(stop)
            # Regular-axis arithmetic preserves boundary and flow-bin assignments.
            with np.errstate(invalid="ignore", over="ignore"):
                indices = np.floor((values - start) / (stop - start) * (len(edges) - 1))
            indices = np.nan_to_num(indices, nan=len(edges) - 1)
            samples.append(np.clip(indices, -1, len(edges) - 1))
            bins.append(np.arange(-1, len(edges) + 1))
        samples = np.column_stack(samples)
        weights = np.asarray(weight, dtype=float)
        self._values["value"] += np.histogramdd(samples, bins=bins, weights=weights)[0]
        self._values["variance"] += np.histogramdd(samples, bins=bins, weights=weights**2)[0]

    def view(self, flow=False):
        """Return the weighted bins, optionally including flow bins."""
        if flow:
            return self._values
        return self._values[(slice(1, -1),) * self._values.ndim]


[docs] class CorsikaHistograms: """ Extract Cherenkov photons from a CORSIKA IACT file and fill histograms. Parameters ---------- input_file: str or Path CORSIKA IACT file. axis_distance: astropy.units.Quantity or float Distance from the axis to consider when calculating the lateral density profiles along x and y axes. If a float is given, it is assumed to be in meters. Raises ------ FileNotFoundError: if the input file given does not exist. """ def __init__(self, input_file, normalization_method="per-telescope", axis_distance=1000 * u.m): self._logger = logging.getLogger(__name__) self.input_file = Path(input_file) if not self.input_file.exists(): raise FileNotFoundError(f"File {self.input_file} does not exist.") self.axis_distance = ( axis_distance.to(u.m).value if isinstance(axis_distance, u.Quantity) else axis_distance ) self.events = None self.hist = self._set_2d_distributions() self.hist.update(self._set_1d_distributions()) self._density_samples = [] self.normalization_method = normalization_method
[docs] def fill(self): """Fill Cherenkov photons histograms.""" self._read_event_headers() with IACTFile(self.input_file) as f: telescope_positions = np.array(f.telescope_positions) for event_counter, event in enumerate(f): if hasattr(event, "photon_bunches"): photons = list(event.photon_bunches.values()) self._fill_histograms(photons, event_counter, telescope_positions, False) self._update_distributions()
def _read_event_headers(self): """Read event information from headers.""" event_dtype = np.dtype( [ ("particle_id", "i4"), ("total_energy", "f8"), ("azimuth_deg", "f8"), ("zenith_deg", "f8"), ("num_photons", "f8"), ] ) with IACTFile(self.input_file) as iact_file: records = [ ( event.header["particle_id"], event.header["total_energy"], np.rad2deg(event.header["azimuth"]), np.rad2deg(event.header["zenith"]), 0.0, # filled later when reading photon bunches ) for event in iact_file ] self.events = np.array(records, dtype=event_dtype) def _create_regular_axes(self, hist, axes): """ Create regular axis for a single histogram. Parameters ---------- hist: dict Histogram dictionary. axes: list List of axis names (e.g. ["x_bins", "y_bins"]). Returns ------- list: List of arrays containing bin edges. """ edges = [] for axis in axes: bins, start, stop = hist[axis][:3] scale = hist[axis][3] if len(hist[axis]) > 3 else "linear" if isinstance(start, u.quantity.Quantity): start, stop = start.value, stop.value if scale == "log": edges.append(np.geomspace(start, stop, bins + 1)) else: edges.append(np.linspace(start, stop, bins + 1)) return edges def _fill_histograms(self, photons, event_counter, telescope_positions, rotate_photons=True): """ Fill Cherenkov photon histograms. For rotate_photons, the Cherenkov photon's coordinates are filled in the shower plane. Parameters ---------- photons: list List of size M of numpy.array of size (N,8), where M is the number of telescopes in the array, N is the number of photons that reached each telescope. The following information of the Cherenkov photons on the ground are saved: x: x position on the ground (CORSIKA coordinate system), y: y position on the ground (CORSIKA coordinate system), cx: direction cosine in the x direction, i.e., the cosine of the angle between the incoming direction and the x axis, cy: direction cosine in the y direction, i.e., the cosine of the angle between the incoming direction and the y axis, time: time of arrival of the photon in ns. zem: altitude where the photon was generated in cm, photons: number of photons associated to this bunch, event_counter: int Event counter. telescope_positions: numpy.array Array with the telescope positions with shape (M, 2), where M is the number of telescopes in the array. The two columns are the x and y positions of the telescopes in the CORSIKA coordinate system. rotate_photons: bool If True, the photon's coordinates are rotated to the plane perpendicular to the incoming direction of the primary particle. """ hist_str = "histogram" photons_per_telescope = np.zeros(len(telescope_positions)) zenith_rad = np.deg2rad(self.events["zenith_deg"][event_counter]) for tel_idx, (photon, telescope) in enumerate(zip(photons, telescope_positions)): if rotate_photons: px, py = rotate( photon["x"], photon["y"], self.events["azimuth_deg"][event_counter], self.events["zenith_deg"][event_counter], ) else: px, py = photon["x"], photon["y"] px = px - telescope["x"] py = py - telescope["y"] w = photon["photons"] pxm = px * u.cm.to(u.m) pym = py * u.cm.to(u.m) zem = (photon["zem"] * u.cm).to(u.km) photons_per_telescope[tel_idx] += np.sum(w) self.hist["counts_xy"][hist_str].fill(pxm, pym, weight=w) self.hist["direction_xy"][hist_str].fill(photon["cx"], photon["cy"], weight=w) self.hist["time_altitude"][hist_str].fill(photon["time"] * u.ns, zem, weight=w) self.hist["wavelength_altitude"][hist_str].fill( np.abs(photon["wavelength"]) * u.nm, zem, weight=w ) r = np.hypot(px, py) * u.cm.to(u.m) self.hist["counts_r"][hist_str].fill(r, weight=w) self.events["num_photons"][event_counter] += np.sum(w) for tel_idx, telescope in enumerate(telescope_positions): area = np.pi * (telescope["r"] ** 2) / np.cos(zenith_rad) / 1.0e4 # in m^2 n_photons = photons_per_telescope[tel_idx] density = n_photons / area if area > 0 else 0.0 density_error = np.sqrt(n_photons) / area if area > 0 else 0.0 self._density_samples.append( { "x": telescope["x"] * u.cm.to(u.m), "y": telescope["y"] * u.cm.to(u.m), "density": density, "density_error": density_error, } )
[docs] def get_hist_2d_projection(self, hist): """ Get 2D distributions. Parameters ---------- hist: histogram Histogram with axes and a weighted or unweighted view. Returns ------- numpy.ndarray Histogram counts. numpy.array Histogram x bin edges. numpy.array Histogram y bin edges. numpy.ndarray or None Histogram uncertainties (sqrt of variance) if available. """ view = hist.view() if self._check_for_all_attributes(view): counts = np.asarray([view["value"].T]) uncertainties = np.asarray([np.sqrt(view["variance"].T)]) else: counts = np.asarray([view.T]) uncertainties = None x_edges = np.asarray([hist.axes[0].edges]) y_edges = np.asarray([hist.axes[1].edges]) return counts, x_edges, y_edges, uncertainties
def _get_hist_1d_from_numpy(self, label, hist): """Get 1D histogram from numpy histogram.""" bins = hist["x_bins"][0] start = hist["x_bins"][1] if hist["x_bins"][1] else np.min(self.events[label]) stop = hist["x_bins"][2] if hist["x_bins"][2] is not None else np.max(self.events[label]) scale = hist["x_bins"][3] if len(hist["x_bins"]) > 3 else "linear" if scale == "log": bin_edges = np.logspace(np.log10(start), np.log10(stop), bins + 1) else: bin_edges = np.linspace(start, stop, bins + 1) histo_1d, _ = np.histogram(self.events[label], bins=bin_edges) uncertainties = np.sqrt(histo_1d) return ( histo_1d.reshape(1, bins), bin_edges.reshape(1, bins + 1), uncertainties.reshape(1, bins), )
[docs] def get_hist_1d_projection(self, label, hist): """ Get 1D distributions from event data or weighted histograms. Parameters ---------- label: str Histogram label. hist: dict Histogram dictionary. Returns ------- numpy.ndarray Histogram counts. numpy.array Histogram x bin edges. numpy.ndarray or None Histogram uncertainties (if available). """ # plain numpy histogram if ( hist.get("projection") is None and hasattr(self, "events") and label in self.events.dtype.names ): return self._get_hist_1d_from_numpy(label, hist) if hist.get("projection") is None: if "histogram" in hist: histo_1d = hist["histogram"] else: return None, None, None edges = histo_1d.axes[0].edges view = histo_1d.view() if self._check_for_all_attributes(view): counts = np.asarray([view["value"].T]) uncertainties = np.asarray([np.sqrt(view["variance"].T)]) else: counts = np.asarray([view.T]) uncertainties = None return counts, np.asarray([edges]), uncertainties histo_2d = self.hist[hist["projection"][0]]["histogram"] retained_axis = 0 if hist["projection"][1] == "x" else 1 edges = histo_2d.axes[retained_axis].edges view = self._project_weighted_bins(histo_2d, retained_axis) if self._check_for_all_attributes(view): counts = np.asarray([view["value"].T]) uncertainties = np.asarray([np.sqrt(view["variance"].T)]) else: counts = np.asarray([view.T]) uncertainties = None return counts, np.asarray([edges]), uncertainties
def _project_weighted_bins(self, histo_2d, retained_axis): """Project bins while retaining flow along the summed axis.""" full_view = histo_2d.view(flow=True) if self._check_for_all_attributes(full_view): view = np.zeros(len(histo_2d.axes[retained_axis].edges) - 1, dtype=full_view.dtype) for field in ("value", "variance"): view[field] = full_view[field].sum(axis=1 - retained_axis)[1:-1] return view return full_view.sum(axis=1 - retained_axis)[1:-1] def _set_1d_distributions(self, r_max=2000 * u.m, bins=100): """ Define 1D histograms. Returns ------- dict: Dictionary with 1D histogram information. """ file_name = "file_name" title = "title" projection = "projection" x_bins = "x_bins" x_axis_unit = "x_axis_unit" x_axis_title = "x_axis_title" y_axis_unit = "y_axis_unit" y_axis_title = "y_axis_title" log_y = "log_y" photon_density = "Photon density" distance_to_center = "Distance to center" hist_1d = { "wavelength": { file_name: "hist_1d_photon_wavelength_distr", title: "Photon wavelength distribution", projection: ["wavelength_altitude", "x"], }, "counts_r": { file_name: "hist_1d_photon_radial_distr", title: "Photon lateral distribution (ground level)", x_bins: [bins, 0 * u.m, r_max, "linear"], x_axis_title: distance_to_center, x_axis_unit: u.m, }, "density_r": { file_name: "hist_1d_photon_density_distr", title: "Photon lateral density distribution (ground level)", x_bins: [bins, 0 * u.m, r_max, "linear"], x_axis_title: distance_to_center, x_axis_unit: u.m, y_axis_title: photon_density, y_axis_unit: u.m**-2, }, "density_r_from_counts": { file_name: "hist_1d_photon_density_from_counts_distr", title: "Photon lateral density from counts distribution (ground level)", x_bins: [bins, 0 * u.m, r_max, "linear"], x_axis_title: distance_to_center, x_axis_unit: u.m, y_axis_title: photon_density, y_axis_unit: u.m**-2, }, "density_x": { file_name: "hist_1d_photon_density_x_distr", title: "Photon lateral density x distribution (ground level)", projection: ["counts_xy", "x"], # projection requires counts_xy histogram x_axis_title: distance_to_center, x_axis_unit: u.m, y_axis_title: photon_density, y_axis_unit: u.m**-2, }, "density_y": { file_name: "hist_1d_photon_density_y_distr", title: "Photon lateral density y distribution (ground level)", projection: ["counts_xy", "y"], # projection requires counts_xy histogram y_axis_title: photon_density, y_axis_unit: u.m**-2, }, "time": { file_name: "hist_1d_photon_time_distr", title: "Photon arrival time distribution", projection: ["time_altitude", "x"], }, "altitude": { file_name: "hist_1d_photon_altitude_distr", title: "Photon emission altitude distribution", projection: ["time_altitude", "y"], }, "direction_cosine_x": { file_name: "hist_1d_photon_direction_cosine_x_distr", title: "Photon direction cosine x distribution", projection: ["direction_xy", "x"], }, "direction_cosine_y": { file_name: "hist_1d_photon_direction_cosine_y_distr", title: "Photon direction cosine y distribution", projection: ["direction_xy", "y"], }, "num_photons": { file_name: "hist_1d_photon_per_event_distr", title: "Photons per event distribution", "event_type": True, x_bins: [100, 0, None, "log"], x_axis_title: "Cherenkov photons per event", x_axis_unit: u.dimensionless_unscaled, log_y: False, }, } for value in hist_1d.values(): value["is_1d"] = True value["log_y"] = value.get("log_y", True) value[y_axis_title] = ( "Counts" if value.get(y_axis_title) is None else value[y_axis_title] ) value[y_axis_unit] = ( u.dimensionless_unscaled if value.get(y_axis_unit) is None else value[y_axis_unit] ) if value.get("projection") is not None: hist_2d_name = value["projection"][0] if value["projection"][1] == "x": value[x_bins] = self.hist[hist_2d_name]["x_bins"] value[x_axis_title] = self.hist[hist_2d_name]["x_axis_title"] value[x_axis_unit] = self.hist[hist_2d_name]["x_axis_unit"] else: value[x_bins] = self.hist[hist_2d_name]["y_bins"] value[x_axis_title] = self.hist[hist_2d_name]["y_axis_title"] value[x_axis_unit] = self.hist[hist_2d_name]["y_axis_unit"] elif value.get("event_type", False) is False: scale = value["x_bins"][3] if len(value["x_bins"]) > 3 else "linear" value["histogram"] = _WeightedHistogram( self._create_regular_axes(value, ["x_bins"]), [scale] ) return hist_1d def _set_2d_distributions(self, xy_maximum=1000 * u.m, xy_bin=100): """ Define 2D histograms. Returns ------- dict: Dictionary with 2D histogram information. """ file_name = "file_name" title = "title" x_bins, y_bins = "x_bins", "y_bins" x_axis_title, x_axis_unit = "x_axis_title", "x_axis_unit" y_axis_title, y_axis_unit = "y_axis_title", "y_axis_unit" z_axis_title, z_axis_unit = "z_axis_title", "z_axis_unit" photon_density = "Photon density" x_pos = "x position on the ground" y_pos = "y position on the ground" hist_2d = { "counts_xy": { file_name: "hist_2d_photon_count_distr", title: "Photon count distribution (ground level)", x_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"], y_bins: [xy_bin, -xy_maximum, xy_maximum], x_axis_title: x_pos, x_axis_unit: xy_maximum.unit, y_axis_title: y_pos, y_axis_unit: xy_maximum.unit, }, "density_xy": { file_name: "hist_2d_photon_density_distr", title: "Photon density distribution (ground level)", x_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"], y_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"], x_axis_title: x_pos, x_axis_unit: xy_maximum.unit, y_axis_title: y_pos, y_axis_unit: xy_maximum.unit, z_axis_title: photon_density, z_axis_unit: u.m**-2, }, "density_xy_from_counts": { file_name: "hist_2d_photon_density_from_counts_distr", title: "Photon density from counts distribution (ground level)", x_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"], y_bins: [xy_bin, -xy_maximum, xy_maximum, "linear"], x_axis_title: x_pos, x_axis_unit: xy_maximum.unit, y_axis_title: y_pos, y_axis_unit: xy_maximum.unit, z_axis_title: photon_density, z_axis_unit: u.m**-2, }, "direction_xy": { file_name: "hist_2d_photon_direction_distr", title: "Photon arrival direction", x_bins: [100, -1, 1, "linear"], y_bins: [100, -1, 1, "linear"], x_axis_title: "x direction cosine", x_axis_unit: u.dimensionless_unscaled, y_axis_title: "y direction cosine", y_axis_unit: u.dimensionless_unscaled, }, "time_altitude": { file_name: "hist_2d_photon_time_altitude_distr", title: "Arrival time vs emission altitude", x_bins: [100, -2000 * u.ns, 2000 * u.ns, "linear"], y_bins: [100, 120 * u.km, 0 * u.km, "linear"], x_axis_title: "Arrival time", x_axis_unit: u.ns, y_axis_title: "Emission altitude", y_axis_unit: u.km, }, "wavelength_altitude": { file_name: "hist_2d_photon_wavelength_altitude_distr", title: "Wavelength vs emission altitude ", x_bins: [100, 100 * u.nm, 1000 * u.nm, "linear"], y_bins: [100, 120 * u.km, 0 * u.km, "linear"], x_axis_title: "Wavelength", x_axis_unit: u.nm, y_axis_title: "Emission altitude", y_axis_unit: u.km, }, } for value in hist_2d.values(): value["is_1d"] = False value["log_z"] = True value[z_axis_title] = ( "Counts" if value.get(z_axis_title) is None else value[z_axis_title] ) value[z_axis_unit] = ( u.dimensionless_unscaled if value.get(z_axis_unit) is None else value[z_axis_unit] ) axes = ["x_bins", "y_bins"] scales = [value[axis][3] if len(value[axis]) > 3 else "linear" for axis in axes] value["histogram"] = _WeightedHistogram(self._create_regular_axes(value, axes), scales) return hist_2d def _update_distributions(self): """Update the distributions dictionary with the histogram values and bin edges.""" self._populate_density_from_probes() self._populate_density_from_counts() self._filter_density_histograms() for key, value in self.hist.items(): value["input_file_name"] = str(self.input_file) if "hist_values" not in value: if value["is_1d"]: ( value["hist_values"], value["x_bin_edges"], value["uncertainties"], ) = self.get_hist_1d_projection(key, value) else: ( value["hist_values"], value["x_bin_edges"], value["y_bin_edges"], value["uncertainties"], ) = self.get_hist_2d_projection(value["histogram"]) def _filter_density_histograms(self): """Filter density histograms based on the normalization method.""" if self.normalization_method == "per-telescope": keys_to_remove = ["density_xy_from_counts", "density_r_from_counts"] elif self.normalization_method == "per-bin": keys_to_remove = ["density_xy", "density_x", "density_y", "density_r"] else: raise ValueError( f"Unknown normalization_method: {self.normalization_method}. " "Must be 'per-telescope' or 'per-bin'." ) for key in keys_to_remove: if key in self.hist: del self.hist[key] def _populate_density_from_probes(self): """Build density distributions from per-telescope sampling.""" if not self._density_samples: return s = self._density_samples xs, ys = (np.array([p[k] for p in s]) for k in ("x", "y")) dens = np.array([p["density"] for p in s]) errs = np.array([p["density_error"] for p in s]) hxy = self.hist["counts_xy"]["histogram"] x_edges, y_edges = hxy.axes[0].edges, hxy.axes[1].edges r_edges = self.hist["density_r"]["histogram"].axes[0].edges def avg_unc_nd(coords, edges, values, errors): num = np.histogramdd(coords, bins=edges, weights=values)[0] den = np.histogramdd(coords, bins=edges)[0] var = np.histogramdd(coords, bins=edges, weights=errors**2)[0] with np.errstate(divide="ignore", invalid="ignore"): avg = np.divide(num, den, out=np.zeros_like(num), where=den > 0) unc = np.sqrt(np.divide(var, den**2, out=np.zeros_like(var), where=den > 0)) return avg, unc # 2D avg_xy, unc_xy = avg_unc_nd(np.column_stack((xs, ys)), (x_edges, y_edges), dens, errs) self.hist["density_xy"].update( { "hist_values": np.asarray([avg_xy.T]), "x_bin_edges": np.asarray([x_edges]), "y_bin_edges": np.asarray([y_edges]), "uncertainties": np.asarray([unc_xy.T]), } ) # 1D helpers def avg_unc_1d(x, e, v, err): return avg_unc_nd(x[:, None], (e,), v, err) ax = self.axis_distance avg_x, unc_x = ( avg_unc_1d(xs[np.abs(ys) < ax], x_edges, dens[np.abs(ys) < ax], errs[np.abs(ys) < ax]) if np.any(np.abs(ys) < ax) else (np.zeros(len(x_edges) - 1),) * 2 ) avg_y, unc_y = ( avg_unc_1d(ys[np.abs(xs) < ax], y_edges, dens[np.abs(xs) < ax], errs[np.abs(xs) < ax]) if np.any(np.abs(xs) < ax) else (np.zeros(len(y_edges) - 1),) * 2 ) # Radial density rs = np.hypot(xs, ys) avg_r, unc_r = avg_unc_1d(rs, r_edges, dens, errs) for k, avg, unc, edges in ( ("density_x", avg_x, unc_x, x_edges), ("density_y", avg_y, unc_y, y_edges), ("density_r", avg_r, unc_r, r_edges), ): self.hist[k].update( { "hist_values": np.asarray([avg]), "x_bin_edges": np.asarray([edges]), "uncertainties": np.asarray([unc]), } ) def _populate_density_from_counts(self): """Build density distributions by dividing counts histograms by bin area.""" # --- 2D --- hxy = self.hist["counts_xy"]["histogram"] xw = np.diff(hxy.axes[0].edges) yw = np.diff(hxy.axes[1].edges) areas2d = np.outer(xw, yw) dens_xy, unc_xy = self._density_and_unc(hxy.view(), areas2d) self.hist["density_xy_from_counts"].update( { "hist_values": np.asarray([dens_xy.T]), "x_bin_edges": np.asarray([hxy.axes[0].edges]), "y_bin_edges": np.asarray([hxy.axes[1].edges]), "uncertainties": np.asarray([unc_xy.T]), } ) # --- 1D --- hr = self.hist["counts_r"]["histogram"] r = hr.axes[0].edges areas1d = np.pi * (r[1:] ** 2 - r[:-1] ** 2) dens_r, unc_r = self._density_and_unc(hr.view(), areas1d) self.hist["density_r_from_counts"].update( { "hist_values": np.asarray([dens_r]), "x_bin_edges": np.asarray([r]), "uncertainties": np.asarray([unc_r]), } ) def _density_and_unc(self, view, areas): """Calculate density and uncertainty by dividing histogram values by areas.""" if self._check_for_all_attributes(view): values = view["value"] unc = np.sqrt(view["variance"]) else: values = view unc = np.sqrt(view) return values / areas, unc / areas def _check_for_all_attributes(self, view): """Check if view has dtype fields ('value', 'variance').""" if hasattr(view, "dtype") and view.dtype.names == ("value", "variance"): return True return False