"""Segmented-circular aperture: N circular sub-apertures arranged on a layout.
Supported layouts:
- "elf": ring of sub-apertures at a given radius
- "custom": user-provided positions
This is the minimal mini-ELF / DASIE aperture builder the canonical-family
fixtures use. Hexagonal / monolithic / external_pupil variants are separate
implementations.
"""
from __future__ import annotations
from dataclasses import dataclass
import hcipy
import numpy as np
from numpy.typing import NDArray
from telescope_sim.abc import Aperture, ApertureResult
from telescope_sim.registry import register
[docs]
@dataclass
class SegmentedCircularAperture(Aperture):
"""N circular sub-apertures on a configurable layout.
Parameters
----------
segment_diameter
Diameter of each sub-aperture in pupil-grid units.
layout
"elf" or "custom".
n_segments
Number of segments (required for "elf").
ring_radius
Radius of the ELF ring (required for "elf").
positions
Explicit (N, 2) list of (x, y) coordinates (required for "custom").
supersample
Supersampling factor for aperture evaluation (default 16, matching
the canonical 2024-09 implementation; older variants used 1).
spider
Optional dict ``{width, angle}``. If provided, two perpendicular
spiders are multiplied into the aperture mask. ``angle`` is in
degrees; the second spider is offset by 90°. Mirrors the canonical
spider construction.
"""
segment_diameter: float
layout: str = "elf"
n_segments: int | None = None
ring_radius: float | None = None
positions: NDArray[np.floating] | None = None
supersample: int = 16
spider: dict | None = None
def __post_init__(self) -> None:
if self.layout not in ("elf", "custom"):
raise ValueError(f"unsupported layout {self.layout!r}; expected 'elf' or 'custom'")
def _build_centers(self) -> tuple[NDArray, int]:
"""Return (n_segments, 2) array of segment centers and the count."""
if self.layout == "elf":
if self.n_segments is None or self.ring_radius is None:
raise ValueError("layout='elf' requires n_segments and ring_radius")
angles = np.linspace(0, 2 * np.pi, self.n_segments + 1)[:-1]
xs = self.ring_radius * np.cos(angles)
ys = self.ring_radius * np.sin(angles)
return np.column_stack([xs, ys]), self.n_segments
positions = np.asarray(self.positions, dtype=float)
if positions.ndim != 2 or positions.shape[1] != 2:
raise ValueError(f"layout='custom' positions must be (N, 2); got {positions.shape}")
return positions, positions.shape[0]
[docs]
def build(self, pupil_grid) -> ApertureResult:
centers, n_seg = self._build_centers()
# HCIPy expects centers as a CartesianGrid
mir_centers = hcipy.CartesianGrid(np.array([centers[:, 0], centers[:, 1]]))
aper_shape = hcipy.make_circular_aperture(self.segment_diameter)
aper_callable, segments_callables = hcipy.make_segmented_aperture(
aper_shape, mir_centers, return_segments=True
)
aper_field = hcipy.evaluate_supersampled(aper_callable, pupil_grid, self.supersample)
segments = hcipy.evaluate_supersampled(segments_callables, pupil_grid, self.supersample)
# Optional spider — multiply into the aperture mask (does NOT affect
# segment masks, which the canonical implementations also leave alone).
if self.spider is not None:
width = float(self.spider["width"])
angle_deg = float(self.spider.get("angle", 0.0))
angle = angle_deg * np.pi / 180.0
# Use the pupil grid's extent as the spider half-length
x_arr = np.asarray(pupil_grid.x)
y_arr = np.asarray(pupil_grid.y)
p_ext = float(max(x_arr.max() - x_arr.min(), y_arr.max() - y_arr.min()))
s1_start = (p_ext * np.cos(angle), p_ext * np.sin(angle))
s1_end = (p_ext * np.cos(angle + np.pi), p_ext * np.sin(angle + np.pi))
s2_start = (
p_ext * np.cos(angle + np.pi / 2),
p_ext * np.sin(angle + np.pi / 2),
)
s2_end = (
p_ext * np.cos(angle + np.pi + np.pi / 2),
p_ext * np.sin(angle + np.pi + np.pi / 2),
)
spider1 = hcipy.aperture.generic.make_spider(s1_start, s1_end, width)
spider2 = hcipy.aperture.generic.make_spider(s2_start, s2_end, width)
aper_field = aper_field * spider1(pupil_grid) * spider2(pupil_grid)
D = self.segment_diameter
area = n_seg * np.pi * (D / 2.0) ** 2
# Segment center coordinates as numpy array (for downstream PTT measurement)
segment_coords = centers
return ApertureResult(
field=aper_field,
area=area,
segments=segments,
segment_coords=segment_coords,
metadata={
"n_segments": n_seg,
"segment_diameter": D,
"layout": self.layout,
},
)
register("aperture", "segmented_circular")(SegmentedCircularAperture)
__all__ = ["SegmentedCircularAperture"]