Source code for visualization.plot_tables

#!/usr/bin/python3
"""Plot tabular data."""

import logging
from pathlib import Path

import numpy as np
import packaging.version
from astropy.table import Table

from simtools.constants import SCHEMA_PATH
from simtools.context import RunContext
from simtools.io import ascii_handler, legacy_data_handler
from simtools.utils import value_conversion
from simtools.visualization import visualize
from simtools.visualization.matplotlib_backend import pyplot as plt

_logger = logging.getLogger(__name__)

# Fraction of the scaled proton rate that is intersected with the NSB rate
PROTON_INTERSECTION_FRACTION = 0.1


[docs] def plot(config, output_file, data_path=None, model_reader=None, *, context=None): """ Plot tabular data from data or from model parameter files. Parameters ---------- config: dict Configuration dictionary for plotting. output_file: str Output file. data_path: Path or str, optional Path to the data files. All files are expected in the same directory. """ context = context or RunContext.from_environment() data = read_table_data(config, data_path, model_reader=model_reader, context=context) return _plot_data(data, config, output_file, context=context)
def _plot_data(data, config, output_file, *, context=None): """Render pre-read table data with the configured table-plot renderer.""" visualize.save_figure( visualize.plot_1d(data, **config), output_file, close=True, context=context ) return output_file
[docs] def read_table_data(config, data_path=None, model_reader=None, *, context=None): """ Read table data from file or parameter repository. Parameters ---------- config: dict Configuration dictionary for plotting. data_path: Path or str, optional Path to the data files. All files are expected in the same directory. Returns ------- Dict Dict with table data (numpy structured arrays). """ context = context or RunContext.from_environment() model_reader = model_reader or context.model_reader data = {} for table_config in config["tables"]: if "file_name" in table_config: table = _read_table_file(table_config, data_path) elif "parameter" in table_config: table = _read_table_from_model_repository(table_config, model_reader, context=context) else: raise ValueError("No table data defined in configuration.") data[_get_plotting_label(table_config, data)] = _process_table_data(table, table_config) return data
def _read_table_file(table_config, data_path): """Read one configured legacy or ECSV table file.""" file_name = table_config["file_name"] if data_path is not None and not table_config.get("ignore_table_data_path", False): file_name = Path(data_path) / file_name _logger.info(f"Reading tabular data from {file_name}") if "legacy" in table_config.get("type", ""): return legacy_data_handler.read_legacy_data_as_table(file_name, table_config["type"]) if Path(file_name).suffix.lower() != ".ecsv": raise ValueError(f"Model table files must use ECSV format: {file_name}") return Table.read(file_name, format="ascii.ecsv") def _get_plotting_label(config, data): """Get a label for plotting based on the configuration, made unique within data.""" label = config.get("label", f"{config.get('column_x')} vs {config.get('column_y')}") if label in data: index = 1 while f"{label} ({index})" in data: index += 1 label = f"{label} ({index})" return label def _process_table_data(table, _config): """Normalise, select and convert table columns to a structured array.""" if _config.get("normalize_y"): table[_config["column_y"]] = table[_config["column_y"]] / table[_config["column_y"]].max() if _config.get("select_values"): table = _select_values_from_table( table, _config["select_values"]["column_name"], _config["select_values"]["value"], ) columns = [ _config["column_x"], _config["column_y"], _config.get("column_x_err"), _config.get("column_y_err"), ] columns = [column for column in columns if column in table.colnames] return np.array( list(zip(*(np.array(table[column]) for column in columns))), dtype=[(column, np.array(table[column]).dtype) for column in columns], ) def _read_table_from_model_repository(table_config, model_reader=None, *, context=None): """Export a table from the simulation-model repository (returns an astropy Table).""" context = context or RunContext.from_environment() model_reader = model_reader or context.model_reader if model_reader is None: raise RuntimeError("No simulation model reader is configured.") destination = table_config.get("model_export_path") or context.paths.get_output_directory() return model_reader.export_model_file( parameter=table_config["parameter"], site=table_config["site"], array_element_name=table_config.get("telescope"), parameter_version=table_config.get("parameter_version"), model_version=table_config.get("model_version"), export_file_as_table=True, dest=destination, ) def _read_parameter_dict_from_model_repository(table_config, model_reader=None, *, context=None): """Read a model parameter dictionary from the simulation-model repository.""" context = context or RunContext.from_environment() model_reader = model_reader or context.model_reader if model_reader is None: raise RuntimeError("No simulation model reader is configured.") parameter_dict = model_reader.get_model_parameter( parameter=table_config["parameter"], site=table_config["site"], array_element_name=table_config.get("telescope"), parameter_version=table_config.get("parameter_version"), model_version=table_config.get("model_version"), ) return parameter_dict[table_config["parameter"]] def _select_values_from_table(table, column_name, value): """Return a table with only the rows where column_name == value.""" column = table[column_name] if getattr(column, "unit", None) is not None: if hasattr(value, "to_value"): value = value.to_value(column.unit) return table[np.isclose(column.value, value)] return table[np.isclose(column, value)] def _validate_config_columns(config, valid_columns, logger): """Return True if the x and y columns of all tables of a config have valid data.""" for table_config in config.get("tables", []): required_cols = [table_config.get("column_x"), table_config.get("column_y")] if not all(col in valid_columns for col in required_cols if col): missing_cols = [col for col in required_cols if col not in valid_columns] logger.info( f"Skipping plot config {config.get('type')}: " f"Missing valid data in columns: {missing_cols}" ) return False return True def _select_schema_entry(schema_data, schema_version=None): """Return the schema dict matching schema_version or the newest available one.""" if isinstance(schema_data, dict): return schema_data if isinstance(schema_data, list) and schema_data: if schema_version is not None: for entry in schema_data: if entry.get("schema_version") == schema_version: return entry return max( schema_data, key=lambda entry: packaging.version.Version(entry.get("schema_version", "0.0.0")), ) return {}
[docs] def generate_plot_configurations( parameter, parameter_version, site, telescope, output_path, plot_type, model_reader=None, model_version=None, *, context=None, ): """ Generate plot configurations for a model parameter from schema files. Parameters ---------- parameter: str Model parameter name. parameter_version: str Parameter version. site: str Site name. telescope: str Telescope name. output_path: str or Path Output path for the plots. plot_type: str Plot type or "all" for all plots. model_version: str, optional Exact model version used to read the table data. Returns ------- tuple or None List of plot configurations and list of output file names, or None if no plot configurations are found. """ logger = logging.getLogger(__name__) table_config = { "parameter": parameter, "site": site, "telescope": telescope, "parameter_version": parameter_version, } if model_version: table_config["model_version"] = model_version parameter_dict = _read_parameter_dict_from_model_repository( table_config, model_reader, context=context ) schema = value_conversion.change_dict_keys_case( _select_schema_entry( ascii_handler.collect_data_from_file( file_name=SCHEMA_PATH / "model_parameters" / f"{parameter}.schema.yml" ), parameter_dict.get("model_parameter_schema_version"), ) ) configs = schema.get("plot_configuration") if not configs: return None table = _read_table_from_model_repository(table_config, model_reader, context=context) valid_columns = [col for col in table.colnames if not all(np.isnan(table[col]))] valid_configs = [ config for config in configs if (plot_type == "all" or config.get("type") == plot_type) and _validate_config_columns(config, valid_columns, logger) ] if not valid_configs: if plot_type != "all": logger.warning("No valid plot config found.") return None output_files = [] for _config in valid_configs: for _table in _config.get("tables", []): _table["parameter_version"] = parameter_version _table["site"] = site _table["telescope"] = telescope if model_version: _table["model_version"] = model_version output_files.append( _generate_output_file_name( parameter=parameter, parameter_version=parameter_version, site=site, telescope=telescope, plot_type=_config.get("type"), output_path=output_path, ) ) return valid_configs, output_files
def _generate_output_file_name( parameter, parameter_version, site, telescope, plot_type, output_path=None, file_extension=".pdf", ): """Generate output file name based on table file and appendix.""" parts = [parameter, parameter_version, site] if telescope: parts.append(telescope) if plot_type != parameter: parts.append(plot_type) filename = "_".join(parts) + file_extension return Path(output_path) / filename if output_path else Path(filename)
[docs] def resolve_plot_output_path(output, file_name="bias_curve.png"): """Resolve output as either an explicit plot file or an output directory.""" output_path = Path(output) if output_path.suffix: return output_path return output_path / file_name
[docs] def plot_bias_curves(nsb_stats, proton_stats, config, output_path, trigger_threshold=None): """ Plot NSB and proton bias curves. Parameters ---------- nsb_stats : dict NSB statistics by threshold. proton_stats : dict Proton statistics by threshold. config : dict Plot configuration with title, ymin, ymax, and scaling_factor. output_path : Path or str Output path for plot image. trigger_threshold : float, optional Trigger threshold value to annotate on the plot. """ fig, axis = plt.subplots(figsize=(10, 7)) _plot_nsb_curve(axis, nsb_stats) _plot_proton_curve(axis, proton_stats) scaling_factor = config.get("scaling_factor", 1.35) _plot_scaled_proton_curve(axis, proton_stats, scaling_factor) _plot_intersection_proton_curve(axis, proton_stats, scaling_factor) # Add vertical line at trigger threshold if provided (before legend creation) if trigger_threshold is not None: axis.axvline( x=trigger_threshold, color="grey", linestyle="--", linewidth=1.5, alpha=0.5, label=f"Trigger Threshold: {trigger_threshold:.1f}", ) _configure_bias_curve_axis(axis, config, nsb_stats, proton_stats) fig.tight_layout() output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) visualize.save_figure( fig, Path(output_path).with_suffix(""), figure_format=[Path(output_path).suffix[1:]], dpi=200, close=True, )
def _plot_nsb_curve(axis, nsb_stats): """Plot NSB trigger rates.""" _plot_rate_curve(axis, nsb_stats, 1, "NSB", "o", "tab:blue") def _plot_proton_curve(axis, proton_stats): """Plot proton trigger rates.""" _plot_rate_curve(axis, proton_stats, 1, "Proton", "s", "tab:orange") def _plot_scaled_proton_curve(axis, proton_stats, scaling_factor=1.35): """Plot scaled proton trigger rates.""" _plot_rate_curve( axis, proton_stats, scaling_factor, f"{scaling_factor} x Proton", "^", "tab:red" ) def _plot_intersection_proton_curve(axis, proton_stats, scaling_factor=1.35): """Plot the fraction of the scaled proton rates that is intersected with the NSB rates.""" _plot_rate_curve( axis, proton_stats, scaling_factor * PROTON_INTERSECTION_FRACTION, f"{PROTON_INTERSECTION_FRACTION} x {scaling_factor} x Proton", "v", "tab:green", ) def _plot_rate_curve(axis, stats, scale, label, fmt, color): """Plot trigger rates multiplied by ``scale`` with error bars and a log-linear fit.""" if not stats: return thresholds = sorted(stats.keys()) rates = [scale * stats[t]["rate_hz"] for t in thresholds] errors = [scale * stats[t]["error_hz"] for t in thresholds] axis.errorbar(thresholds, rates, yerr=errors, fmt=fmt, label=label, color=color, capsize=3) _plot_log_linear_fit(axis, thresholds, rates, color=color) def _plot_log_linear_fit(axis, thresholds, rates, color): """Plot a log-linear fit when at least two distinct valid thresholds are available.""" fit = fit_bias_curve(thresholds, rates) if fit is None: _logger.info( "Skipping log-linear fit: need at least two distinct thresholds with " "finite, positive rates." ) return fit_thresholds, coeffs = fit x_fit = np.linspace(min(fit_thresholds), max(fit_thresholds), 100) y_fit = 10 ** (coeffs[0] * x_fit + coeffs[1]) axis.plot(x_fit, y_fit, "--", color=color, alpha=0.5, linewidth=1)
[docs] def fit_bias_curve(thresholds, rates): """ Fit log10(rate) as a linear function of threshold. Parameters ---------- thresholds : array-like Trigger thresholds. rates : array-like Trigger rates; only finite positive values are used. Returns ------- tuple or None Valid thresholds and fit coefficients (slope, intercept), or None if fewer than two distinct finite thresholds have corresponding finite, positive rates. """ thresholds = np.asarray(thresholds, dtype=float) rates = np.asarray(rates, dtype=float) valid = np.isfinite(thresholds) & np.isfinite(rates) & (rates > 0) fit_thresholds = thresholds[valid] if len(np.unique(fit_thresholds)) < 2: return None return fit_thresholds, np.polyfit(fit_thresholds, np.log10(rates[valid]), 1)
def _configure_bias_curve_axis(axis, config, nsb_stats, proton_stats): """Configure bias-curve axis labels, scaling, and legend.""" axis.set_title(config["title"], fontsize=14, fontweight="bold") axis.set_xlabel("Threshold", fontsize=12) axis.set_ylabel("Trigger Rate [Hz]", fontsize=12) axis.set_yscale("log") # Dynamically set y-axis limits based on data all_rates = [] scaling_factor = config.get("scaling_factor", 1.35) if nsb_stats: all_rates.extend(stats["rate_hz"] for stats in nsb_stats.values() if stats["rate_hz"] > 0) if proton_stats: proton_rates = [stats["rate_hz"] for stats in proton_stats.values() if stats["rate_hz"] > 0] all_rates.extend(proton_rates) all_rates.extend(scaling_factor * rate for rate in proton_rates) all_rates.extend( PROTON_INTERSECTION_FRACTION * scaling_factor * rate for rate in proton_rates ) if all_rates: # Add 10% padding above and below y_min = min(all_rates) * 0.9 y_max = max(all_rates) * 1.1 # Ensure reasonable minimum for log scale if y_min <= 0: y_min = 0.1 axis.set_ylim(y_min, y_max) else: # Fallback to config values if no data axis.set_ylim(config.get("ymin", 1), config.get("ymax", 1e6)) axis.grid(which="both", alpha=0.3, linestyle=":") handles, _ = axis.get_legend_handles_labels() if handles: axis.legend(fontsize=11, loc="best") else: _logger.warning("No NSB or proton rates found; writing empty bias-curve plot")