Source code for ray_tracing.backends.sim_telarray

"""Ray tracing with sim_telarray.

sim_telarray simulates the star light of each random instrument instance in one
run and writes photon lists; images are read from these lists (or analysed with
sim_telarray's rx). Incidence angles come from the imaging list of the
sim_telarray debug-trace build.
"""

import gzip
import logging
import math
import re
import shutil
from concurrent.futures import ThreadPoolExecutor

from simtools.context import RunContext
from simtools.job_execution import execution
from simtools.job_execution.execution import JobExecutionError
from simtools.ray_tracing import parameter_changes
from simtools.ray_tracing.backends.interface import image_file_name
from simtools.ray_tracing.psf_analysis import PSFImage
from simtools.simtel.simulator_ray_tracing import SimulatorRayTracing, telescope_pointing
from simtools.simulation.configuration import get_model_writer


[docs] class SimtelBackend: """ Ray tracing with sim_telarray (see ``simtools.ray_tracing.backends.interface``). Parameters ---------- telescope_model: TelescopeModel Telescope model. site_model: SiteModel Site model. label: str Label of the output files. zenith_angle: float Telescope zenith angle [deg]. single_mirror_mode: bool Trace single mirrors. """ # Calibration light: sim_telarray's LightEmission programs and optics. traces_light_sources = False def __init__( self, telescope_model, site_model, label, zenith_angle, single_mirror_mode, *, context=None ): self._logger = logging.getLogger(__name__) self.context = context or RunContext.from_environment() self.telescope_model = telescope_model self.site_model = site_model self.label = label self.zenith_angle = zenith_angle self.single_mirror_mode = single_mirror_mode self._output_directory = self.context.paths.get_output_directory()
[docs] def simulate(self, requests, test=False, force=False, compress_photons=True, max_workers=8): """ Run one sim_telarray simulation per image; photon lists are written to disk. Independent full-telescope simulations run concurrently. Single-mirror simulations run serially because their temporary camera files are shared. Parameters ---------- requests: list of StarImageRequest Images to trace. test: bool Test mode (single short run). force: bool Simulate again even if photon lists exist. compress_photons: bool Compress the photon lists (gzip). max_workers: int Maximum number of concurrent full-telescope simulations. """ if not self.single_mirror_mode: get_model_writer(self.telescope_model).write_sim_telarray_config_file( additional_models=self.site_model, label=self.label ) simulations = [(self._simulator(request, test, force), request) for request in requests] if not self.single_mirror_mode and len(simulations) > 1: with ThreadPoolExecutor(max_workers=min(max_workers, len(simulations))) as executor: list( executor.map( lambda simulation: self._run(simulation, test, compress_photons), simulations, ) ) return for simulation in simulations: self._run(simulation, test, compress_photons)
def _simulator(self, request, test, force): """Create the simulator of one image.""" self._logger.info( f"Simulating RayTracing for off_axis=({request.off_axis_x:.3f}, " f"{request.off_axis_y:.3f}), mirror={request.mirror_number}" ) simulator = SimulatorRayTracing( telescope_model=self.telescope_model, site_model=self.site_model, label=self.label, test=test, config_data={ "zenith_angle": self.zenith_angle, "off_axis_x": request.off_axis_x, "off_axis_y": request.off_axis_y, "source_distance": request.source_distance_km, "single_mirror_mode": self.single_mirror_mode, "mirror_numbers": request.mirror_number, "photons_per_run": request.photons_per_instance, "parameter_changes": request.parameter_changes, "seeds": request.seeds, }, force_simulate=force, context=self.context, ) if not self.single_mirror_mode: simulator._write_config = False # pylint: disable=protected-access return simulator def _run(self, simulation, test, compress_photons): """Run one simulator and optionally compress its photon list.""" simulator, request = simulation simulator.run(test=test) if not compress_photons: return photons_file = self._files(request)[0] self._logger.debug(f"Using gzip to compress the photons file {photons_file}.") with open(photons_file, "rb") as f_in: with gzip.open(photons_file.with_suffix(photons_file.suffix + ".gz"), "wb") as f_out: shutil.copyfileobj(f_in, f_out) photons_file.unlink()
[docs] def psf_image( self, request, focal_length, containment_fraction, use_rx=False, save_photons=False ): """ Return the PSF image of one simulated image. Parameters ---------- request: StarImageRequest Image. focal_length: float Focal length [cm]. containment_fraction: float PSF containment fraction. use_rx: bool Analyse the photon list with sim_telarray's rx. save_photons: bool Keep the photon and star lists; they are removed otherwise. Returns ------- tuple of PSFImage and bool The image, and False (sim_telarray does not trace the telescope structure). """ photons_file_lis, photons_file_gz, stars_file = self._files(request) photons_file = photons_file_gz if not (use_rx and photons_file_gz.exists()) and photons_file_lis.exists(): photons_file = photons_file_lis image = PSFImage( focal_length=focal_length, containment_fraction=containment_fraction, context=self.context, ) image.process_photon_list(photons_file, use_rx) if not save_photons: for file in (photons_file_lis, photons_file_gz, stars_file): file.unlink(missing_ok=True) return image, False
def _files(self, request): """Photon list (and its compressed name) and star list of one image.""" photons_file = self._output_directory / image_file_name( self, request, "ray_tracing_photons", ".lis" ) return ( photons_file, photons_file.with_suffix(photons_file.suffix + ".gz"), self._output_directory / image_file_name(self, request, "ray_tracing_stars", ".lis"), )
[docs] def incidence_angles(self, request, work_directory, name, with_mirrors, keep_files=False): """ Per-photon incidence angles from a sim_telarray debug-trace run. Parameters ---------- request: StarImageRequest Star image (one instance; off_axis_x is the field angle). work_directory: Path Directory for logs, scripts and photon files. name: str Name of the run's files. with_mirrors: bool Also angles and hit points on the primary and secondary mirror. keep_files: bool Keep the imaging list (not reported as intermediate file). Returns ------- tuple of dict and list Angles [deg] and hit points [m] (``angle_incidence_focal``, and with mirrors ``angle_incidence_primary``, ``angle_incidence_secondary``, ``primary_hit_x``, ``primary_hit_y``, ``secondary_hit_x``, ``secondary_hit_y``), and the intermediate files written. """ get_model_writer(self.telescope_model).write_sim_telarray_config_file( additional_models=self.site_model ) directories = {d: work_directory / d for d in ("logs", "scripts", "photons_files")} for directory in directories.values(): directory.mkdir(parents=True, exist_ok=True) photons_file = directories["photons_files"] / f"incident_angles_photons_{name}.lis" stars_file = directories["scripts"] / f"incident_angles_stars_{name}.lis" log_file = directories["logs"] / f"incident_angles_{name}.log" script = directories["scripts"] / f"run_incident_angles_{name}.sh" self._write_incidence_inputs(request, photons_file, stars_file) self._write_incidence_script(request, script, photons_file, stars_file, log_file) self._logger.info("Executing %s (logging to %s)", script, log_file) try: execution.run_command(script) except JobExecutionError as exc: raise RuntimeError(f"Incident angles run failed, see log: {log_file}") from exc data = _ImagingList(with_mirrors).read(photons_file) intermediate = [script, stars_file] + ([] if keep_files else [photons_file]) return data, intermediate
def _write_incidence_inputs(self, request, photons_file, stars_file): """Write the imaging-list header and the star list.""" with photons_file.open("w", encoding="utf-8") as file: file.write(f"#{'=' * 50}\n") file.write("# Imaging list for Incident Angle simulations\n") file.write(f"#{'=' * 50}\n") file.write(f"# config_file = {self.telescope_model.config_file_path}\n") file.write(f"# zenith_angle [deg] = {self.zenith_angle}\n") file.write(f"# off_axis_angle [deg] = {request.off_axis_x}\n") file.write(f"# source_distance [km] = {request.source_distance_km}\n") with stars_file.open("w", encoding="utf-8") as file: file.write(f"0. {90.0 - self.zenith_angle} 1.0 {request.source_distance_km}\n") def _write_incidence_script(self, request, script, photons_file, stars_file, log_file): """Write the run script of the sim_telarray debug-trace build.""" theta, phi = telescope_pointing(self.zenith_angle, request.off_axis_x, request.off_axis_y) instrument_seed, simulation_seed = request.seeds[0] options = { "random_seed": f"{instrument_seed},{simulation_seed}", "IMAGING_LIST": photons_file, "stars": stars_file, "altitude": self.site_model.get_parameter_value("corsika_observation_level"), "telescope_theta": theta, "star_photons": request.photons_per_instance, "telescope_phi": phi, "camera_transmission": 1.0, "nightsky_background": "all:0.", "trigger_current_limit": "1e10", "telescope_random_angle": 0, "telescope_random_error": 0, "convergent_depth": 0, "maximum_telescopes": 1, "show": "all", "camera_filter": "none", } | parameter_changes.sim_telarray_options(request.parameter_changes) command = " ".join( [ str(self.context.software.sim_telarray_exe_debug_trace), f"-c {self.telescope_model.config_file_path}", f"-I{self.telescope_model.config_file_directory}", *(f"-C {key}={value}" for key, value in options.items()), str(self.context.software.corsika_dummy_file), ] ) with script.open("w", encoding="utf-8") as file: file.write("#!/usr/bin/env bash\n\n") file.write("set -e\nset -o pipefail\n\n") file.write(f"exec > '{log_file}' 2> '{log_file.with_suffix('.err')}'\n\n") file.write(f"{command}\n") script.chmod(script.stat().st_mode | 0o110)
class _ImagingList: """Parser of the imaging list of the sim_telarray debug-trace build.""" def __init__(self, with_mirrors): self.with_mirrors = with_mirrors def read(self, photons_file): """Return angles [deg] and hit points [m] of the photons in an imaging list.""" data = self._compute_incidence_angles_from_imaging_list(photons_file) renamed = { "angle_incidence_focal": data["angle_incidence_focal_deg"], } if self.with_mirrors: renamed |= { "angle_incidence_primary": data["angle_incidence_primary_deg"], "angle_incidence_secondary": data["angle_incidence_secondary_deg"], "primary_hit_x": data["primary_hit_x_m"], "primary_hit_y": data["primary_hit_y_m"], "secondary_hit_x": data["secondary_hit_x_m"], "secondary_hit_y": data["secondary_hit_y_m"], } return renamed def _compute_incidence_angles_from_imaging_list(self, photons_file): """Compute incidence angles from an imaging list file. Column positions may differ between telescope types and sim_telarray versions. Header lines (``# Column N: ...``) are required to identify the physical angle definitions. Missing required headers raise ValueError. Parameters ---------- photons_file : pathlib.Path Path to the imaging list file (``.lis``). Returns ------- dict[str, list[float]] Parsed columns in degrees/meters as plain Python lists. Always contains ``angle_incidence_focal_deg``; additional keys are present when primary/secondary angles are enabled. """ col_idx = self._find_column_indices(photons_file) focal = [] primary = secondary = radius_m = secondary_radius_m = None primary_hit_x_m = primary_hit_y_m = secondary_hit_x_m = secondary_hit_y_m = None if self.with_mirrors: primary, secondary = [], [] radius_m, secondary_radius_m = [], [] primary_hit_x_m, primary_hit_y_m = [], [] secondary_hit_x_m, secondary_hit_y_m = [], [] for parts in self._iter_data_rows(photons_file): self._append_values( parts, col_idx, focal, primary, secondary, radius_m, secondary_radius_m, primary_hit_x_m, primary_hit_y_m, secondary_hit_x_m, secondary_hit_y_m, ) result = { "angle_incidence_focal_deg": focal, } if self.with_mirrors: result["angle_incidence_primary_deg"] = primary result["angle_incidence_secondary_deg"] = secondary result["primary_hit_radius_m"] = radius_m result["secondary_hit_radius_m"] = secondary_radius_m result["primary_hit_x_m"] = primary_hit_x_m result["primary_hit_y_m"] = primary_hit_y_m result["secondary_hit_x_m"] = secondary_hit_x_m result["secondary_hit_y_m"] = secondary_hit_y_m return result def _find_column_indices(self, photons_file): """Return 0-based column indices found from headers as a dict. Returns a mapping with keys ``'focal'`` and, when applicable, ``'primary'``, ``'secondary'``, ``'prim_x'``, ``'prim_y'``, ``'sec_x'``, ``'sec_y'``. Parameters ---------- photons_file : pathlib.Path Imaging list file whose headers may define column numbers. Returns ------- dict[str, int] 0-based indices for the required columns. """ indices = {} col_pat = re.compile(r"^\s*#\s*Column\s+(\d{1,4})\s*:(.*)$", re.IGNORECASE) with photons_file.open("r", encoding="utf-8") as fh: for raw in fh: s = raw.strip() if not s or not s.startswith("#"): continue m = col_pat.match(s) if not m: continue num = int(m.group(1)) desc = m.group(2).strip().lower() self._update_indices_from_header_desc(desc, num, indices) required = {"focal"} if self.with_mirrors: required |= {"primary", "secondary"} missing = required - indices.keys() if missing: raise ValueError(f"Missing imaging-list column headers: {sorted(missing)}") return indices def _update_indices_from_header_desc(self, desc, num, indices): """Update indices dict in-place based on a header description and column number. Parameters ---------- desc : str Header description text (lower-cased). num : int 1-based column number from the header. indices : dict[str, int] Mapping to update in-place. """ if "angle of incidence" in desc: if "focal surface" in desc and "optical axis" in desc: indices["focal"] = num - 1 return if not self.with_mirrors: return if "angle of incidence" in desc: for mirror in ("primary", "secondary"): if f"{mirror} mirror" in desc: indices[mirror] = num - 1 return if "reflection point" not in desc: return self._set_reflection_index_if_match(desc, num, indices) @staticmethod def _contains_axis(desc, axis): """Check whether a description contains a stand-alone axis label. Parameters ---------- desc : str Header description string. axis : str Either ``"x"`` or ``"y"``. Returns ------- bool True when the token is present as a separate word; False otherwise. """ desc_l = desc.lower() axis_l = axis.lower() return bool(re.search(r"(^|\s)" + re.escape(axis_l) + r"(\s|$)", desc_l)) def _set_reflection_index_if_match(self, desc, num, indices): """Set reflection point indices for primary/secondary mirrors if the header matches. Parameters ---------- desc : str Header description string (lower-cased). num : int 1-based column number from the header. indices : dict[str, int] Mapping to update in-place with keys ``prim_x``, ``prim_y``, ``sec_x``, or ``sec_y``. """ is_primary = "primary mirror" in desc is_secondary = "secondary mirror" in desc if not is_primary and not is_secondary: return is_x = self._contains_axis(desc, "x") is_y = self._contains_axis(desc, "y") if not (is_x or is_y): return key_prefix = "prim" if is_primary else "sec" key = f"{key_prefix}_{'x' if is_x else 'y'}" indices[key] = num - 1 @staticmethod def _parse_float(parts, idx): """Try to parse a float from ``parts[idx]``. Parameters ---------- parts : list[str] Tokenized row. idx : int | None Index into ``parts``. Returns ------- tuple[bool, float] Tuple of ``(ok, value)`` where ``ok`` is False when parsing fails or the index is out of range; in that case ``value`` is 0.0. """ if idx is None or idx < 0 or idx >= len(parts): return False, 0.0 try: return True, float(parts[idx]) except ValueError: return False, 0.0 @staticmethod def _parse_float_with_nan(parts, idx): """Parse a float or return NaN when missing/invalid. Parameters ---------- parts : list[str] Tokenized row. idx : int | None Index into ``parts``. Returns ------- float Parsed float value, or ``nan`` when unavailable/invalid. """ if idx is None or idx < 0 or idx >= len(parts): return float("nan") try: return float(parts[idx]) except ValueError: return float("nan") @staticmethod def _iter_data_rows(photons_file): """Iterate over tokenized, non-empty, non-comment rows. Parameters ---------- photons_file : pathlib.Path Imaging list file to read. Returns ------- Iterator[list[str]] Iterator over tokenized rows. """ with photons_file.open("r", encoding="utf-8") as fh: for line in fh: if not line.strip() or line.lstrip().startswith("#"): continue yield line.split() def _append_values( self, parts, col_idx, focal, primary, secondary, radius_m, secondary_radius_m, primary_hit_x_m, primary_hit_y_m, secondary_hit_x_m, secondary_hit_y_m, ): """Append parsed values from parts into target arrays if valid. Parameters ---------- parts : list[str] Tokenized input row. col_idx : dict[str, int] Column indices used to read values. focal, primary, secondary : list | None Output arrays to append into. radius_m, secondary_radius_m : list | None Output arrays for radii in meters. primary_hit_x_m, primary_hit_y_m, secondary_hit_x_m, secondary_hit_y_m : list | None Output arrays for hit coordinates in meters. """ foc_ok, foc_val = self._parse_float(parts, col_idx.get("focal")) if not foc_ok: return focal.append(foc_val) if not self.with_mirrors: return self._append_primary_secondary_angles(parts, col_idx, primary, secondary) self._append_primary_hit_geometry( parts, col_idx, radius_m, primary_hit_x_m, primary_hit_y_m ) self._append_secondary_hit_geometry( parts, col_idx, secondary_radius_m, secondary_hit_x_m, secondary_hit_y_m ) def _append_primary_secondary_angles(self, parts, col_idx, primary, secondary): """Append primary/secondary angle values (or NaN) if arrays are provided. Parameters ---------- parts : list[str] Tokenized input row. col_idx : dict[str, int] Indices for angle columns. primary, secondary : list | None Output arrays to append into. """ if primary is not None: primary.append(self._parse_float_with_nan(parts, col_idx.get("primary"))) if secondary is not None: secondary.append(self._parse_float_with_nan(parts, col_idx.get("secondary"))) def _append_primary_hit_geometry( self, parts, col_idx, radius_m, primary_hit_x_m, primary_hit_y_m ): """Append primary-mirror hit geometry (radius and x/y in meters). Parameters ---------- parts : list[str] Tokenized input row. col_idx : dict[str, int] Indices for hit coordinate columns. radius_m, primary_hit_x_m, primary_hit_y_m : list | None Output arrays to append into. """ x_ok, x_cm = self._parse_float(parts, col_idx.get("prim_x")) y_ok, y_cm = self._parse_float(parts, col_idx.get("prim_y")) if x_ok and y_ok: x_m, y_m = x_cm / 100.0, y_cm / 100.0 r_m = math.hypot(x_cm, y_cm) / 100.0 else: x_m = y_m = r_m = math.nan if radius_m is not None: radius_m.append(r_m) if primary_hit_x_m is not None: primary_hit_x_m.append(x_m) if primary_hit_y_m is not None: primary_hit_y_m.append(y_m) def _append_secondary_hit_geometry( self, parts, col_idx, secondary_radius_m, secondary_hit_x_m, secondary_hit_y_m ): """Append secondary-mirror hit geometry (radius and x/y in meters). Parameters ---------- parts : list[str] Tokenized input row. col_idx : dict[str, int] Indices for hit coordinate columns. secondary_radius_m, secondary_hit_x_m, secondary_hit_y_m : list | None Output arrays to append into. """ sx_ok, sx_cm = self._parse_float(parts, col_idx.get("sec_x")) sy_ok, sy_cm = self._parse_float(parts, col_idx.get("sec_y")) if sx_ok and sy_ok: x_m, y_m = sx_cm / 100.0, sy_cm / 100.0 r_m = math.hypot(sx_cm, sy_cm) / 100.0 else: x_m = y_m = r_m = math.nan if secondary_radius_m is not None: secondary_radius_m.append(r_m) if secondary_hit_x_m is not None: secondary_hit_x_m.append(x_m) if secondary_hit_y_m is not None: secondary_hit_y_m.append(y_m)