Source code for utils.geometry

"""A collection of functions related to geometrical transformations."""

import math

import astropy.units as u
import numpy as np
from astropy.units import UnitsError


[docs] @u.quantity_input(rotation_around_z_axis=u.rad, rotation_around_y_axis=u.rad) def rotate(x, y, rotation_around_z_axis, rotation_around_y_axis=0 * u.rad): """ Rotate the x and y coordinates of the telescopes. The two rotations are: - rotation_around_z_axis gives the rotation on the observation plane (x, y) - rotation_around_y_axis allows to rotate the observation plane in space. The function returns the rotated x and y values in the same unit given. The direction of rotation of the elements in the plane is counterclockwise, i.e., the rotation of the coordinate system is clockwise. Parameters ---------- x: numpy.array or list x positions of the entries (e.g. telescopes), usually in meters. y: numpy.array or list y positions of the entries (e.g. telescopes), usually in meters. rotation_around_z_axis: astropy.units.rad Angle to rotate the array in the observation plane (around z axis) in radians. rotation_around_y_axis: astropy.units.rad Angle to rotate the observation plane around the y axis in radians. Returns ------- 2-tuple of list x and y positions of the rotated entry (e.g. telescopes) positions. Raises ------ TypeError: If type of x and y parameters are not valid. RuntimeError: If the length of x and y are different. UnitsError: If the unit of x and y are different. """ allowed_types = (list, np.ndarray, u.Quantity, float, int) if not all(isinstance(variable, (allowed_types)) for variable in [x, y]): raise TypeError("x and y types are not valid! Cannot perform transformation.") if not isinstance(x, list | np.ndarray): x = [x] if not isinstance(y, list | np.ndarray): y = [y] if ( np.sum( np.array([isinstance(x, type_now) for type_now in allowed_types[:-2]]) * np.array([isinstance(y, type_now) for type_now in allowed_types[:-2]]) ) == 0 ): raise TypeError("x and y are not from the same type! Cannot perform transformation.") if len(x) != len(y): raise RuntimeError( "Cannot perform coordinate transformation when x and y have different lengths." ) if all(isinstance(variable, (u.Quantity)) for variable in [x, y]): if not isinstance(x[0].unit, type(y[0].unit)): raise UnitsError( "Cannot perform coordinate transformation when x and y have different units." ) x_trans = np.cos(rotation_around_y_axis) * ( x * np.cos(rotation_around_z_axis) - y * np.sin(rotation_around_z_axis) ) y_trans = x * np.sin(rotation_around_z_axis) + y * np.cos(rotation_around_z_axis) return x_trans, y_trans
[docs] def calculate_circular_mean(angles): """ Calculate circular mean of angles in radians. Parameters ---------- angles: numpy.array Array of angles in radians. Returns ------- float Circular mean of the angles. """ sin_sum = np.sum(np.sin(angles)) cos_sum = np.sum(np.cos(angles)) return np.arctan2(sin_sum, cos_sum)
[docs] @u.quantity_input(angle_max=u.rad, angle_min=u.rad) def solid_angle(angle_max, angle_min=0 * u.rad): """ Calculate the solid angle subtended by a given range of angles. Parameters ---------- angle_max: astropy.units.Quantity The maximum angle for which to calculate the solid angle. angle_min: astropy.units.Quantity The minimum angle for which to calculate the solid angle (default is 0 rad). Returns ------- astropy.units.Quantity The solid angle subtended by the given range of angles (in steradians). """ return 2 * np.pi * (np.cos(angle_min.to("rad")) - np.cos(angle_max.to("rad"))) * u.sr
[docs] def project_ground_to_corsika_shower_coordinates( x_ground, y_ground, z_ground, azimuth_from_north_east, altitude ): """ Transform ground to shower coordinates following the CORSIKA/sim_telarray convention. This function applies the same horizontal coordinate transformation used internally by sim_telarray for shower-oriented coordinates. Input ground coordinates follow the CORSIKA/sim_telarray ground system: - x_ground : points North - y_ground : points West - z_ground : points Up The input azimuth is assumed to follow the standard astronomical convention: - 0 rad = North - pi/2 = East Internally, the azimuth is converted to the CORSIKA 'phi' convention used in 'iact.c': - phi = 0 : shower propagates toward North - phi = pi/2 : shower propagates toward West The transformation corresponds to the inverse of the projection applied in sim_telarray when converting shower coordinates into ground coordinates. Parameters ---------- x_ground: numpy.array Ground x coordinate. y_ground: numpy.array Ground y coordinate. z_ground: numpy.array Ground z coordinate. azimuth_from_north_east: numpy.array Azimuth angle of the shower (in radians). altitude: numpy.array Altitude angle of the shower (in radians). Returns ------- tuple Transformed shower coordinates (x', y', z'). """ x, y, z, az, alt = np.broadcast_arrays( x_ground, y_ground, z_ground, azimuth_from_north_east, altitude ) # Convert to CORSIKA phi convention used in iact.c: # 0 = moves North, 90 deg = moves West phi = np.mod(np.pi - az, 2.0 * np.pi) ca, sa = np.cos(phi), np.sin(phi) cos_zenith = np.sin(alt) # pi/2 - altitude x_s = (ca * ca * cos_zenith + sa * sa) * x + ca * sa * (cos_zenith - 1.0) * y y_s = ca * sa * (cos_zenith - 1.0) * x + (sa * sa * cos_zenith + ca * ca) * y z_s = z return x_s, y_s, z_s
[docs] def geographic_to_corsika_azimuth(az_deg): """Convert geographic arrival azimuth to CORSIKA propagation azimuth. The input is an astronomical/geographic azimuth: the direction from which the primary arrives, measured clockwise from geographic North toward East. CORSIKA phi describes the direction toward which the primary propagates and increases from North toward West. The conversion therefore consists of: 1. Negating the angle because the two azimuth conventions increase in opposite directions. 2. Adding 180 degrees to convert an arrival direction into the opposite propagation direction. No geomagnetic-field alignment correction is applied here. Parameters ---------- az_deg : float Geographic arrival azimuth in degrees, measured clockwise from geographic North toward East. Returns ------- float CORSIKA propagation azimuth in degrees, before geomagnetic-field alignment correction. """ return (-az_deg + 180.0) % 360.0
[docs] def fiducial_radius_from_shape(width, shape): """ Calculate minimum radius including different geometrical shapes. Assumes definition of shapes as in 'camera_body_shape' model parameter: - circle: shape = 0, width is diameter - hexagon: shape = 1 or 3, width is flat-to-flat distance - square: shape = 2, width is side length Parameters ---------- width : float Characteristic width shape : int Geometrical shape parameter Returns ------- float Minimum fiducial radius """ if shape == 0: return width / 2.0 if shape == 2: return width / math.sqrt(2.0) if shape in (1, 3): return width / math.sqrt(3.0) raise ValueError( f"Unknown shape value {shape}. Valid values are: 0 (circle), 1 or 3 (hexagon), 2 (square)." )
[docs] def telescope_fiducial_sphere_radius( radial_extent, axial_extent, axes_offsets, mirror_offset=0 * u.m, zenith_range=(0, 90) * u.deg, margin=0 * u.m, ): """Enclose optical elements for a continuous range of telescope pointings. Each element is bounded by a cylinder about the optical axis. Use bounds containing the entire surface, including panel corners and surface sag, for every relevant primary, secondary and camera element. The result is conservative for these enclosing cylinders, rather than a minimum sphere for the individual optical surfaces. Azimuth rotations preserve distance from the CORSIKA sphere center. Parameters ---------- radial_extent : astropy.units.Quantity One maximum radial distance from the optical axis per element. axial_extent : astropy.units.Quantity Array of shape (number of elements, 2) giving each element's minimum and maximum axial coordinate relative to the mirror backplane. axes_offsets : astropy.units.Quantity The two sim_telarray offsets: azimuth-to-altitude axis, followed by altitude-to-optical axis. Positive offsets follow ``axes_offsets``. mirror_offset : astropy.units.Quantity, optional sim_telarray mirror-backplane offset from the altitude axis. zenith_range : astropy.units.Quantity, optional Minimum and maximum zenith angle, within [0, 180] degrees. margin : astropy.units.Quantity, optional Nonnegative extra radial clearance. No clearance is added by default. Returns ------- astropy.units.Quantity Required radius about the CORSIKA telescope position, in meters. Raises ------ ValueError If bounds are empty, malformed, non-finite or outside their allowed ranges. Notes ----- The offsets follow ``tel_newdir`` and ``transform_off`` in sim_telarray. The calculation checks the endpoints and analytic stationary points of the distance over zenith angle; it does not sample a pointing grid. """ radii = np.atleast_1d(radial_extent.to_value(u.m)) heights = np.asarray(axial_extent.to_value(u.m)) offsets = np.asarray(axes_offsets.to_value(u.m)) angles = np.asarray(zenith_range.to_value(u.rad)) backplane_offset = float(mirror_offset.to_value(u.m)) clearance = float(margin.to_value(u.m)) _validate_fiducial_sphere_bounds(radii, heights, offsets, angles) if not np.isfinite(backplane_offset) or not np.isfinite(clearance) or clearance < 0: raise ValueError("Mirror offset and nonnegative margin must be finite.") # For each cylinder, extrema lie on its radial boundary and axial endpoints. x = np.repeat(np.stack((-radii, radii), axis=1), 2, axis=1).ravel() - offsets[1] z = np.tile(heights - backplane_offset, (1, 2)).ravel() azimuth_axis_offset = offsets[0] stationary = np.arctan2(azimuth_axis_offset * z, azimuth_axis_offset * x) % (2 * np.pi) candidates = np.array( [np.full_like(x, angles[0]), np.full_like(x, angles[1]), np.clip(stationary, *angles)] ) ground_x = azimuth_axis_offset + x * np.cos(candidates) + z * np.sin(candidates) ground_z = -x * np.sin(candidates) + z * np.cos(candidates) return (float(np.max(np.hypot(ground_x, ground_z))) + clearance) * u.m
def _validate_fiducial_sphere_bounds(radii, heights, offsets, angles): """Validate cylindrical optical bounds and axis/pointing dimensions.""" if radii.ndim != 1 or radii.size == 0 or heights.shape != (radii.size, 2): raise ValueError("Provide one radial extent and two axial bounds per optical element.") if offsets.shape != (2,) or angles.shape != (2,): raise ValueError("Provide two axes offsets and two zenith-angle bounds.") if not all(np.all(np.isfinite(values)) for values in (radii, heights, offsets, angles)): raise ValueError("Optical bounds, axes offsets and zenith angles must be finite.") if np.any(radii < 0) or np.any(heights[:, 0] > heights[:, 1]): raise ValueError("Radial extents must be nonnegative and axial bounds ordered.") if not 0 <= angles[0] <= angles[1] <= np.pi: raise ValueError("Zenith-angle bounds must be ordered within [0, 180] degrees.") def _rotation(alpha, beta): """Rotation by alpha [rad] about z, then by beta [rad] about the new y axis. Vectors transform as rows: v' = v @ matrix. """ matrix = np.eye(3) for angle, (i, j) in ((beta, (2, 0)), (alpha, (0, 1))): step = np.eye(3) step[i, i] = step[j, j] = math.cos(angle) step[i, j], step[j, i] = math.sin(angle), -math.sin(angle) matrix = step @ matrix return matrix
[docs] def ground_to_telescope_frame(point, theta_deg, phi_deg, axes_offsets=()): """ Transform a point relative to the telescope position into the telescope frame. The ground frame is the array frame (x north, y west, z up); the telescope frame has +z along the optical axis. Parameters ---------- point: array_like Point relative to the telescope position (ground frame). theta_deg, phi_deg: float Telescope pointing: zenith angle and azimuth (array frame) [deg]. axes_offsets: sequence of float Offsets azimuth axis - altitude axis and altitude axis - optical axis (same unit as ``point``); missing values are zero. Returns ------- numpy.ndarray Point in the telescope frame. """ offsets = np.zeros(2) offsets[: len(axes_offsets)] = axes_offsets theta, phi = math.radians(theta_deg), math.radians(phi_deg) point = np.asarray(point, dtype=float) @ _rotation(phi, 0.0) point[0] -= offsets[0] point = point @ _rotation(0.0, -theta) point[0] += offsets[1] return point
[docs] def ground_direction_to_telescope_frame(direction, theta_deg, phi_deg): """Transform a direction from the ground frame into the telescope frame (see above).""" theta, phi = math.radians(theta_deg), math.radians(phi_deg) return np.asarray(direction, dtype=float) @ _rotation(phi, -theta)