Source code for telescope_sim.focal_planes.physical

"""Physical focal plane — focal-plane grid in physical units (meters).

Used when the focal-plane physics depends on absolute spot size rather
than angular extent (e.g. fiber coupling, where the MMF mode field is
defined in physical coordinates). Construction requires an explicit
``focal_length`` so the FraunhoferPropagator can convert between pupil
and focal-plane geometries.

This focal plane retains the per-wavelength focal-plane wavefronts so
downstream taps (notably ``fiber_coupled``) can use the full complex
field rather than just summed intensity.
"""

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.registry import register


@dataclass
class _PhysicalLamSetup:
    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] @dataclass class FocalPlaneResult: """Per-sample focal-plane output retained for downstream taps.""" intensity: NDArray[np.floating] # summed intensity across wavelengths wavefronts: list[Any] # per-wavelength focal Wavefronts
[docs] @register("focal_plane", "physical") class PhysicalFocalPlane(FocalPlane): """Focal-plane grid in physical (metres) units. Parameters ---------- central_lam Central wavelength in meters. focal_extent Full focal-plane extent in meters (square). focal_res Number of pixels per side. focal_length Optical focal length (meters); passed to ``FraunhoferPropagator``. fractional_bandwidth, num_samples Broadband sampling — same semantics as :class:`AngularFocalPlane`. """ def __init__( self, *, central_lam: float, focal_extent: float, focal_res: int, focal_length: float, fractional_bandwidth: float = 0.0, num_samples: int = 1, wavefront_total_power: float | None = None, 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.focal_length = float(focal_length) self.fractional_bandwidth = float(fractional_bandwidth) self.num_samples = int(num_samples) # If set, force each monochromatic wavefront's total_power to this # value. Used by fiber-coupling variants that explicitly normalize. self.wavefront_total_power = wavefront_total_power self._lam_setup: _PhysicalLamSetup | None = None
[docs] def build(self, pupil_grid: Any, aperture_field: Any) -> None: # Physical focal grid: extent in meters, build via make_pupil_grid # (which gives us a uniform metric grid; the legacy fiber variant # uses this exact construction). focal_grid = hcipy.make_pupil_grid(self.focal_res, self.focal_extent) prop = hcipy.FraunhoferPropagator(pupil_grid, focal_grid, focal_length=self.focal_length) 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] if self.wavefront_total_power is not None: for wf in wavefronts: wf.total_power = float(self.wavefront_total_power) self._lam_setup = _PhysicalLamSetup( 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: if self._lam_setup is None: raise RuntimeError("call build() before compute_reference_psf()") for c in corrector_chain: c.flatten() 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())
def _propagate_chain( self, corrector_chain: list[Any], *, coronagraph: Any | None = None, atmos: Any | None = None, ) -> FocalPlaneResult: """Propagate per-λ wavefronts: (atmos?) → correctors → (coronagraph?) → propagator.""" 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: assert self._lam_setup is not None return self._lam_setup.propagator(wf)
@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) -> _PhysicalLamSetup: assert self._lam_setup is not None return self._lam_setup
__all__ = ["PhysicalFocalPlane", "FocalPlaneResult"]