Source code for ray_tracing.psf_analysis

"""
PSF image analysis for ray tracing results.

Processes photon position lists to compute image centroids,
PSF containment diameters (D80 and other fractions),
effective area, and cumulative radial distributions.
"""

import gzip
import logging
import tempfile
from math import fabs, pi, sqrt
from pathlib import Path

import astropy.units as u
import numpy as np

from simtools.context import RunContext
from simtools.job_execution import execution
from simtools.visualization import plot_ray_tracing_psf


[docs] class PSFImage: """ Image composed of list of photon positions (2D). Load photon list from sim_telarray file and compute centroids, psf containers, effective area, as well as plot the image as a 2D histogram. Internal units: photon positions in cm internally. Parameters ---------- focal_length: float Focal length of the system in cm. If not given, PSF can only be computed in cm. total_scattered_area: float Scatter area of all photons in m^2. If not given, effective area cannot be computed. containment_fraction: float Containment fraction for PSF calculation. """ __PSF_RADIUS = "Radius [cm]" __PSF_CUMULATIVE = "Cumulative PSF" def __init__( self, focal_length=None, total_scattered_area=None, containment_fraction=None, context=None, ): """Initialize PSFImage class.""" self._logger = logging.getLogger(__name__) self.context = context or RunContext.from_environment() self._total_photons = None self._number_of_detected_photons = None self._effective_area = None self.photon_pos_x = [] self.photon_pos_y = [] self.photon_r = [] self.centroid_x = None self.centroid_y = None self.centroid_x_error = 0.0 self.centroid_y_error = 0.0 self._total_area = total_scattered_area self.cos_camera_rotation = 1.0 self.sin_camera_rotation = 0.0 self._stored_psf = {} try: self._cm_to_deg = 180.0 / pi / focal_length if focal_length is not None else None except ZeroDivisionError: self._cm_to_deg = None self._logger.warning("Focal length is zero; no conversion from cm to deg possible.") self._containment_fraction = containment_fraction
[docs] def process_photon_list(self, photon_file, use_rx): """ Read and process a photon list file generated by sim_telarray. Extracts photon positions, computes centroids and effective area. Can use either sim_telarray's rx binary or internal computation. Parameters ---------- photons_file: str Name of sim_telarray file with photon list. use_rx: bool Use the RX method for analysis. """ if use_rx: self._process_simtel_file_using_rx(photon_file) else: self.read_photon_list_from_simtel_file(photon_file)
def _process_simtel_file_using_rx(self, photon_file): """ Process a sim_telarray file with photon lists using the RX method. Parameters ---------- photons_file: str Name of sim_telarray file with photon list. """ rx_command = [ str(self.context.software.sim_telarray_path / "bin" / "rx"), "-f", f"{self._containment_fraction:.2f}", "-v", ] try: with tempfile.NamedTemporaryFile(mode="wb", delete=False) as tmp_file: with gzip.open(photon_file, "rb") as gz: tmp_file.write(gz.read()) tmp_file_path = tmp_file.name try: with open(tmp_file_path, "rb") as tmp_f: result = execution.run_command(rx_command, stdin=tmp_f) data_lines = [ line.strip() for line in result.output.strip().split("\n") if line.strip() and not line.startswith("#") ] if not data_lines: raise IndexError("No data line found in RX output") rx_output = data_lines[-1].split() self.set_psf( 2 * float(rx_output[0]), fraction=self._containment_fraction, unit="cm" ) self.centroid_x = float(rx_output[1]) self.centroid_y = float(rx_output[2]) self.centroid_x_error = 0.0 self.centroid_y_error = 0.0 self._effective_area = float(rx_output[5]) finally: Path(tmp_file_path).unlink(missing_ok=True) except FileNotFoundError as e: raise FileNotFoundError(f"Photon list file not found: {photon_file}") from e except (IndexError, ValueError) as e: raise type(e)( f"Invalid RX output format: {locals().get('rx_output', 'unknown')}" ) from e
[docs] def read_photon_list_from_simtel_file(self, photons_file): """ Read photon list file generated by sim_telarray and store the photon positions (2D). Computes image centroid as mean of photon positions, effective area as (detected_photons * total_scattered_area) / total_photons, and sorts radial distances from centroid for efficient PSF calculation. Parameters ---------- photons_file: str Name of sim_telarray file with photon list. Raises ------ RuntimeError If photon positions X and Y are not compatible or are empty. """ self._logger.info(f"Reading sim_telarray photon file {photons_file}") file_open_function = gzip.open if Path(photons_file).suffix == ".gz" else open # Local variables avoid repeated attribute lookups in the tight parsing loop. total_photons = 0 total_area = self._total_area cos_camera_rotation = self.cos_camera_rotation sin_camera_rotation = self.sin_camera_rotation photon_pos_x = [] photon_pos_y = [] with file_open_function(photons_file, "rb") as f: for line in f: if b"falling on an area of" in line: words = line.split() total_photons += int(words[4]) total_area_in_file = float(words[14]) if total_area is None: total_area = total_area_in_file elif total_area_in_file != total_area: self._logger.warning( "Conflicting value of the total area found" f" {total_area} != {total_area_in_file}" " - Keeping the original value" ) continue if b"Camera rotation angle" in line: words = line.split() camera_rotation_angle = np.deg2rad(float(words[5])) cos_camera_rotation = np.cos(camera_rotation_angle) sin_camera_rotation = np.sin(camera_rotation_angle) continue if b"#" in line or not line.strip(): continue # Photon positions from cols 2 and 3; apply camera rotation. words = line.split() x, y = float(words[2]), float(words[3]) photon_pos_x.append(x * cos_camera_rotation - y * sin_camera_rotation) photon_pos_y.append(y * cos_camera_rotation + x * sin_camera_rotation) self.cos_camera_rotation = cos_camera_rotation self.sin_camera_rotation = sin_camera_rotation try: self.set_photon_positions(photon_pos_x, photon_pos_y, total_photons, total_area) except RuntimeError as error: raise RuntimeError( f"Problems reading sim_telarray photon file {photons_file} - invalid data" ) from error
[docs] def set_photon_positions(self, x, y, total_photons, total_area): """ Set the photon image and compute centroid, effective area and radii. Parameters ---------- x, y: array_like Photon positions in the camera plane [cm]. total_photons: int Number of photons simulated (detected or not). total_area: float Area the photons were sampled on [m^2]. """ self._stored_psf.clear() self.photon_pos_x = np.asarray(x, dtype=float) self.photon_pos_y = np.asarray(y, dtype=float) if not self._is_photon_positions_ok(): raise RuntimeError("Invalid photon positions: empty or of different lengths") self._total_photons = total_photons self._total_area = total_area self.centroid_x = np.mean(self.photon_pos_x) self.centroid_y = np.mean(self.photon_pos_y) self.centroid_x_error = self._compute_centroid_standard_error(self.photon_pos_x) self.centroid_y_error = self._compute_centroid_standard_error(self.photon_pos_y) self._number_of_detected_photons = len(self.photon_pos_x) self._effective_area = ( self._number_of_detected_photons * self._total_area / self._total_photons ) self.photon_r = np.sort( np.sqrt( (self.photon_pos_x - self.centroid_x) ** 2 + (self.photon_pos_y - self.centroid_y) ** 2 ) )
@staticmethod def _compute_centroid_standard_error(photon_positions): """Return standard error of the centroid estimate for one image axis.""" n_positions = len(photon_positions) if n_positions <= 1: return 0.0 return float(np.std(photon_positions, ddof=1) / np.sqrt(n_positions)) def _is_photon_positions_ok(self): """ Verify if the photon positions are ok. Returns ------- bool True if photon positions are ok, False if they are not. """ return len(self.photon_pos_x) > 0 and len(self.photon_pos_x) == len(self.photon_pos_y)
[docs] def get_effective_area(self, tel_transmission=1.0): """ Return effective area pre calculated. Parameters ---------- telescope_transmission : float Telescope transmission parameter. Returns ------- float Pre-calculated effective area. None if it could not be calculated (e.g because the total scattering area was not set). """ if "_effective_area" in self.__dict__ and self._effective_area is not None: return self._effective_area * tel_transmission self._logger.error("Effective Area could not be calculated") return None
[docs] def get_psf(self, fraction=0.8, unit="cm"): """ Return PSF containment diameter. Computes (if not cached) the diameter containing the specified fraction of photons using an iterative algorithm or brute-force scanning. Parameters ---------- fraction: float Fraction of photons within the containing radius. unit: str 'cm' or 'deg'. 'deg' will not work if focal length was not set. Returns ------- float: Containing diameter for a certain intensity fraction (PSF). """ if unit == "deg" and self._cm_to_deg is None: self._logger.error("PSF cannot be computed in deg because focal length is not set") return None if fraction not in self._stored_psf: self._compute_psf(fraction) unit_factor = 1 if unit == "cm" else self._cm_to_deg return self._stored_psf[fraction] * unit_factor
[docs] def set_psf(self, value, fraction=0.8, unit="cm"): """ Set PSF calculated from other methods. Parameters ---------- value: float PSF value to be set fraction: float Fraction of photons within the containing radius. unit: str 'cm' or 'deg'. 'deg' will not work if focal length was not set. """ if unit == "deg" and self._cm_to_deg is None: self._logger.error("PSF cannot be set in deg because focal length is not set") return unit_factor = 1 if unit == "cm" else 1.0 / self._cm_to_deg self._stored_psf[fraction] = value * unit_factor
def _compute_psf(self, fraction): """ Compute and store PSF. Parameters ---------- fraction: float Fraction of photons within the containing radius """ if not 0 < fraction <= 1: raise ValueError("containment fraction must be in (0, 1]") self._stored_psf[fraction] = self._find_psf(fraction) def _find_psf(self, fraction): """ Find PSF containment diameter using iterative gradient-based algorithm. Computes the radius containing a specified fraction of photons by iterating from an initial estimate (1.5 * radius_sigma) with adaptive step size. Falls back to brute-force scanning if convergence fails after 1000 iterations. Parameters ---------- fraction: float Fraction of photons within the containing radius. Returns ------- float: Diameter of the circular container with a certain fraction of the photons. """ self._logger.debug(f"Finding PSF for fraction = {fraction}") x_pos_sig = np.std(self.photon_pos_x) y_pos_sig = np.std(self.photon_pos_y) radius_sig = sqrt(x_pos_sig**2 + y_pos_sig**2) if np.isclose(radius_sig, 0.0): return 0.0 target_number = fraction * self._number_of_detected_photons current_radius = 1.5 * radius_sig start_number = self._sum_photons_in_radius(current_radius) scale = 0.5 * sqrt(current_radius * current_radius / start_number) delta_number = start_number - target_number n_iter = 0 max_iter = 1000 tolerance = self._number_of_detected_photons / 1000.0 found_radius = False while not found_radius and n_iter < max_iter: n_iter += 1 dr = -delta_number * scale / sqrt(target_number) while current_radius + dr < 0: dr *= 0.5 current_radius += dr current_number = self._sum_photons_in_radius(current_radius) delta_number = current_number - target_number found_radius = fabs(delta_number) < tolerance if found_radius: return 2 * current_radius self._logger.warning("Could not find PSF efficiently - trying by scanning") return 2 * self._find_radius_by_scanning(target_number, radius_sig) def _find_radius_by_scanning(self, target_number, radius_sig): """ Find PSF containment radius by brute-force scanning. Performs two-pass scan with progressively finer step sizes (0.1*radius_sig, then 0.005*radius_sig) to find the radius containing the target number of photons. Parameters ---------- target_number: float Number of photons inside the diameter to be found. radius_sig: float Sigma of the radius to be used as scale. Returns ------- float: Radius of the circle with target_number photons inside. """ self._logger.debug("Finding PSF by scanning") def scan(dr, rad_min, rad_max): """ Scan the image from rad_min to rad_max until it finds target_number photons inside. Scanning is done in steps of dr. Returns ------- (float, float, float): Average radius, min radius, max radius of the interval where target_number photons are inside. Raises ------ RuntimeError if radius is not found (found_radius is False) """ r0, r1 = rad_min, rad_min + dr found_radius = False while not found_radius: s0, s1 = self._sum_photons_in_radius(r0), self._sum_photons_in_radius(r1) if s0 < target_number <= s1: found_radius = True break if r1 > rad_max: break r0 += dr r1 += dr if found_radius: return (r0 + r1) / 2, r0, r1 self._logger.error("Could not find PSF by scanning") raise RuntimeError # Run scan few times with smaller dr to optimize search. # Step 0 radius, rad_min, rad_max = scan(0.1 * radius_sig, 0, 4 * radius_sig) # Step 1 radius, rad_min, rad_max = scan(0.005 * radius_sig, rad_min, rad_max) return radius def _sum_photons_in_radius(self, radius): """Return number of photons inside radius using binary search on sorted radial distances.""" return np.searchsorted(self.photon_r, radius)
[docs] def get_image_data(self, centralized=True): """ Provide image data (2D photon positions in cm) as lists. Parameters ---------- centralized: bool Centroid of the image is set to (0, 0) if True. Returns ------- (x, y), the photons positions in cm. """ if centralized: x_pos_data = np.array(self.photon_pos_x) - self.centroid_x y_pos_data = np.array(self.photon_pos_y) - self.centroid_y else: x_pos_data = np.array(self.photon_pos_x) y_pos_data = np.array(self.photon_pos_y) d_type = {"names": ("X", "Y"), "formats": ("f8", "f8")} result = np.recarray((len(x_pos_data),), dtype=d_type) result.X = x_pos_data result.Y = y_pos_data return result
[docs] def plot_image(self, centralized=True, file_name=None, **kwargs): """ Plot 2D image as histogram (in cm). Parameters ---------- centralized: bool Centroid of the image is set to (0, 0) if True. **kwargs: image_* for the histogram plot and psf_* for the psf circle. """ data = self.get_image_data(centralized) for key, value in { "image_bins": 150, "image_cmap": "gist_heat_r", "psf_color": "k", "psf_fill": False, "psf_lw": 2, "psf_ls": "--", }.items(): kwargs.setdefault(key, value) kwargs_for_image = { key.removeprefix("image_"): value for key, value in kwargs.items() if key.startswith("image_") } kwargs_for_psf = { key.removeprefix("psf_"): value for key, value in kwargs.items() if key.startswith("psf_") } fraction = self._containment_fraction if self._containment_fraction is not None else 0.8 center = (0, 0) if centralized else (self.centroid_x, self.centroid_y) fig, _ = plot_ray_tracing_psf.create_psf_image_figure( data, containment_radius=self.get_psf(fraction) / 2, center=center, psf_kwargs=kwargs_for_psf, use_current_axes=True, **kwargs_for_image, ) if file_name is not None: plot_ray_tracing_psf.save_and_close_figure(fig, file_name)
[docs] def get_cumulative_data(self, radius=None): """ Compute cumulative intensity as function of radius from centroid. For each radius, counts the fraction of total photons within that distance using binary search on pre-sorted radial distances. Parameters ---------- radius: array Array with radius calculate the cumulative PSF in distance units. Returns ------- (radius, intensity) """ if radius is not None: radius_all = radius.to(u.cm).value if isinstance(radius, u.Quantity) else radius else: fraction = self._containment_fraction if self._containment_fraction is not None else 0.8 radius_all = list(np.linspace(0, 1.6 * self.get_psf(fraction), 30)) intensity = [ self._sum_photons_in_radius(rad) / self._number_of_detected_photons for rad in radius_all ] d_type = { "names": (self.__PSF_RADIUS, self.__PSF_CUMULATIVE), "formats": ("f8", "f8"), } result = np.recarray((len(radius_all),), dtype=d_type) result[self.__PSF_RADIUS] = radius_all result[self.__PSF_CUMULATIVE] = intensity return result
[docs] def plot_cumulative(self, file_name=None, psf_diameter_cm=None, **kwargs): """Plot cumulative data (intensity vs radius). Parameters ---------- file_name: str Name of the file to save the plot to. psf_diameter_cm: float PSF diameter value to be marked in the plot (in cm). **kwargs: Customization of line plot (e.g., color, linestyle, linewidth). """ data = self.get_cumulative_data() fraction = self._containment_fraction if self._containment_fraction is not None else 0.8 fig, _ = plot_ray_tracing_psf.create_cumulative_psf_figure( data, radius_key=self.__PSF_RADIUS, cumulative_key=self.__PSF_CUMULATIVE, containment_radius_cm=self.get_psf(fraction) / 2, psf_diameter_cm=psf_diameter_cm, **kwargs, ) if file_name is not None: plot_ray_tracing_psf.save_and_close_figure(fig, file_name)