Source code for simtel.simtel_file_writer

"""Writer for sim_telarray files."""

import logging
from pathlib import Path

import numpy as np

from simtools.simtel.pulse_shapes import generate_pulse_from_rise_fall_times

logger = logging.getLogger(__name__)


[docs] def write_camera_file(camera_components, output_path): """Write validated camera components in sim_telarray camera syntax. ``camera_components`` is a mapping containing ``rotate``, ``pixel_types``, ``pixels`` and optional ``triggers``/``trigger_members`` sequences. The function deliberately accepts plain mappings so model-repository values can be passed without an intermediate bespoke class. """ output_path = Path(output_path) _validate_camera_components(camera_components) if any(part == ".." for part in output_path.parts): raise ValueError(f"Unsafe camera configuration path: {output_path}") output_path.parent.mkdir(parents=True, exist_ok=True) lines = _camera_file_lines(camera_components) output_path.write_text("".join(lines), encoding="utf-8") return output_path.name
def _camera_file_lines(configuration): """Build serialized sim_telarray camera-file lines.""" rotate = float(configuration.get("rotate", 0.0)) pixel_types = configuration.get("pixel_types", []) pixels = configuration.get("pixels", []) triggers = configuration.get("triggers", configuration.get("trigger_groups", [])) members = configuration.get("trigger_members", []) members_by_group = {} for member in members: members_by_group.setdefault(member["group_id"], []).append(member) lines = ["# Generated from camera model parameters\n", f"Rotate {rotate:.12g}\n"] lines.extend(_pixel_type_lines(pixel_types)) lines.extend(_pixel_lines(pixels)) lines.extend(_trigger_lines(triggers, members_by_group)) return lines def _pixel_type_lines(pixel_types): """Build PixType lines.""" lines = [] for item in pixel_types: angle_file = item.get("lightguide_angle_file") wavelength_file = item.get("lightguide_wavelength_file") if not angle_file and item.get("funnel_transparency") is None: raise ValueError("Camera pixel type has no resolved lightguide or transparency") fields = [ "PixType", str(item["type_id"]), str(item["pmt_type"]), str(item["cathode_shape"]), str(item["cathode_diameter_cm"]), str(item["funnel_shape"]), str(item["funnel_diameter_cm"]), str(item["funnel_depth_cm"]), ] if angle_file: fields.append(f'"{_safe_basename(angle_file, "lightguide angle")}"') else: fields.extend((str(item["funnel_transparency"]), str(item["funnel_wall_reflectivity"]))) if wavelength_file: fields.append(f'"{_safe_basename(wavelength_file, "lightguide wavelength")}"') lines.append(" ".join(fields) + "\n") return lines def _pixel_lines(pixels): """Build Pixel lines.""" lines = [] for pixel in pixels: values = [ pixel["pixel_id"], pixel["type_id"], pixel["x_cm"], pixel["y_cm"], pixel["module"], pixel["board"], pixel["channel"], _module_id(pixel["module_id"]), int(bool(pixel["enabled"])), pixel["relative_qe"], pixel["relative_gain"], pixel["z_offset_cm"], pixel["rotation_deg"], pixel["normal_x"], pixel["normal_y"], ] lines.append("Pixel " + " ".join(str(value) for value in values) + "\n") return lines def _trigger_lines(triggers, members_by_group): """Build trigger lines.""" return [ _trigger_line(trigger, index, members_by_group) for index, trigger in enumerate(triggers) ] def _trigger_line(trigger, index, members_by_group): """Build one trigger line.""" keyword = { "majority": "MajorityTrigger", "analogsum": "AnalogSumTrigger", "digitalsum": "DigitalSumTrigger", }.get(trigger["kind"].lower()) if keyword is None: raise ValueError(f"Unsupported camera trigger kind: {trigger['kind']}") multiplicity = ( "*" if bool(trigger["use_default_multiplicity"]) else str(trigger["multiplicity"]) ) group_id = trigger.get("group_id", index) tokens = _trigger_member_tokens(members_by_group.get(group_id, [])) return f"{keyword} {multiplicity} of {' '.join(tokens)}\n" def _trigger_member_tokens(members): """Build sim_telarray tokens for normalized trigger members.""" grouped_members = {} for member in members: grouped_members.setdefault(member["member_order"], []).append(member) return [_trigger_member_token(rows) for rows in grouped_members.values()] def _trigger_member_token(member_rows): """Build one scalar or bracketed trigger member token.""" member_rows.sort(key=lambda row: row["pixel_order"]) first = member_rows[0] prefix = "+" if bool(first["required"]) else "" if len(member_rows) == 1: return prefix + str(first["pixel_id"]) slaves = ",".join(str(row["pixel_id"]) for row in member_rows[1:]) return f"{prefix}{first['pixel_id']}[{slaves}]" def _validate_camera_components(configuration): """Validate camera component records before serializing them.""" pixel_types = configuration.get("pixel_types", []) pixels = configuration.get("pixels", []) triggers = configuration.get("triggers", configuration.get("trigger_groups", [])) members = configuration.get("trigger_members", []) if not pixel_types or not pixels: raise ValueError("Camera configuration requires pixel types and pixels") type_ids = _validate_pixel_types(pixel_types) _validate_pixels(pixels, type_ids) _validate_triggers(triggers, members, pixels) def _validate_pixel_types(pixel_types): """Validate pixel types and return their IDs.""" type_ids = [item.get("type_id") for item in pixel_types] if len(set(type_ids)) != len(type_ids): raise ValueError("Camera pixel type IDs must be unique") for item in pixel_types: if item.get("lightguide_angle_file"): _safe_basename(item["lightguide_angle_file"], "lightguide angle") elif ( item.get("funnel_transparency") is None or item.get("funnel_wall_reflectivity") is None ): raise ValueError("Camera pixel type has no resolved lightguide or transparency") return type_ids def _validate_pixels(pixels, type_ids): """Validate pixel ordering and foreign keys.""" pixel_ids = [item.get("pixel_id") for item in pixels] if pixel_ids != list(range(len(pixels))): raise ValueError("Camera pixel IDs must be contiguous and ordered") if not any(bool(item.get("enabled")) for item in pixels): raise ValueError("Camera configuration must contain an enabled pixel") if any(item.get("type_id") not in type_ids for item in pixels): raise ValueError("Camera pixel references an unknown pixel type") def _validate_triggers(triggers, members, pixels): """Validate trigger groups and their foreign keys.""" pixel_ids = [item.get("pixel_id") for item in pixels] group_ids = [item.get("group_id") for item in triggers] if group_ids != list(range(len(triggers))): raise ValueError("Camera trigger group IDs must be contiguous and ordered") members_by_group = {} for member in members: members_by_group.setdefault(member.get("group_id"), []).append(member) if set(members_by_group) - set(group_ids): raise ValueError("Camera trigger member references an unknown group") for trigger in triggers: _validate_trigger(trigger, members_by_group.get(trigger["group_id"], []), pixel_ids) def _validate_trigger(trigger, members, pixel_ids): """Validate one trigger group and its normalized member rows.""" _validate_trigger_kind(trigger) use_default = bool(trigger.get("use_default_multiplicity")) multiplicity = trigger.get("multiplicity") _validate_trigger_multiplicity(use_default, multiplicity) if not members: raise ValueError(f"Camera trigger group has no members: {trigger['group_id']}") _validate_trigger_members(members, pixel_ids) def _validate_trigger_kind(trigger): """Validate the trigger kind.""" if trigger.get("kind", "").lower() not in {"majority", "analogsum", "digitalsum"}: raise ValueError(f"Unsupported camera trigger kind: {trigger.get('kind')}") def _validate_trigger_multiplicity(use_default, multiplicity): """Validate default or explicit trigger multiplicity.""" if use_default: if multiplicity not in (None, 0): raise ValueError("Default trigger multiplicity must not be positive") return if multiplicity is None or int(multiplicity) < 1: raise ValueError("Explicit trigger multiplicity must be positive") def _validate_trigger_members(members, pixel_ids): """Validate normalized trigger member rows.""" member_orders = sorted({member["member_order"] for member in members}) if member_orders != list(range(len(member_orders))): raise ValueError("Camera trigger member orders must be contiguous") for member_order in member_orders: rows = sorted( (row for row in members if row["member_order"] == member_order), key=lambda row: row["pixel_order"], ) if [row["pixel_order"] for row in rows] != list(range(len(rows))): raise ValueError("Camera trigger pixel orders must be contiguous") if rows[0]["required"] and any(row["required"] for row in rows[1:]): raise ValueError("Only the first pixel of a trigger member may be required") if any(row["pixel_id"] not in pixel_ids for row in rows): raise ValueError("Camera trigger contains an unknown pixel ID") def _safe_basename(value, label): """Return a safe generated dependency basename.""" path = Path(value) if path.name != str(value) or path.name in {"", ".", ".."}: raise ValueError(f"Unsafe {label} filename: {value}") return path.name def _module_id(value): """Format a module ID as a safe hexadecimal sim_telarray token.""" try: integer = int(str(value), 0) except (TypeError, ValueError) as exc: raise ValueError(f"Invalid camera module ID: {value}") from exc if integer < 0: raise ValueError(f"Invalid camera module ID: {value}") return f"0x{integer:x}"
[docs] def write_light_pulse_table_gauss_exp_conv( file_path, width_ns, exp_decay_ns, fadc_sum_bins, dt_ns=0.1, rise_range=(0.1, 0.9), fall_range=(0.9, 0.1), time_margin_ns=10.0, ): """Write a pulse table for a Gaussian convolved with a causal exponential. Parameters ---------- file_path : str or Path Destination path of the ASCII pulse table. Parent directory must exist. width_ns : float Rise time in ns between the fractional levels defined by ``rise_range``. exp_decay_ns : float Fall time in ns between the fractional levels defined by ``fall_range``. fadc_sum_bins : int FADC integration window length in bins, used to set the time range. dt_ns : float, optional Time sampling step in ns. rise_range : tuple[float, float], optional Fractional amplitude bounds (low, high) for rise-time definition. fall_range : tuple[float, float], optional Fractional amplitude bounds (high, low) for fall-time definition. time_margin_ns : float, optional Extra margin in ns added to both ends of the time window. Returns ------- Path Path to the created pulse table file. Raises ------ ValueError If ``width_ns`` or ``exp_decay_ns`` is None. """ if width_ns is None or exp_decay_ns is None: raise ValueError("width_ns (rise 10-90) and exp_decay_ns (fall 90-10) are required") logger.info( "Generating pulse-shape table with " f"rise{int(rise_range[0] * 100)}-{int(rise_range[1] * 100)}={width_ns} ns, " f"fall{int(fall_range[0] * 100)}-{int(fall_range[1] * 100)}={exp_decay_ns} ns, " f"dt={dt_ns} ns" ) width = float(fadc_sum_bins) t_start_ns = -abs(time_margin_ns + width) t_stop_ns = +abs(time_margin_ns + width) t, y = generate_pulse_from_rise_fall_times( width_ns, exp_decay_ns, dt_ns=dt_ns, rise_range=rise_range, fall_range=fall_range, t_start_ns=t_start_ns, t_stop_ns=t_stop_ns, center_on_peak=True, ) return write_ascii_pulse_table(file_path, t, y)
[docs] def write_angular_distribution_table_lambertian( file_path, max_angle_deg, n_samples=100, ): """Write a Lambertian angular distribution table (intensity ~ cos(angle)). Parameters ---------- file_path : str or Path Destination path of the ASCII table. Parent directory must exist. max_angle_deg : float Upper bound of the angular range in degrees. n_samples : int, optional Number of equally spaced samples from 0 to ``max_angle_deg``. Returns ------- Path Path to the created angular distribution table. """ logger.info( f"Generating Lambertian angular distribution table up to {max_angle_deg} deg " f"with {n_samples} samples" ) angles = np.linspace(0.0, float(max_angle_deg), int(n_samples), dtype=float) intensities = np.cos(np.deg2rad(angles)) intensities[intensities < 0] = 0.0 if intensities.max() > 0: intensities /= intensities.max() return write_ascii_angle_distribution_table(file_path, angles, intensities)
[docs] def write_ascii_pulse_table(file_path, t, y): """Write a two-column (time, amplitude) ASCII pulse table. Parameters ---------- file_path : str or Path Destination path. t : array-like Time values in ns. y : array-like Amplitude values. Returns ------- Path Path to the written file. """ with open(file_path, "w", encoding="utf-8") as fh: fh.write("# time[ns] amplitude\n") for ti, yi in zip(t, y): fh.write(f"{ti:.6f} {yi:.8f}\n") return Path(file_path)
[docs] def write_ascii_angle_distribution_table(file_path, angles, intensities): """Write a two-column (angle, relative intensity) ASCII angular distribution table. Parameters ---------- file_path : str or Path Destination path. angles : array-like Angle values in degrees. intensities : array-like Relative intensity values. Returns ------- Path Path to the written file. """ with open(file_path, "w", encoding="utf-8") as fh: fh.write("# angle[deg] relative_intensity\n") for a, i in zip(angles, intensities): fh.write(f"{a:.6f} {i:.8f}\n") return Path(file_path)