Source code for production_configuration.production_event_data_helpers

"""Shared helpers for production workflows based on reduced event-data files."""

from collections import Counter, defaultdict

import numpy as np

from simtools.layout.array_layout_sources import get_array_elements_from_model_repository
from simtools.layout.array_layout_utils import resolve_array_layout_name
from simtools.sim_events.histograms import EventDataHistograms
from simtools.utils import names
from simtools.utils.names import normalize_array_element_identifier_container


[docs] def resolve_telescope_configs(args_dict, context=None): """ Resolve telescope configurations from supported input options. Parameters ---------- args_dict : dict Application arguments containing layout or array-element selection. Returns ------- dict or list[dict] Telescope configuration payload returned by the chosen selector. Raises ------ ValueError If no supported telescope selector is provided. """ if args_dict.get("array_layout_name"): model_version = args_dict.get("model_version") if isinstance(model_version, list): if len(model_version) != 1: raise ValueError( "Telescope layout resolution requires exactly one model version, " f"got {model_version}." ) model_version = model_version[0] layouts = resolve_array_layout_name(args_dict["array_layout_name"], model_version) return get_array_elements_from_model_repository( layouts if isinstance(layouts, list) else [layouts], args_dict.get("site"), model_version, context=context, ) if args_dict.get("array_element_list"): return {"array_element_list": args_dict["array_element_list"]} raise ValueError( "No telescope configuration provided. Use one of --array_layout_name " "or --array_element_list." )
[docs] def normalize_telescope_configs(telescope_configs): """ Normalize telescope configurations to a list of array-name descriptors. Parameters ---------- telescope_configs : dict or list[dict] Raw telescope configuration payload. Returns ------- list[dict] List of dictionaries with ``array_name`` and normalized ``telescope_ids``. """ pairs = ( telescope_configs.items() if isinstance(telescope_configs, dict) else [(config["array_name"], config["telescope_ids"]) for config in telescope_configs] ) return [ { "array_name": array_name, "telescope_ids": normalize_array_element_identifier_container(telescope_ids), } for array_name, telescope_ids in pairs ]
[docs] def accumulate_histograms_by_telescope_config( file_path, telescope_configs, *, energy_bins_per_decade, angular_distance_bin_count=100, angular_distance_bin_width=None, core_distance_bin_width=None, skip_invalid_event_data_files=False, fill_efficiency_histogram=False, collect_trigger_topology=False, minimum_triggered_telescopes=2, ): """ Read one production once and accumulate histograms for all telescope configurations. Parameters ---------- file_path : str Reduced event-data input path or glob pattern for one production. telescope_configs : list[dict] Telescope configurations with ``array_name`` and normalized ``telescope_ids``. energy_bins_per_decade : int Number of logarithmic energy bins per decade. angular_distance_bin_count : int, optional Number of angular-distance bins used by the accumulators. angular_distance_bin_width : astropy.units.Quantity, optional Angular-distance bin width used to derive bins from broad-range viewcone limits. core_distance_bin_width : astropy.units.Quantity, optional Core-distance bin width used to derive a common physical grid across ranges. skip_invalid_event_data_files : bool, optional Skip malformed event-data files instead of aborting the run. fill_efficiency_histogram : bool, optional Whether to finalize with efficiency histograms enabled. collect_trigger_topology : bool, optional Whether to also collect trigger topology summaries. minimum_triggered_telescopes : int, optional Minimum number of triggered telescopes within each telescope configuration. Returns ------- list[tuple] One finalized ``(config, histograms)`` pair per telescope configuration, with a trigger-topology accumulator appended when ``collect_trigger_topology`` is set. """ histogram_options = { "energy_bins_per_decade": energy_bins_per_decade, "angular_distance_bin_count": angular_distance_bin_count, "angular_distance_bin_width": angular_distance_bin_width, "core_distance_bin_width": core_distance_bin_width, } event_source = EventDataHistograms( file_path, skip_invalid_event_data_files=skip_invalid_event_data_files, require_triggered_data=True, **histogram_options, ) accumulators = [ EventDataHistograms.create_accumulator( array_name=config["array_name"], telescope_list=config["telescope_ids"], **histogram_options, ) for config in telescope_configs ] topology = ( [_initialize_trigger_topology_accumulator() for _ in telescope_configs] if collect_trigger_topology else None ) for reader, values in event_source.iter_event_data(): file_info_table, shower_data, triggered_shower, triggered_data = values for index, (config, histograms) in enumerate(zip(telescope_configs, accumulators)): if config["telescope_ids"]: filtered_data, filtered_shower = reader.filter_by_telescopes( triggered_data, triggered_shower, telescope_list=config["telescope_ids"], minimum_triggered_telescopes=minimum_triggered_telescopes, ) else: filtered_data, filtered_shower = triggered_data, triggered_shower histograms.accumulate(file_info_table, shower_data, filtered_shower, filtered_data) if collect_trigger_topology: _accumulate_trigger_topology( filtered_shower, filtered_data, topology[index], allowed_telescopes=config["telescope_ids"], ) for histograms in accumulators: histograms.finalize(fill_efficiency_histogram=fill_efficiency_histogram) if collect_trigger_topology: return list(zip(telescope_configs, accumulators, topology)) return list(zip(telescope_configs, accumulators))
def _initialize_trigger_topology_accumulator(): """Return empty counters and quantity buffers for trigger topology summaries.""" return { "trigger_multiplicity": Counter(), "trigger_combinations": Counter(), "telescope_participation": Counter(), "subset_multiplicity": defaultdict(Counter), "subset_values": { "energy": defaultdict(list), "core_distance": defaultdict(list), "angular_distance": defaultdict(list), }, } def _accumulate_trigger_topology( triggered_shower_data, triggered_data, accumulator, allowed_telescopes=None ): """Accumulate trigger topology counters and per-subset triggered quantities.""" if triggered_shower_data is None or triggered_data is None: return allowed = set(allowed_telescopes or []) energies = np.asarray(triggered_shower_data.simulated_energy) core_distances = np.asarray(triggered_shower_data.core_distance_shower) angular_distances = np.asarray(triggered_shower_data.angular_distance) for event_index, telescope_list in enumerate(triggered_data.telescope_list): telescopes = _normalize_trigger_telescopes(telescope_list, allowed) if not telescopes: continue accumulator["trigger_multiplicity"][len(telescopes)] += 1 accumulator["trigger_combinations"][",".join(telescopes)] += 1 for telescope in set(telescopes): accumulator["telescope_participation"][telescope] += 1 for subset_name, multiplicity in _subset_counts_for_trigger(telescopes).items(): accumulator["subset_multiplicity"][subset_name][multiplicity] += 1 values = accumulator["subset_values"] values["energy"][subset_name].append(float(energies[event_index])) values["core_distance"][subset_name].append(float(core_distances[event_index])) values["angular_distance"][subset_name].append(float(angular_distances[event_index])) def _subset_counts_for_trigger(telescopes): """Return per-type and special subset multiplicities for one trigger.""" type_counts = Counter(names.get_array_element_type_from_name(tel) for tel in telescopes) subset_counts = {tel_type: count for tel_type, count in type_counts.items() if count >= 2} if len(telescopes) == 1: subset_counts["single_telescope"] = 1 elif len(type_counts) > 1: subset_counts["mixed_type"] = len(telescopes) return subset_counts def _normalize_trigger_telescopes(telescope_list, allowed_telescopes=None): """Return sorted valid telescope names, restricted to ``allowed_telescopes`` if given.""" normalized = [] for telescope in telescope_list: name = str(telescope) if allowed_telescopes and name not in allowed_telescopes: continue try: names.get_array_element_type_from_name(name) except ValueError: continue normalized.append(name) return tuple(sorted(normalized))