Source code for visualization.plot_resource_requirements

"""Plot normalized CORSIKA and sim_telarray resource requirements."""

import logging
from pathlib import Path

import numpy as np
from matplotlib import colormaps

from simtools.visualization.matplotlib_backend import pyplot as plt
from simtools.visualization.visualize import pdf_report, save_figure

_ROLE_STYLE = {
    "sim_telarray": {"marker": "s", "zorder": 3},
    "corsika": {"marker": "o", "zorder": 4},
}
_BYTES_PER_MEGABYTE = 1_000_000
_BYTES_PER_GIGABYTE = 1_000_000_000
_RESOURCE_CHANGE_WARNING_FACTOR = 1.25
_RESOURCE_CHANGE_MAJOR_FACTOR = 1.5
_RATIO_CONFIGURATION_COLUMNS = ("primary", "site", "array_layout_name", "model_version")
_STORAGE = "sim_telarray storage"
_SIMTEL = "sim_telarray"
_CORSIKA = "corsika"
_SIMTEL_OUTPUT = "sim_telarray output"
_REDUCED = "reduced event data"
_HISTOGRAM = "sim_telarray histogram"
# (column, label, file name stem, process role or None for both roles)
_DIAGNOSTIC_PLOTS = (
    ("wall_time_seconds", "Wall time (s/run)", "resource_wall_time_run", None),
    ("cpu_time_seconds", "CPU time (s/run)", "resource_cpu_time_run", None),
    ("wall_time_seconds_per_event", "Wall time (s/event)", "resource_wall_time", None),
    ("cpu_time_seconds_per_event", "CPU time (s/event)", "resource_cpu_time", None),
    ("peak_rss_bytes", "Peak RSS", "resource_peak_rss", None),
    ("sim_telarray_storage_bytes", _STORAGE, "resource_storage_run", None),
    ("sim_telarray_storage_bytes_per_event", _STORAGE, "resource_storage", None),
    ("corsika_output_bytes", "CORSIKA output", "resource_corsika_output_run", _CORSIKA),
    ("corsika_output_bytes_per_event", "CORSIKA output", "resource_corsika_output", _CORSIKA),
    ("sim_telarray_output_bytes", _SIMTEL_OUTPUT, "resource_sim_telarray_output_run", _SIMTEL),
    (
        "sim_telarray_output_bytes_per_event",
        _SIMTEL_OUTPUT,
        "resource_sim_telarray_output",
        _SIMTEL,
    ),
    ("reduced_event_data_bytes", _REDUCED, "resource_reduced_event_data_run", _SIMTEL),
    ("reduced_event_data_bytes_per_event", _REDUCED, "resource_reduced_event_data", _SIMTEL),
    ("sim_telarray_histogram_bytes", _HISTOGRAM, "resource_sim_telarray_histogram_run", _SIMTEL),
    (
        "sim_telarray_histogram_bytes_per_event",
        _HISTOGRAM,
        "resource_sim_telarray_histogram",
        _SIMTEL,
    ),
    (
        "wall_time_seconds_per_triggered_event",
        "Wall time (s/triggered event)",
        "resource_wall_time_triggered",
        _SIMTEL,
    ),
    (
        "cpu_time_seconds_per_triggered_event",
        "CPU time (s/triggered event)",
        "resource_cpu_time_triggered",
        _SIMTEL,
    ),
    (
        "sim_telarray_storage_bytes_per_triggered_event",
        _STORAGE,
        "resource_storage_triggered",
        _SIMTEL,
    ),
    (
        "sim_telarray_output_bytes_per_triggered_event",
        _SIMTEL_OUTPUT,
        "resource_sim_telarray_output_triggered",
        _SIMTEL,
    ),
    (
        "reduced_event_data_bytes_per_triggered_event",
        _REDUCED,
        "resource_reduced_event_data_triggered",
        _SIMTEL,
    ),
    (
        "sim_telarray_histogram_bytes_per_triggered_event",
        _HISTOGRAM,
        "resource_sim_telarray_histogram_triggered",
        _SIMTEL,
    ),
)
_COMPACT_PLOTS = (
    ("wall_time_seconds_per_event", "Wall time (s/event)"),
    ("cpu_time_seconds_per_event", "CPU time (s/event)"),
    ("peak_rss_bytes", "Peak RSS (bytes)"),
    ("sim_telarray_storage_bytes_per_event", "Storage (bytes/event)"),
)
_logger = logging.getLogger(__name__)


[docs] def plot(rows, output_path, figure_format=None, report_mode="diagnostic"): """Write resource plots versus energy. Parameters ---------- rows : iterable[dict] Normalized process rows returned by the resource collector. output_path : str or pathlib.Path Destination directory for the figures. figure_format : iterable[str], optional File formats passed to :func:`visualization.visualize.save_figure`. report_mode : str, optional ``"diagnostic"`` writes one figure per metric and role, ``"compact"`` a multipage PDF, and ``"none"`` writes nothing. Returns ------- list[pathlib.Path] Base paths of the written figures. Notes ----- Byte-based quantities are plotted in MB or GB, selected from the largest value in each plot, using decimal units. Per-run plots use the ``*_bytes`` and raw time columns; per-event and per-triggered-event plots use their normalized columns. When both baseline and candidate rows are present, additional plots show candidate-to-baseline ratios for matching primary, site, layout, and model-version configurations. """ if report_mode not in {"compact", "diagnostic", "none"}: raise ValueError(f"Unsupported report mode: {report_mode}") rows = list(rows) output_path = Path(output_path) if report_mode == "none": return [] if report_mode == "compact": return _write_compact_report(rows, output_path) return _write_diagnostic_plots(rows, output_path, figure_format)
def _write_diagnostic_plots(rows, output_path, figure_format): """Write absolute and ratio figures for the configured resource metrics.""" output_files = [] for column, label, filename, required_role in _DIAGNOSTIC_PLOTS: available = [ row for row in rows if row.get(column) is not None and row.get("energy_midpoint_gev") is not None and row["energy_midpoint_gev"] > 0 and (required_role is None or row["role"] == required_role) and (not column.startswith("sim_telarray_storage") or row["role"] == "sim_telarray") ] if not available: continue plot_label, value_scale = _byte_plot_label(column, label, available) zenith_colors = _zenith_colors( sorted({float(row.get("zenith_angle_deg", 0.0)) for row in available}) ) for role in sorted({row["role"] for row in available}): role_rows = [row for row in available if row["role"] == role] fig, axis = plt.subplots(figsize=(8, 5)) _plot_role(axis, role_rows, column, zenith_colors, value_scale=value_scale) axis.set_yscale(_resource_y_scale(role_rows, column)) _finish_axis(axis, plot_label, f"{plot_label}: {role}") output_file = output_path / f"{filename}_{role}" save_figure(fig, output_file, figure_format=figure_format, dpi=300, close=True) output_files.append(output_file) ratio_output = _write_ratio_plot( role_rows, output_path, column, filename, plot_label, zenith_colors, role, figure_format, ) if ratio_output is not None: output_files.append(ratio_output) return output_files def _write_ratio_plot( rows, output_path, column, filename, plot_label, zenith_colors, role, figure_format ): """Write a candidate-to-baseline ratio plot when both roles have valid data.""" labels = _comparison_display_labels(rows) ratio_series = _ratio_series(rows, column) if {"baseline", "candidate"} <= set(labels) else {} if not ratio_series: return None fig, axis = plt.subplots(figsize=(8, 5)) _plot_ratio(axis, ratio_series, role, zenith_colors) ratio_label = f"{labels['candidate']} / {labels['baseline']}" _warn_on_large_changes(ratio_series, plot_label, role, ratio_label) axis.axhline(1.0, color="black", linestyle="--", linewidth=1.0) _finish_axis(axis, ratio_label, f"{plot_label} ratio: {ratio_label}: {role}") output_file = output_path / f"{filename}_ratio_{role}" save_figure(fig, output_file, figure_format=figure_format, dpi=300, close=True) return output_file def _write_compact_report(rows, output_path): """Write a short multipage resource report without diagnostic image files.""" output_file = output_path / "resources-report.pdf" figures = [] for column, label in _COMPACT_PLOTS: available = [row for row in rows if row.get(column) is not None] if not available: continue roles = sorted({row["role"] for row in available}) fig, axes = plt.subplots(1, len(roles), figsize=(9, 5), squeeze=False) for role, axis in zip(roles, axes[0]): role_rows = [row for row in available if row["role"] == role] zeniths = sorted({float(row.get("zenith_angle_deg", 0.0)) for row in role_rows}) _plot_role(axis, role_rows, column, _zenith_colors(zeniths)) axis.set_yscale(_resource_y_scale(role_rows, column)) _finish_axis(axis, label, role, fontsize=6) fig.suptitle(label) fig.tight_layout() figures.append(fig) with pdf_report(output_file) as report: for figure in figures: report.add(figure) return [output_file] def _finish_axis(axis, ylabel, title, **legend_kwargs): """Apply the shared log-energy axis scale, labels, grid, and legend.""" axis.set_xscale("log") axis.set_xlabel("Energy midpoint (GeV)") axis.set_ylabel(ylabel) axis.set_title(title) axis.grid(alpha=0.25) axis.legend(**legend_kwargs) def _resource_y_scale(rows, column): """Return a y-axis scale that can represent all selected resource values.""" return "linear" if any(float(row[column]) <= 0 for row in rows) else "log" def _byte_plot_label(column, label, rows): """Return a human-readable label and scale factor; byte columns use MB or GB.""" if "_bytes" not in column: return label, 1.0 maximum = max(float(row[column]) for row in rows) if maximum >= _BYTES_PER_GIGABYTE: unit, scale = "GB", 1 / _BYTES_PER_GIGABYTE else: unit, scale = "MB", 1 / _BYTES_PER_MEGABYTE if column == "peak_rss_bytes": suffix = "" elif column.endswith("_per_triggered_event"): suffix = "/triggered event" elif column.endswith("_bytes"): suffix = "/run" else: suffix = "/event" return f"{label} ({unit}{suffix})", scale def _ratio_series(rows, column): """Return candidate-to-baseline ratios grouped by configuration, zenith, and energy.""" values = {} for row in rows: production = _comparison_role(row) if production not in {"baseline", "candidate"}: continue key = ( production, _ratio_configuration_key(row), float(row.get("zenith_angle_deg", 0.0)), row["energy_midpoint_gev"], ) values.setdefault(key, []).append(float(row[column])) series = {} for configuration, zenith in {(key[1], key[2]) for key in values}: baseline = _statistics_by_energy(values, "baseline", configuration, zenith) candidate = _statistics_by_energy(values, "candidate", configuration, zenith) points = [] for energy in sorted(set(baseline) & set(candidate)): baseline_mean, baseline_rms, baseline_count = baseline[energy] candidate_mean, candidate_rms, candidate_count = candidate[energy] if baseline_mean <= 0 or candidate_mean <= 0: continue ratio = candidate_mean / baseline_mean baseline_error = baseline_rms / np.sqrt(baseline_count) candidate_error = candidate_rms / np.sqrt(candidate_count) ratio_error = ratio * np.sqrt( (candidate_error / candidate_mean) ** 2 + (baseline_error / baseline_mean) ** 2 ) points.append((energy, ratio, ratio_error)) if points: series[(configuration, zenith)] = points return series def _ratio_configuration_key(row): """Return configuration dimensions used to match ratio series.""" return tuple( "unknown" if row.get(column) is None else str(row[column]) for column in _RATIO_CONFIGURATION_COLUMNS ) def _comparison_role(row): """Return the stable baseline/candidate role for a resource row.""" return row.get("comparison_role") or row.get("production_label") def _comparison_display_labels(rows): """Return display labels for baseline and candidate rows.""" labels = {} for row in rows: comparison_role = _comparison_role(row) if comparison_role in {"baseline", "candidate"}: labels.setdefault(comparison_role, row.get("production_label", comparison_role)) return labels def _warn_on_large_changes(series, plot_label, role, ratio_label): """Warn once for the largest substantial candidate-to-baseline change.""" varying_dimensions = _ratio_varying_dimensions(series) changes = [ (configuration, zenith, energy, ratio) for (configuration, zenith), points in series.items() for energy, ratio, _ in points if np.isfinite(ratio) and ratio > 0 and max(ratio, 1 / ratio) >= _RESOURCE_CHANGE_WARNING_FACTOR ] if not changes: return configuration, zenith, energy, ratio = max(changes, key=lambda item: max(item[3], 1 / item[3])) factor = max(ratio, 1 / ratio) _logger.warning( "%s resource change in %s for %s at %s and energy %.6g GeV: %s shows a %.3g-fold %s.", "Major" if factor >= _RESOURCE_CHANGE_MAJOR_FACTOR else "Large", plot_label, role, _ratio_series_label(configuration, zenith, varying_dimensions), energy, ratio_label, factor, "increase" if ratio >= 1 else "decrease", ) def _statistics_by_energy(values, production, configuration, zenith): """Return means, RMS spreads, and sample counts keyed by energy.""" return { energy: ( float(np.mean(samples)), float(np.sqrt(np.mean((np.asarray(samples) - np.mean(samples)) ** 2))), len(samples), ) for (label, sample_configuration, sample_zenith, energy), samples in values.items() if label == production and sample_configuration == configuration and sample_zenith == zenith } def _plot_ratio(axis, series, role, zenith_colors): """Plot candidate-to-baseline ratios with propagated mean errors.""" varying_dimensions = _ratio_varying_dimensions(series) style = _ROLE_STYLE.get(role, {}) for series_index, ((configuration, zenith), points) in enumerate(sorted(series.items())): energies, ratios, errors = zip(*points, strict=True) axis.errorbar( energies, ratios, yerr=errors, fmt=style.get("marker", "o"), label=_ratio_series_label(configuration, zenith, varying_dimensions), color=zenith_colors[zenith], markerfacecolor=zenith_colors[zenith], markeredgecolor="black", linestyle=("-", "--", ":", "-.")[series_index % 4], markersize=8, capsize=3, linewidth=1.0, zorder=style.get("zorder", 3) + 1, ) bounds = [ bound for points in series.values() for _, ratio, error in points for bound in (ratio - error, ratio + error) ] lower, upper = min([*bounds, 1.0]), max([*bounds, 1.0]) margin = max((upper - lower) * 0.1, 0.1) axis.set_ylim(max(0.0, lower - margin), upper + margin) axis.set_yscale("linear") def _ratio_varying_dimensions(series): """Return configuration dimensions that differ between ratio series.""" configurations = {configuration for configuration, _ in series} return tuple( index for index in range(len(_RATIO_CONFIGURATION_COLUMNS)) if len({configuration[index] for configuration in configurations}) > 1 ) def _ratio_series_label(configuration, zenith, varying_dimensions): """Return a compact label for a configuration-matched ratio series.""" if not varying_dimensions: return f"za={zenith:g} deg" configuration_label = ", ".join( f"{_RATIO_CONFIGURATION_COLUMNS[index]}={configuration[index]}" for index in varying_dimensions ) return f"{configuration_label}; za={zenith:g} deg" def _series_key(row): """Return the identity of one plotted series (production, version, production id, zenith).""" return ( row.get("production_label", "production"), row.get("model_version", row.get("simtools_version", "unknown")), row.get("production_id", "unknown"), float(row.get("zenith_angle_deg", 0.0)), ) def _plot_role(axis, rows, column, zenith_colors, value_scale=1.0): """Plot individual samples and averages for one process role.""" keys = sorted({_series_key(row) for row in rows}) role = rows[0]["role"] show_production_labels = any(row.get("production_label") for row in rows) comparison_roles = {_comparison_role(row) for row in rows} is_comparison = {"baseline", "candidate"} <= comparison_roles seen_series = set() for series_index, key in enumerate(keys): production, _, _, zenith = key series_rows = [row for row in rows if _series_key(row) == key] series_id = (production, zenith) if series_id in seen_series: legend_label = "_nolegend_" elif is_comparison or (show_production_labels and "baseline" not in comparison_roles): legend_label = f"{production}: za={zenith:g} deg" else: legend_label = f"za={zenith:g} deg" seen_series.add(series_id) _plot_averages( axis, series_rows, column, role, zenith_colors[zenith], legend_label, series_index, value_scale, _comparison_role(series_rows[0]), ) def _plot_averages( axis, rows, column, role, color, legend_label, series_index=0, value_scale=1.0, comparison_role=None, ): """Overlay one mean and RMS error bar for each energy in a series.""" grouped = {} for row in rows: grouped.setdefault(row["energy_midpoint_gev"], []).append(row[column] * value_scale) style = _ROLE_STYLE.get(role, {}) energies = sorted(grouped) means = [float(np.mean(grouped[energy])) for energy in energies] rms = [ float(np.sqrt(np.mean((values - np.mean(values)) ** 2))) for values in (np.asarray(grouped[energy], dtype=float) for energy in energies) ] axis.errorbar( energies, means, yerr=rms, fmt=style.get("marker", "o"), label=legend_label, color=color, markerfacecolor=color, markeredgecolor="black", linestyle={"baseline": "-", "candidate": "--"}.get( comparison_role, ("-", "--", ":", "-.")[series_index % 4] ), markersize=8, capsize=3, linewidth=1.0, zorder=style.get("zorder", 3) + 1, ) def _zenith_colors(zenith_values): """Return stable colors for the zenith values present in one plot.""" colormap = colormaps["viridis"] denominator = max(len(zenith_values) - 1, 1) return {zenith: colormap(index / denominator) for index, zenith in enumerate(zenith_values)}