Source code for model.telescope_geometry

"""Nominal optical geometry derived from telescope model data."""

import math
from dataclasses import dataclass, field

import astropy.units as u
import numpy as np
from astropy.table import Table

SHAPES = {0: "circle", 1: "hexagon_flat_x", 2: "square", 3: "hexagon_flat_y"}

STRUCTURE_FRAME = "primary_mirror_frame"

CIRCLE_CORNERS = 48


[docs] @dataclass(frozen=True) class Element: """Element of the telescope in its frame, with positions in metres. A cylinder has two points (the end points of its axis) and a diameter. A polygon is given by its corners (a mirror aperture, a camera plate, or the corners of a structure element whose convex hull is drawn). A wire is drawn as its polylines. """ name: str group: str points: np.ndarray diameter: float = 0.0 kind: str = "polygon" # "polygon", or "wire": polylines, separated by rows of nan. side_only: bool = False # Drawn in the side view only (e.g. a profile). @property def is_cylinder(self): """Whether the element is a cylinder.""" return self.diameter > 0.0 and len(self.points) == 2
[docs] @dataclass class TelescopeGeometry: """Elements to draw: mirrors, camera housing and telescope structure.""" name: str mirrors: list = field(default_factory=list) camera: list = field(default_factory=list) structure: list = field(default_factory=list)
def _in_metres(value, unit): """Return a length (or a list of lengths with one unit per value) in metres.""" if isinstance(unit, list): return np.array( [u.Quantity(v, un).to_value(u.m) for v, un in zip(value, unit, strict=True)] ) return u.Quantity(value, unit).to_value(u.m) def _metres(column): """Column (of a table with units) as an array in metres.""" return u.Quantity(column).to_value(u.m) def _parameter(parameters, name, default=None): """Value of a model parameter in metres, or the plain value if it has no length unit.""" record = parameters.get(name) if record is None or record.get("value") is None: return default unit = record.get("unit") if unit in (None, "", "None", "dimensionless"): return record["value"] return _in_metres(record["value"], unit) def _aperture_corners(shape, half_width): """Corners (n, 2) of an aperture in its (u, v) frame; hexagon flats at +-half_width.""" h = half_width if shape == "circle": angles = np.linspace(0.0, 2 * np.pi, CIRCLE_CORNERS, endpoint=False) return h * np.column_stack([np.cos(angles), np.sin(angles)]) if shape == "square": return np.array([(h, h), (-h, h), (-h, -h), (h, -h)]) r = 2 * h / math.sqrt(3) # Distance of a hexagon corner from its centre. if shape == "hexagon_flat_x": return np.array([(0, r), (h, r / 2), (h, -r / 2), (0, -r), (-h, -r / 2), (-h, r / 2)]) if shape == "hexagon_flat_y": return np.array([(r, 0), (r / 2, h), (-r / 2, h), (-r, 0), (-r / 2, -h), (r / 2, -h)]) raise ValueError(f"unsupported aperture shape {shape}") def _plane_corners(centre, normal, tangent, shape, half_width): """Corners (n, 3) of an aperture in the plane through centre (frame as in obdeect).""" n = np.asarray(normal, dtype=float) n = n / np.linalg.norm(n) u_axis = np.asarray(tangent, dtype=float) u_axis = u_axis - np.dot(u_axis, n) * n u_axis = u_axis / np.linalg.norm(u_axis) v_axis = np.cross(n, u_axis) local = _aperture_corners(shape, half_width) return np.asarray(centre, dtype=float) + local[:, :1] * u_axis + local[:, 1:] * v_axis def _nominal_placement(x, y, z_file, focal, dish, parabolic, opt): """Nominal height and inclination of a mirror on the dish (sim_telarray's placement).""" radius = math.hypot(x, y) if z_file: height = abs(z_file) elif parabolic: height = radius**2 / (4 * dish) else: if radius > dish: raise ValueError("a mirror lies outside the dish sphere") height = dish - math.sqrt(dish * dish - radius * radius) distance = math.hypot(focal - height, radius) * (1 + opt[2] + opt[1] * radius**2 / focal**2) z = z_file if z_file > 0 else focal - math.sqrt(distance * distance - radius * radius) inclination = 0.5 * math.asin(radius / distance) * (1 + opt[0] * radius**2 / focal**2) return z, inclination def _mirror_facets(parameters, mirror_table, focal, overall): """Facets of a segmented dish: the mirror list at its nominal positions.""" dish = _parameter(parameters, "dish_shape_length", 0.0) or focal parabolic = bool(parameters.get("parabolic_dish", {}).get("value", False)) opt = [float(v) for v in parameters.get("mirror_opt", {}).get("value", [0.0, 0.0, 0.0])] flip = bool(parameters.get("flip_mirrors", {}).get("value", False)) x_all = _metres(mirror_table["mirror_x"]) y_all = _metres(mirror_table["mirror_y"]) z_all = _metres(mirror_table["mirror_z"]) if "mirror_z" in mirror_table.colnames else 0.0 z_all = np.broadcast_to(z_all, x_all.shape) diameter_all = _metres(mirror_table["mirror_diameter"]) facets = [] for i, (x, y, z_file, diameter, code) in enumerate( zip(x_all, y_all, z_all, diameter_all, mirror_table["shape_type"], strict=True) ): shape = SHAPES[int(code)] if flip: x, y = y, x shape = {"hexagon_flat_x": "hexagon_flat_y", "hexagon_flat_y": "hexagon_flat_x"}.get( shape, shape ) z, inclination = _nominal_placement(x, y, z_file, focal, dish, parabolic, opt) phi = math.atan2(y, x) c, s = math.cos(phi), math.sin(phi) ci, si = math.cos(inclination), math.sin(inclination) normal = [-c * si, -s * si, ci] tangent = [c * c * ci + s * s, c * s * ci - c * s, c * si] facets.append( Element( name=f"mirror-{i:03d}", group="mirror", points=_plane_corners([x, y, z - overall], normal, tangent, shape, 0.5 * diameter), ) ) return facets def _asphere_coefficients(parameters, prefix): """Coefficients (m) and radial scale (m) of an asphere, as in obdeect and sim_config.c. The sag is z(r) = sum_i c_i (r / scale)^(2 i). Without a reference radius, the coefficients are powers of centimetres. """ record = parameters[f"{prefix}_parameters"] values = record["value"] if f"{prefix}_ref_radius" in parameters: scale = _parameter(parameters, f"{prefix}_ref_radius") return [float(v) * scale for v in values], scale return [float(v) * 0.01 ** (1 - 2 * i) for i, v in enumerate(values)], 1.0 def _secondary_coefficients(parameters): """Secondary coefficients in the telescope frame, as in obdeect and sim_telarray. sim_telarray rotates the secondary frame by pi about y: the vertex changes sign if it lies below the primary's, and the polynomial terms always change sign. """ primary, _ = _asphere_coefficients(parameters, "primary_mirror") coefficients, scale = _asphere_coefficients(parameters, "secondary_mirror") if coefficients[0] < primary[0]: coefficients[0] = -coefficients[0] coefficients[1:] = [-c for c in coefficients[1:]] return coefficients, scale def _sag(coefficients, scale, radius): """Sag z(r) of an asphere in metres, at radii in metres (vertex included).""" q = (np.asarray(radius, dtype=float) / scale) ** 2 return sum(c * q**i for i, c in enumerate(coefficients)) def _dual_mirror_apertures(parameters, overall): """Dual-mirror surfaces: rim discs, sag profiles and holes, in the telescope frame. The rim of each mirror is a disc at the sag height of its rim; its profile is drawn in the side view, where the curvature shows. """ apertures = [] for prefix in ("primary_mirror", "secondary_mirror"): name = prefix.split("_", maxsplit=1)[0] if prefix == "secondary_mirror": coefficients, scale = _secondary_coefficients(parameters) else: coefficients, scale = _asphere_coefficients(parameters, prefix) outer = 0.5 * _parameter(parameters, f"{prefix}_diameter") hole = 0.5 * (_parameter(parameters, f"{prefix}_hole_diameter", 0.0) or 0.0) radii = np.linspace(hole, outer, 40) profile = [] for theta in (0.0, np.pi): profile.append( np.column_stack( [ radii * np.cos(theta), radii * np.sin(theta), _sag(coefficients, scale, radii) - overall, ] ) ) profile.append(np.full((1, 3), np.nan)) apertures.append( Element( name=name, group="mirror", points=_plane_corners( [0.0, 0.0, _sag(coefficients, scale, outer) - overall], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], "circle", outer, ), ) ) apertures.append( Element( name=f"{name}-profile", group="mirror", points=np.vstack(profile), kind="wire", side_only=True, ) ) if hole > 0.0: apertures.append( Element( name=f"{name}-hole", group="mirror_hole", points=_plane_corners( [0.0, 0.0, _sag(coefficients, scale, hole) - overall], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0], "circle", hole, ), ) ) return apertures def _circle(z, radius): """Circle (48 points) of a given radius in the plane at height z.""" return _plane_corners([0.0, 0.0, z], [0, 0, 1], [1, 0, 0], "circle", radius) def _secondary_baffle(parameters, overall): """Secondary baffle (open cone wall) as a wire: its rings and generators. The values are z1, z2, R1, the wall thickness and R2 (zero: R2 = R1), as in obdeect. """ record = parameters.get("secondary_mirror_baffle") if not record or not record.get("value"): return [] values = list(record["value"]) + [0.0] * (5 - len(record["value"])) unit = record["unit"][0] if isinstance(record["unit"], list) else record["unit"] z1, z2, r1, thickness, r2 = _in_metres(values, unit) if r1 <= 0.0: return [] r2 = r2 or r1 if thickness >= r1 > 0.0: thickness -= r1 z1, z2 = z1 - overall, z2 - overall rings, generators = [], [] for radii in ((r1, r2), (r1 - thickness, r2 - thickness)): for z, radius in zip((z1, z2), radii, strict=True): rings.append(_circle(z, radius)) rings.append(np.full((1, 3), np.nan)) for theta in (0.0, np.pi): for radii in ((r1, r2), (r1 - thickness, r2 - thickness)): generators.append( np.array( [ [radii[0] * np.cos(theta), radii[0] * np.sin(theta), z1], [radii[1] * np.cos(theta), radii[1] * np.sin(theta), z2], [np.nan] * 3, ] ) ) return [ Element(name="secondary-baffle", group="secondary", points=np.vstack(rings), kind="wire"), Element( name="secondary-baffle", group="secondary", points=np.vstack(generators), kind="wire", side_only=True, ), ] def _secondary_shadow(parameters, overall): """Secondary shadow plate (as in obdeect): a disc that stops the light before the mirror.""" keys = ("secondary_mirror_shadow_diameter", "secondary_mirror_shadow_offset") if not all(key in parameters for key in keys): return [] diameter = _parameter(parameters, keys[0]) coefficients, scale = _secondary_coefficients(parameters) if diameter < 0: diameter = 2 * 0.5 * _parameter(parameters, "secondary_mirror_diameter") if diameter <= 0: return [] offset = _parameter(parameters, keys[1]) if offset <= 0: # Concave secondary: the plate at its rim, otherwise at its vertex. offset = _sag(coefficients, scale, 0.5 * diameter) if coefficients[1] >= 0: offset = coefficients[0] else: offset -= overall return [ Element(name="secondary-shadow", group="secondary", points=_circle(offset, 0.5 * diameter)) ] def _camera_housing(parameters, front, dual): """Camera body: the front plate, and the back plate of its depth (camera_depth).""" diameter = _parameter(parameters, "camera_body_diameter", 0.0) if not diameter: return [] shape_code = parameters.get("camera_body_shape", {}).get("value", 0) shape = SHAPES.get(int(shape_code)) if shape is None: raise ValueError(f"unsupported camera_body_shape {shape_code}") depth = _parameter(parameters, "camera_depth", 0.0) or 0.0 # The body extends from the front plate towards the mirrors for dual-mirror # telescopes (sim_telarray's camera body), and away from them otherwise. heights = [front - depth, front] if dual and depth > 0 else [front, front + depth] heights = sorted(set(heights)) corners = [ _plane_corners([0.0, 0.0, z], [0, 0, 1], [1, 0, 0], shape, 0.5 * diameter) for z in heights ] return [Element(name="camera-housing", group="camera", points=np.vstack(corners))] def _structure_elements(structure_file, overall): """Telescope structure (ECSV table, primary mirror frame, metres) as elements.""" table = Table.read(structure_file, format="ascii.ecsv") frame = table.meta.get("coordinate_frame") if frame != STRUCTURE_FRAME: raise ValueError(f"telescope_structure has coordinate_frame {frame}, not {STRUCTURE_FRAME}") corners = sum(1 for c in table.colnames if c[0] == "x" and c[1:].isdigit()) coordinates = { f"{axis}{i}": _metres(table[f"{axis}{i}"]) for i in range(1, corners + 1) for axis in "xyz" } diameters = _metres(table["diameter"]) if "diameter" in table.colnames else None elements = [] for index, row in enumerate(table): points = [] for i in range(1, corners + 1): x = coordinates[f"x{i}"][index] if not np.isnan(x): points.append( [x, coordinates[f"y{i}"][index], coordinates[f"z{i}"][index] - overall] ) group = str(row["group"]) if "group" in table.colnames else "structure" diameter = float(diameters[index]) if diameters is not None else 0.0 elements.append( Element( name=str(row["id"]), group=group, points=np.array(points), diameter=0.0 if np.isnan(diameter) else diameter, ) ) return elements
[docs] def telescope_geometry(tel_model): """Nominal geometry of a telescope model, for plotting. Parameters ---------- tel_model: TelescopeModel Telescope model with exported model files (``model_reader.export_model_files``). Returns ------- TelescopeGeometry Mirrors (facets, or the apertures of a dual-mirror telescope), camera housing, and telescope structure (empty if the model has none). """ parameters = tel_model.parameters overall = _parameter(parameters, "mirror_offset", 0.0) dual = "primary_mirror_parameters" in parameters if dual: mirrors = _dual_mirror_apertures(parameters, overall) front = _asphere_coefficients(parameters, "focal_surface")[0][0] - overall else: focal = _parameter(parameters, "focal_length") mirrors = _mirror_facets(parameters, tel_model.mirrors.mirror_table, focal, overall) front = focal - overall structure = [] if dual: structure += _secondary_baffle(parameters, overall) structure += _secondary_shadow(parameters, overall) record = parameters.get("telescope_structure") if record and record.get("value"): structure += _structure_elements(tel_model.config_file_directory / record["value"], overall) return TelescopeGeometry( name=tel_model.name, mirrors=mirrors, camera=_camera_housing(parameters, front, dual), structure=structure, )