Source code for esis.flights.f1.optics._fits._fits

"""Reproduce the ESIS-I distortion fits from the flight data."""

import pathlib
import numpy as np
import astropy.units as u
import astropy.table
import named_arrays as na
import optika
import esis

__all__ = [
    "fit_distortion_reference",
    "fit_distortion_pointing",
]


def _idealized(instrument: esis.optics.Instrument) -> esis.optics.Instrument:
    """
    Replace the material models of an instrument with ideal ones.

    The distortion fit measures geometry, not throughput, so the multilayer
    coatings, filter, and sensor quantum efficiency are replaced with ideal
    equivalents to save raytracing time. The uncertainty wrappers of the
    measured ruling coefficients are also stripped, so that the distortion
    parameters are plain scalars.

    Parameters
    ----------
    instrument
        The instrument model to idealize (modified in place and returned).
    """
    instrument.grating.material = optika.materials.Mirror()
    instrument.primary_mirror.material = optika.materials.Mirror()
    instrument.filter.material = None
    instrument.camera.sensor.material = optika.sensors.materials.IdealSensorMaterial()
    coefficients = instrument.grating.rulings.spacing.coefficients
    for k in list(coefficients):
        coefficients[k] = na.nominal(coefficients[k])
    return instrument


def _wavelength_lines() -> na.ScalarArray:  # pragma: nocover
    """Return the wavelengths of the three bright spectral lines seen by ESIS-I."""
    from esis.flights.f1.spectrum import He_I, Mg_X, O_V

    return na.ScalarArray(
        u.Quantity([He_I.wavelength, Mg_X.wavelength, O_V.wavelength]),
        axes="wavelength",
    )


def _pupil() -> na.AbstractCartesian2dVectorArray:  # pragma: nocover
    """Build the 2x2 pupil grid used by the production fits."""
    return na.Cartesian2dVectorLinearSpace(
        -0.25,
        0.25,
        axis=na.Cartesian2dVectorArray("pupil_x", "pupil_y"),
        num=2,
    )


def _frame(time: int, num_scene: int):  # pragma: nocover
    """
    Prepare the resampled AIA scene and the Level-1 observation of one frame.

    Parameters
    ----------
    time
        The frame index into :func:`esis.flights.f1.data.level_1`.
    num_scene
        The number of samples along each axis of the resampled scene.
    """
    scene_full = esis.flights.f1.data.synth.scene_aia()
    frame_l1 = esis.flights.f1.data.level_1()[dict(time=time)]

    time_exposure = frame_l1.inputs.time_start[dict(channel=0)].ndarray
    difference = (scene_full.inputs.time - time_exposure).mean("wavelength")
    frame_scene = scene_full[np.argmin(np.abs(difference))]

    inputs = na.TemporalSpectralPositionalVectorArray(
        time=frame_scene.inputs.time,
        wavelength=frame_scene.inputs.wavelength,
        position=na.Cartesian2dVectorArray(
            x=na.ScalarLinearSpace(
                frame_scene.inputs.position.x.min(),
                frame_scene.inputs.position.x.max(),
                axis="detector_x",
                num=num_scene,
            ),
            y=na.ScalarLinearSpace(
                frame_scene.inputs.position.y.min(),
                frame_scene.inputs.position.y.max(),
                axis="detector_y",
                num=num_scene,
            ),
        ),
    )
    scene = frame_scene(
        inputs,
        axis=("detector_x", "detector_y"),
        method="conservative",
    )
    return scene, frame_l1.outputs.value


def _grids_reference() -> list[dict]:
    """
    Build the scan schedule of the from-scratch reference fit.

    Three repetitions of a wide capture round (with the strongly-coupled
    pairs scanned jointly), two shrinking refinement rounds, and a polish
    round which :func:`esis.optics.fit_distortion_scan` repeats until it
    stops improving.
    """
    capture = {
        ("pitch_grating", "pitch"): (
            np.linspace(-10, 10, 13) * u.arcmin,
            np.linspace(-60, 60, 13) * u.arcsec,
        ),
        ("yaw_grating", "yaw"): (
            np.linspace(-3, 3, 13) * u.arcmin,
            np.linspace(-60, 60, 13) * u.arcsec,
        ),
        "roll_grating": np.linspace(-2, 2, 17) * u.deg,
        "roll_field_stop": np.linspace(-2, 2, 17) * u.deg,
        "roll": np.linspace(-2, 2, 17) * u.deg,
        ("spacing_rulings", "displacement_primary"): (
            np.linspace(-2e-3, 2e-3, 13) * u.um,
            np.linspace(-20, 20, 13) * u.mm,
        ),
    }
    refine_1 = dict(
        pitch_grating=np.linspace(-2, 2, 13) * u.arcmin,
        yaw_grating=np.linspace(-0.6, 0.6, 13) * u.arcmin,
        pitch=np.linspace(-9, 9, 13) * u.arcsec,
        yaw=np.linspace(-9, 9, 13) * u.arcsec,
        roll_grating=np.linspace(-0.4, 0.4, 13) * u.deg,
        roll_field_stop=np.linspace(-0.4, 0.4, 13) * u.deg,
        roll=np.linspace(-0.4, 0.4, 13) * u.deg,
        spacing_rulings=np.linspace(-4e-4, 4e-4, 13) * u.um,
        displacement_primary=np.linspace(-4, 4, 13) * u.mm,
    )
    refine_2 = dict(
        pitch_grating=np.linspace(-0.4, 0.4, 11) * u.arcmin,
        yaw_grating=np.linspace(-0.12, 0.12, 11) * u.arcmin,
        pitch=np.linspace(-1.8, 1.8, 11) * u.arcsec,
        yaw=np.linspace(-1.8, 1.8, 11) * u.arcsec,
        roll_grating=np.linspace(-0.08, 0.08, 11) * u.deg,
        roll_field_stop=np.linspace(-0.08, 0.08, 11) * u.deg,
        roll=np.linspace(-0.08, 0.08, 11) * u.deg,
        spacing_rulings=np.linspace(-8e-5, 8e-5, 11) * u.um,
        displacement_primary=np.linspace(-0.8, 0.8, 11) * u.mm,
    )
    polish = dict(
        pitch_grating=np.linspace(-0.1, 0.1, 9) * u.arcmin,
        yaw_grating=np.linspace(-0.05, 0.05, 9) * u.arcmin,
        pitch=np.linspace(-0.6, 0.6, 9) * u.arcsec,
        yaw=np.linspace(-0.6, 0.6, 9) * u.arcsec,
        roll_grating=np.linspace(-0.02, 0.02, 9) * u.deg,
        roll_field_stop=np.linspace(-0.02, 0.02, 9) * u.deg,
        roll=np.linspace(-0.02, 0.02, 9) * u.deg,
        spacing_rulings=np.linspace(-3e-5, 3e-5, 9) * u.um,
        displacement_primary=np.linspace(-0.3, 0.3, 9) * u.mm,
    )
    return 3 * [capture] + [refine_1, refine_2, polish]


def _grids_pointing() -> list[dict]:
    """Build the per-frame scan schedule of the production pointing series."""
    return [
        dict(
            pitch=np.linspace(-10, 10, 21) * u.arcsec,
            yaw=np.linspace(-10, 10, 21) * u.arcsec,
            roll=np.linspace(-0.4, 0.4, 11) * u.deg,
        ),
        dict(
            pitch=np.linspace(-1, 1, 11) * u.arcsec,
            yaw=np.linspace(-1, 1, 11) * u.arcsec,
            roll=np.linspace(-0.05, 0.05, 11) * u.deg,
        ),
    ]


[docs] def fit_distortion_reference( instrument: None | esis.optics.Instrument = None, time: int = 15, num_scene: int = 401, grids: None | list[dict] = None, sigma_psf: None | float = 1.0, seed: int = 0, tolerance: None | float = 3e-4, path: None | str | pathlib.Path = None, directory: None | str | pathlib.Path = None, ) -> esis.optics.DistortionParameters: # pragma: nocover """ Fit the per-channel reference distortion parameters from scratch. Reproduces (and lets anyone verify) the fit stored in ``_data/distortion_reference.ecsv``: all nine per-channel degrees of freedom of :class:`esis.optics.DistortionParameters` are fit to one Level-1 frame with :func:`esis.optics.fit_distortion_scan`, reading every channel's peak from a single pass of coherent scans. Expect several thousand merit evaluations at a few seconds each: hours of runtime on a workstation. Requires the ESIS Level-1 data and the AIA synthetic scene (downloaded and cached on first use). Parameters ---------- instrument The starting instrument model. If :obj:`None`, the as-built model (:func:`esis.flights.f1.optics.as_built`, which carries the measured grating radii and ruling density) is used, idealized for raytracing speed. time The frame index of the reference frame. num_scene The number of samples along each axis of the resampled AIA scene. grids The scan schedule. If :obj:`None`, the from-scratch schedule (wide joint capture rounds, shrinking refinement, repeated polish) is used. sigma_psf The standard deviation, in detector pixels, of the Gaussian point-spread function convolved with the modeled image. seed The seed used to make each evaluation deterministic. tolerance The mean-correlation gain below which the repeated polish round stops. path If given, the fitted parameters are written to this path as an ECSV table, in the format of ``_data/distortion_reference.ecsv``. directory A directory where the scan curves and convergence history are logged. If :obj:`None`, the fit is not logged. Examples -------- .. code-block:: python import esis parameters = esis.flights.f1.optics.fit_distortion_reference( path="distortion_reference.ecsv", directory="distortion_reference_scan", ) """ if instrument is None: instrument = _idealized( esis.flights.f1.optics.as_built(num_distribution=0), ) instrument.wavelength = _wavelength_lines() if grids is None: grids = _grids_reference() scene, observation = _frame(time, num_scene) parameters = esis.optics.fit_distortion_scan( instrument=instrument, scene=scene, observation=observation, grids=grids, pupil=_pupil(), axis_wavelength="velocity", axis_field=("detector_x", "detector_y"), axis_channel="channel", sigma_psf=sigma_psf, seed=seed, tolerance=tolerance, directory=directory, ) if path is not None: parameters.to_file( path, metadata=dict( description=( "Best-fit ESIS-I distortion parameters, optimized " f"against the time={time} frame of " "esis.flights.f1.data.level_1()." ), provenance=( "esis.flights.f1.optics.fit_distortion_reference(" f"time={time}, num_scene={num_scene}, " f"sigma_psf={sigma_psf}, seed={seed})" ), ), ) return parameters
[docs] def fit_distortion_pointing( instrument: None | esis.optics.Instrument = None, num_scene: int = 401, grids: None | list[dict] = None, sigma_psf: None | float = 1.0, seed: int = 0, path: None | str | pathlib.Path = None, directory: None | str | pathlib.Path = None, workers: int = 1, ) -> astropy.table.QTable: # pragma: nocover r""" Fit the per-frame payload pointing of the whole flight. Reproduces (and lets anyone verify) the time series stored in ``_data/distortion_pointing.ecsv``: for every frame of :func:`esis.flights.f1.data.level_1`, a coherent pitch/yaw/roll offset from the reference model is fit with :func:`esis.optics.fit_distortion_series` under a rigid-payload model (the same offset for all four channels). Expect roughly a hundred merit evaluations per frame at a few seconds each; the frames are independent, so `workers` parallelizes them perfectly. Requires the ESIS Level-1 data and the AIA synthetic scene (downloaded and cached on first use). Parameters ---------- instrument The reference instrument model whose pointing is offset. If :obj:`None`, :func:`esis.flights.f1.optics.distortion_fit` is used, idealized for raytracing speed. num_scene The number of samples along each axis of the resampled AIA scenes. grids The per-frame scan schedule. If :obj:`None`, the production schedule (coarse :math:`\\pm 10''`, fine :math:`\\pm 1''`) is used. sigma_psf The standard deviation, in detector pixels, of the Gaussian point-spread function convolved with the modeled images. seed The seed used to make each evaluation deterministic. path If given, the fitted offsets are written to this path as an ECSV table, in the format of ``_data/distortion_pointing.ecsv``. directory A directory under which each frame's fit is logged. If :obj:`None`, the fits are not logged. workers The number of processes used to fit frames concurrently. Examples -------- .. code-block:: python import esis pointing = esis.flights.f1.optics.fit_distortion_pointing( path="distortion_pointing.ecsv", workers=8, ) """ if instrument is None: instrument = _idealized( esis.flights.f1.optics.distortion_fit(num_distribution=0), ) if grids is None: grids = _grids_pointing() observations = esis.flights.f1.data.level_1() num_time = na.shape(observations.outputs)["time"] frames = [_frame(t, num_scene) for t in range(num_time)] parameters = esis.optics.DistortionParameters.from_instrument(instrument) results = esis.optics.fit_distortion_series( instrument=instrument, scenes=[scene for scene, observation in frames], observations=[observation for scene, observation in frames], grids=grids, parameters=parameters, pupil=_pupil(), axis_wavelength="velocity", axis_field=("detector_x", "detector_y"), axis_channel="channel", coherent=True, sigma_psf=sigma_psf, seed=seed, directory=directory, workers=workers, ) def offset(index: int, field: str, unit: u.Unit) -> float: delta = getattr(results[index], field) - getattr(parameters, field) delta = na.as_named_array(delta) if "channel" in na.shape(delta): delta = delta.mean("channel") return float(na.value(delta.to(unit)).ndarray) table = astropy.table.QTable( dict( frame=np.arange(num_time), pitch=[offset(t, "pitch", u.arcsec) for t in range(num_time)] * u.arcsec, yaw=[offset(t, "yaw", u.arcsec) for t in range(num_time)] * u.arcsec, roll=[offset(t, "roll", u.deg) for t in range(num_time)] * u.deg, ) ) table.meta.update( dict( description=( "Fitted per-frame payload pointing offsets of the ESIS-I " "flight, relative to the reference fit, one row per frame " "of esis.flights.f1.data.level_1(). The offsets are common " "to all four channels (a rigid-payload model)." ), provenance=( "esis.flights.f1.optics.fit_distortion_pointing(" f"num_scene={num_scene}, sigma_psf={sigma_psf}, seed={seed})" ), ) ) if path is not None: table.write(path, format="ascii.ecsv", overwrite=True) return table