Source code for simtel.light_emission_config_writer

"""Translate calibration-source settings into LightEmission configuration."""

import logging
import shlex
import shutil

import astropy.units as u

from simtools.job_execution.process_accounting import build_accounting_command
from simtools.simtel import simtel_file_writer
from simtools.utils.geometry import fiducial_radius_from_shape

TELESCOPE_CONFIG_KEY = "telescope"
LIGHT_SOURCE_CONFIG_KEY = "light_source"
DEFAULT_LIGHT_SOURCE_TOKEN = "calibration"
LIGHT_EMISSION_OUTPUT_DIRECTORY = "light_emission"


[docs] class LightEmissionConfigWriter: """Write settings and tables for ff-1m and xyzls. Parameters ---------- simulation : SimulatorLightEmission Physical source configuration, model parameters, and geometry helpers. """ def __init__(self, simulation): self.simulation = simulation self._logger = logging.getLogger(__name__)
[docs] def get_light_emission_application_name(self): """ Return the LightEmission application and mode from type. Returns ------- str app_name """ if self.simulation.light_emission_config["light_source_type"] == "flat_fielding": return "ff-1m" # default to illuminator xyzls, mode from setup return "xyzls"
[docs] def prepare_flasher_atmosphere_files(self, config_directory, model_id=1): """ Prepare canonical atmosphere aliases for ff-1m and return model id. The ff-1m tool requires atmosphere files atmprof1.dat or atm_profile_model_1.dat and as configuration parameter the atmosphere id ('--atmosphere id'). """ src_path = config_directory / self.simulation.site_model.get_parameter_value( "atmospheric_profile" ) self._logger.debug(f"Using atmosphere profile: {src_path}") for name in (f"atmprof{model_id}.dat", f"atm_profile_model_{model_id}.dat"): dst = config_directory / name try: if dst.exists() or dst.is_symlink(): dst.unlink() try: dst.symlink_to(src_path) except OSError: shutil.copy2(src_path, dst) except OSError as copy_err: self._logger.warning(f"Failed to create atmosphere alias {dst.name}: {copy_err}") return model_id
[docs] def make_command(self, iact_output): """ Create the light emission command to run the light emission package. Require the specified pre-compiled light emission package application in the sim_telarray/LightEmission/ path. Parameters ---------- iact_output: str or Path The output iact file path. Returns ------- str The commands to run the Light Emission package """ config_directory = self.simulation.telescope_model.config_file_directory obs_level = self.simulation.site_model.get_parameter_value_with_unit( "corsika_observation_level" ) app = self.get_light_emission_application_name() cmd = [ shlex.quote( str(self.simulation.context.software.sim_telarray_path / "LightEmission" / app) ), *self.get_site_command(app, config_directory, obs_level), *self.get_light_source_command(), ] if self.simulation.light_emission_config["light_source_type"] == "illuminator": cmd += [ "-A", ( shlex.quote( f"{config_directory}/" f"{self.simulation.site_model.get_parameter_value('atmospheric_profile')}" ) ), ] cmd += ["-o", shlex.quote(str(iact_output))] log_file = self.simulation.runner_service.get_file_name(file_type="light_emission_log") resources_file = self.simulation.runner_service.get_file_name( file_type="light_emission_resources" ) command = build_accounting_command( shlex.split(" ".join(cmd)), resources_file, LIGHT_EMISSION_OUTPUT_DIRECTORY, self.simulation.light_emission_config.get("run_number", 1), log_file=log_file, ) return shlex.join(command) + "\n"
[docs] def get_site_command(self, app_name, config_directory, corsika_observation_level): """Return site command with altitude, atmosphere and telescope_position handling.""" if app_name in ("ff-1m",): atmo_id = self.prepare_flasher_atmosphere_files(config_directory) return [ "-I.", f"-I{self.simulation.context.software.sim_telarray_path / 'cfg'}", f"-I{config_directory}", f"--altitude {corsika_observation_level.to(u.m).value}", f"--atmosphere {atmo_id}", ] # default path (not used for flasher now, but kept for completeness) cmd = [f"-h {corsika_observation_level.to(u.m).value} "] if self.simulation.light_emission_config.get("light_source_type") == "illuminator": illuminator_position = self.simulation.get_illuminator_position() position_file = self.simulation.write_telescope_position_file(illuminator_position) cmd.append(f"--telpos-file {position_file}") return cmd
[docs] def get_light_source_command(self): """Return light-source specific command options.""" if self.simulation.light_emission_config["light_source_type"] == "flat_fielding": return self.add_flasher_command_options() if self.simulation.light_emission_config["light_source_type"] == "illuminator": return self.add_illuminator_command_options() source_type = self.simulation.light_emission_config["light_source_type"] raise ValueError(f"Unknown light_source_type '{source_type}'")
[docs] def add_flasher_command_options(self): """Add flasher options for all telescope types (ff-1m style).""" events, photons = self.simulation.build_flasher_event_and_photon_sequences() flasher_xyz = self.simulation.calibration_model.get_parameter_value_with_unit( "flasher_position" ) camera_diam_cm = ( self.simulation.telescope_model.get_parameter_value_with_unit("camera_body_diameter") .to(u.cm) .value ) camera_shape = self.simulation.telescope_model.get_parameter_value("camera_body_shape") camera_radius = fiducial_radius_from_shape(camera_diam_cm, camera_shape) flasher_wavelength = self.simulation.calibration_model.get_parameter_value_with_unit( "flasher_wavelength" ) distance = self.simulation.calculate_distance_focal_plane_calibration_device() dist_cm = distance.to(u.cm).value angular_distribution = self.get_angular_distribution_string_for_sim_telarray() pulse_arg = self.get_pulse_shape_argument_for_sim_telarray() bunch_size = self.simulation.calibration_model.get_parameter_value("flasher_bunch_size") return [ f"--events {','.join(str(event) for event in events)}", f"--photons {','.join(str(photon) for photon in photons)}", f"--bunchsize {bunch_size}", f"--xy {flasher_xyz[0].to(u.cm).value},{flasher_xyz[1].to(u.cm).value}", f"--distance {dist_cm}", f"--camera-radius {camera_radius}", f"--spectrum {int(flasher_wavelength.to(u.nm).value)}", f"--lightpulse {pulse_arg}", f"--angular-distribution {angular_distribution}", ]
[docs] def add_illuminator_command_options(self): """Get illuminator-specific command options for light emission script.""" pos = self.simulation.get_illuminator_position() x_cal, y_cal, z_cal = pos pointing_vector = self.simulation.get_illuminator_pointing_vector(pos) flasher_wavelength = self.simulation.calibration_model.get_parameter_value_with_unit( "flasher_wavelength" ) angular_distribution = self.get_angular_distribution_string_for_sim_telarray() pulse_arg = self.get_pulse_shape_argument_for_sim_telarray() return [ f"-x {x_cal.to(u.cm).value}", f"-y {y_cal.to(u.cm).value}", f"-z {z_cal.to(u.cm).value}", f"-d {','.join(map(str, pointing_vector))}", f"-n {self.simulation.light_emission_config['flasher_photons']}", f"-N {int(self.simulation.light_emission_config.get('number_of_events') or 1)}", f"-s {int(flasher_wavelength.to(u.nm).value)}", f"-p {pulse_arg}", f"-a {angular_distribution}", ]
[docs] def generate_lambertian_angular_distribution_table(self): """Generate Lambertian angular distribution table and return path. Uses a pure cosine profile normalized to 1 at 0 deg and spans 0..max_angle_deg. """ tel = self.simulation.get_file_name_token( self.simulation.light_emission_config.get(TELESCOPE_CONFIG_KEY) or TELESCOPE_CONFIG_KEY ) cal = self.simulation.get_file_name_token( self.simulation.light_emission_config.get(LIGHT_SOURCE_CONFIG_KEY) or DEFAULT_LIGHT_SOURCE_TOKEN ) fname = f"flasher_angular_distribution_{tel}_{cal}.dat" max_angle_deg = ( self.simulation.calibration_model.get_parameter_value_with_unit( "flasher_angular_distribution_width" ) .to(u.deg) .value ) path = simtel_file_writer.write_angular_distribution_table_lambertian( file_path=self.simulation.io_handler.get_output_directory( LIGHT_EMISSION_OUTPUT_DIRECTORY ) / fname, max_angle_deg=max_angle_deg, n_samples=100, ) return str(path)
[docs] def get_angular_distribution_string_for_sim_telarray(self): """ Get the angular distribution string for sim_telarray. Returns ------- str The angular distribution string. """ opt = self.simulation.calibration_model.get_parameter_value("flasher_angular_distribution") option_string = str(opt).lower() if opt is not None else "" if option_string == "lambertian": try: return self.generate_lambertian_angular_distribution_table() except (OSError, ValueError) as err: self._logger.warning( f"Failed to write Lambertian angular distribution table: {err};" f" using token instead." ) return option_string if option_string == "isotropic": return option_string width = self.simulation.calibration_model.get_parameter_value_with_unit( "flasher_angular_distribution_width" ) return f"{option_string}:{width.to(u.deg).value}" if width is not None else option_string
[docs] def get_pulse_shape_argument_for_sim_telarray(self): """ Get the pulse shape argument for sim_telarray. For Gauss-Exponential shapes, writes a DAT file and returns the file path. For other shapes, returns a string token representation. Returns ------- str The pulse shape argument (either a file path or a token string). """ pulse_shape_value = self.simulation.calibration_model.get_parameter_value( "flasher_pulse_shape" ) shape_name = pulse_shape_value[0] width_ns = pulse_shape_value[1] exp_ns = pulse_shape_value[2] if shape_name != "Gauss-Exponential": return self.get_pulse_shape_string_token(shape_name, width_ns, exp_ns) if width_ns <= 0 or exp_ns <= 0: raise ValueError( "Gauss-Exponential pulse shape requires positive width and exponential decay values" ) try: tel = ( self.simulation.light_emission_config.get(TELESCOPE_CONFIG_KEY) or TELESCOPE_CONFIG_KEY ) cal = ( self.simulation.light_emission_config.get(LIGHT_SOURCE_CONFIG_KEY) or DEFAULT_LIGHT_SOURCE_TOKEN ) telescope_token = self.simulation.get_file_name_token(tel) source_token = self.simulation.get_file_name_token(cal) fname = f"flasher_pulse_shape_{telescope_token}_{source_token}.dat" table_path = ( self.simulation.io_handler.get_output_directory(LIGHT_EMISSION_OUTPUT_DIRECTORY) / fname ) fadc_bins = self.simulation.telescope_model.get_parameter_value("fadc_sum_bins") simtel_file_writer.write_light_pulse_table_gauss_exp_conv( file_path=table_path, width_ns=width_ns, exp_decay_ns=exp_ns, fadc_sum_bins=fadc_bins, time_margin_ns=5.0, ) return str(table_path) except (ValueError, OSError) as err: self._logger.warning(f"Failed to write pulse shape table, using token: {err}") return self.get_pulse_shape_string_token(shape_name, width_ns, exp_ns)
[docs] def get_pulse_shape_string_token(self, shape_name, width_ns, exp_ns): """ Get the pulse shape string token for sim_telarray. Parameters ---------- shape_name : str Name of the pulse shape. width_ns : float Width parameter in nanoseconds. exp_ns : float Exponential decay parameter in nanoseconds. Returns ------- str The pulse shape token string. """ shape = shape_name.lower() # Map internal shapes to sim_telarray expected tokens shape_token_map = { "tophat": "simple", } shape_out = shape_token_map.get(shape, shape) if shape_out in ("gauss", "simple") and width_ns is not None: return f"{shape_out}:{float(width_ns)}" if shape_out == "exponential" and exp_ns is not None: return f"{shape_out}:{float(exp_ns)}" return shape_out