Source code for model.telescope_sphere

"""Derive a CORSIKA enclosure from the main optical telescope elements."""

import astropy.units as u
import numpy as np

from simtools.model.model_parameter import InvalidModelParameterError
from simtools.utils.geometry import fiducial_radius_from_shape, telescope_fiducial_sphere_radius

_PRIMARY_DIAMETER = "primary_mirror_diameter"
_SECONDARY_DIAMETER = "secondary_mirror_diameter"
_SECONDARY = "secondary"
_FOCAL_LENGTH = "focal_length"
_DISH_SHAPE_LENGTH = "dish_shape_length"
_MIRROR_FOCAL_LENGTH = "mirror_focal_length"
_MIRROR_LIST = "mirror_list"
_MIRROR_X = "mirror_x"
_MIRROR_Y = "mirror_y"
_MIRROR_DIAMETER = "mirror_diameter"
_MIRROR_Z = "mirror_z"
_PARABOLIC_DISH = "parabolic_dish"
_SHAPE_TYPE = "shape_type"


def _length(model, name):
    """Read a model length in metres with a useful error for absent values."""
    try:
        value = model.get_parameter_value_with_unit(name)
        return float(value.to_value(u.m))
    except (AttributeError, InvalidModelParameterError, KeyError, TypeError, ValueError) as exc:
        raise ValueError(f"A length-valued {name} is required to derive the sphere.") from exc


def _polynomial_bounds(model, surface, radius):
    """Bound a rotational mirror polynomial at its stationary points and rim."""
    name = f"{surface}_mirror_parameters"
    reference = _length(model, f"{surface}_mirror_ref_radius")
    if not np.isfinite(reference) or reference <= 0:
        raise ValueError(f"{surface} mirror reference radius must be positive and finite.")
    try:
        coefficients = np.asarray(
            u.Quantity(model.get_parameter_value_with_unit(name)).to_value(u.m)
        )
    except (AttributeError, InvalidModelParameterError, KeyError, TypeError, ValueError) as exc:
        raise ValueError(f"Length-valued {name} coefficients are required.") from exc
    if coefficients.ndim != 1 or coefficients.size == 0 or not np.all(np.isfinite(coefficients)):
        raise ValueError(f"{name} must contain finite coefficients.")
    # The secondary local optical axis points toward the primary.
    sign = -1 if surface == _SECONDARY else 1
    polynomial = np.polynomial.Polynomial(coefficients)
    critical = polynomial.deriv().roots()
    upper = (radius / reference) ** 2
    points = [0.0, upper]
    points.extend(
        float(root.real) for root in critical if abs(root.imag) < 1e-10 and 0 < root.real < upper
    )
    values = sign * polynomial(points)
    return float(np.min(values)), float(np.max(values))


def _panel_heights(model, radius, listed_z):
    """Calculate nominal panel-centre heights as sim_telarray does."""
    if np.all(listed_z != 0):
        return listed_z
    focal_length = _length(model, _FOCAL_LENGTH)
    dish_length = _length(model, _DISH_SHAPE_LENGTH) or focal_length
    if not np.isfinite(dish_length) or dish_length <= 0:
        raise ValueError("Dish shape length must be positive and finite.")
    try:
        parabolic_dish = model.get_parameter_value(_PARABOLIC_DISH)
    except InvalidModelParameterError as exc:
        raise ValueError(f"A model parameter {_PARABOLIC_DISH} is required.") from exc
    if parabolic_dish:
        height = radius**2 / (4 * dish_length)
    else:
        if dish_length <= np.max(radius):
            raise ValueError("Davies-Cotton dish length must exceed panel-centre radii.")
        height = dish_length - np.sqrt(dish_length**2 - radius**2)
    return np.where(listed_z != 0, listed_z, height)


def _panel_extent(model, centre_radius, panel_radius, height, focal_length):
    """Bound axial tilt and spherical sag of each panel."""
    nominal_focus = _length(model, _FOCAL_LENGTH)
    if np.any(focal_length <= 0):
        default_focal_length = _length(model, _MIRROR_FOCAL_LENGTH) or nominal_focus
        focal_length = np.where(focal_length > 0, focal_length, default_focal_length)
    if np.any(focal_length <= 0) or np.any(2 * focal_length <= panel_radius):
        raise ValueError("Mirror panel focal lengths must exceed half the panel radius.")
    distance = np.hypot(nominal_focus - height, centre_radius)
    if np.any(distance <= 0):
        raise ValueError("Mirror panel distance to focus must be positive.")
    inclination = np.arcsin(centre_radius / distance) / 2
    sag = 2 * focal_length - np.sqrt((2 * focal_length) ** 2 - panel_radius**2)
    return panel_radius * np.sin(inclination) + sag


def _single_mirror_bounds(model):
    """Bound mirror panels using their dish height, corners, tilt, and sag."""
    try:
        table = model.get_parameter_table(_MIRROR_LIST)
    except InvalidModelParameterError as exc:
        raise ValueError("A mirror_list table is required to derive the sphere.") from exc
    needed = (
        _MIRROR_X,
        _MIRROR_Y,
        _MIRROR_DIAMETER,
        _MIRROR_Z,
        _SHAPE_TYPE,
        _FOCAL_LENGTH,
    )
    if len(table) == 0 or any(column not in table.colnames for column in needed):
        raise ValueError("mirror_list needs positions, diameters, shapes, and focal lengths.")
    x = np.asarray(u.Quantity(table[_MIRROR_X]).to_value(u.m))
    y = np.asarray(u.Quantity(table[_MIRROR_Y]).to_value(u.m))
    diameter = np.asarray(u.Quantity(table[_MIRROR_DIAMETER]).to_value(u.m))
    z = np.asarray(u.Quantity(table[_MIRROR_Z]).to_value(u.m))
    coordinates = (x, y, diameter, z)
    if not all(np.all(np.isfinite(column)) for column in coordinates) or np.any(diameter <= 0):
        raise ValueError("mirror_list positions and positive panel diameters must be finite.")
    shape = np.asarray(table[_SHAPE_TYPE])
    if not np.all(np.isfinite(shape)):
        raise ValueError("Mirror panel shapes must be finite.")
    panel_radius = np.array(
        [fiducial_radius_from_shape(width, kind) for width, kind in zip(diameter, shape)]
    )
    centre_radius = np.hypot(x, y)
    height = _panel_heights(model, centre_radius, z)
    focal_length = u.Quantity(table[_FOCAL_LENGTH]).to_value(u.m)
    if not np.all(np.isfinite(focal_length)):
        raise ValueError("Mirror panel focal lengths must be finite.")
    clearance = _panel_extent(model, centre_radius, panel_radius, height, focal_length)
    radius = float(np.max(centre_radius + panel_radius))
    return radius, (float(np.min(height - clearance)), float(np.max(height + clearance)))


[docs] def derive_telescope_sphere_radius(model, zenith_range=(0, 90) * u.deg, margin=0 * u.m): """Return a conservative CORSIKA entrance sphere radius for the model's mirrors. Parameters ---------- model : TelescopeModel Simulation model with primary and optional secondary reflector geometry. zenith_range : astropy.units.Quantity, optional Minimum and maximum telescope zenith angles. margin : astropy.units.Quantity, optional Extra radial clearance. Returns ------- astropy.units.Quantity Fiducial sphere radius in metres. Raises ------ ValueError If required model geometry is missing or invalid. """ try: mirror_class = model.get_parameter_value("mirror_class") except InvalidModelParameterError as exc: raise ValueError("A mirror_class parameter is required to derive the sphere.") from exc if mirror_class in (0, 1): primary = _single_mirror_bounds(model) elements = [primary] elif mirror_class == 2: elements = [] for surface, diameter_name in ( ("primary", _PRIMARY_DIAMETER), (_SECONDARY, _SECONDARY_DIAMETER), ): diameter = _length(model, diameter_name) if not np.isfinite(diameter) or diameter <= 0: raise ValueError(f"{surface} mirror diameter must be positive and finite.") radius = diameter / 2 elements.append((radius, _polynomial_bounds(model, surface, radius))) else: raise ValueError(f"Unsupported mirror_class {mirror_class}; expected 0, 1, or 2.") radii = np.array([radius for radius, _ in elements]) * u.m bounds = np.array([axial for _, axial in elements]) * u.m try: axes_offsets = u.Quantity(model.get_parameter_value_with_unit("axes_offsets")).to(u.m) mirror_offset = model.get_parameter_value_with_unit("mirror_offset") except (AttributeError, InvalidModelParameterError, KeyError, TypeError, ValueError) as exc: raise ValueError("Axes and mirror offsets are required to derive the sphere.") from exc return telescope_fiducial_sphere_radius( radii, bounds, axes_offsets, mirror_offset=mirror_offset, zenith_range=zenith_range, margin=margin, )