Source code for visualization.plot_psf

"""
Optical PSF plotting functions for parameter optimization visualization.

This module provides plotting functionality for PSF parameter optimization,
including parameter comparison plots, convergence plots, and PSF diameter vs off-axis plots.
"""

import logging
from pathlib import Path

import numpy as np

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

# Constants
RADIUS = "Radius [cm]"
CUMULATIVE_PSF = "Cumulative PSF"
DEFAULT_FRACTION = 0.8  # Default PSF containment fraction

logger = logging.getLogger(__name__)


[docs] def get_psf_diameter_label(fraction, unit="cm"): """ Generate PSF diameter label from the containment fraction, e.g. "D80 (cm)". Parameters ---------- fraction : float PSF containment fraction (0.8 for D80). unit : str, optional Unit appended in parentheses (default: "cm"). Returns ------- str Label such as "D80 (cm)", or "D80" if unit is empty. """ label = f"D{int(fraction * 100)}" return f"{label} ({unit})" if unit else label
[docs] def get_significance_label(p_value): """Get significance label for p-value: GOOD (>0.05), FAIR (>0.01) or POOR.""" if p_value > 0.05: return "GOOD" if p_value > 0.01: return "FAIR" return "POOR"
def _format_metric_text( psf_diameter, metric, fraction=DEFAULT_FRACTION, p_value=None, use_ks_statistic=False, second_metric=None, ): text = f"{get_psf_diameter_label(fraction, unit='')} = {psf_diameter:.5f} cm" if second_metric is not None: text += f"\nRMSD = {metric:.4f}\nKS statistic = {second_metric:.4f}" if p_value is not None: text += f"\np-value = {p_value:.4f}" elif use_ks_statistic: text += f"\nKS stat = {metric:.6f}" if p_value is not None: text += f"\np-value = {p_value:.6f} ({get_significance_label(p_value)})" else: text += f"\nRMSD = {metric:.4f}" return text def _create_base_plot_figure(data_to_plot, simulated_data=None): plot_data = dict(data_to_plot) if simulated_data is not None: plot_data["simulated"] = simulated_data fig = visualize.plot_1d(plot_data, plot_difference=True, no_markers=True) for difference_ax in fig.get_axes()[1:]: for line in difference_ax.lines: line.set_ydata(np.asarray(line.get_ydata()) * 100) difference_ax.set_ylabel("Difference\n[%]") difference_ax.relim() difference_ax.autoscale_view(scalex=False) ax = fig.get_axes()[0] ax.set_ylim(0, 1.05) ax.set_ylabel(CUMULATIVE_PSF) return fig, ax def _build_parameter_title(pars, is_best): lines = [] if "mirror_reflection_random_angle" in pars: refl = pars["mirror_reflection_random_angle"] lines.append(f"reflection = {refl[0]:.5f}, {refl[1]:.5f}, {refl[2]:.5f}") for key, name in ( ("mirror_align_random_vertical", "align_vertical"), ("mirror_align_random_horizontal", "align_horizontal"), ): if key in pars: lines.append(f"{name} = " + ", ".join(f"{x:.5f}" for x in pars[key])) return ("* " if is_best else "") + "\n".join(lines) def _add_plot_annotations( ax, fig, pars, psf_diameter, metric, is_best, fraction=DEFAULT_FRACTION, p_value=None, use_ks_statistic=False, second_metric=None, ): ax.set_title(_build_parameter_title(pars, is_best)) metrics_text = _format_metric_text( psf_diameter, metric, fraction, p_value, use_ks_statistic, second_metric ) ax.text( 0.5, 0.3, metrics_text, verticalalignment="center", horizontalalignment="left", transform=ax.transAxes, color="red" if is_best else "black", weight="bold" if is_best else "normal", bbox={"boxstyle": "round,pad=0.3", "facecolor": "yellow", "alpha": 0.7} if is_best else None, ) if is_best: fig.text( 0.02, 0.02, "* Best parameter set (lowest RMSD)", fontsize=8, style="italic", color="red", )
[docs] def create_psf_parameter_plot( data_to_plot, pars, psf_diameter, metric, is_best, pdf_pages, fraction=DEFAULT_FRACTION, p_value=None, use_ks_statistic=False, second_metric=None, ): """ Add a cumulative PSF plot of one parameter set to a PDF report. Parameters ---------- data_to_plot : dict Data dictionary for plotting. pars : dict Parameter set dictionary. psf_diameter, metric : float PSF diameter and metric (RMSD, or KS statistic if ``use_ks_statistic``). is_best : bool Whether this is the best parameter set. pdf_pages : PDF report object Open report for adding plots. fraction, p_value, use_ks_statistic, second_metric : optional PSF containment fraction, KS p-value, KS mode flag and second metric. """ fig, ax = _create_base_plot_figure(data_to_plot) _add_plot_annotations( ax, fig, pars, psf_diameter, metric, is_best, fraction, p_value, use_ks_statistic, second_metric, ) pdf_pages.add(fig)
[docs] def create_gradient_descent_convergence_plot( gd_results, threshold, output_file, fraction=DEFAULT_FRACTION, use_ks_statistic=False ): """ Create convergence plot during gradient descent. Parameters ---------- gd_results : list List of (params, metric, p_value, psf_diameter, simulated_data) tuples. threshold : float Optimization metric threshold used for convergence. output_file : Path Output file path for saving the plot. fraction : float, optional PSF containment fraction for labeling (default: 0.8). use_ks_statistic : bool Whether to use KS statistic or RMSD labels and titles. """ logger.info("Creating gradient descent convergence plot...") has_p_values = len(gd_results[0]) >= 4 and gd_results[0][2] is not None show_p_panel = has_p_values and use_ks_statistic _, axes = plt.subplots( 3 if show_p_panel else 2, 1, figsize=(10, 12 if show_p_panel else 8), tight_layout=True ) iterations = list(range(len(gd_results))) metric_name = "KS Statistic" if use_ks_statistic else "RMSD" title = "Gradient Descent Convergence" axes[0].plot(iterations, [r[1] for r in gd_results], "b.-", linewidth=2, markersize=6) axes[0].axhline(y=threshold, color="r", linestyle="--", label=f"Threshold: {threshold}") axes[0].set(xlabel="Iteration", ylabel=metric_name, title=f"{title} - {metric_name}") axes[1].plot(iterations, [r[3] for r in gd_results], "g.-", linewidth=2, markersize=6) axes[1].set( xlabel="Iteration", ylabel=get_psf_diameter_label(fraction), title=f"{title} - {get_psf_diameter_label(fraction, unit='')}", ) if show_p_panel: points = [(i, r[2]) for i, r in enumerate(gd_results) if r[2] is not None] axes[2].plot( [i for i, _ in points], [p for _, p in points], "m.-", linewidth=2, markersize=6 ) axes[2].axhline( y=0.05, color="orange", linestyle="--", alpha=0.7, label="p = 0.05 (significance)" ) axes[2].axhline( y=0.01, color="r", linestyle="--", alpha=0.7, label="p = 0.01 (high significance)" ) axes[2].set( xlabel="Iteration", ylabel="p-value", title=f"{title} - Statistical Significance", yscale="log", ) for ax in axes: ax.grid(True, alpha=0.3) axes[0].legend() if show_p_panel: axes[2].legend() visualize.save_figure( plt.gcf(), Path(output_file).with_suffix(""), figure_format=[Path(output_file).suffix[1:]], close=True, ) logger.info(f"Convergence plot saved to {output_file}")
def _plot_moment_histogram(ax, values, mean, std, color, xlabel, title, fmt, unit=""): ax.hist(values, bins=20, alpha=0.7, color=color, edgecolor="black") ax.axvline(mean, color="red", linestyle="--", linewidth=2, label=f"Mean: {mean:{fmt}}{unit}") ax.axvline( mean - std, color="orange", linestyle=":", alpha=0.7, label=f"$\\sigma$: {std:{fmt}}{unit}" ) ax.axvline(mean + std, color="orange", linestyle=":", alpha=0.7) ax.set(xlabel=xlabel, ylabel="Counts", title=title) ax.legend() ax.grid(True, alpha=0.3)
[docs] def create_monte_carlo_uncertainty_plot( mc_results, output_file, fraction=DEFAULT_FRACTION, use_ks_statistic=False ): """ Create uncertainty analysis plots showing optimization metric and p-value distributions. Parameters ---------- mc_results : tuple (mean_metric, std_metric, metric_values, mean_p, std_p, p_values, mean_psf_diameter, std_psf_diameter, psf_diameter_values) from the Monte Carlo analysis. output_file : Path Output file path; its suffix is replaced by ``_ks`` or ``_rmsd``. fraction : float, optional PSF containment fraction for labeling (default: 0.8). use_ks_statistic : bool, optional Whether KS statistic mode is being used (affects filename suffix). """ ( mean_metric, std_metric, metric_values, mean_p_value, _, p_values, mean_psf_diameter, std_psf_diameter, psf_diameter_values, ) = mc_results logger.info("Creating Monte Carlo uncertainty analysis plot...") valid_p_values = [p for p in p_values if p is not None] if p_values else [] is_ks_mode = len(valid_p_values) > 0 if is_ks_mode: _, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10), tight_layout=True) else: _, (ax1, ax3) = plt.subplots(1, 2, figsize=(12, 5), tight_layout=True) metric_name = "KS Statistic" if is_ks_mode else "RMSD" _plot_moment_histogram( ax1, metric_values, mean_metric, std_metric, "blue", metric_name, f"{metric_name} Distribution", ".6f", ) _plot_moment_histogram( ax3, psf_diameter_values, mean_psf_diameter, std_psf_diameter, "green", get_psf_diameter_label(fraction), f"{get_psf_diameter_label(fraction, unit='')} Distribution", ".4f", unit=" cm", ) if is_ks_mode: ax2.hist(valid_p_values, bins=20, alpha=0.7, color="magenta", edgecolor="black") if mean_p_value is not None: ax2.axvline( mean_p_value, color="red", linestyle="--", linewidth=2, label=f"Mean: {mean_p_value:.6f}", ) ax2.axvline(0.05, color="orange", linestyle="--", alpha=0.7, label="p = 0.05") ax2.axvline(0.01, color="red", linestyle="--", alpha=0.7, label="p = 0.01") ax2.set(xlabel="p-value", ylabel="Counts", title="p-value Distribution") ax2.legend() ax2.grid(True, alpha=0.3) ax4.scatter(metric_values, valid_p_values, alpha=0.6, color="purple") ax4.axhline(y=0.05, color="orange", linestyle="--", alpha=0.7, label="p = 0.05") ax4.axhline(y=0.01, color="red", linestyle="--", alpha=0.7, label="p = 0.01") ax4.set( xlabel=metric_name, ylabel="p-value", title=f"{metric_name} vs p-value Correlation", yscale="log", ) ax4.legend() ax4.grid(True, alpha=0.3) suffix = "_ks" if use_ks_statistic else "_rmsd" visualize.save_figure( plt, f"{output_file.with_suffix('')}{suffix}", log_title="Monte Carlo uncertainty", dpi=150 ) plt.close()
[docs] def plot_psf_histogram(measured, simulated, args_dict): """Write histogram comparing measured vs simulated PSF diameter distributions.""" out_name = args_dict.get("psf_hist") if not out_name: return None out_path = Path(args_dict.get("output_path", ".")) / out_name out_path.parent.mkdir(parents=True, exist_ok=True) measured = np.asarray(measured, dtype=float) simulated = np.asarray(simulated, dtype=float) measured = measured[np.isfinite(measured)] simulated = simulated[np.isfinite(simulated)] if measured.size == 0 or simulated.size == 0: return None all_vals = np.concatenate([measured, simulated]) x_min = float(np.nanmin(all_vals)) x_max = float(np.nanmax(all_vals)) if not np.isfinite(x_min) or not np.isfinite(x_max) or x_max <= x_min: return None bin_edges = np.linspace(x_min, x_max, 26) fig, ax = plt.subplots(figsize=(7.5, 4.5), constrained_layout=True) for values, name, color in ( (measured, "Measured", "tab:red"), (simulated, "Simulated", "tab:blue"), ): mean = float(np.mean(values)) rms = float(np.std(values, ddof=0)) ax.hist( values, bins=bin_edges, alpha=0.55, color=color, edgecolor="white", label=f"{name} (mean={mean:.2f} mm, rms={rms:.2f} mm)", ) ax.axvline(mean, color=color, linestyle="--", linewidth=1) tel = args_dict.get("telescope", "") model_version = args_dict.get("model_version", "") fraction = args_dict.get("fraction") label = get_psf_diameter_label(fraction, unit="mm") if fraction is not None else "PSF" suffix = " ".join([s for s in (tel, model_version) if s]) ax.set(xlabel=label, ylabel="Count", title=f"{label} ({suffix})" if suffix else label) ax.legend(loc="best", fontsize=9, frameon=True) visualize.save_figure( fig, out_path.with_suffix(""), figure_format=[out_path.suffix[1:]], close=True ) logger.info("PSF histogram written to %s", str(out_path)) return str(out_path)