"""Calculate photon incident angles on focal plane and primary/secondary mirrors.
The ray-tracing backend (``simtools.ray_tracing.backends.interface``) provides, per photon,
the angle of incidence at the focal surface with respect to the optical axis
[deg] and, for dual-mirror telescopes, the angles of incidence on the primary
and secondary mirror [deg] and the hit points on them.
"""
import logging
from pathlib import Path
import astropy.units as u
import numpy as np
from astropy.table import QTable, vstack
from simtools.context import RunContext
from simtools.data_model.metadata_collector import MetadataCollector
from simtools.data_model.model_data_writer import ModelDataWriter
from simtools.model.model_utils import initialize_simulation_models
from simtools.ray_tracing import parameter_changes
from simtools.ray_tracing.backends import interface
from simtools.ray_tracing.backends.interface import StarImageRequest
from simtools.simtel import simtel_seeds
[docs]
class IncidentAnglesCalculator:
"""Trace a star and compute incident angles at mirrors or focal surfaces.
Parameters
----------
config_data : dict
Simulation configuration (e.g. ``site``, ``telescope``, ``model_version``,
``zenith_angle``, ``off_axis_angle``, ``source_distance``, ``number_of_photons``).
Zenith defaults to 0 degrees. Off-axis angles use the PSF camera-x convention.
output_dir : str or pathlib.Path
Output directory where logs, scripts, photons files and results are written.
label : str, optional
Label used to name outputs; defaults to ``incident_angles_<telescope>`` when omitted.
Notes
-----
Additional options are read from ``config_data`` when present:
- ``perfect_mirror`` (bool, default False)
Focal-plane incidence angles are calculated for all telescope models. Primary and
secondary mirror incidence angles are additionally calculated
only for dual-mirror telescopes.
"""
def __init__(
self,
config_data,
output_dir,
label=None,
context=None,
):
self.logger = logging.getLogger(__name__)
self.context = context or RunContext.from_environment()
self.config_data = dict(config_data)
self.zenith_angle_deg = u.Quantity(config_data.get("zenith_angle", 0 * u.deg)).to_value(
u.deg
)
self.output_dir = Path(output_dir)
self.label = label or f"incident_angles_{config_data['telescope']}"
self.perfect_mirror = config_data.get("perfect_mirror", False)
self.results = None
self.output_dir.mkdir(parents=True, exist_ok=True)
self.logs_dir = self.output_dir / "logs"
self.scripts_dir = self.output_dir / "scripts"
self.photons_dir = self.output_dir / "photons_files"
self.model_dir = self.output_dir / "model" / f"incident_angles_{self.label}"
self._intermediate_files = set()
self.cleanup_options = {
"patterns": (f"{self.model_dir.relative_to(self.output_dir)}/**/*",),
"files": self._intermediate_files,
"directories": (
self.model_dir,
self.model_dir.parent,
self.scripts_dir,
self.photons_dir,
),
"exclude": set(self.model_dir.rglob("*")),
}
self.results_dir = self.output_dir / "incident_angles"
for d in (self.logs_dir, self.scripts_dir, self.photons_dir, self.results_dir):
d.mkdir(parents=True, exist_ok=True)
self.logger.info(
"Initializing models for %s, %s",
config_data["site"],
config_data["telescope"],
)
self.telescope_model, self.site_model, _ = initialize_simulation_models(
label=self.label,
site=config_data["site"],
telescope_name=config_data["telescope"],
model_version=config_data["model_version"],
model_directory=self.model_dir,
context=self.context,
)
self.calculate_primary_secondary_angles = (
self.telescope_model.get_parameter_value("mirror_class") == 2
)
def _label_suffix(self):
"""Build a filename suffix including telescope and off-axis angle.
Returns
-------
str
Suffix of the form ``"<label>_<telescope>_off<angle>"`` where
``<angle>`` is formatted without trailing zeros.
"""
tel = str(self.config_data.get("telescope", "TEL"))
off = float(self.config_data.get("off_axis_angle", 0.0 * u.deg).to_value(u.deg))
return f"{self.label}_{tel}_off{off:g}"
[docs]
def run(self):
"""Trace the star with the ray-tracing backend and return an angle table.
Returns
-------
astropy.table.QTable
Table containing at least the ``angle_incidence_focal`` column
and, when configured, primary/secondary angles and hit geometry.
"""
request = StarImageRequest(
off_axis_x=float(self.config_data["off_axis_angle"].to_value(u.deg)),
off_axis_y=0.0,
source_distance_km=self._source_distance_km(),
seeds=tuple(simtel_seeds.instance_seed_pairs(context=self.context)),
photons_per_instance=int(self.config_data["number_of_photons"]),
parameter_changes=parameter_changes.PERFECT_MIRROR if self.perfect_mirror else {},
)
backend = interface.get_backend(context=self.context)(
self.telescope_model,
self.site_model,
self.label,
self.zenith_angle_deg,
False,
context=self.context,
)
data, intermediate_files = backend.incidence_angles(
request,
self.output_dir,
self._label_suffix(),
self.calculate_primary_secondary_angles,
keep_files=self.config_data.get("keep_photon_files", False),
)
self._intermediate_files.update(intermediate_files)
self.results = QTable()
self.results["angle_incidence_focal"] = np.asarray(data["angle_incidence_focal"]) * u.deg
if self.calculate_primary_secondary_angles:
for mirror in ("primary", "secondary"):
self.results[f"angle_incidence_{mirror}"] = (
np.asarray(data[f"angle_incidence_{mirror}"]) * u.deg
)
for mirror in ("primary", "secondary"):
x, y = (np.asarray(data[f"{mirror}_hit_{axis}"]) for axis in "xy")
self.results[f"{mirror}_hit_radius"] = np.hypot(x, y) * u.m
for mirror in ("primary", "secondary"):
for axis in "xy":
self.results[f"{mirror}_hit_{axis}"] = (
np.asarray(data[f"{mirror}_hit_{axis}"]) * u.m
)
self._save_results()
return self.results
[docs]
def run_for_offsets(self, offsets):
"""Run the simulation for multiple off-axis angles.
For each off-axis angle provided, run a full simulation, labeling output files
accordingly.
Parameters
----------
offsets : Iterable[float]
Off-axis angles in degrees.
Returns
-------
dict[float, astropy.table.QTable]
Mapping from off-axis angle (deg) to the resulting table.
"""
results_by_offset = {}
base_off = self.config_data.get("off_axis_angle", 0.0 * u.deg)
for off in offsets:
self.config_data["off_axis_angle"] = float(off) * u.deg
self.logger.info(f"Running for off-axis angle {off:g} deg")
tbl = self.run()
results_by_offset[float(off)] = tbl.copy()
self.config_data["off_axis_angle"] = base_off
return results_by_offset
def _source_distance_km(self):
"""Return source distance as a scalar value in km."""
source_distance = self.config_data["source_distance"]
if isinstance(source_distance, u.Quantity):
return source_distance.to_value(u.km)
return float(source_distance)
def _save_results(self):
"""Save the results to an ECSV file with metadata."""
if self.results is None or len(self.results) == 0:
self.logger.warning("No results to save")
return
output_file = self.results_dir / f"incident_angles_{self._label_suffix()}.ecsv"
data = self.results["angle_incidence_focal"].to(u.deg).value
bin_centers, hist = self._calculate_histogram(data, bins=1000)
output_table = QTable()
output_table["incidence_angle"] = bin_centers * u.deg
output_table["fraction"] = hist
output_table.meta["normalization"] = "probability density per degree"
output_table.meta["bin_width_deg"] = 0.09
output_table.write(output_file, format="ascii.ecsv", overwrite=True)
MetadataCollector.dump(
args_dict=self.config_data,
output_file=output_file.with_suffix(".yml"),
context=self.context,
)
[docs]
def save_model_parameters(self, results_by_offset):
"""Write incident-angle model-parameter tables and their JSON metadata.
Export the filter distribution and enabled mirror distributions. Both mirror
parameters are exported only for dual-mirror optics. Densities describe unweighted rays
completing the optical path, without requiring a camera pixel hit.
Files are placed under a telescope-named subdirectory to match repository convention.
Parameters
----------
results_by_offset : dict
Dictionary with results for each offset.
"""
tables = [res for res in results_by_offset.values() if res is not None and len(res) > 0]
if not tables:
self.logger.warning("No results to write model parameters.")
return
combined_table = vstack(tables)
telescope = self.config_data["telescope"]
param_version = (
self.config_data.get("parameter_version") or self.config_data["model_version"]
)
param_dir = self.output_dir / telescope
param_dir.mkdir(parents=True, exist_ok=True)
parameters = {"camera_filter_photon_incident_angle": "angle_incidence_focal"}
mirror_class = self.telescope_model.get_parameter_value("mirror_class")
if mirror_class == 2:
parameters["primary_mirror_incidence_angle"] = "angle_incidence_primary"
parameters["secondary_mirror_incidence_angle"] = "angle_incidence_secondary"
for param_name, col_name in parameters.items():
if col_name not in combined_table.colnames:
raise ValueError(f"Missing incidence-angle column for {param_name}: {col_name}")
data = combined_table[col_name].to(u.deg).value
data = data[np.isfinite(data)]
if len(data) == 0:
raise ValueError(f"No finite incidence angles for {param_name}")
table = self._build_incidence_distribution_table(data)
table.meta["off_axis_angles_deg"] = [float(offset) for offset in results_by_offset]
table.meta["zenith_angle_deg"] = float(self.zenith_angle_deg)
if param_name == "camera_filter_photon_incident_angle":
table.meta["angle_reference"] = "optical axis at focal surface"
else:
table.meta["angle_reference"] = "local mirror surface normal"
ecsv_file = f"{param_name}.ecsv"
ModelDataWriter.write_product_data(
output_file=param_dir / ecsv_file,
product_data=table,
metadata=MetadataCollector(args_dict=self.config_data, context=self.context),
context=self.context,
)
ModelDataWriter.write_model_parameter(
parameter_name=param_name,
value=ecsv_file,
instrument=telescope,
parameter_version=param_version,
output_file=f"{param_name}.json",
output_path=param_dir,
metadata_input_dict=self.config_data,
context=self.context,
)
@staticmethod
def _build_incidence_distribution_table(data):
"""Build an angle probability-density table for model-parameter export."""
bin_centers, hist = IncidentAnglesCalculator._calculate_histogram(data, bins=100)
table = QTable()
table["incidence_angle"] = bin_centers * u.deg
table["fraction"] = hist
table.meta["normalization"] = "probability density per degree"
table.meta["bin_width_deg"] = 0.9
table.meta["sampling"] = "unweighted rays completing the telescope optical path"
return table
@staticmethod
def _calculate_histogram(data, bins=100):
"""
Calculate the probability density of finite incidence angles in [0, 90] degrees.
Parameters
----------
data : numpy.ndarray
Data to calculate histogram for.
bins : int
Number of bins.
Returns
-------
tuple
Bin centers in degrees and probability densities per degree, whose
sum multiplied by the bin width in degrees equals one.
"""
data = np.asarray(data)
data = data[np.isfinite(data)]
if len(data) == 0:
return np.array([]), np.array([])
if np.any((data < 0) | (data > 90)):
raise ValueError("Incidence angles must be between 0 and 90 degrees.")
hist, bin_edges = np.histogram(data, bins=bins, range=(0.0, 90.0), density=True)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
return bin_centers, hist