Source code for visualization.plot_corsika_limits

"""Plotting utilities for CORSIKA limits tables."""

import logging
from pathlib import Path

import numpy as np

from simtools.visualization.matplotlib_backend import lazy_module
from simtools.visualization.matplotlib_backend import pyplot as plt
from simtools.visualization.visualize import save_figure

lines = lazy_module("matplotlib.lines")

_logger = logging.getLogger(__name__)

ZENITH_LABEL = "Zenith [deg]"
BROAD_RANGE_COLUMN_ALIASES = {
    "lower_energy_limit": ["br_energy_min", "br_lower_energy_limit"],
    "upper_radius_limit": ["br_core_scatter_max", "br_upper_radius_limit"],
    "viewcone_radius": ["br_viewcone_max", "br_viewcone_radius"],
}
_LIMIT_COLUMNS = ("lower_energy_limit", "upper_radius_limit", "viewcone_radius")
_PANELS = (
    ("Lower Energy Limit vs Zenith", "Lower Energy Limit [TeV]"),
    ("Upper Radius Limit vs Zenith", "Upper Radius Limit [m]"),
    ("Viewcone Radius vs Zenith", "Viewcone Radius [deg]"),
)
_AZIMUTH_LINESTYLES = ("-", "--")


def _resolve_broad_range_columns(limits_table):
    """Return broad-range column names per limit, or None if any limit is missing."""
    resolved = {
        key: next((alias for alias in aliases if alias in limits_table.colnames), None)
        for key, aliases in BROAD_RANGE_COLUMN_ALIASES.items()
    }
    if any(column is None for column in resolved.values()):
        return None
    return resolved


def _mean_by_zenith(group, columns):
    """Average the given columns per zenith angle, sorted by zenith."""
    agg_data = group[columns].group_by("zenith").groups.aggregate(np.mean)
    return agg_data[np.argsort(agg_data["zenith"])]


def _plot_broad_range(axes, azimuth_group, zeniths, broad_range_columns):
    """Plot the broad-range limit series of one azimuth direction as dashed gray lines."""
    broad_columns = [broad_range_columns[column] for column in _LIMIT_COLUMNS]
    broad_data = _mean_by_zenith(azimuth_group, ["zenith", *broad_columns])
    for axis, column in zip(axes, broad_columns):
        axis.plot(zeniths, broad_data[column], linestyle="--", color="gray", linewidth=1.5)


def _plot_azimuth_group(axes, azimuth_group, color, azimuth_index, broad_range_columns):
    """Plot the limit series, and optionally the broad-range series, of one azimuth direction."""
    linestyle = _AZIMUTH_LINESTYLES[azimuth_index % len(_AZIMUTH_LINESTYLES)]
    agg_data = _mean_by_zenith(azimuth_group, ["zenith", *_LIMIT_COLUMNS])
    zeniths = agg_data["zenith"].value
    for axis, column in zip(axes, _LIMIT_COLUMNS):
        axis.plot(
            zeniths,
            agg_data[column],
            "o",
            color=color,
            linestyle=linestyle,
            markerfacecolor=color if azimuth_index == 0 else "none",
        )
    if broad_range_columns:
        _plot_broad_range(axes, azimuth_group, zeniths, broad_range_columns)


def _plot_limit_group(axes, group, broad_range_columns):
    """Plot all NSB and azimuth limit series for one layout and particle."""
    legend_handles, legend_labels = [], []
    palette = plt.get_cmap("Set1").colors  # don't expect more than 9 NSB levels
    azimuth_values = [
        getattr(g["azimuth"][0], "value", g["azimuth"][0]) for g in group.group_by("azimuth").groups
    ]

    for index, nsb_group in enumerate(group.group_by("nsb_level").groups):
        color = palette[index]
        legend_handles.append(lines.Line2D([0], [0], color=color))
        legend_labels.append(f"NSB={nsb_group['nsb_level'][0]} GHz")

        for azimuth_group in nsb_group.group_by("azimuth").groups:
            azimuth = getattr(azimuth_group["azimuth"][0], "value", azimuth_group["azimuth"][0])
            azimuth_index = azimuth_values.index(azimuth)
            _plot_azimuth_group(axes, azimuth_group, color, azimuth_index, broad_range_columns)

    return legend_handles, legend_labels, azimuth_values


[docs] def plot_limits(limits_table, output_dir): """ Create plots of derived CORSIKA limits for each array and primary particle. NSB levels are distinguished by color. Azimuth directions share a plot and use filled and open markers in ascending azimuth order. Parameters ---------- limits_table (Table) An astropy Table containing the CORSIKA limits data. output_dir (str or Path) Directory where the generated plots will be saved. Returns ------- list of Path List of file paths to the generated plots. """ _logger.info("Generating limit plots") output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) output_files = [] has_particle = "primary_particle" in limits_table.colnames group_columns = ["array_name", "primary_particle"] if has_particle else ["array_name"] broad_range_columns = _resolve_broad_range_columns(limits_table) for group in limits_table.group_by(group_columns).groups: array_name = group["array_name"][0] primary_particle = "unknown" if has_particle: primary_particle = "/".join(np.unique(np.array(group["primary_particle"], dtype=str))) fig, axes = plt.subplots(1, 3, figsize=(18, 6)) legend_handles, legend_labels, azimuth_values = _plot_limit_group( axes, group, broad_range_columns ) for axis, (title, ylabel) in zip(axes, _PANELS): axis.relim() axis.autoscale_view() axis.set_title(title) axis.set_xlabel(ZENITH_LABEL) axis.set_ylabel(ylabel) axis.grid(True) if broad_range_columns: legend_handles.append(lines.Line2D([0], [0], linestyle="--", color="gray")) legend_labels.append("broad-range limits") legend_handles += [ lines.Line2D( [0], [0], marker="o", color="black", linestyle=_AZIMUTH_LINESTYLES[index % len(_AZIMUTH_LINESTYLES)], markerfacecolor="black" if index == 0 else "none", ) for index in range(len(azimuth_values)) ] legend_labels += [f"Az={azimuth} deg" for azimuth in azimuth_values] fig.legend(legend_handles, legend_labels, loc="lower center", ncol=len(legend_labels)) plt.suptitle(f"CORSIKA Limits: {array_name}, {primary_particle}") plt.tight_layout() plt.subplots_adjust(bottom=0.15) output_file = output_dir / f"limits_{array_name}_{primary_particle}.png" save_figure(fig, output_file.with_suffix(""), figure_format=["png"], close=True) output_files.append(output_file) return output_files