Source code for visualization.plot_simtel_event_histograms

"""Plot simtel event histograms filled with EventDataHistograms."""

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

_logger = logging.getLogger(__name__)
colors = lazy_module("matplotlib.colors")

# Histograms whose output filename differs from their key (triggered variants).
_PLOT_FILENAME_OVERRIDES = {
    name: f"{name}_triggered"
    for name in ("core_distance_vs_energy", "angular_distance_vs_energy", "energy")
}

# Histograms with fixed-coordinate projection slices, and their projection kind.
_PROJECTION_KINDS = {
    "core_distance_vs_energy": "core_distance",
    "angular_distance_vs_energy": "angular_distance",
}

# Reference lines per histogram: axis ("x", "y", "r" or "curve") -> limits key.
_LINE_LIMITS = {
    "energy": {"x": "lower_energy_limit"},
    "core_distance": {"x": "upper_radius_limit"},
    "angular_distance": {"x": "viewcone_radius"},
    "x_core_shower_vs_y_core_shower": {"r": "upper_radius_limit"},
    **{
        f"{prefix}{suffix}": {"x": x_key, "y": "lower_energy_limit", "curve": f"{prefix}_curve"}
        for prefix, x_key in (
            ("core_distance_vs_energy", "upper_radius_limit"),
            ("angular_distance_vs_energy", "viewcone_radius"),
        )
        for suffix in ("", "_cumulative")
    },
}

_AXIS_LABELS = {
    "energy": "Energy (TeV)",
    "core_distance": "Core Distance (m)",
    "angular_distance": "Angular Distance (deg)",
    "core_x": "Core X (m)",
    "core_y": "Core Y (m)",
}

_LOG_SCALES = {"x": "log", "y": "log"}

_HISTOGRAM_STYLES = {
    "cr_rates_mc": {
        "title": "Cosmic Ray Rates (MC)",
        "value_label": "Cosmic Ray Rate (Hz)",
        "scales": _LOG_SCALES,
    },
    "trigger_rates": {
        "title": "Trigger Rates (MC)",
        "value_label": "Trigger Rate (Hz)",
        "scales": _LOG_SCALES,
    },
    "*_mc": {"title": "Simulated Events"},
    "*_eff": {"title": "Efficiency", "value_label": "Efficiency"},
    "*_cumulative": {"title": "Cumulative triggered events", "value_label": "Fraction of Events"},
    "reuse_*": {"title": "Triggered reuse"},
    "*": {"title": "Triggered Events"},
}

_REUSE_VALUE_LABELS = {"mean": "Mean Reuse", "max": "Max Reuse", "std": "Reuse Std Dev"}

_HIST_1D_PARAMS = {"color": "tab:green", "edgecolor": "tab:green", "lw": 1}
_HIST_2D_PARAMS = {"norm": "log", "cmap": "viridis"}
_HIST_2D_NORMALIZED_PARAMS = {"norm": "linear", "cmap": "viridis"}


[docs] def plot( histograms, output_path=None, limits=None, array_name=None, add_distance_projections=False, use_broad_range_limits=False, ): """ Plot simtel event histograms. Parameters ---------- histograms: EventDataHistograms Instance containing the histograms to plot. output_path: Path or str, optional Directory to save plots. If None, plots will be displayed. limits: dict, optional Dictionary containing limits for plotting. Keys can include: - "upper_radius_limit": Upper limit for core distance - "lower_energy_limit": Lower limit for energy - "viewcone_radius": Radius for the viewcone array_name: str, optional Name of the telescope array configuration. add_distance_projections: bool, optional Add overall and sliced x/y projections to raw distance-vs-energy plots. use_broad_range_limits: bool, optional Restrict plot axes to the broad-range simulation limits in ``limits``. """ _logger.info(f"Plotting histograms written to {output_path}") for name, histogram in histograms.items(): if histogram.ndim > 2: _logger.warning(f"Skipping plot {name} - plotting supports only 1D and 2D histograms") continue config, base_filename = _build_plot_config(name, histogram, limits) if use_broad_range_limits: config["axis_limits"] = _get_broad_range_axis_limits(name, limits) projection_kind = _PROJECTION_KINDS.get(name.removesuffix("_mc")) if add_distance_projections and projection_kind: config["projection_kind"] = projection_kind if config["data"] is None: _logger.warning(f"Skipping plot {name} - no data available") continue if array_name: config["labels"]["title"] += f" ({array_name})" filename = _build_plot_filename(base_filename, array_name, limits) _create_plot(**config, output_file=output_path / filename if output_path else None)
[docs] def plot_trigger_histograms(loaded_histograms, output_dir): """ Plot loaded trigger histograms, writing one directory per array layout. Parameters ---------- loaded_histograms : iterable Loaded histogram records, each paired with its array layout. output_dir : str or pathlib.Path Base directory where plot files will be written. """ output_dir = Path(output_dir) for _, record in loaded_histograms: output_path = output_dir / record.array_name output_path.mkdir(parents=True, exist_ok=True) plot({n: h for n, h in record.histograms.items() if h.ndim <= 2}, output_path=output_path)
[docs] def plot_monte_carlo_statistics_diagnostics( output_dir, array_name, file_info, energy_edges, angular_edges, expected_counts, relative_uncertainty, ): """ Write Monte Carlo statistics diagnostic plots. Parameters ---------- output_dir : str or pathlib.Path Directory to write plot files. array_name : str Array or telescope-selection name used in plot titles and filenames. file_info : dict, optional Simulation metadata (zenith, azimuth, nsb_level) to make filenames unique per setup. energy_edges : array-like Energy bin edges in TeV. angular_edges : array-like Angular-distance bin edges in deg. expected_counts : numpy.ndarray Expected triggered events per angular-distance and energy bin. relative_uncertainty : numpy.ndarray Poisson relative uncertainty per angular-distance and energy bin. """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) file_prefix = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(array_name)) file_info_suffix = _format_file_info_suffix(file_info or {}) if file_info_suffix: file_prefix = f"{file_prefix}_{file_info_suffix}" expected_title = f"Expected triggered events ({array_name})" relative_title = f"Expected relative uncertainty ({array_name})" specs = [ (expected_counts, "expected_events", expected_title, "Expected events", True), ( relative_uncertainty, "relative_uncertainty", relative_title, "Relative uncertainty", False, ), ] for matrix, suffix, title, colorbar_label, mask_zero in specs: plot_matrix = np.ma.masked_invalid(np.asarray(matrix, dtype=float)) if mask_zero: plot_matrix = np.ma.masked_less_equal(plot_matrix, 0.0) fig, ax = plt.subplots(figsize=(8, 6)) cmap = plt.get_cmap("viridis").with_extremes(bad="white") log_energy_edges = np.log10(np.asarray(energy_edges, dtype=float)) mesh = ax.pcolormesh( log_energy_edges, angular_edges, plot_matrix, shading="auto", cmap=cmap ) fig.colorbar(mesh, ax=ax, label=colorbar_label) ax.set(xlabel="log10(E / TeV)", ylabel="Angular distance (deg)", title=title) output_file = output_dir / f"{file_prefix}_{suffix}.png" save_figure(fig, output_file.with_suffix(""), figure_format=["png"], dpi=150, close=True)
def _get_limit_value(limits, key): """Return a plain scalar for one limits entry.""" item = limits.get(key) return getattr(item, "value", item) def _get_broad_range_axis_limits(name, limits): """Return axis limits from broad-range simulation metadata.""" if not limits: return {} core_max = _get_limit_value(limits, "br_core_scatter_max") viewcone_max = _get_limit_value(limits, "br_viewcone_max") lower = _get_limit_value(limits, "br_energy_min") upper = _get_limit_value(limits, "br_energy_max") derived = _get_limit_value(limits, "lower_energy_limit") # Leave room below the broad-range minimum when it coincides with the derived limit. if None not in (lower, upper, derived) and lower > 0.0 and np.isclose(derived, lower): lower *= 0.9 symmetric_core = (-core_max, core_max) if core_max is not None else (None, None) axis_limits_by_group = { "energy": {"x": (lower, upper)}, "core_distance_vs_energy": {"x": (0.0, core_max), "y": (lower, upper)}, "angular_distance_vs_energy": {"x": (0.0, viewcone_max), "y": (lower, upper)}, "core_distance": {"x": (0.0, core_max)}, "angular_distance": {"x": (0.0, viewcone_max)}, "x_core_shower_vs_y_core_shower": {"x": symmetric_core, "y": symmetric_core}, } # Groups are matched by name prefix in table order; the first match wins. group = next((g for g in axis_limits_by_group if name.startswith(g)), None) return axis_limits_by_group.get(group, {}) def _get_limits(name, limits): """Return the reference-line positions of a histogram, or None if it has none.""" sources = _LINE_LIMITS.get(name) if sources is None: return None return { axis: limits.get(key) if axis == "curve" else getattr(limits.get(key), "value", None) for axis, key in sources.items() } def _style_key(name): """Return the key of the histogram style table that applies to a histogram name.""" if name in ("cr_rates_mc", "trigger_rates"): return name suffix = next((x for x in ("_mc", "_eff", "_cumulative") if name.endswith(x)), None) if suffix: return f"*{suffix}" return "reuse_*" if name.startswith("reuse_") else "*" def _reuse_default_scales(axis_names): """Return default axis scales for reuse histograms (log for energy axes).""" names_by_axis = dict(zip("xy", axis_names)) return {axis: "log" if names_by_axis.get(axis) == "energy" else "linear" for axis in "xy"} def _default_scales(name, histogram): """Return default axis scales derived from the histogram name and dimensionality.""" axis_names = histogram.axis_names or [] base_name = name for x in ("_mc", "_eff", "_cumulative"): base_name = base_name.removesuffix(x) if histogram.ndim == 1 and base_name == "energy": return _LOG_SCALES if histogram.ndim == 1 and base_name in {"core_distance", "angular_distance"}: return {"y": "log"} if histogram.ndim == 2 and "energy" in axis_names: return {"y": "log"} return {} def _histogram_style(name, histogram): """Return title, axis labels, scales and value label selected by histogram name.""" style_key = _style_key(name) style = _HISTOGRAM_STYLES[style_key] axis_names = histogram.axis_names or [] axis_labels = [_AXIS_LABELS.get(axis, axis) for axis in axis_names] title, value_label = style["title"], style.get("value_label", "Event Count") if style_key == "reuse_*": statistic = name.split("_")[1] title = f"{title} {statistic}" value_label = _REUSE_VALUE_LABELS.get(statistic, value_label) default_scales = _reuse_default_scales(axis_names) else: default_scales = _default_scales(name, histogram) scales = dict(style.get("scales", default_scales)) return title, axis_labels, scales, value_label def _build_plot_config(name, histogram, limits): """ Return the keyword arguments of _create_plot for one histogram. Returns ------- tuple ``(config, base_filename)``; the base filename is not part of the config dict. """ title, axis_labels, scales, value_label = _histogram_style(name, histogram) is_1d = histogram.ndim == 1 lowered = name.lower() normalized = ( lowered.startswith("reuse_") or "cumulative" in lowered or "efficiency" in lowered ) or lowered.endswith("_eff") if is_1d: plot_params = _HIST_1D_PARAMS y_label = value_label else: plot_params = _HIST_2D_NORMALIZED_PARAMS if normalized else _HIST_2D_PARAMS y_label = axis_labels[1] if len(axis_labels) > 1 else None config = { "data": histogram.values, "bins": histogram.edges[0] if is_1d else [histogram.edges[0], histogram.edges[1]], "plot_type": "histogram" if is_1d else "histogram2d", "plot_params": plot_params, "labels": { "x": axis_labels[0] if axis_labels else None, "y": y_label, "title": f"{title}: {name.replace('_', ' ')}", }, "scales": scales, "lines": _get_limits(name, limits) if limits else {}, } if not is_1d: config["colorbar_label"] = value_label return config, _PLOT_FILENAME_OVERRIDES.get(name, name) def _build_plot_filename(base_filename, array_name=None, file_info=None): """Return the PNG filename built from the base name, array name and file-info suffix.""" parts = [base_filename, array_name, _format_file_info_suffix(file_info or {})] return "_".join(part for part in parts if part) + ".png" def _format_file_info_suffix(file_info): """Return a filename-safe suffix such as ``z20_az0_nsb0.3`` from file-info metadata.""" parts = [] for key, prefix in (("zenith", "z"), ("azimuth", "az")): value = file_info.get(key) if value is not None: parts.append( f"{prefix}{round(value.value if hasattr(value, 'value') else float(value))}" ) if file_info.get("nsb_level") is not None: parts.append(f"nsb{file_info['nsb_level']:g}") return "_".join(parts) def _create_plot( data, bins=None, plot_type="histogram", plot_params=None, labels=None, scales=None, colorbar_label=None, output_file=None, lines=None, axis_limits=None, projection_kind=None, ): """Create and save (or show) a plot with the given parameters.""" if data is None or (isinstance(data, np.ndarray) and data.size == 0): _logger.warning("No data available for plotting") return None plot_params, labels = plot_params or {}, labels or {} scales, lines, axis_limits = scales or {}, lines or {}, axis_limits or {} if plot_type == "histogram2d" and projection_kind: fig = plt.figure(figsize=(10.4, 6), constrained_layout=True) grid = fig.add_gridspec(4, 2, width_ratios=(3.6, 2.1), wspace=0.08, hspace=0.08) ax = fig.add_subplot(grid[:, 0]) ax_x, ax_y = fig.add_subplot(grid[:2, 1]), fig.add_subplot(grid[2:, 1]) pcm = _create_2d_histogram_plot(data, bins, plot_params, ax=ax) fig.colorbar(pcm, ax=ax, label=colorbar_label, pad=0.01) _plot_distance_projections( ax_x, ax_y, data, bins, labels, axis_limits, lines, projection_kind ) else: fig, ax = plt.subplots(figsize=(8, 6)) if plot_type == "histogram": ax.bar(bins[:-1], data, width=np.diff(bins), **plot_params) elif plot_type == "histogram2d": plt.colorbar(_create_2d_histogram_plot(data, bins, plot_params), label=colorbar_label) _add_lines(ax, lines) ax.set( xlabel=labels.get("x", ""), ylabel=labels.get("y", ""), title=labels.get("title", ""), xscale=scales.get("x", "linear"), yscale=scales.get("y", "linear"), ) _apply_axis_limits(ax, axis_limits) if not output_file: plt.tight_layout() plt.show() return fig output_file = Path(output_file) _logger.info(f"Saving plot to {output_file}") file_format = output_file.suffix[1:] save_figure(fig, output_file.with_suffix(""), figure_format=[file_format], dpi=300, close=True) return fig def _apply_axis_limits(ax, axis_limits): """Apply finite, non-degenerate axis limits.""" for axis_name, setter in (("x", ax.set_xlim), ("y", ax.set_ylim)): bounds = axis_limits.get(axis_name) if bounds and None not in bounds and np.isfinite(bounds).all() and bounds[1] > bounds[0]: setter(*bounds) def _plot_distance_projections(ax_x, ax_y, data, bins, labels, axis_limits, lines, projection_kind): """Plot overall projections and fixed-coordinate slices of a 2D histogram.""" x_centers = 0.5 * (bins[0][:-1] + bins[0][1:]) y_centers = 0.5 * (bins[1][:-1] + bins[1][1:]) ax_x.step(x_centers, np.sum(data, axis=1), where="mid", color="black", label="overall") ax_y.step(y_centers, np.sum(data, axis=0), where="mid", color="black", label="overall") for value, label in _slice_values(bins[1], axis_limits.get("y")): index = np.clip(np.searchsorted(bins[1], value, side="right") - 1, 0, len(bins[1]) - 2) if np.any(data[:, index] > 0): ax_x.step(x_centers, data[:, index], where="mid", label=label) for value, label in _slice_values(bins[0], axis_limits.get("x"), projection_kind): index = np.clip(np.searchsorted(bins[0], value, side="right") - 1, 0, len(bins[0]) - 2) if np.any(data[index, :] > 0): ax_y.step(y_centers, data[index, :], where="mid", label=label) count = "Event count" x_label, y_label = labels.get("x", ""), labels.get("y", "") ax_x.set(xlabel=x_label, ylabel=count, title="Projection distance axis", yscale="log") ax_y.set( xlabel=y_label, ylabel=count, title="Projection energy axis", xscale="log", yscale="log" ) _apply_axis_limits(ax_x, {"x": axis_limits.get("x")}) _apply_axis_limits(ax_y, {"x": axis_limits.get("y")}) _add_lines(ax_x, {"x": lines.get("x")}) _add_lines(ax_y, {"x": lines.get("y")}) for ax in (ax_x, ax_y): handles, legend_labels = ax.get_legend_handles_labels() if handles: ax.legend(handles, legend_labels, fontsize="x-small", loc="upper right") def _slice_values(bin_edges, plot_limits, projection_kind=None): """Return fixed slice coordinates and labels (energy when ``projection_kind`` is None).""" lower = plot_limits[0] if plot_limits and plot_limits[0] is not None else bin_edges[0] upper = plot_limits[1] if plot_limits and plot_limits[1] is not None else bin_edges[-1] if projection_kind is None: if lower <= 0 or upper <= 0: return [] exponents = np.arange(-1.5, np.log10(upper) + 1.0e-12, 1.0) return [ (10.0**exponent, f"log10(E/TeV)={exponent:g}") for exponent in exponents if lower <= 10.0**exponent <= upper ] step = 500.0 if projection_kind == "core_distance" else 1.0 unit = "m" if projection_kind == "core_distance" else "deg" values = np.arange(0.0, upper + 1.0e-12, step) return [(value, f"{value:g} {unit}") for value in values if lower <= value <= upper] def _add_lines(ax, lines): """Add reference lines (circle, vertical, horizontal and curve) to the plot.""" if lines.get("r") is not None: style = {"color": "r", "fill": False, "linestyle": "--", "linewidth": 0.5} ax.add_artist(plt.Circle((0, 0), lines["r"], **style)) for axis, add_line in (("x", ax.axvline), ("y", ax.axhline)): for value in np.atleast_1d(lines.get(axis, [])): if value is not None: add_line(value, color="r", linestyle="--", linewidth=0.5) curve = lines.get("curve") if curve: curve_x = np.asarray(curve.get("x", []), dtype=float).ravel() curve_y = np.asarray(curve.get("y", []), dtype=float).ravel() if curve_x.size and curve_x.size == curve_y.size: marker = {"marker": "o", "markersize": 3.0} if curve_x.size == 1 else {"marker": "None"} ax.plot(curve_x, curve_y, color="r", linestyle="--", linewidth=1.0, **marker) def _create_2d_histogram_plot(data, bins, plot_params, ax=None): """Draw a 2D histogram with linear or logarithmic color scaling and return its mesh.""" cmap = plt.get_cmap(plot_params.get("cmap", "viridis")).with_extremes(bad=(1.0, 1.0, 1.0, 0.0)) plotter = ax if ax is not None else plt if plot_params.get("norm") == "linear": finite_values = np.asarray(data, dtype=float)[np.isfinite(data)] data_min = float(np.min(finite_values)) if finite_values.size else 0.0 data_max = float(np.max(finite_values)) if finite_values.size else 1.0 data_max = data_min + 1.0 if data_max <= data_min else data_max masked_data, norm_kwargs = ( np.ma.masked_invalid(data.T), {"vmin": data_min, "vmax": data_max}, ) else: masked_data = np.ma.masked_less_equal(data.T, 0) data_max = data.max() if data_max <= 0: _logger.warning("No positive data found for logarithmic scaling, using linear scale") norm_kwargs = {"vmin": 0, "vmax": max(1, data_max)} else: # Ensure vmin is less than vmax for LogNorm vmin = max(1, data[data > 0].min()) if np.any(data > 0) else 1 norm_kwargs = {"norm": colors.LogNorm(vmin=vmin, vmax=max(vmin + 1, data_max))} return plotter.pcolormesh(bins[0], bins[1], masked_data, cmap=cmap, **norm_kwargs)