Source code for telescope_sim.focal_planes.angular

"""Angular focal plane: arcsec-extent focal grid with FraunhoferPropagator.

This is the canonical-family focal plane: extent in arcsec, broadband
sampling via N monochromatic wavefronts across a fractional bandwidth.
The reference PSF (at-rest, no actuators, no atmosphere) is built once at
construction time and exposed via :attr:`reference_psf`,
:attr:`reference_peak_intensity`, and :attr:`reference_psf_sum`.

Detector noise is currently deferred; we'll add a NoisyDetector path when
the canonical fixtures that exercise it land. The canonical-family
fixtures #01/#02/#10/#11 don't use detectors.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import hcipy
import numpy as np
from numpy.typing import NDArray

from telescope_sim.abc import FocalPlane
from telescope_sim.focal_planes.physical import FocalPlaneResult
from telescope_sim.registry import register


@dataclass
class _LamSetup:
    """Per-filter HCIPy artefacts (mirrors the legacy lam_setup dict)."""

    name: str
    central_lam: float
    fractional_bandwidth: float
    num_samples: int
    focal_grid: Any
    propagator: Any
    wavefronts: list[Any]
    filter_lams: NDArray[np.floating]
    reference_psf: NDArray[np.floating] | None = None
    reference_peak_intensity: float | None = None
    reference_psf_sum: float | None = None
    detector: Any | None = None


[docs] @register("focal_plane", "angular") class AngularFocalPlane(FocalPlane): """One filter on an arcsec-extent focal grid. Parameters ---------- central_lam Central wavelength in meters. focal_extent Full focal-plane extent in arcsec (square). focal_res Number of pixels per side (square). fractional_bandwidth Width of the broadband sampling range, fraction of central_lam. num_samples Number of monochromatic wavelength samples across the bandwidth. name Filter name, used as the wavefront name in the chain (e.g. ``"focal:H_band"``). """ def __init__( self, *, central_lam: float, focal_extent: float, focal_res: int, fractional_bandwidth: float = 0.0, num_samples: int = 1, name: str = "filter", ) -> None: self.name = name self.central_lam = float(central_lam) self.focal_extent = float(focal_extent) self.focal_res = int(focal_res) self.fractional_bandwidth = float(fractional_bandwidth) self.num_samples = int(num_samples) # Populated by :meth:`build` self._lam_setup: _LamSetup | None = None self._aperture_field: Any | None = None self._pupil_grid: Any | None = None # --- Construction -------------------------------------------------------
[docs] def build(self, pupil_grid: Any, aperture_field: Any) -> None: """Build the propagator, wavefronts, and reference PSF for this filter.""" self._pupil_grid = pupil_grid self._aperture_field = aperture_field # Focal grid: convert arcsec extent to radians (HCIPy convention) fov_rad = self.focal_extent * np.pi / (180.0 * 3600.0) focal_grid = hcipy.make_uniform_grid([self.focal_res] * 2, fov_rad) prop = hcipy.FraunhoferPropagator(pupil_grid, focal_grid) if self.num_samples > 1: half = self.fractional_bandwidth / 2.0 filter_lams = self.central_lam * np.linspace(1.0 - half, 1.0 + half, self.num_samples) else: filter_lams = np.array([self.central_lam]) wavefronts = [hcipy.Wavefront(aperture_field, lam) for lam in filter_lams] self._lam_setup = _LamSetup( name=self.name, central_lam=self.central_lam, fractional_bandwidth=self.fractional_bandwidth, num_samples=self.num_samples, focal_grid=focal_grid, propagator=prop, wavefronts=wavefronts, filter_lams=filter_lams, )
[docs] def compute_reference_psf(self, corrector_chain: list[Any]) -> None: """Walk the chain at-rest (no coronagraph) for normalization/strehl.""" if self._lam_setup is None: raise RuntimeError("call build() before compute_reference_psf()") for c in corrector_chain: c.flatten() # Reference PSF always bypasses the coronagraph (legacy convention). result = self._propagate_chain(corrector_chain, coronagraph=None) ref = result.intensity self._lam_setup.reference_psf = ref self._lam_setup.reference_peak_intensity = float(ref.max()) self._lam_setup.reference_psf_sum = float(ref.sum())
# --- Per-sample ------------------------------------------------------- def _propagate_chain( self, corrector_chain: list[Any], *, coronagraph: Any | None = None, atmos: Any | None = None, ) -> FocalPlaneResult: """Propagate this filter's wavefronts through the chain. Order of operations per monochromatic wavefront: wf → (atmos?) → c1 → c2 → ... → cN → (coronagraph?) → focal propagator → |.|² ``atmos`` is any callable ``Wavefront → Wavefront`` (typically an HCIPy ``InfiniteAtmosphericLayer`` or ``MultiLayerAtmosphere``, but a plain callable works too — useful for tests). Returns the summed intensity and the per-wavelength focal Wavefronts. """ assert self._lam_setup is not None focal_wavefronts: list[Any] = [] total = np.zeros((self.focal_res, self.focal_res), dtype=np.float64) for wf in self._lam_setup.wavefronts: wf_out = wf if atmos is None else atmos(wf) for c in corrector_chain: wf_out = c.apply(wf_out) if coronagraph is not None: wf_out = coronagraph.apply(wf_out) wf_focal = self._lam_setup.propagator(wf_out) focal_wavefronts.append(wf_focal) total += np.asarray(wf_focal.intensity.shaped) return FocalPlaneResult(intensity=total, wavefronts=focal_wavefronts)
[docs] def propagate(self, wf: Any) -> Any: # ABC method, not used by orchestrator directly assert self._lam_setup is not None return self._lam_setup.propagator(wf)
# --- Convenience accessors --------------------------------------------- @property def reference_psf(self) -> NDArray[np.floating] | None: return None if self._lam_setup is None else self._lam_setup.reference_psf @property def reference_peak_intensity(self) -> float | None: return None if self._lam_setup is None else self._lam_setup.reference_peak_intensity @property def reference_psf_sum(self) -> float | None: return None if self._lam_setup is None else self._lam_setup.reference_psf_sum @property def lam_setup(self) -> _LamSetup: assert self._lam_setup is not None return self._lam_setup
__all__ = ["AngularFocalPlane"]