Source code for production_configuration.derive_corsika_limits

"""Derive CORSIKA limits from trigger histograms."""

import argparse
import datetime as dt
import logging
import math
import os
from pathlib import Path

import astropy.units as u
import numpy as np
from astropy.io.registry import IORegistryError
from astropy.table import Column, Table

from simtools.constants import SCHEMA_PATH
from simtools.context import RunContext
from simtools.data_model.metadata_collector import MetadataCollector
from simtools.histogram.histogram import Histogram
from simtools.io import ascii_handler, table_handler
from simtools.production_configuration.histogram_output_metadata import (
    extract_histogram_output_metadata,
)
from simtools.production_configuration.trigger_histograms import (
    TRIGGER_HISTOGRAM_METADATA_TABLE,
    load_event_data_histograms,
)
from simtools.utils.general import resolve_file_patterns
from simtools.visualization import plot_simtel_event_histograms

_logger = logging.getLogger(__name__)

CORSIKA_LIMITS_TABLE_SCHEMA_FILE = SCHEMA_PATH / "corsika_limits_table.schema.yml"
_ONAXIS_GAMMA_PARTICLE = "gamma-0.00deg"
_PARTICLE_METADATA_COLUMNS = (
    "primary_particle",
    "viewcone_min",
    "viewcone_max",
)


def _load_output_table_configuration_from_schema(schema_file):
    """Load output table columns, descriptions, and file-info mappings from schema."""
    schema_data = ascii_handler.collect_data_from_file(file_name=schema_file)
    data_entries = schema_data.get("data", [])
    if not data_entries:
        raise KeyError(f"No 'data' entry found in schema {schema_file}")

    table_columns = data_entries[0].get("table_columns", [])
    if not table_columns:
        raise KeyError(f"No 'table_columns' entry found in schema {schema_file}")

    result_columns = [entry["name"] for entry in table_columns]
    column_descriptions = {
        entry["name"]: entry.get("description")
        for entry in table_columns
        if entry.get("description") is not None
    }
    file_info_columns = {
        entry["name"]: entry["file_info_key"]
        for entry in table_columns
        if entry.get("file_info_key") is not None
    }
    return result_columns, column_descriptions, file_info_columns


RESULT_COLUMNS, COLUMN_DESCRIPTIONS, FILE_INFO_COLUMNS = (
    _load_output_table_configuration_from_schema(CORSIKA_LIMITS_TABLE_SCHEMA_FILE)
)
LOSS_AXES = ("core_distance", "angular_distance")
_NO_PLOT_LAYOUTS = ()
_REDUCED_PLOT_HISTOGRAM_NAMES = frozenset(
    {
        "angular_distance_vs_energy",
        "core_distance_vs_energy",
    }
)


class _ArgumentTypeValueError(argparse.ArgumentTypeError, ValueError):
    """Error that preserves a detailed message for argparse and library callers."""


[docs] def validate_differential_loss_bins_per_decade(value): """Validate and return a non-negative differential-bin count. Parameters ---------- value : int or str Differential energy-bin count per decade. Returns ------- int Validated bin count. Raises ------ ValueError If ``value`` is not a non-negative integer. The raised exception is also an ``argparse.ArgumentTypeError`` for detailed CLI diagnostics. """ try: parsed_value = int(value) except (TypeError, ValueError) as exc: raise _ArgumentTypeValueError( "differential_loss_bins_per_decade must be a non-negative integer" ) from exc if parsed_value < 0: raise _ArgumentTypeValueError( "differential_loss_bins_per_decade must be a non-negative integer" ) return parsed_value
def _parse_allowed_loss_value(raw_value): """Parse and validate one compact allowed-loss value.""" parts = [part.strip() for part in raw_value.split(",")] if len(parts) != 3: raise ValueError( f"Invalid --allowed_losses value '{raw_value}'. " "Expected format: axis,fraction,min_events" ) axis_raw, fraction_raw, min_events_raw = parts try: fraction = float(fraction_raw) min_events = int(min_events_raw) except (TypeError, ValueError) as exc: raise ValueError( f"Invalid --allowed_losses value '{raw_value}': " "fraction must be float and min_events must be int" ) from exc if not math.isfinite(fraction) or not 0.0 <= fraction <= 1.0: raise ValueError( f"Invalid --allowed_losses value '{raw_value}': " "fraction must be finite and in the interval [0, 1]" ) if min_events < 0: raise ValueError( f"Invalid --allowed_losses value '{raw_value}': " "min_events must be a non-negative integer" ) return axis_raw.lower(), { "loss_fraction": fraction, "loss_min_events": min_events, } def _allowed_loss_axes(axis_name): """Return the axes selected by one allowed-loss axis name.""" if axis_name == "all": return LOSS_AXES if axis_name not in LOSS_AXES: raise ValueError( f"Invalid axis for --allowed_losses. Allowed axes: {', '.join(LOSS_AXES)}, all." ) return (axis_name,)
[docs] def parse_allowed_losses(allowed_losses_args): """ Parse repeatable --allowed_losses values for core/viewcone axes. Parameters ---------- allowed_losses_args : list[str] List of values in the form "axis,fraction,min_events". Returns ------- dict Mapping of axis name to dict with keys "loss_fraction" and "loss_min_events". """ if not allowed_losses_args: raise ValueError( "No allowed-loss configuration provided. Use --allowed_losses axis,fraction,min_events" ) parsed = {} for raw_value in allowed_losses_args: axis_name, loss_config = _parse_allowed_loss_value(raw_value) for selected_axis in _allowed_loss_axes(axis_name): parsed[selected_axis] = loss_config.copy() missing_axes = [axis_name for axis_name in LOSS_AXES if axis_name not in parsed] if missing_axes: raise ValueError( f"Missing --allowed_losses entries for axis/axes: {', '.join(missing_axes)}" ) return parsed
[docs] def generate_corsika_limits_grid(args_dict=None, context=None): """ Generate CORSIKA limits from precomputed trigger-histogram products. Reads histograms, computes limits, optionally plots histograms, and writes results to ECSV files. A literal file or glob preserves the existing output behavior. A directory processes supported particle products independently and writes one result below a particle-specific subdirectory. """ context = context or RunContext.from_environment() args_dict = args_dict or context.args trigger_histogram_file = args_dict.get("trigger_histogram_file") trigger_histogram_directory = args_dict.get("trigger_histogram_directory") if trigger_histogram_file and trigger_histogram_directory: raise ValueError( "Provide only one of trigger_histogram_file and trigger_histogram_directory." ) if not trigger_histogram_file and not trigger_histogram_directory: raise ValueError( "Use trigger_histogram_file or trigger_histogram_directory to provide input." ) if ( trigger_histogram_directory and args_dict.get("output_file") and not args_dict.get("output_file_from_default") ): raise ValueError( "output_file cannot be used with trigger_histogram_directory; " "one corsika_limits.ecsv is written per particle directory." ) allowed_losses = parse_allowed_losses(args_dict.get("allowed_losses")) energy_threshold_fraction = float(args_dict.get("energy_threshold_fraction", 0.01)) differential_loss_bins_per_decade = validate_differential_loss_bins_per_decade( args_dict.get("differential_loss_bins_per_decade", 0) ) if trigger_histogram_directory: return _generate_corsika_limits_from_histogram_directory( args_dict, allowed_losses, energy_threshold_fraction, differential_loss_bins_per_decade, context=context, ) results = _generate_corsika_limits_from_histogram_file( args_dict, allowed_losses, energy_threshold_fraction, differential_loss_bins_per_decade, context=context, ) if results: write_results(results, args_dict, allowed_losses, energy_threshold_fraction, context) else: _logger.warning("No non-empty trigger histograms were available; no limits were written.") return None
def _generate_corsika_limits_from_histogram_file( args_dict, allowed_losses, energy_threshold_fraction, differential_loss_bins_per_decade, histogram_files=None, output_dir=None, context=None, ): """Derive CORSIKA limits from one or more precomputed trigger-histogram files.""" context = context or RunContext.from_environment() selected_array_names = _selected_array_names(args_dict) plot_layouts = _normalize_plot_histogram_selection(args_dict.get("plot_histograms", False)) loaded_histograms = [] input_files = histogram_files or _resolve_trigger_histogram_files( args_dict["trigger_histogram_file"] ) for histogram_file in input_files: loaded_histograms.extend( load_event_data_histograms( histogram_file, array_names=selected_array_names, ) ) output_dir = output_dir or context.paths.get_output_directory() usable_histograms = [] for row, histograms in loaded_histograms: if _has_positive_energy_histogram(histograms): usable_histograms.append((row, histograms)) continue _logger.warning( "Skipping production index %s for array %s: energy histogram has no positive entries.", row["production_index"], row["array_name"], ) production_indices = sorted({int(row["production_index"]) for row, _ in usable_histograms}) production_subdirs = {} if plot_layouts != _NO_PLOT_LAYOUTS and len(production_indices) > 1: production_subdirs = _build_production_subdirectories(production_indices, output_dir) results = [] for row, histograms in usable_histograms: _logger.info( f"Processing production index {row['production_index']} for array {row['array_name']}" ) output_subdir = production_subdirs.get(int(row["production_index"]), output_dir) result = _derive_limits_from_histograms( histograms, row["array_name"], allowed_losses, energy_threshold_fraction, _plot_selected_for_array(plot_layouts, row["array_name"]), output_subdir, differential_loss_bins_per_decade, args_dict.get("plot_reduced_histograms", False), context=context, ) result.update( { "production_index": int(row["production_index"]), "array_name": row["array_name"], "telescope_ids": list(filter(None, str(row["telescope_ids"]).split(","))), } ) results.append(result) return results def _normalize_plot_histogram_selection(plot_histograms): """Normalize plot selection to all layouts, no layouts, or named layouts. Parameters ---------- plot_histograms : bool, str, or sequence[str] Plot configuration. ``False`` disables plotting. ``True``, ``all``, an empty sequence from a bare command-line flag, or a sequence containing ``all`` enables plotting for every layout. Other strings are interpreted as one layout name. Returns ------- None or tuple[str, ...] ``None`` means all layouts, an empty tuple means no layouts, and a non-empty tuple contains the selected layout names. Raises ------ ValueError If the value is not a boolean, string, or sequence of strings. """ if plot_histograms is True: return None if plot_histograms is False or plot_histograms is None: return _NO_PLOT_LAYOUTS if isinstance(plot_histograms, str): selections = [plot_histograms] elif isinstance(plot_histograms, list | tuple | set): selections = list(plot_histograms) else: raise ValueError( "plot_histograms must be False, True, 'all', or a list of array layout names." ) if not selections: return None normalized = tuple(str(selection).strip() for selection in selections) lowered = {selection.lower() for selection in normalized} if "all" in lowered or "true" in lowered: return None if "false" in lowered: if len(normalized) > 1: raise ValueError("plot_histograms cannot combine 'False' with layout names.") return _NO_PLOT_LAYOUTS if any(not selection for selection in normalized): raise ValueError("plot_histograms layout names must not be empty.") return normalized def _plot_selected_for_array(plot_layouts, array_name): """Return whether a layout should receive diagnostic histogram plots.""" return plot_layouts is None or str(array_name) in plot_layouts def _has_positive_energy_histogram(histograms): """Return whether finalized histograms contain a usable energy distribution.""" histogram_definitions = getattr(histograms, "histograms", None) if not isinstance(histogram_definitions, dict): return True energy = histogram_definitions.get("energy") if not isinstance(energy, Histogram): return False return bool(np.any(np.asarray(energy.values, dtype=float) > 0)) def _generate_corsika_limits_from_histogram_directory( args_dict, allowed_losses, energy_threshold_fraction, differential_loss_bins_per_decade, context=None, ): """Derive one CORSIKA-limits table per metadata-defined particle in a directory.""" context = context or RunContext.from_environment() particle_files = _discover_trigger_histogram_groups(args_dict["trigger_histogram_directory"]) output_root = context.paths.get_output_directory() for particle_name, histogram_files in particle_files.items(): particle_output_dir = output_root / particle_name particle_output_dir.mkdir(parents=True, exist_ok=True) results = _generate_corsika_limits_from_histogram_file( args_dict, allowed_losses, energy_threshold_fraction, differential_loss_bins_per_decade, histogram_files=histogram_files, output_dir=particle_output_dir, context=context, ) particle_args = dict(args_dict) particle_args["output_file"] = str(particle_output_dir / "corsika_limits.ecsv") particle_args["output_path"] = str(particle_output_dir) if results: write_results( results, particle_args, allowed_losses, energy_threshold_fraction, context=context ) else: _logger.warning( "No non-empty trigger histograms found for particle %s; no limits were written.", particle_name, ) def _discover_trigger_histogram_groups(trigger_histogram_directory): """Return valid trigger-histogram files grouped by metadata-defined particle name. Parameters ---------- trigger_histogram_directory : str or pathlib.Path Directory containing finalized trigger-histogram HDF5 products. Returns ------- dict[str, list[str]] Sorted particle names mapped to sorted input filenames. Particle names are read from ``TRIGGER_REFERENCE_METADATA``. Raises ------ FileNotFoundError If the input directory does not exist or is not a directory. ValueError If no valid trigger-histogram products are found. """ directory = Path(trigger_histogram_directory) if not directory.is_dir(): raise FileNotFoundError(f"Trigger-histogram directory not found: {directory}") groups = {} for file_path in sorted(directory.glob("*.trigger_histograms.hdf5")): if not file_path.is_file(): continue try: particle_name = _particle_name_from_histogram_file(file_path) except (OSError, KeyError, TypeError, ValueError, IndexError, IORegistryError) as exc: _logger.warning("Ignoring invalid trigger-histogram candidate %s: %s", file_path, exc) continue groups.setdefault(particle_name, []).append(str(file_path)) if not groups: raise ValueError(f"No valid trigger-histogram HDF5 products found in {directory}.") return {particle_name: groups[particle_name] for particle_name in sorted(groups)} def _particle_name_from_histogram_file(file_path): """Return the canonical output particle name from trigger-histogram metadata.""" metadata = table_handler.read_tables( file_path, [TRIGGER_HISTOGRAM_METADATA_TABLE], file_type="HDF5", )[TRIGGER_HISTOGRAM_METADATA_TABLE] missing_columns = [ column for column in _PARTICLE_METADATA_COLUMNS if column not in metadata.colnames ] if missing_columns: raise ValueError(f"missing required metadata column(s): {', '.join(missing_columns)}") if not metadata: raise ValueError("metadata table is empty") particle_names = set() for row in metadata: particle_names.add(_canonical_particle_name(row)) if len(particle_names) != 1: names = ", ".join(sorted(particle_names)) raise ValueError(f"metadata contains conflicting canonical particle names: {names}") return particle_names.pop() def _canonical_particle_name(metadata_row): """Return one canonical output particle name from a metadata row.""" primary_particle = metadata_row["primary_particle"] if isinstance(primary_particle, bytes | np.bytes_): primary_particle = primary_particle.decode("utf-8") primary_particle = str(primary_particle).strip() if not primary_particle: raise ValueError("primary_particle metadata is empty") if Path(primary_particle).name != primary_particle or primary_particle in {".", ".."}: raise ValueError( f"primary_particle metadata is not a safe directory name: {primary_particle}" ) if primary_particle.casefold() != "gamma": return primary_particle viewcone_min = _metadata_angle_in_degrees(metadata_row["viewcone_min"], "viewcone_min") viewcone_max = _metadata_angle_in_degrees(metadata_row["viewcone_max"], "viewcone_max") if viewcone_min > viewcone_max: raise ValueError( f"viewcone_min ({viewcone_min}) is greater than viewcone_max ({viewcone_max})" ) if np.isclose(viewcone_min, 0.0, rtol=0.0, atol=1e-12) and np.isclose( viewcone_max, 0.0, rtol=0.0, atol=1e-12 ): return _ONAXIS_GAMMA_PARTICLE return "gamma" def _metadata_angle_in_degrees(value, column_name): """Return one finite viewcone metadata value in degrees.""" try: value = value.to_value(u.deg) if hasattr(value, "to_value") else float(value) value = float(value) except (TypeError, ValueError) as exc: raise ValueError(f"{column_name} metadata is not a scalar angle") from exc if not np.isfinite(value): raise ValueError(f"{column_name} metadata is not finite") return value def _resolve_trigger_histogram_files(trigger_histogram_file): """Resolve a trigger-histogram filename or glob pattern into input files. A literal path is returned unchanged so the existing file-validation error is preserved. Glob patterns must match at least one file. """ if any(character in trigger_histogram_file for character in "*?["): try: return [str(path) for path in sorted(resolve_file_patterns(trigger_histogram_file))] except FileNotFoundError as exc: raise ValueError( f"No trigger-histogram files matched pattern '{trigger_histogram_file}'." ) from exc return [trigger_histogram_file] def _build_production_subdirectories(production_indices, output_dir): """Create stable plot directories keyed by production index.""" production_subdirs = {} for production_index in production_indices: output_subdir = output_dir / f"production_{production_index}" output_subdir.mkdir(parents=True, exist_ok=True) production_subdirs[int(production_index)] = output_subdir return production_subdirs def _selected_array_names(args_dict): """Return the selected array-layout names.""" return args_dict.get("array_layout_name") def _derive_limits_from_histograms( histograms, array_name, allowed_losses, energy_threshold_fraction, plot_histograms, output_subdir, differential_loss_bins_per_decade, plot_reduced_histograms=False, context=None, ): """Compute, optionally plot, and return limits from finalized histograms.""" limits = { "lower_energy_limit": compute_lower_energy_limit( histograms, energy_threshold_fraction, ), } limits.update( _compute_limits( histograms, allowed_losses, differential_loss_bins_per_decade, ) ) limits.update( extract_histogram_output_metadata( histograms.file_info, FILE_INFO_COLUMNS, include_array_name=False, ) ) if plot_histograms: context = context or RunContext.from_environment() plot_output_path = output_subdir or context.paths.get_output_directory() histograms_to_plot = { name: histogram for name, histogram in histograms.histograms.items() if name != "energy" } if plot_reduced_histograms: histograms_to_plot = { name: histogram for name, histogram in histograms_to_plot.items() if name in _REDUCED_PLOT_HISTOGRAM_NAMES } if limits.get("angular_distance_is_constant", False): histograms_to_plot = { name: histogram for name, histogram in histograms_to_plot.items() if not name.startswith("angular_distance_vs_") } plot_simtel_event_histograms.plot( histograms_to_plot, output_path=plot_output_path, limits=limits, array_name=array_name, add_distance_projections=True, use_broad_range_limits=True, ) return limits def _compute_limits(histograms, allowed_losses, bins_per_decade): """ Compute core and viewcone limits per energy bin and return max limits. Apply the allowed loss criteria per energy bin and return the maximum limit """ energy_range = [float(histograms.energy_bins[0]), float(histograms.energy_bins[-1])] axis_configs = { "core_distance": { "x_bins": histograms.core_distance_bins, "name": "core_scatter", "unit": "m", }, "angular_distance": { "x_bins": histograms.view_cone_bins, "name": "viewcone", "unit": "deg", }, } per_axis_limits = {} constant_angular_distance = _get_constant_data_value(histograms, "angular_distance") differential_energy_bins = None if bins_per_decade > 0: low = int(np.floor(np.log10(np.min(histograms.energy_bins)))) high = int(np.ceil(np.log10(np.max(histograms.energy_bins)))) differential_energy_bins = np.logspace(low, high, (high - low) * bins_per_decade + 1) for axis_name, config in axis_configs.items(): if axis_name == "angular_distance" and constant_angular_distance is not None: axis_max = constant_angular_distance curve_x = [axis_max, axis_max] curve_y = energy_range _logger.info( f"All simulated events have angular distance {axis_max} deg; " "using this exact value as the viewcone limit." ) elif bins_per_decade > 0: axis_max, curve_x, curve_y = _differential_upper_limits( histograms.histograms[f"{axis_name}_vs_energy"].values, config["x_bins"], histograms.energy_bins, differential_energy_bins, allowed_losses[axis_name], config["name"], config["unit"], ) else: axis_max = _integral_limits( histograms.histograms[axis_name].values, config["x_bins"], allowed_losses[axis_name]["loss_fraction"], allowed_losses[axis_name]["loss_min_events"], limit_type="upper", ) curve_x = [axis_max, axis_max] curve_y = energy_range per_axis_limits[axis_name] = { "max": axis_max, "curve": {"x": curve_x, "y": curve_y}, "curve_key": f"{axis_name}_vs_energy_curve", } core_max = per_axis_limits["core_distance"]["max"] vc_max = per_axis_limits["angular_distance"]["max"] upper_radius_limit = core_max * u.m upper_radius_limit = _is_close( upper_radius_limit, histograms.file_info["core_scatter_max"].to("m") if "core_scatter_max" in histograms.file_info else None, "Upper radius limit is equal to the maximum core scatter distance of", ) viewcone_radius = _is_close( vc_max * u.deg, histograms.file_info["viewcone_max"].to("deg") if "viewcone_max" in histograms.file_info else None, "Upper viewcone limit is equal to the maximum viewcone distance of", ) _logger.info(f"Differential upper_radius_limit (max over bins): {upper_radius_limit}") _logger.info(f"Differential viewcone_radius (max over bins): {viewcone_radius}") return { "upper_radius_limit": upper_radius_limit, "viewcone_radius": viewcone_radius, "angular_distance_is_constant": constant_angular_distance is not None, per_axis_limits["core_distance"]["curve_key"]: per_axis_limits["core_distance"]["curve"], per_axis_limits["angular_distance"]["curve_key"]: per_axis_limits["angular_distance"][ "curve" ], } def _get_constant_data_value(histograms, name, abs_tol=0.01): """Return an event-data value when its accumulated range is constant.""" data_ranges = getattr(histograms, "data_ranges", None) if not isinstance(data_ranges, dict) or name not in data_ranges: return None minimum, maximum = data_ranges[name] if np.isclose(minimum, maximum, rtol=0, atol=abs_tol): avg_value = (minimum + maximum) / 2.0 return float(avg_value) if avg_value > abs_tol else 0.0 return None def _differential_upper_limits( histogram2d, x_bins, y_bins, diff_e_bins, allowed_loss, name, unit, ): """Compute upper limits per energy slice of a 2D (x, energy) histogram.""" y_centers = 0.5 * (y_bins[:-1] + y_bins[1:]) limits, energy_centers = [], [] n = len(diff_e_bins) - 1 for i in range(n): e_low, e_high = diff_e_bins[i], diff_e_bins[i + 1] hi_op = np.less_equal if i == n - 1 else np.less projected = np.sum(histogram2d[:, (y_centers >= e_low) & hi_op(y_centers, e_high)], axis=1) total = float(np.sum(projected)) if total <= 0: continue limit = _integral_limits( projected, x_bins, allowed_loss["loss_fraction"], allowed_loss["loss_min_events"], limit_type="upper", ) keep = np.searchsorted(x_bins, limit, side="left") loss = (total - float(np.sum(projected[:keep]))) / total limits.append(limit) energy_centers.append(float(np.sqrt(e_low * e_high))) _logger.info( f"Differential {name}: E=[{e_low:.4g}, {e_high:.4g}] TeV, " f"N={int(total)}, limit={limit:.4g} {unit}, loss={loss:.5f}" ) return ( (float(np.max(limits)), limits, energy_centers) if limits else (float(x_bins[-1]), [], []) )
[docs] def write_results(results, args_dict, allowed_losses, energy_threshold_fraction, context=None): """ Write the computed limits as astropy table to file. Parameters ---------- results : list[dict] List of computed limits. allowed_losses : dict Per-axis loss settings for core_distance/angular_distance. energy_threshold_fraction : float Fraction used for deriving the lower energy threshold. """ context = context or RunContext.from_environment() table = _create_results_table( results, allowed_losses, energy_threshold_fraction, ) table.meta.update(MetadataCollector(args_dict, context=context).get_top_level_metadata()) io = context.paths output_file_arg = args_dict.get("output_file") if isinstance(output_file_arg, str | os.PathLike): output_file = Path(output_file_arg) if not output_file.is_absolute(): output_file = io.get_output_directory() / output_file else: output_file = io.get_output_directory() / "corsika_limits.ecsv" table.write(output_file, format="ascii.ecsv", overwrite=True) _logger.info(f"Results saved to {output_file}")
def _create_results_table(results, allowed_losses, energy_threshold_fraction): """ Convert list of simulation results to an astropy Table with metadata. Round values to appropriate precision and add metadata. Parameters ---------- results : list[dict] Computed limits per file and telescope configuration. allowed_losses : dict Per-axis loss settings added to metadata. energy_threshold_fraction : float Fraction used for deriving the lower energy threshold. Returns ------- astropy.table.Table Table with computed limits and production-origin columns. """ cols = list(RESULT_COLUMNS) columns = {name: [] for name in cols} units = {} for res in results: _process_result_row(res, cols, columns, units) table_cols = _create_table_columns(cols, columns, units) table = Table(table_cols) table.sort(["production_index", "primary_particle", "array_name", "zenith"]) table.meta.update( { "created": dt.datetime.now().isoformat(), "description": "Lookup table for CORSIKA limits computed from simulations.", } ) for axis_name in LOSS_AXES: table.meta[f"loss_fraction_{axis_name}"] = allowed_losses[axis_name]["loss_fraction"] table.meta[f"loss_min_events_{axis_name}"] = int( allowed_losses[axis_name]["loss_min_events"] ) table.meta["energy_threshold_fraction"] = energy_threshold_fraction return table def _process_result_row(res, cols, columns, units): """Process a single result row and add values to columns.""" for k in cols: val = res.get(k, None) if val is not None: val = _round_value(k, val, res) _logger.debug(f"Adding {k}: {val} to column data") if hasattr(val, "unit"): columns[k].append(val.value) units[k] = val.unit else: columns[k].append(val) if k not in units: units[k] = None def _enforce_minimum_value(candidate, minimum): """Ensure candidate is not below minimum while preserving units when present.""" if minimum is None: return candidate if isinstance(candidate, u.Quantity) and isinstance(minimum, u.Quantity): minimum = minimum.to(candidate.unit) elif isinstance(candidate, u.Quantity) and not isinstance(minimum, u.Quantity): minimum = minimum * candidate.unit elif not isinstance(candidate, u.Quantity) and isinstance(minimum, u.Quantity): minimum = minimum.value return candidate if candidate >= minimum else minimum def _round_value(key, val, row=None): """Round value based on key type.""" if key == "lower_energy_limit": rounded = np.floor(val * 1e3) / 1e3 if row is not None: rounded = _enforce_minimum_value(rounded, row.get("br_energy_min")) return rounded if key == "upper_radius_limit": return np.ceil(val / 25) * 25 if key == "viewcone_radius": if row is not None and row.get("angular_distance_is_constant", False): return val return np.ceil(val / 0.25) * 0.25 return val def _create_table_columns(cols, columns, units): """Create table columns with appropriate data types.""" table_cols = [] for k in cols: col_data = columns[k] col_description = COLUMN_DESCRIPTIONS.get(k) if any(isinstance(v, list | tuple) for v in col_data): col = Column( data=col_data, name=k, unit=units.get(k), dtype=object, description=col_description, ) else: col = Column(data=col_data, name=k, unit=units.get(k), description=col_description) table_cols.append(col) return table_cols def _find_bin_index_for_value(bin_edges, value): """Return index of the histogram bin containing value.""" edges = np.asarray(bin_edges, dtype=float) idx = int(np.searchsorted(edges, float(value), side="right") - 1) return int(np.clip(idx, 0, len(edges) - 2)) def _apply_broad_range_lower_energy_floor(derived_limit, broad_range_min, energy_bins): """Apply physical and bin-consistent floor to the derived lower energy limit.""" if broad_range_min is None: return derived_limit derived_tev = derived_limit.to("TeV") broad_range_tev = broad_range_min.to("TeV") derived_idx = _find_bin_index_for_value(energy_bins, derived_tev.value) broad_range_idx = _find_bin_index_for_value(energy_bins, broad_range_tev.value) if derived_idx == broad_range_idx: return broad_range_tev.to(derived_limit.unit) return _enforce_minimum_value(derived_limit, broad_range_min) def _integral_limits(hist, bin_edges, loss_fraction, loss_min_events=10, limit_type="lower"): """ Compute integral limits based on the loss fraction and minimal required lost events. Add or subtract one bin to be on the safe side of the limit. Parameters ---------- hist : np.ndarray 1D histogram array. bin_edges : np.ndarray Array of bin edges. loss_fraction : float Fraction of events to be lost. loss_min_events : int, optional Minimum number of events to be lost after applying a limit. limit_type : str, optional Type of limit ('lower' or 'upper'). Default is 'lower'. Returns ------- float Bin edge value corresponding to the threshold. """ total_events = np.sum(hist) # Keep-threshold corresponding to a strictly greater-than requested absolute event loss. max_kept_for_min_loss = np.nextafter(total_events - float(loss_min_events), -np.inf) threshold = min( (1 - loss_fraction) * total_events, max_kept_for_min_loss, ) threshold = np.clip(threshold, 0.0, total_events) if limit_type == "upper": cum = np.cumsum(hist) idx = np.searchsorted(cum, threshold) + 1 return bin_edges[min(idx, len(bin_edges) - 1)] if limit_type == "lower": cum = np.cumsum(hist[::-1]) idx = np.searchsorted(cum, threshold) return bin_edges[max(len(bin_edges) - 1 - idx, 0)] raise ValueError("limit_type must be 'lower' or 'upper'") def _find_low_energy_threshold_from_histogram(counts, bin_edges, threshold_fraction=0.1): """Find low-energy threshold from a 1D histogram using a peak-relative criterion. The threshold is defined as the first bin (walking to lower energies from the histogram maximum) where the count drops below ``threshold_fraction`` times a stable peak estimate. The stable peak estimate is the mean of the maximum bin and its immediate neighbors (neighbors included only when available). Parameters ---------- counts : np.ndarray Histogram bin counts. bin_edges : np.ndarray Histogram bin edges (length must be ``len(counts) + 1``). threshold_fraction : float, optional Fraction of the stable peak used as threshold. Returns ------- float Derived energy threshold. """ counts = np.asarray(counts, dtype=float) bin_edges = np.asarray(bin_edges, dtype=float) if counts.ndim != 1 or bin_edges.ndim != 1: raise ValueError("counts and bin_edges must be one-dimensional arrays") if counts.size == 0: raise ValueError("counts must not be empty") if not np.any(counts > 0): raise ValueError("counts must contain at least one positive entry") if bin_edges.size != counts.size + 1: raise ValueError("bin_edges length must be len(counts) + 1") if not 0.0 < threshold_fraction <= 1.0: raise ValueError("threshold_fraction must be in the interval (0, 1]") peak_idx = int(np.argmax(counts)) left = max(peak_idx - 1, 0) right = min(peak_idx + 1, counts.size - 1) n_peak = float(np.mean(counts[left : right + 1])) threshold = threshold_fraction * n_peak for idx in range(peak_idx, -1, -1): if counts[idx] < threshold: return float(bin_edges[idx]) return float(bin_edges[0])
[docs] def compute_lower_energy_limit(histograms, threshold_fraction): """ Compute the lower energy limit in TeV based on the threshold fraction. Parameters ---------- histograms : EventDataHistograms Histograms. threshold_fraction : float Fraction of the stable peak used as threshold. Returns ------- astropy.units.Quantity Lower energy limit. """ energy_min = ( _find_low_energy_threshold_from_histogram( histograms.histograms["energy"].values, histograms.energy_bins, threshold_fraction=threshold_fraction, ) * u.TeV ) broad_range_energy_min = ( histograms.file_info["energy_min"].to("TeV") if "energy_min" in histograms.file_info else None ) energy_min = _apply_broad_range_lower_energy_floor( energy_min, broad_range_energy_min, histograms.energy_bins, ) return _is_close( energy_min, broad_range_energy_min, "Lower energy limit is equal to the minimum energy of", )
def _is_close(value, reference, warning_text): """Check if the value is close to the reference value and log a warning if so.""" if reference is not None and np.isclose(value.value, reference.value, rtol=1.0e-2): _logger.warning(f"{warning_text} {value}.") return value