Source code for ray_tracing.mirror_panel_psf

"""Mirror panel PSF calculation with per-mirror PSF diameter optimization."""

import json
import logging
from dataclasses import asdict, dataclass
from pathlib import Path

import numpy as np
from astropy.table import Table

import simtools.utils.general as gen
from simtools.context import RunContext
from simtools.data_model import model_data_writer
from simtools.data_model import parameter_catalog as catalog
from simtools.job_execution.execution import map_ordered
from simtools.model.model_utils import initialize_simulation_models
from simtools.ray_tracing.ray_tracing import RayTracing
from simtools.visualization import plot_psf


[docs] @dataclass class MirrorOptimizationResult: """Dataclass to store the result of a single mirror RNDA optimization.""" mirror: int # Zero-based mirror index measured_psf_mm: float # Measured PSF diameter in mm optimized_rnda: list[float] # Optimized RNDA values [sigma1, fraction2, sigma2] simulated_psf_mm: float # Simulated PSF diameter after optimization percentage_diff: float # Absolute percentage difference containment_fraction: float # PSF containment fraction used for the diameter
[docs] @dataclass(frozen=True) class MirrorOptimizationJob: """Compact input required to reconstruct one mirror optimization.""" label: str args: dict mirror_index: int measured_psf: float
def _optimize_single_mirror_worker(job, context=None): """ Reconstruct optimization state and optimize one mirror. Each mirror has its own model directory: the sim_telarray configuration written for every evaluation holds the trial RNDA values, and mirrors are optimized in parallel. """ context = context or RunContext.from_environment() model_directory = context.paths.get_model_configuration_directory( job.args["model_version"], sub_dir=f"mirror_{job.mirror_index}" ) instance = MirrorPanelPSF(job.label, job.args, model_directory=model_directory, context=context) return instance.optimize_single_mirror(job.mirror_index, job.measured_psf)
[docs] class MirrorPanelPSF: """ Mirror panel PSF and random reflection angle (RNDA) calculation. This class derives the RNDA for mirror panels by optimizing per-mirror PSF diameters (at a given containment fraction) using a percentage difference metric. Optimization uses a gradient descent with finite-difference gradients. Parameters ---------- label : str Application label. args_dict : dict Dictionary with input arguments, e.g. site, telescope, data path, model version. model_directory : Path, optional Directory of the model configuration files. """ # Hard guardrails GRAD_CLIP = 1e4 MAX_LOG_STEP = 0.25 MAX_FRAC_STEP = 0.1 MAX_ITER = 100 def __init__(self, label, args_dict, model_directory=None, *, context=None): self.context = context or RunContext.from_environment() self._logger = logging.getLogger(__name__) self.label = label self.args_dict = args_dict self.fraction = args_dict.get("fraction", 0.8) self.telescope_model, self.site_model, _ = initialize_simulation_models( label=label, site=args_dict["site"], telescope_name=args_dict["telescope"], model_version=args_dict["model_version"], model_directory=model_directory, context=self.context, ) self.measured_data = self._load_measured_data() self.rnda_start = list( self.telescope_model.get_parameter_value("mirror_reflection_random_angle") ) self.per_mirror_results = [] self.rnda_opt = None self.final_percentage_diff = None def _load_measured_data(self): """ Load measured PSF diameter from ECSV file. Returns ------- astropy.table.Column Column containing PSF diameter values in mm. """ data_file = gen.find_file(self.args_dict["data"], self.args_dict.get("model_path", ".")) table = Table.read(data_file) if "psf_opt" in table.colnames: return table["psf_opt"] if "d80" in table.colnames: fraction = float(self.args_dict.get("fraction", 0.8)) if not np.isclose(fraction, 0.8, rtol=1e-09, atol=1e-09): self._logger.warning( "Input table provides 'd80' column, but --fraction=%.3f was requested. " "Make sure the measured column matches the selected containment fraction.", fraction, ) return table["d80"] raise ValueError("Data file must contain either 'psf_opt' or 'd80' column") def _simulate_single_mirror_psf(self, mirror_idx, rnda_values): """ Simulate a single mirror and return its PSF diameter. Parameters ---------- mirror_idx : int Zero-based index of the mirror. rnda_values : list of float RNDA values [sigma1, fraction2, sigma2]. Returns ------- float Simulated PSF diameter in mm. """ fraction = self.fraction self.telescope_model.overwrite_model_parameter( "mirror_reflection_random_angle", rnda_values ) rt_label = f"{self.label}_m{mirror_idx}" ray = RayTracing( telescope_model=self.telescope_model, site_model=self.site_model, label=rt_label, single_mirror_mode=True, mirror_numbers=[mirror_idx], off_axis_angle=[(0.0, 0.0)], context=self.context, ) ray.simulate(test=self.args_dict.get("test", False), force=True, compress_photons=False) ray.analyze(force=True, containment_fraction=fraction, export=False, keep_images=False) return float(ray.get_psf_mm()) @staticmethod def _signed_pct_diff(measured, simulated): """Compute signed percentage difference.""" if measured <= 0: raise ValueError("Measured PSF diameter must be positive") return 100.0 * (simulated - measured) / measured def _evaluate(self, mirror_idx, measured, rnda): """ Evaluate the objective function for a mirror. Parameters ---------- mirror_idx : int Mirror index (0-based). measured : float Measured PSF diameter (mm). rnda : list of float Current RNDA values. Returns ------- tuple of (float, float, float) Simulated PSF diameter, signed percentage difference, squared percentage difference. """ sim = self._simulate_single_mirror_psf(mirror_idx, rnda) pct = self._signed_pct_diff(measured, sim) return sim, pct, pct * pct @staticmethod def _rnda_bounds(): """ Get allowed bounds for RNDA parameters. Returns ------- list of tuple of float [(min1, max1), (min2, max2), (min3, max3)] """ schema = catalog.model_parameters()["mirror_reflection_random_angle"]["data"] return [ (max(schema[0]["allowed_range"]["min"], 1e-12), schema[0]["allowed_range"]["max"]), (schema[1]["allowed_range"]["min"], schema[1]["allowed_range"]["max"]), (max(schema[2]["allowed_range"]["min"], 1e-12), schema[2]["allowed_range"]["max"]), ]
[docs] def optimize_single_mirror(self, mirror_idx, measured_psf): """ Optimize RNDA for a single mirror using gradient descent. Parameters ---------- mirror_idx : int Zero-based mirror index. measured_psf : float Measured PSF diameter in mm. Returns ------- MirrorOptimizationResult Optimization results for the mirror. """ threshold_pct = 100 * float(self.args_dict.get("threshold", 0.05)) learning_rate = float(self.args_dict.get("learning_rate", 1e-3)) bounds = self._rnda_bounds() fraction = self.fraction eval_log_interval = int(self.args_dict.get("profile_log_every", 10)) eval_count = 0 def evaluate_with_progress(rnda_values, stage): """Evaluate objective and emit periodic heartbeat logs.""" nonlocal eval_count eval_count += 1 sim_value, pct_value, obj_value = self._evaluate(mirror_idx, measured_psf, rnda_values) if eval_count == 1 or (eval_log_interval > 0 and eval_count % eval_log_interval == 0): self._logger.info( "Mirror %d | eval %d | %s | sim %.3f mm | pct %.2f%%", mirror_idx, eval_count, stage, sim_value, pct_value, ) return sim_value, pct_value, obj_value rnda = list(self.rnda_start) sim, pct, obj = evaluate_with_progress(rnda, "initial") best = {"rnda": list(rnda), "sim": sim, "pct": abs(pct), "obj": obj} self._logger.info( "Mirror %d | initial PSF %.3f mm (f=%.2f) | pct %.2f%%", mirror_idx, sim, fraction, pct, ) for iteration in range(self.MAX_ITER): old_rnda = list(rnda) for param_idx, (param_value, param_bounds) in enumerate(zip(old_rnda, bounds)): rnda[param_idx] = self._update_single_rnda_parameter( mirror_idx=mirror_idx, measured_psf=measured_psf, rnda=rnda, param_idx=param_idx, param_value=param_value, param_bounds=param_bounds, learning_rate=learning_rate, evaluate_function=evaluate_with_progress, iteration=iteration + 1, ) sim, pct, obj = evaluate_with_progress(rnda, f"iter{iteration + 1}_post_update") pct = abs(pct) old_rnda_str = "[" + ", ".join(f"{v:.4g}" for v in old_rnda) + "]" rnda_str = "[" + ", ".join(f"{v:.4g}" for v in rnda) + "]" self._logger.info( "Iter %d | rnda %s -> %s | pct %.2f -> %.2f | lr %.2g", iteration + 1, old_rnda_str, rnda_str, best["pct"], pct, learning_rate, ) if obj < best["obj"]: best.update(rnda=list(rnda), sim=sim, pct=pct, obj=obj) learning_rate *= 1.1 else: rnda = list(old_rnda) learning_rate *= 0.5 if best["pct"] <= threshold_pct or learning_rate < 1e-12: break return MirrorOptimizationResult( mirror=mirror_idx, measured_psf_mm=float(measured_psf), optimized_rnda=best["rnda"], simulated_psf_mm=best["sim"], percentage_diff=best["pct"], containment_fraction=fraction, )
def _update_single_rnda_parameter( self, mirror_idx, measured_psf, rnda, param_idx, param_value, param_bounds, learning_rate, evaluate_function=None, iteration=None, ): """ Update a single RNDA parameter using finite-difference gradient descent. Parameters ---------- mirror_idx : int measured_psf : float rnda : list of float param_idx : int param_value : float param_bounds : tuple of float learning_rate : float evaluate_function : callable or None Function used to evaluate objective. Falls back to self._evaluate when None. iteration : int or None Iteration number used for logging context. Returns ------- float Updated RNDA parameter value. """ param_min, param_max = param_bounds is_log_param = param_idx != 1 epsilon = max(1e-6, 0.05 * param_value) if is_log_param else 0.05 if evaluate_function is None: def evaluate_function(rnda_values, _stage): return self._evaluate(mirror_idx, measured_psf, rnda_values) stage_prefix = f"iter{iteration}_p{param_idx}" if iteration is not None else f"p{param_idx}" rnda[param_idx] = min(param_max, param_value + epsilon) _, _, f_plus = evaluate_function(rnda, f"{stage_prefix}_plus") rnda[param_idx] = max(param_min, param_value - epsilon) _, _, f_minus = evaluate_function(rnda, f"{stage_prefix}_minus") rnda[param_idx] = param_value gradient = (f_plus - f_minus) / (2 * epsilon) # Clip the gradient to avoid excessively large steps. if is_log_param: gradient = np.clip(gradient * param_value, -self.GRAD_CLIP, self.GRAD_CLIP) step = np.clip(-learning_rate * gradient, -self.MAX_LOG_STEP, self.MAX_LOG_STEP) # clip the parameters so they are within allowed range return np.clip(param_value * np.exp(step), param_min, param_max) gradient = np.clip(gradient, -self.GRAD_CLIP, self.GRAD_CLIP) step = np.clip(-learning_rate * gradient, -self.MAX_FRAC_STEP, self.MAX_FRAC_STEP) return np.clip(param_value + step, param_min, param_max)
[docs] def optimize_with_gradient_descent(self): """ Optimize all mirrors in parallel using process pool. Notes ----- This method updates these instance attributes: - per_mirror_results (list of MirrorOptimizationResult) - rnda_opt (list of float) - final_percentage_diff (float) """ n_mirrors = len(self.measured_data) if self.args_dict.get("test"): n_mirrors = min(n_mirrors, self.args_dict.get("number_of_mirrors_to_test")) if n_mirrors > self.telescope_model.mirrors.number_of_mirrors: raise ValueError( f"Number of mirrors to optimize ({n_mirrors}) exceeds the number of " f" mirrors in the model ({self.telescope_model.mirrors.number_of_mirrors})" ) max_workers = self.args_dict.get("max_workers") worker_args = [ MirrorOptimizationJob( self.label, dict(self.args_dict), mirror_index, float(self.measured_data[mirror_index]), ) for mirror_index in range(n_mirrors) ] self.per_mirror_results = map_ordered( _optimize_single_mirror_worker, worker_args, backend=self.args_dict.get("backend", "local"), max_workers=max_workers, backend_config=self.args_dict.get("backend_config"), mp_start_method="fork", context=self.context, ) self.rnda_opt = np.mean( [r.optimized_rnda for r in self.per_mirror_results], axis=0 ).tolist() self.final_percentage_diff = float( np.mean([r.percentage_diff for r in self.per_mirror_results]) )
[docs] def write_optimization_data(self): """Write optimization results and optionally export as a model parameter.""" output_dir = Path(self.args_dict.get("output_path", ".")) telescope = self.args_dict.get("telescope") parameter_version = self.args_dict.get("parameter_version") parameter_name = "mirror_reflection_random_angle" parameter_output_path = output_dir / str(telescope) parameter_output_path.mkdir(parents=True, exist_ok=True) output_file = parameter_output_path / "per_mirror_rnda.json" per_mirror_results_out = [] for result in self.per_mirror_results: result_dict = asdict(result) for k, v in result_dict.items(): if k == "optimized_rnda": result_dict[k] = [float(f"{x:.4f}") for x in v] elif isinstance(v, (int, float)): result_dict[k] = float(f"{v:.4f}") per_mirror_results_out.append(result_dict) output_file.write_text( json.dumps( { "telescope": telescope, "model_version": self.args_dict.get("model_version"), "per_mirror_results": per_mirror_results_out, }, indent=2, ), encoding="utf-8", ) self._logger.info("Results written to %s", output_file) # Export averaged RNDA if telescope and parameter_version and self.rnda_opt is not None: try: model_data_writer.ModelDataWriter.write_model_parameter( parameter_name=parameter_name, value=[float(f"{v:.4f}") for v in self.rnda_opt], instrument=str(telescope), parameter_version=str(parameter_version), output_file=f"{parameter_name}-{parameter_version}.json", output_path=parameter_output_path, unit=["deg", "dimensionless", "deg"], context=self.context, ) self._logger.info( "Exported model parameter %s (%s) to %s", parameter_name, parameter_version, parameter_output_path, ) except (OSError, ValueError, TypeError) as e: self._logger.warning("Failed to export model parameter %s: %s", parameter_name, e)
[docs] def write_psf_histogram(self): """Plot histogram of measured vs simulated psf values.""" measured = [r.measured_psf_mm for r in self.per_mirror_results] simulated = [r.simulated_psf_mm for r in self.per_mirror_results] return plot_psf.plot_psf_histogram(measured, simulated, self.args_dict)