"""
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
import astropy.units as u
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.backends.backend_pdf import PdfPages
from simtools.ray_tracing.ray_tracing import RayTracing
from simtools.visualization import visualize
# Constants
RADIUS = "Radius [cm]"
CUMULATIVE_PSF = "Cumulative PSF"
MAX_OFFSET_DEFAULT = 4.5 # Maximum off-axis angle in degrees
OFFSET_STEPS_DEFAULT = 0.1 # Step size for off-axis angle sampling
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 based on containment fraction.
Parameters
----------
fraction : float
PSF containment fraction (e.g., 0.8 for D80, 0.95 for D95)
unit : str, optional
Unit for the label (default: "cm")
Returns
-------
str
Formatted PSF diameter label (e.g., "D80 (cm)", "D95 (cm)", "D95")
"""
percentage = int(fraction * 100)
if unit:
return f"D{percentage} ({unit})"
return f"D{percentage}"
[docs]
def get_significance_label(p_value):
"""Get significance label for p-value."""
if p_value > 0.05: # null hypothesis not rejected at the 95% level
return "GOOD"
if p_value > 0.01: # null hypothesis rejected at 95% but not at 99% level
return "FAIR"
return "POOR" # null hypothesis rejected at 99% level
def _format_metric_text(
psf_diameter,
metric,
fraction=DEFAULT_FRACTION,
p_value=None,
use_ks_statistic=False,
second_metric=None,
):
"""
Format metric text for display in plots.
Parameters
----------
psf_diameter : float
PSF diameter value
metric : float
Primary metric value (RMSD or KS statistic)
fraction : float, optional
PSF containment fraction (default: 0.8)
p_value : float, optional
P-value from KS test
use_ks_statistic : bool
If True, metric is KS statistic; if False, metric is RMSD
second_metric : float, optional
Second metric value to display alongside the primary metric
Returns
-------
str
Formatted metric text
"""
psf_label = get_psf_diameter_label(fraction, unit="")
psf_text = f"{psf_label} = {psf_diameter:.5f} cm"
# Create metric text based on the optimization method
if second_metric is not None:
# Special case: show both RMSD and KS statistic (for final best plot)
metric_text = f"RMSD = {metric:.4f}" # metric is RMSD in this case
metric_text += f"\nKS statistic = {second_metric:.4f}" # second_metric is KS statistic
if p_value is not None:
metric_text += f"\np-value = {p_value:.4f}"
elif use_ks_statistic:
metric_text = f"KS stat = {metric:.6f}"
if p_value is not None:
significance = get_significance_label(p_value)
metric_text += f"\np-value = {p_value:.6f} ({significance})"
else:
metric_text = f"RMSD = {metric:.4f}"
return f"{psf_text}\n{metric_text}"
def _create_base_plot_figure(data_to_plot, simulated_data=None):
"""
Create base figure for PSF parameter plots.
Parameters
----------
data_to_plot : dict
Data dictionary for plotting
simulated_data : array, optional
Simulated data to add to the plot
Returns
-------
tuple
(fig, ax) - figure and axis objects
"""
plot_data = data_to_plot.copy()
if simulated_data is not None:
plot_data["simulated"] = simulated_data
fig = visualize.plot_1d(
plot_data,
plot_difference=True,
no_markers=True,
)
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):
"""Build parameter title string for plots."""
title_prefix = "* " if is_best else ""
return (
f"{title_prefix}reflection = "
f"{pars['mirror_reflection_random_angle'][0]:.5f}, "
f"{pars['mirror_reflection_random_angle'][1]:.5f}, "
f"{pars['mirror_reflection_random_angle'][2]:.5f}\n"
f"align_vertical = {pars['mirror_align_random_vertical'][0]:.5f}, "
f"{pars['mirror_align_random_vertical'][1]:.5f}, "
f"{pars['mirror_align_random_vertical'][2]:.5f}, "
f"{pars['mirror_align_random_vertical'][3]:.5f}\n"
f"align_horizontal = {pars['mirror_align_random_horizontal'][0]:.5f}, "
f"{pars['mirror_align_random_horizontal'][1]:.5f}, "
f"{pars['mirror_align_random_horizontal'][2]:.5f}, "
f"{pars['mirror_align_random_horizontal'][3]:.5f}"
)
def _add_metric_text_box(ax, metrics_text, is_best):
"""Add metric text box to plot."""
psf_color = "red" if is_best else "black"
psf_weight = "bold" if is_best else "normal"
ax.text(
0.5,
0.3,
metrics_text,
verticalalignment="center",
horizontalalignment="left",
transform=ax.transAxes,
color=psf_color,
weight=psf_weight,
bbox={"boxstyle": "round,pad=0.3", "facecolor": "yellow", "alpha": 0.7}
if is_best
else None,
)
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,
):
"""
Add title, text annotations, and best parameter indicators to plot.
Parameters
----------
ax : matplotlib.axes.Axes
The plot axes
fig : matplotlib.figure.Figure
The plot figure
pars : dict
Parameter set dictionary
psf_diameter : float
PSF diameter value
metric : float
Primary metric value
is_best : bool
Whether this is the best parameter set
fraction : float, optional
PSF containment fraction (default: 0.8)
p_value : float, optional
P-value from KS test
use_ks_statistic : bool
If True, metric is KS statistic; if False, metric is RMSD
second_metric : float, optional
Second metric value to display
"""
title = _build_parameter_title(pars, is_best)
ax.set_title(title)
metrics_text = _format_metric_text(
psf_diameter, metric, fraction, p_value, use_ks_statistic, second_metric
)
_add_metric_text_box(ax, metrics_text, is_best)
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,
):
"""
Create a plot for PSF simulation results.
Parameters
----------
data_to_plot : dict
Data dictionary for plotting.
pars : dict
Parameter set dictionary.
psf_diameter : float
PSF diameter value.
metric : float
RMSD value (if use_ks_statistic=False) or KS statistic (if use_ks_statistic=True).
is_best : bool
Whether this is the best parameter set.
pdf_pages : PdfPages
PDF pages object for saving plots.
fraction : float, optional
PSF containment fraction (default: 0.8).
p_value : float, optional
P-value from KS test (only used when use_ks_statistic=True).
use_ks_statistic : bool, optional
If True, metric is KS statistic; if False, metric is RMSD.
second_metric : float, optional
Second metric value to display alongside the primary metric (for final best plot).
"""
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.savefig(fig, bbox_inches="tight")
plt.clf()
[docs]
def create_detailed_parameter_plot(
pars,
ks_statistic,
psf_diameter,
simulated_data,
data_to_plot,
is_best,
pdf_pages,
fraction=DEFAULT_FRACTION,
p_value=None,
):
"""
Create a detailed plot for a parameter set showing all parameter values.
Parameters
----------
pars : dict
Parameter set dictionary
ks_statistic : float
KS statistic value for this parameter set
psf_diameter : float
PSF diameter value for this parameter set
simulated_data : array
Simulated data for plotting
data_to_plot : dict
Data dictionary for plotting
is_best : bool
Whether this is the best parameter set
pdf_pages : PdfPages
PDF pages object to save the plot
fraction : float, optional
PSF containment fraction (default: 0.8)
p_value : float, optional
P-value from KS test for statistical significance
"""
# Check if we have valid simulated data for plotting
if simulated_data is None:
logger.warning(
"No simulated data available for plotting this parameter set, skipping plot creation"
)
return
try:
fig, ax = _create_base_plot_figure(data_to_plot, simulated_data)
except (ValueError, RuntimeError, KeyError, TypeError) as e:
logger.error(f"Failed to create plot for parameters: {e}")
return
_add_plot_annotations(
ax,
fig,
pars,
psf_diameter,
ks_statistic,
is_best,
fraction,
p_value,
use_ks_statistic=True,
second_metric=None,
)
pdf_pages.savefig(fig, bbox_inches="tight")
plt.clf()
[docs]
def create_parameter_progression_plots(
results, best_pars, data_to_plot, pdf_pages, fraction=DEFAULT_FRACTION
):
"""
Create plots for all parameter sets showing optimization progression.
Parameters
----------
results : list
List of (pars, ks_statistic, p_value, psf_diameter, simulated_data) tuples
best_pars : dict
Best parameter set for highlighting
data_to_plot : dict
Data dictionary for plotting
pdf_pages : PdfPages
PDF pages object to save plots
fraction : float, optional
PSF containment fraction (default: 0.8)
"""
logger.info("Creating plots for all parameter sets...")
for i, (pars, ks_statistic, p_value, psf_diameter, simulated_data) in enumerate(results):
if simulated_data is None:
logger.warning(f"No simulated data for iteration {i}, skipping plot")
continue
is_best = pars is best_pars
logger.info(f"Creating plot {i + 1}/{len(results)}{' (BEST)' if is_best else ''}")
create_detailed_parameter_plot(
pars,
ks_statistic,
psf_diameter,
simulated_data,
data_to_plot,
is_best,
pdf_pages,
fraction,
p_value,
)
[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 from gradient descent
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...")
# Check if results include p-values (for KS statistic mode)
has_p_values = len(gd_results[0]) >= 4 and gd_results[0][2] is not None
if has_p_values and use_ks_statistic:
_, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(10, 12), tight_layout=True)
else:
_, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), tight_layout=True)
iterations = list(range(len(gd_results)))
metrics = [r[1] for r in gd_results]
psf_diameters = [r[3] for r in gd_results]
metric_name = "KS Statistic" if use_ks_statistic else "RMSD"
psf_label = get_psf_diameter_label(fraction)
# Plot optimization metric progression
ax1.plot(iterations, metrics, "b.-", linewidth=2, markersize=6)
ax1.axhline(y=threshold, color="r", linestyle="--", label=f"Threshold: {threshold}")
ax1.set_xlabel("Iteration")
ax1.set_ylabel(metric_name)
ax1.set_title(f"Gradient Descent Convergence - {metric_name}")
ax1.grid(True, alpha=0.3)
ax1.legend()
# Plot PSF diameter progression
ax2.plot(iterations, psf_diameters, "g.-", linewidth=2, markersize=6)
ax2.set_xlabel("Iteration")
ax2.set_ylabel(psf_label)
ax2.set_title(f"Gradient Descent Convergence - {get_psf_diameter_label(fraction, unit='')}")
ax2.grid(True, alpha=0.3)
# Plot p-value progression if available and using KS statistic
if has_p_values and use_ks_statistic:
p_values = [r[2] for r in gd_results if r[2] is not None]
p_iterations = [i for i, r in enumerate(gd_results) if r[2] is not None]
ax3.plot(p_iterations, p_values, "m.-", linewidth=2, markersize=6)
ax3.axhline(
y=0.05, color="orange", linestyle="--", alpha=0.7, label="p = 0.05 (significance)"
)
ax3.axhline(
y=0.01, color="r", linestyle="--", alpha=0.7, label="p = 0.01 (high significance)"
)
ax3.set_xlabel("Iteration")
ax3.set_ylabel("p-value")
ax3.set_title("Gradient Descent Convergence - Statistical Significance")
ax3.set_yscale("log") # Log scale for p-values
ax3.grid(True, alpha=0.3)
ax3.legend()
plt.savefig(output_file, bbox_inches="tight")
plt.close()
logger.info(f"Convergence plot saved to {output_file}")
[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
Results from Monte Carlo analysis: (mean_metric, std_metric, metric_values,
mean_p, std_p, p_values, mean_psf_diameter, std_psf_diameter, psf_diameter_values)
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, optional
Whether KS statistic mode is being used (affects filename suffix)
"""
(
mean_metric,
std_metric,
metric_values,
mean_p_value,
_, # std_p_value (unused)
p_values,
mean_psf_diameter,
std_psf_diameter,
psf_diameter_values,
) = mc_results
logger.info("Creating Monte Carlo uncertainty analysis plot...")
# Check if we have valid p-values to determine if this is KS statistic or RMSD mode
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
# Create subplot layout based on mode
if is_ks_mode:
# KS mode: 2x2 layout with all 4 plots
_, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 10), tight_layout=True)
else:
# RMSD mode: 1x2 layout with only metric and PSF diameter plots
_, (ax1, ax3) = plt.subplots(1, 2, figsize=(12, 5), tight_layout=True)
# Metric histogram (KS statistic or RMSD)
metric_name = "KS Statistic" if is_ks_mode else "RMSD"
psf_label = get_psf_diameter_label(fraction)
ax1.hist(metric_values, bins=20, alpha=0.7, color="blue", edgecolor="black")
ax1.axvline(
mean_metric, color="red", linestyle="--", linewidth=2, label=f"Mean: {mean_metric:.6f}"
)
ax1.axvline(
mean_metric - std_metric,
color="orange",
linestyle=":",
alpha=0.7,
label=f"$\\sigma$: {std_metric:.6f}",
)
ax1.axvline(mean_metric + std_metric, color="orange", linestyle=":", alpha=0.7)
ax1.set_xlabel(metric_name)
ax1.set_ylabel("Counts")
ax1.set_title(f"{metric_name} Distribution")
ax1.legend()
ax1.grid(True, alpha=0.3)
# p-value histogram (only for KS statistic mode)
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")
ax2.set_ylabel("Counts")
ax2.set_title("p-value Distribution")
ax2.legend()
ax2.grid(True, alpha=0.3)
# PSF diameter histogram
ax3.hist(psf_diameter_values, bins=20, alpha=0.7, color="green", edgecolor="black")
ax3.axvline(
mean_psf_diameter,
color="red",
linestyle="--",
linewidth=2,
label=f"Mean: {mean_psf_diameter:.4f} cm",
)
ax3.axvline(
mean_psf_diameter - std_psf_diameter,
color="orange",
linestyle=":",
alpha=0.7,
label=f"$\\sigma$: {std_psf_diameter:.4f} cm",
)
ax3.axvline(mean_psf_diameter + std_psf_diameter, color="orange", linestyle=":", alpha=0.7)
ax3.set_xlabel(psf_label)
ax3.set_ylabel("Counts")
ax3.set_title(f"{get_psf_diameter_label(fraction, unit='')} Distribution")
ax3.legend()
ax3.grid(True, alpha=0.3)
# Scatter plot: Metric vs p-value (only for KS statistic mode)
if is_ks_mode:
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)
ax4.set_ylabel("p-value")
ax4.set_title(f"{metric_name} vs p-value Correlation")
ax4.set_yscale("log")
ax4.legend()
ax4.grid(True, alpha=0.3)
# Save plot in both PDF and PNG formats with appropriate suffix
suffix = "_ks" if use_ks_statistic else "_rmsd"
# Generate base filename without extension
base_path = output_file.with_suffix("")
base_name = str(base_path)
# Add suffix and save in both formats
pdf_file = f"{base_name}{suffix}.pdf"
png_file = f"{base_name}{suffix}.png"
plt.savefig(pdf_file, bbox_inches="tight")
plt.savefig(png_file, bbox_inches="tight", dpi=150)
plt.close()
logger.info(f"Monte Carlo uncertainty plot saved to {pdf_file}")
logger.info(f"Monte Carlo uncertainty plot saved to {png_file}")
[docs]
def create_psf_vs_offaxis_plot(tel_model, site_model, args_dict, best_pars, output_dir):
"""
Create PSF diameter vs off-axis angle plot using the best parameters.
Parameters
----------
tel_model : TelescopeModel
Telescope model object.
site_model : SiteModel
Site model object.
args_dict : dict
Dictionary containing parsed command-line arguments.
best_pars : dict
Best parameter set.
output_dir : Path
Output directory for saving plots.
"""
fraction = args_dict.get("fraction", DEFAULT_FRACTION)
psf_label_cm = get_psf_diameter_label(fraction, unit="cm")
psf_label_deg = get_psf_diameter_label(fraction, unit="degrees")
logger.info(f"Creating {psf_label_cm} vs off-axis angle plot with best parameters...")
# Apply best parameters to telescope model
tel_model.change_multiple_parameters(**best_pars)
# Create off-axis angle array
max_offset = args_dict.get("max_offset", MAX_OFFSET_DEFAULT)
offset_steps = args_dict.get("offset_steps", OFFSET_STEPS_DEFAULT)
off_axis_angles = np.linspace(
0,
max_offset,
int(max_offset / offset_steps) + 1,
)
ray = RayTracing(
telescope_model=tel_model,
site_model=site_model,
simtel_path=args_dict["simtel_path"],
zenith_angle=args_dict["zenith"] * u.deg,
source_distance=args_dict["src_distance"] * u.km,
off_axis_angle=off_axis_angles * u.deg,
)
logger.info(f"Running ray tracing for {len(off_axis_angles)} off-axis angles...")
ray.simulate(test=args_dict.get("test", False), force=True)
ray.analyze(force=True)
for key in ["d80_cm", "d80_deg"]:
plt.figure(figsize=(10, 6), tight_layout=True)
ray.plot(key, marker="o", linestyle="-", color="blue", linewidth=2, markersize=6)
parameters_text = (
f"Best Parameters: \n"
f"reflection=["
f"{', '.join(f'{x:.4f}' for x in best_pars['mirror_reflection_random_angle'])}],\n"
f"align_horizontal=["
f"{', '.join(f'{x:.4f}' for x in best_pars['mirror_align_random_horizontal'])}]\n"
f"align_vertical=["
f"{', '.join(f'{x:.4f}' for x in best_pars['mirror_align_random_vertical'])}]\n"
)
plt.title(parameters_text)
plt.xlabel("Off-axis Angle (degrees)")
plt.ylabel(psf_label_cm if "_cm" in key else psf_label_deg)
plt.ylim(bottom=0)
plt.xticks(rotation=45)
plt.xlim(0, max_offset)
plt.grid(True, alpha=0.3)
# Create dynamic file name based on fraction
psf_identifier = get_psf_diameter_label(fraction, unit="").lower() # e.g., "d80", "d95"
unit_suffix = "cm" if "_cm" in key else "deg"
plot_file_name = f"{tel_model.name}_best_params_{psf_identifier}_{unit_suffix}.png"
plot_file = output_dir.joinpath(plot_file_name)
visualize.save_figure(
plt, plot_file, figure_format=["png"], log_title=f"{psf_label_cm} vs off-axis ({key})"
)
plt.close("all")
[docs]
def setup_pdf_plotting(args_dict, output_dir, tel_model_name):
"""
Set up PDF plotting for gradient descent optimization if requested.
Parameters
----------
args_dict : dict
Dictionary containing command-line arguments with plot_all flag.
output_dir : Path
Directory where the PDF file will be saved.
tel_model_name : str
Name of the telescope model for filename generation.
Returns
-------
PdfPages or None
PdfPages object for saving plots if plotting is requested, None otherwise.
Notes
-----
Creates a PDF file for saving cumulative PSF plots during gradient descent
optimization. Returns None if plotting is not requested via the plot_all flag.
"""
if not args_dict.get("plot_all", False):
return None
pdf_filename = output_dir / f"psf_gradient_descent_plots_{tel_model_name}.pdf"
pdf_pages = PdfPages(pdf_filename)
logger.info(f"Creating cumulative PSF plots for each iteration (saving to {pdf_filename})")
return pdf_pages
[docs]
def create_optimization_plots(args_dict, gd_results, tel_model, data_to_plot, output_dir):
"""
Create optimization plots for all iterations if requested.
Parameters
----------
args_dict : dict
Dictionary containing command-line arguments with save_plots flag.
gd_results : list
List of (params, rmsd, _, psf_diameter, _) tuples from gradient descent optimization.
tel_model : TelescopeModel
Telescope model object for naming files.
data_to_plot : dict
Dictionary containing measured PSF data.
output_dir : Path
Directory where the PDF file will be saved.
Notes
-----
Creates a PDF file with plots for optimization iterations. Only creates plots
every 5 iterations plus the final iteration to avoid excessively large files.
Returns early if save_plots flag is not set.
"""
if not args_dict.get("save_plots", False):
return
fraction = args_dict.get("fraction", DEFAULT_FRACTION)
pdf_filename = output_dir.joinpath(f"psf_optimization_results_{tel_model.name}.pdf")
pdf_pages = PdfPages(pdf_filename)
logger.info(f"Creating PSF plots for each optimization iteration (saving to {pdf_filename})")
for i, (params, rmsd, _, psf_diameter, _) in enumerate(gd_results):
if i % 5 == 0 or i == len(gd_results) - 1:
create_psf_parameter_plot(
data_to_plot,
params,
psf_diameter,
rmsd,
is_best=(i == len(gd_results) - 1),
pdf_pages=pdf_pages,
fraction=fraction,
use_ks_statistic=False,
)
pdf_pages.close()