Source code for ray_tracing.backends.obdeect

"""Ray tracing with obdeect, in process.

obdeect compiles the model parameters of a telescope into an optical model and
traces star light through the optics to the camera entrance. The light source,
the telescope pointing and the parameter changes of the ray-tracing modes are
those of the sim_telarray backend, so that both backends answer the same
question. Images stay in memory.

obdeect also makes the light of calibration light sources (point emitters) and
traces it to the camera entrance.
"""

import math
from copy import deepcopy
from pathlib import Path

import astropy.units as u
import numpy as np
from astropy.constants import c as speed_of_light  # pylint: disable=no-name-in-module
from astropy.table import QTable
from obdeect.model import DETECTED, OpticalModel

from simtools.context import RunContext
from simtools.ray_tracing.backends.interface import image_file_name
from simtools.ray_tracing.parameter_changes import TELESCOPE_STRUCTURE
from simtools.ray_tracing.psf_analysis import PSFImage
from simtools.simtel.simulator_ray_tracing import telescope_pointing
from simtools.simulation.configuration import export_model_files
from simtools.utils import geometry

_SPEED_OF_LIGHT_M_PER_NS = speed_of_light.to_value(u.m / u.ns)


[docs] def star_direction(zenith_angle, off_axis_x, off_axis_y): """Return the unit vector to the star in the telescope frame (+z: optical axis).""" zenith = math.radians(zenith_angle) direction = geometry.ground_direction_to_telescope_frame( (math.sin(zenith), 0.0, math.cos(zenith)), *telescope_pointing(zenith_angle, off_axis_x, off_axis_y), ) return tuple(direction.tolist())
[docs] class ObdeectRayTracer: """Trace star light through one telescope model with obdeect. Parameters ---------- telescope_model: TelescopeModel Telescope model. """ def __init__(self, telescope_model): self.telescope_model = telescope_model export_model_files(telescope_model, update_if_necessary=True) self._models = {}
[docs] def optical_model(self, telescope_zenith, instrument_seed, changes=None): """Optical model at a telescope zenith angle [deg] (cached). ``instrument_seed`` fixes the random mirror errors of this instrument instance; ``changes`` are model-parameter values replacing those of the telescope model (see ``simtools.ray_tracing.parameter_changes``). """ changes = changes or {} key = (round(telescope_zenith, 9), instrument_seed, repr(sorted(changes.items()))) if key not in self._models: records, files = self._records(changes) self._models[key] = OpticalModel.from_records( records, lambda name: files[name].read_text(encoding="utf-8"), configuration_seed=instrument_seed, zenith_deg=telescope_zenith, provenance={ "telescope": self.telescope_model.name, "model_version": self.telescope_model.model_version, }, ) return self._models[key]
def _records(self, changes): """Parameter records and their files, with the changes of this run.""" directory = self.telescope_model.config_file_directory records = { name: record for name, record in self.telescope_model.parameters.items() if isinstance(record, dict) and "value" in record } files = { name: directory / record["value"] for name, record in records.items() if record.get("file") and isinstance(record["value"], str) } for name, value in changes.items(): if name not in records: if name == TELESCOPE_STRUCTURE and value == "none": continue model = f"{self.telescope_model.name} ({self.telescope_model.model_version})" raise ValueError( f"Ray-tracing change of '{name}' has no record in the telescope model {model}" ) if records[name].get("file"): files[name] = Path(value) value = Path(value).name records[name] = deepcopy(records[name]) | {"value": value} return records, files
[docs] def trace_star( self, zenith_angle, off_axis_x, off_axis_y, source_distance_m, photons_per_instance, seeds, changes=None, ): """Trace star light, as in the sim_telarray ray-tracing runs. ``photons_per_instance`` photons for each random instrument instance, given by its (instrument seed, simulation seed) in ``seeds``, as sim_telarray runs one simulation per instance; ``changes`` as in ``optical_model``. Returns ------- dict Arrival arrays (see ``obdeect.OpticalModel.trace``) of the photons reaching the camera entrance, the number of photons traced and the area they were sampled on [m^2], and whether the optical model shadows with its own structure. """ telescope_zenith, _ = telescope_pointing(zenith_angle, off_axis_x, off_axis_y) towards_source = star_direction(zenith_angle, off_axis_x, off_axis_y) arrivals = [] for instrument_seed, simulation_seed in seeds: model = self.optical_model(telescope_zenith, instrument_seed, changes) sample = model.star_photons( photons_per_instance, seed=simulation_seed, towards_source=towards_source, distance_m=source_distance_m, ) traced = model.trace(sample, seed=simulation_seed, apply_responses=False) detected = traced["status"] == DETECTED arrivals.append({name: values[detected] for name, values in traced.items()}) return {name: np.concatenate([a[name] for a in arrivals]) for name in arrivals[0]} | { "total_photons": photons_per_instance * len(seeds), "total_area_m2": model.sampling_area_m2, "has_structure": bool(model.has_structure), }
[docs] def trace_light_source(self, request): """ Make and trace the light of a calibration light source, pulse by pulse. Parameters ---------- request: LightSourceRequest Light source. Yields ------ iterator of dict The photons of one pulse crossing the camera plane, block by block (see ``simtools.ray_tracing.backends``). """ instrument_seed, simulation_seed = request.seeds model = self.optical_model(request.telescope_zenith, instrument_seed) plane_z = model.focal_surface_z_m disk = ( ((0.0, 0.0, plane_z), (0.0, 0.0, 1.0), request.camera_radius_m) if request.inside_telescope else None # The telescope entrance. ) for pulse_number, photons in enumerate(request.photons_per_pulse): blocks = model.trace_emitter_blocks( photons, seed=simulation_seed, first_id=pulse_number << 40, position_m=request.position_m, intensity=request.angular_profile, axis=request.axis, pulse=request.pulse, wavelength_nm=request.wavelength_nm, disk=disk, direct=request.inside_telescope, ) yield (_at_plane(block, plane_z, model.propagation_group_index) for block in blocks)
def _at_plane(photons, plane_z, group_index): """Move photons on the focal surface along their direction to the plane z = plane_z [m].""" position, direction = photons["position_m"], photons["direction"] path = (plane_z - position[:, 2]) / direction[:, 2] photons["position_m"] = position + path[:, None] * direction photons["time_ns"] = photons["time_ns"] + path * group_index / _SPEED_OF_LIGHT_M_PER_NS return photons
[docs] class ObdeectBackend: """ Ray tracing with obdeect, in process (see ``simtools.ray_tracing.backends.interface``). Parameters ---------- telescope_model: TelescopeModel Telescope model. site_model: SiteModel Site model (not used: obdeect traces from the telescope entrance). label: str Label of the output files. zenith_angle: float Telescope zenith angle [deg]. single_mirror_mode: bool Trace single mirrors. """ traces_light_sources = True def __init__( self, telescope_model, site_model, label, zenith_angle, single_mirror_mode, *, context=None ): del site_model self.telescope_model = telescope_model self.context = context or RunContext.from_environment() self.label = label self.zenith_angle = zenith_angle self.single_mirror_mode = single_mirror_mode self._tracer = ObdeectRayTracer(telescope_model) self._images = {} def _trace(self, request): return self._tracer.trace_star( self.zenith_angle, request.off_axis_x, request.off_axis_y, request.source_distance_km * 1000.0, request.photons_per_instance, request.seeds, request.parameter_changes, )
[docs] def simulate(self, requests, test=False, force=False, compress_photons=True, max_workers=8): """ Trace the images; they are kept in memory. ``test``, ``force``, ``compress_photons`` and ``max_workers`` concern photon files and processes and have no effect here. """ del test, force, compress_photons, max_workers for request in requests: # Only what the image needs: camera positions [cm] of the detected photons. arrivals = self._trace(request) self._images[request.key] = { "position_cm": 100.0 * arrivals["position_m"][:, :2], "total_photons": arrivals["total_photons"], "total_area_m2": arrivals["total_area_m2"], "has_structure": arrivals["has_structure"], }
[docs] def psf_image( self, request, focal_length, containment_fraction, use_rx=False, save_photons=False ): """ Return the PSF image of one traced image. Parameters as for the sim_telarray backend; ``save_photons`` writes the photons as an ECSV table. Returns ------- tuple of PSFImage and bool The image, and whether the telescope structure was traced. """ if use_rx: raise ValueError("rx analyses sim_telarray photon lists only") traced = self._images.get(request.key) if traced is None: raise RuntimeError(f"Image {request.key} was not traced; run simulate first") position_cm = traced["position_cm"] image = PSFImage( focal_length=focal_length, containment_fraction=containment_fraction, context=self.context, ) image.set_photon_positions( position_cm[:, 0], position_cm[:, 1], traced["total_photons"], traced["total_area_m2"] ) if save_photons: self._write_photons(request, traced, position_cm) del self._images[request.key] return image, traced["has_structure"]
def _write_photons(self, request, traced, position_cm): """Write the photons of an image as an ECSV table (camera-entrance positions).""" file_name = self.context.paths.get_output_directory() / image_file_name( self, request, "ray_tracing_photons", ".ecsv" ) table = QTable( [position_cm[:, 0] * u.cm, position_cm[:, 1] * u.cm], names=("x", "y"), meta={ "total_photons": int(traced["total_photons"]), "total_area_m2": float(traced["total_area_m2"]), "traced_structure": traced["has_structure"], }, ) table.write(file_name, format="ascii.ecsv", overwrite=True)
[docs] def incidence_angles(self, request, work_directory, name, with_mirrors, keep_files=False): """ Per-photon incidence angles (see the sim_telarray backend); no files are written. Returns ------- tuple of dict and list Angles [deg] and hit points [m], and no intermediate files. """ del work_directory, name, keep_files arrivals = self._trace(request) if len(arrivals["direction"]) == 0: raise RuntimeError("No photon reached the focal surface") # Angle to the optical axis, as in the sim_telarray imaging list. data = {"angle_incidence_focal": np.degrees(np.arccos(np.abs(arrivals["direction"][:, 2])))} if with_mirrors: for mirror in ("primary", "secondary"): point = arrivals[f"{mirror}_point_m"] data[f"angle_incidence_{mirror}"] = arrivals[f"{mirror}_incidence_deg"] data[f"{mirror}_hit_x"] = point[:, 0] data[f"{mirror}_hit_y"] = point[:, 1] return data, []
[docs] def camera_photons(self, request): """Photons of a calibration light source at the camera entrance, per pulse in blocks.""" return self._tracer.trace_light_source(request)