from __future__ import annotations
from typing import Any, Sequence
import copy
import dataclasses
import datetime
import itertools
import json
import pathlib
import time
import numpy as np
import scipy.optimize
import matplotlib.pyplot as plt
import astropy.units as u
import named_arrays as na
import optika
import esis
__all__ = [
"DistortionParameters",
"DistortionObjective",
"DistortionResidual",
"ConvergenceLogger",
"fit_distortion",
"fit_distortion_scan",
"fit_distortion_series",
]
[docs]
@dataclasses.dataclass(repr=False)
class DistortionParameters(
optika.mixins.Printable,
):
"""
The degrees of freedom adjusted when fitting an instrument model to images.
An instance of this class serves both as a point in parameter space and as
the prototype that defines the units and structure of the flat vector seen
by :mod:`scipy.optimize` routines: :func:`named_arrays.pack` flattens it
into a dimensionless vector and :func:`named_arrays.unpack` rebuilds it.
Examples
--------
Gather the distortion parameters of the ESIS flight-1 design and flatten
them into a vector suitable for :mod:`scipy.optimize`.
.. jupyter-execute::
import named_arrays as na
import esis
instrument = esis.flights.f1.optics.design(num_distribution=0)
parameters = esis.optics.DistortionParameters.from_instrument(instrument)
na.pack(parameters)
"""
yaw_grating: u.Quantity | na.AbstractScalar
"""The yaw angle of the diffraction grating."""
pitch_grating: u.Quantity | na.AbstractScalar
"""The pitch angle of the diffraction grating."""
roll_grating: u.Quantity | na.AbstractScalar
"""The roll angle of the diffraction grating."""
roll_field_stop: u.Quantity | na.AbstractScalar
"""The roll angle of the field stop."""
spacing_rulings: u.Quantity | na.AbstractScalar
"""The constant coefficient of the grating ruling spacing polynomial."""
displacement_primary: u.Quantity | na.AbstractScalar
"""
The displacement of the primary mirror along the optic axis relative to
its nominal focal length.
A displacement :math:`d` simultaneously sets the focal length to
:math:`f_\\mathrm{nominal} + d` and the translation of the primary mirror
to :math:`-d`, so that the primary moves while the focal plane stays put.
"""
pitch: u.Quantity | na.AbstractScalar
"""The pitch angle of the entire instrument."""
yaw: u.Quantity | na.AbstractScalar
"""The yaw angle of the entire instrument."""
roll: u.Quantity | na.AbstractScalar
"""The roll angle of the entire instrument."""
[docs]
@classmethod
def from_instrument(
cls,
instrument: esis.optics.abc.AbstractInstrument,
) -> DistortionParameters:
"""
Gather the current distortion parameters of the given instrument.
The parameters are converted to a canonical set of units
(:obj:`~astropy.units.arcmin` for the grating angles,
:obj:`~astropy.units.arcsec` for the instrument pointing, etc.)
so that the components of the packed vector are of order unity
and bounds built from different instances are consistent.
Parameters
----------
instrument
The instrument model to gather the parameters from.
"""
primary_mirror = instrument.primary_mirror
return cls(
yaw_grating=instrument.grating.yaw.to(u.arcmin),
pitch_grating=instrument.grating.pitch.to(u.arcmin),
roll_grating=instrument.grating.roll.to(u.deg),
roll_field_stop=instrument.field_stop.roll.to(u.deg),
spacing_rulings=instrument.grating.rulings.spacing.coefficients[0].to(u.um),
displacement_primary=-primary_mirror.translation.z.to(u.mm),
pitch=instrument.pitch.to(u.arcsec),
yaw=instrument.yaw.to(u.arcsec),
roll=instrument.roll.to(u.deg),
)
[docs]
def to_instrument(
self,
instrument: esis.optics.abc.AbstractInstrument,
) -> esis.optics.abc.AbstractInstrument:
"""
Apply these parameters to a copy of the given instrument.
The given instrument is left unmodified, and any cached optical
system on the result is discarded so that it is rebuilt with the
new parameters.
The nominal focal length of the primary mirror is recovered from the
invariant ``focal_length + translation.z``, which is unchanged by
applying a :attr:`displacement_primary`, so this method may be applied
repeatedly to the results of previous applications.
Parameters
----------
instrument
The instrument model to apply the parameters to.
"""
result = copy.copy(instrument)
# discard the cached system before the deep copy so that the
# (potentially large) raytrace results are not copied
result.__dict__.pop("system", None)
result = copy.deepcopy(result)
primary_mirror = result.primary_mirror
focal_length_nominal = (
primary_mirror.sag.focal_length + primary_mirror.translation.z
)
result.grating.yaw = self.yaw_grating
result.grating.pitch = self.pitch_grating
result.grating.roll = self.roll_grating
result.field_stop.roll = self.roll_field_stop
result.grating.rulings.spacing.coefficients[0] = self.spacing_rulings
primary_mirror.sag.focal_length = (
focal_length_nominal + self.displacement_primary
)
primary_mirror.translation.z = -self.displacement_primary
result.pitch = self.pitch
result.yaw = self.yaw
result.roll = self.roll
return result
[docs]
def to_file(
self,
path: str | pathlib.Path,
metadata: None | dict[str, Any] = None,
) -> None:
"""
Save these parameters as a plain-text ECSV table.
The fields become the columns of the table (with their units), and
the elements along the (single) logical axis of the parameters become
its rows, so that fit results can be committed to version control and
reviewed as text. Scalar fields are broadcast along the axis.
Parameters
----------
path
The path of the file to write.
metadata
Additional provenance recorded in the table header, for example
the date and configuration of the fit that produced the
parameters.
Raises
------
ValueError
If the parameters have more than one logical axis.
See Also
--------
from_file : The inverse of this method.
"""
import astropy.table
shape = na.shape(self)
if len(shape) > 1:
raise ValueError(
f"only parameters with at most one axis can be saved, " f"got {shape=}"
)
axis = next(iter(shape), None)
num = shape.get(axis, 1)
columns = {}
for field in dataclasses.fields(self):
value = na.as_named_array(getattr(self, field.name))
unit = na.unit(value)
data = na.value(value).ndarray
if unit is not None:
data = data * unit
columns[field.name] = np.broadcast_to(data, (num,), subok=True)
table = astropy.table.QTable(columns)
table.meta["axis"] = axis
if metadata is not None:
table.meta.update(metadata)
table.write(path, format="ascii.ecsv", overwrite=True)
[docs]
@classmethod
def from_file(
cls,
path: str | pathlib.Path,
axis: None | str = None,
) -> DistortionParameters:
"""
Load parameters saved by :meth:`to_file`.
The logical axis of the parameters is recovered from the ``axis``
entry of the table header.
Parameters
----------
path
The path of the file to read.
axis
The name to use for the logical axis of the parameters,
overriding the name recorded in the file.
"""
import astropy.table
table = astropy.table.QTable.read(path, format="ascii.ecsv")
if axis is None:
axis = table.meta["axis"]
fields = {}
for field in dataclasses.fields(cls):
column = table[field.name]
if axis is None:
fields[field.name] = column[0]
else:
fields[field.name] = (
na.ScalarArray(np.asarray(column.value), axes=axis) * column.unit
)
return cls(**fields)
[docs]
@dataclasses.dataclass(repr=False)
class DistortionObjective(
optika.mixins.Printable,
):
"""
An objective function which compares modeled images to an observation.
Instances of this class are intended to be passed directly to
:mod:`scipy.optimize` routines such as
:func:`scipy.optimize.differential_evolution`: they are callables of a
flat, dimensionless parameter vector, and they are picklable so that the
optimizer can evaluate them in parallel worker processes.
Each evaluation unpacks the vector into a :class:`DistortionParameters`
using :attr:`parameters` as the prototype, applies it to a copy of
:attr:`instrument`, images :attr:`scene` through the resulting optical
system, and compares the result to :attr:`observation` using the Pearson
correlation coefficient. A penalty proportional to the squared distance
between the mean ray position and the origin of the sensor discourages
solutions that walk the image off the detector.
Note that the imaging model samples photons randomly, so evaluations of
this objective are stochastic even for identical parameter vectors, and
it should only be used with optimizers that tolerate a noisy objective,
such as :func:`scipy.optimize.differential_evolution`.
"""
instrument: esis.optics.abc.AbstractInstrument
"""The instrument model being fit to the observation."""
parameters: DistortionParameters
"""
The prototype which defines the units and structure of the flat
parameter vector, usually the initial guess of the fit.
"""
scene: na.FunctionArray
"""
The spectral radiance of the scene as a function of wavelength and field
position, imaged through the instrument on every evaluation.
"""
observation: na.AbstractScalar
"""
The observed image that the modeled image is compared against.
Any axes of the modeled image which are not present in this array are
summed over before comparing.
"""
pupil: None | na.AbstractCartesian2dVectorArray = None
"""The vertices of the pupil grid used to image the scene."""
axis_wavelength: None | str = None
"""The logical axis of the scene corresponding to changing wavelength."""
axis_field: None | tuple[str, str] = None
"""The logical axes of the scene corresponding to changing field position."""
axis_channel: None | str = None
"""
The logical axis of the observation corresponding to changing camera
channel.
If given, the correlation and the off-target penalty are computed
independently for each channel and then averaged, so that brightness
differences between the channels (e.g. from differing gains) do not
suppress the correlation. This is the appropriate setting when fitting
several channels at once with shared parameters.
"""
weight_correlation: float = 1000
"""The weight of the correlation term relative to the distance penalty."""
def __call__(self, x: np.ndarray) -> float:
"""
Evaluate the objective for a flat, dimensionless parameter vector.
Parameters
----------
x
The flat parameter vector, interpreted in the units and structure
of :attr:`parameters`.
"""
parameters = na.unpack(x, self.parameters)
instrument = parameters.to_instrument(self.instrument)
system = instrument.system
image = system.image(
scene=self.scene,
pupil=self.pupil,
axis_wavelength=self.axis_wavelength,
axis_field=self.axis_field,
noise=False,
)
image = na.value(image.outputs)
observation = na.value(self.observation)
axis_extra = tuple(set(na.shape(image)) - set(na.shape(observation)))
if axis_extra:
image = image.sum(axis=axis_extra)
axis_image = tuple(set(na.shape(observation)) - {self.axis_channel})
correlation = _correlation(image, observation, axis=axis_image).mean()
position = system.rayfunction_default.outputs.position.to(u.mm)
axis_position = tuple(set(na.shape(position)) - {self.axis_channel})
distance = np.square(position.mean(axis_position).length).mean()
result = -self.weight_correlation * correlation + distance.value
return float(na.value(result).ndarray)
[docs]
@dataclasses.dataclass(repr=False)
class DistortionResidual(
optika.mixins.Printable,
):
r"""
A least-squares residual comparing modeled images to an observation.
This is the residual-vector counterpart of :class:`DistortionObjective`,
suitable for :func:`scipy.optimize.least_squares`: each evaluation returns
a flat vector of per-pixel residuals rather than a single scalar, which
lets a Gauss-Newton optimizer exploit the structure of the problem.
The residual is built from the same comparison as
:class:`DistortionObjective`. For images standardized to zero mean and unit
deviation, the sum of squared residuals is a monotonic function of the
Pearson correlation (:math:`\lVert\hat a - \hat b\rVert^2 = 2N(1 -
\mathrm{corr})`), so minimizing it maximizes the correlation. The squared
off-target penalty of :class:`DistortionObjective` is appended as extra
residual rows.
Two features make this objective usable with a derivative-based optimizer,
where :class:`DistortionObjective` is not:
* **Determinism.** The imaging model jitters each ray within its scene cell
using the global :mod:`numpy` random state, so it is stochastic by
default. Re-seeding that state with :attr:`seed` before every evaluation
freezes the jitter realization, making the residual a deterministic
function of the parameters that finite-difference Jacobians can read.
Note that this mutates the global :mod:`numpy` random state as a side
effect; it is intended for serial use.
* **Smoothing.** The images are sparse, so a sub-pixel shift can leave the
residual unchanged until a feature crosses a pixel boundary. Convolving
the modeled image with a :attr:`sigma_psf`-wide Gaussian point-spread
function widens the features so that small parameter changes produce a
readable gradient, while also modeling the blur that the real optics
imprint on the observation. A :attr:`smoothing`-wide box filter applied
to both images is retained as a purely numerical alternative.
Notes
-----
In practice, :func:`scipy.optimize.least_squares` was found to
under-converge on this residual for realistic scenes even with the
features above: the smoothed correlation surface remains locally flat
enough that the optimizer terminates after moving a small fraction of the
distance to the true peak. :func:`fit_distortion_scan`, which reads the
shape of the merit basin directly instead of differentiating it, is the
recommended driver.
"""
instrument: esis.optics.abc.AbstractInstrument
"""The instrument model being fit to the observation."""
parameters: DistortionParameters
"""
The prototype which defines the units and structure of the flat
parameter vector, usually the initial guess of the fit.
"""
scene: na.FunctionArray
"""
The spectral radiance of the scene as a function of wavelength and field
position, imaged through the instrument on every evaluation.
"""
observation: na.AbstractScalar
"""
The observed image that the modeled image is compared against.
Any axes of the modeled image which are not present in this array are
summed over before comparing.
"""
pupil: None | na.AbstractCartesian2dVectorArray = None
"""The vertices of the pupil grid used to image the scene."""
axis_wavelength: None | str = None
"""The logical axis of the scene corresponding to changing wavelength."""
axis_field: None | tuple[str, str] = None
"""The logical axes of the scene corresponding to changing field position."""
axis_channel: None | str = None
"""
The logical axis of the observation corresponding to changing camera
channel.
If given, each channel is standardized independently before the residual
is formed, so that brightness differences between the channels do not
dominate the fit. See :attr:`DistortionObjective.axis_channel`.
"""
weight_correlation: float = 1000
"""The weight of the correlation residual relative to the off-target penalty."""
weight_distance: float = 1.0
"""The weight of the off-target penalty residual."""
smoothing: None | int = 1
"""
The width, in detector pixels, of the box filter applied to both images
before comparing. A value of :obj:`None` or :math:`\\leq 1` disables
smoothing.
"""
sigma_psf: None | float = None
"""
The standard deviation, in detector pixels, of a Gaussian point-spread
function convolved with the modeled image only.
The observation already carries the blur of the real optics, so applying
the point-spread function to the modeled image makes the two directly
comparable while also letting a sparsely-sampled raytraced image produce
a smooth, differentiable residual. A value of :obj:`None` disables the
convolution.
"""
seed: int = 0
"""
The seed used to freeze the imaging model's random ray jitter, so that the
residual is a deterministic function of the parameters.
"""
def _smooth(
self,
image: na.AbstractScalar,
axis_image: tuple[str, ...],
) -> na.AbstractScalar:
return _smooth_box(image, self.smoothing, axis_image)
def __call__(self, x: np.ndarray) -> np.ndarray:
"""
Evaluate the residual vector for a flat, dimensionless parameter vector.
Parameters
----------
x
The flat parameter vector, interpreted in the units and structure
of :attr:`parameters`.
"""
parameters = na.unpack(x, self.parameters)
instrument = parameters.to_instrument(self.instrument)
system = instrument.system
# freeze the per-cell ray jitter so the residual is deterministic
np.random.seed(self.seed)
image = system.image(
scene=self.scene,
pupil=self.pupil,
axis_wavelength=self.axis_wavelength,
axis_field=self.axis_field,
noise=False,
)
image = na.value(image.outputs)
observation = na.value(self.observation)
axis_extra = tuple(set(na.shape(image)) - set(na.shape(observation)))
if axis_extra:
image = image.sum(axis=axis_extra)
axis_image = tuple(set(na.shape(observation)) - {self.axis_channel})
if self.sigma_psf is not None:
kernel = _kernel_gaussian(self.sigma_psf, axis_image)
image = na.convolve(image, kernel, axis=axis_image)
model = _standardize(self._smooth(image, axis_image), axis_image)
data = _standardize(self._smooth(observation, axis_image), axis_image)
num_pixel = 1
for ax in axis_image:
num_pixel *= na.shape(observation)[ax]
residual_correlation = (model - data) * np.sqrt(
self.weight_correlation / num_pixel
)
# off-target penalty: the components of the mean ray position, so that
# the sum of their squares reproduces the squared-distance penalty.
position = system.rayfunction_default.outputs.position.to(u.mm)
axis_position = tuple(set(na.shape(position)) - {self.axis_channel})
mean_position = position.mean(axis_position)
num_channel = na.shape(position).get(self.axis_channel, 1)
residual_distance = na.stack(
arrays=[mean_position.x, mean_position.y, mean_position.z],
axis="_component",
)
residual_distance = na.value(residual_distance) * np.sqrt(
self.weight_distance / num_channel
)
return np.concatenate(
[
na.value(residual_correlation).ndarray.reshape(-1),
na.value(residual_distance).ndarray.reshape(-1),
]
)
def _kernel_gaussian(
sigma: float,
axis: tuple[str, ...],
) -> na.ScalarArray:
"""
Build a normalized Gaussian convolution kernel.
Parameters
----------
sigma
The standard deviation of the Gaussian in pixels.
axis
The logical axes of the kernel, one per dimension.
"""
radius = max(2, int(np.ceil(3 * sigma)))
x = np.arange(-radius, radius + 1)
profile = np.exp(-np.square(x / sigma) / 2)
kernel = profile
for _ in range(len(axis) - 1):
kernel = np.multiply.outer(kernel, profile)
kernel = kernel / kernel.sum()
return na.ScalarArray(ndarray=kernel, axes=axis)
def _standardize(
a: na.AbstractScalar,
axis: None | tuple[str, ...] = None,
) -> na.AbstractScalar:
"""
Shift and scale an array to zero mean and unit standard deviation.
A constant array is left at zero, so that it contributes nothing to a
correlation or a normalized residual.
Parameters
----------
a
The array to standardize.
axis
The logical axes along which to compute the mean and deviation.
If :obj:`None`, every axis is used.
"""
a = a - a.mean(axis)
deviation = a.std(axis)
return a / np.where(deviation == 0, 1, deviation)
def _correlation(
a: na.AbstractScalar,
b: na.AbstractScalar,
axis: None | tuple[str, ...] = None,
) -> na.AbstractScalar:
"""
Compute the Pearson correlation coefficient of two arrays.
Parameters
----------
a
The first array, standardized before comparing.
If it is constant, the correlation is zero.
b
The second array, standardized before comparing.
If it is constant, the correlation is zero.
axis
The logical axes along which to compute the correlation.
If :obj:`None`, the correlation is computed over every axis.
"""
return (_standardize(a, axis) * _standardize(b, axis)).mean(axis)
def _smooth_box(
a: na.AbstractScalar,
size: None | int,
axis: tuple[str, ...],
) -> na.AbstractScalar:
r"""
Smooth an array with a normalized box filter.
Parameters
----------
a
The array to smooth.
size
The width of the box in pixels. A value of :obj:`None` or
:math:`\leq 1` disables smoothing.
axis
The logical axes along which to smooth.
"""
if size is None or size <= 1:
return a
size = int(size)
kernel = na.ScalarArray(
ndarray=np.ones(len(axis) * (size,)) / size ** len(axis),
axes=axis,
)
return na.convolve(a, kernel, axis=axis)
def _peak_parabola(
x: np.ndarray,
y: np.ndarray,
) -> float:
"""
Locate the peak of a sampled curve with a parabola refinement.
A parabola is fit through the best sample and its two neighbors, and the
abscissa of its vertex is returned, clipped to the sampled interval.
If the parabola is not concave, the abscissa of the best sample is
returned instead.
Parameters
----------
x
The sample positions.
y
The sampled curve values.
"""
i = int(np.argmax(y))
i = min(max(i, 1), len(x) - 2)
c = np.polyfit(x[i - 1 : i + 2], y[i - 1 : i + 2], 2)
if c[0] >= 0:
return float(x[int(np.argmax(y))])
return float(np.clip(-c[1] / (2 * c[0]), x.min(), x.max()))
def _correlation_model(
instrument: esis.optics.abc.AbstractInstrument,
parameters: DistortionParameters,
scene: na.FunctionArray,
observation: na.AbstractScalar,
pupil: None | na.AbstractCartesian2dVectorArray,
axis_wavelength: None | str,
axis_field: None | tuple[str, str],
axis_channel: None | str,
smoothing: None | int,
sigma_psf: None | float,
seed: int,
) -> na.AbstractScalar:
"""
Compute the correlation between the modeled image and the observation.
Applies `parameters` to `instrument`, images `scene` deterministically,
optionally convolves the modeled image with a Gaussian point-spread
function, and computes the Pearson correlation against `observation`
(per channel, if `axis_channel` is given).
Parameters
----------
instrument
The instrument model being fit to the observation.
parameters
The distortion parameters applied to the instrument.
scene
The spectral radiance of the scene imaged through the instrument.
observation
The observed image that the modeled image is compared against.
pupil
The vertices of the pupil grid used to image the scene.
axis_wavelength
The logical axis of the scene corresponding to changing wavelength.
axis_field
The logical axes of the scene corresponding to changing field position.
axis_channel
The logical axis of the observation corresponding to changing camera
channel.
smoothing
The width, in detector pixels, of a box filter applied to both images.
sigma_psf
The standard deviation, in detector pixels, of a Gaussian point-spread
function convolved with the modeled image only.
seed
The seed used to freeze the imaging model's random ray jitter.
"""
instrument = parameters.to_instrument(instrument)
# freeze the per-cell ray jitter so the correlation is deterministic
np.random.seed(seed)
image = instrument.system.image(
scene=scene,
pupil=pupil,
axis_wavelength=axis_wavelength,
axis_field=axis_field,
noise=False,
)
image = na.value(image.outputs)
observation = na.value(observation)
axis_extra = tuple(set(na.shape(image)) - set(na.shape(observation)))
if axis_extra:
image = image.sum(axis=axis_extra)
axis_image = tuple(set(na.shape(observation)) - {axis_channel})
if sigma_psf is not None:
kernel = _kernel_gaussian(sigma_psf, axis_image)
image = na.convolve(image, kernel, axis=axis_image)
image = _smooth_box(image, smoothing, axis_image)
observation = _smooth_box(observation, smoothing, axis_image)
return _correlation(image, observation, axis_image)
[docs]
def fit_distortion(
instrument: esis.optics.abc.AbstractInstrument,
scene: na.FunctionArray,
observation: na.AbstractScalar,
bounds: tuple[DistortionParameters, DistortionParameters],
parameters: None | DistortionParameters = None,
pupil: None | na.AbstractCartesian2dVectorArray = None,
axis_wavelength: None | str = None,
axis_field: None | tuple[str, str] = None,
axis_channel: None | str = None,
weight_correlation: float = 1000,
directory: None | pathlib.Path = None,
kwargs_optimizer: None | dict[str, Any] = None,
) -> DistortionParameters:
"""
Fit the distortion parameters of an instrument to an observed image.
Wraps :func:`scipy.optimize.differential_evolution` around a
:class:`DistortionObjective`: the given `parameters` are flattened with
:func:`named_arrays.pack` to seed the optimizer, the `bounds` are
flattened the same way, and the best solution found is unpacked back
into a :class:`DistortionParameters`, which can be applied to the
instrument with :meth:`DistortionParameters.to_instrument`.
Parameters
----------
instrument
The instrument model to fit, usually reduced to a single channel.
scene
The spectral radiance of the scene as a function of wavelength and
field position, imaged through the instrument on every evaluation.
observation
The observed image that the modeled images are compared against.
Any axes of the modeled image which are not present in this array
are summed over before comparing.
bounds
The lower and upper bounds of the fit, expressed in the same units
as `parameters`.
parameters
The initial guess of the fit, which also defines the units and
structure of the parameter vector seen by the optimizer.
If :obj:`None`, the current parameters of `instrument` are used.
pupil
The vertices of the pupil grid used to image the scene.
axis_wavelength
The logical axis of the scene corresponding to changing wavelength.
axis_field
The logical axes of the scene corresponding to changing field position.
axis_channel
The logical axis of the observation corresponding to changing camera
channel, used to compare each channel independently when fitting
several channels at once with shared parameters.
See :attr:`DistortionObjective.axis_channel`.
weight_correlation
The weight of the correlation term of the objective relative to its
off-target distance penalty.
directory
A directory where the convergence history is logged using a
:class:`ConvergenceLogger`.
If :obj:`None`, the fit is not logged.
kwargs_optimizer
Additional keyword arguments passed to
:func:`scipy.optimize.differential_evolution`, for example
``dict(workers=-1, popsize=40)``.
Examples
--------
Fit each channel of the ESIS flight-1 design to the Level-1 frame that
:func:`esis.flights.f1.optics.distortion_fit` was optimized against.
.. code-block:: python
import pathlib
import named_arrays as na
import esis
obs = esis.flights.f1.data.level_1()[dict(time=15)]
scene = esis.flights.f1.data.synth.scene_aia()[dict(time=15)]
instrument = esis.flights.f1.optics.design(num_distribution=0)
for i in range(obs.shape[instrument.axis_channel]):
channel = instrument[dict(channel=i)]
parameters = esis.optics.DistortionParameters.from_instrument(channel)
# the scene's wavelength coordinate varies within each spectral
# line along "velocity"; its per-line axis ("wavelength") is
# absent from the observation and therefore summed over
fitted = esis.optics.fit_distortion(
instrument=channel,
scene=scene,
observation=obs.outputs[dict(channel=i)].value,
bounds=esis.flights.f1.optics.distortion_fit_bounds(parameters),
parameters=parameters,
axis_wavelength="velocity",
axis_field=("detector_x", "detector_y"),
directory=pathlib.Path(f"distortion_fit/channel_{i}"),
kwargs_optimizer=dict(workers=-1, popsize=40),
)
model = fitted.to_instrument(channel)
"""
if parameters is None:
parameters = DistortionParameters.from_instrument(instrument)
if kwargs_optimizer is None:
kwargs_optimizer = dict()
objective = DistortionObjective(
instrument=instrument,
parameters=parameters,
scene=scene,
observation=observation,
pupil=pupil,
axis_wavelength=axis_wavelength,
axis_field=axis_field,
axis_channel=axis_channel,
weight_correlation=weight_correlation,
)
lower, upper = bounds
callback = None
if directory is not None:
callback = ConvergenceLogger(
directory=directory,
offset_energy=weight_correlation,
)
time_start = time.perf_counter()
result = scipy.optimize.differential_evolution(
objective,
bounds=scipy.optimize.Bounds(
lb=na.pack(lower).ndarray,
ub=na.pack(upper).ndarray,
),
x0=na.pack(parameters).ndarray,
callback=callback,
**kwargs_optimizer,
)
result_parameters = na.unpack(result.x, parameters)
if callback is not None:
elapsed = time.perf_counter() - time_start
callback.log(f"{result}")
callback.log(f"{result_parameters}")
callback.log(f"elapsed time: {datetime.timedelta(seconds=elapsed)}")
return result_parameters
[docs]
def fit_distortion_scan(
instrument: esis.optics.abc.AbstractInstrument,
scene: na.FunctionArray,
observation: na.AbstractScalar,
grids: Sequence[dict[str, u.Quantity]],
parameters: None | DistortionParameters = None,
pupil: None | na.AbstractCartesian2dVectorArray = None,
axis_wavelength: None | str = None,
axis_field: None | tuple[str, str] = None,
axis_channel: None | str = None,
coherent: bool = False,
smoothing: None | int = None,
sigma_psf: None | float = 1.0,
seed: int = 0,
tolerance: None | float = None,
num_repeat: int = 8,
directory: None | pathlib.Path = None,
) -> DistortionParameters:
r"""
Fit the distortion parameters of an instrument by scanning the merit.
A derivative-free coordinate search: for each round in `grids`, every
listed parameter is scanned along the given offsets while the others are
held at their current best, and the parameter is moved to the peak of the
sampled correlation curve, refined with a three-point parabola. Rounds
are typically coarse-to-fine, so early rounds capture the solution and
later rounds polish it.
If `axis_channel` is given, the correlation of each channel is recorded
separately during every scan, and each channel's peak is read off its own
curve. Because a channel's correlation depends only on that channel's
parameters, one scan pass fits all channels simultaneously, and the
fitted parameters gain an `axis_channel` axis.
This is the production fitting engine: unlike
:func:`scipy.optimize.least_squares` driving a
:class:`DistortionResidual`, which was found to under-converge on the
locally-flat correlation surface of realistic scenes, the scans read the
shape of the merit basin directly and are robust as long as the true
solution lies within the coarsest scan range (measured to be at least
:math:`\pm 10''` in pointing for the ESIS-I flight data).
Parameters
----------
instrument
The instrument model to fit.
scene
The spectral radiance of the scene as a function of wavelength and
field position, imaged through the instrument on every evaluation.
observation
The observed image that the modeled images are compared against.
Any axes of the modeled image which are not present in this array
are summed over before comparing.
grids
The scan schedule: a sequence of rounds, each a mapping from a field
name of :class:`DistortionParameters` to a one-dimensional
:class:`~astropy.units.Quantity` of offsets (relative to the current
best) to scan, for example
``[dict(pitch=np.linspace(-10, 10, 21) * u.arcsec)]``.
Strongly-coupled fields can be scanned jointly by using a tuple of
field names as the key and a matching tuple of offset grids as the
value, for example ``{("pitch_grating", "pitch"): (grid_a, grid_b)}``,
which scans the full outer product and reads the joint peak —
independent scans of such pairs converge to a compensating local
optimum instead of the true solution.
parameters
The starting point of the fit.
If :obj:`None`, the current parameters of `instrument` are used.
pupil
The vertices of the pupil grid used to image the scene.
axis_wavelength
The logical axis of the scene corresponding to changing wavelength.
axis_field
The logical axes of the scene corresponding to changing field position.
axis_channel
The logical axis of the observation corresponding to changing camera
channel. If given, each channel is standardized independently and
each channel's peak is read from its own correlation curve.
coherent
If :obj:`True`, the per-channel correlation curves are averaged
before locating the peak, so that every channel receives the same
offset (a rigid-payload model). Requires `axis_channel`, which
controls the per-channel standardization of the merit.
smoothing
The width, in detector pixels, of a box filter applied to both images
before comparing.
sigma_psf
The standard deviation, in detector pixels, of a Gaussian point-spread
function convolved with the modeled image only.
See :attr:`DistortionResidual.sigma_psf`.
seed
The seed used to make each evaluation deterministic.
See :attr:`DistortionResidual.seed`.
tolerance
If given, the last round of `grids` is repeated until the mean
correlation improves by less than this amount, at most `num_repeat`
extra rounds.
num_repeat
The maximum number of extra polish rounds appended when `tolerance`
is given.
directory
A directory where the scan curves and convergence history are written
as ``scan.json`` and ``scan.log``. If :obj:`None`, the fit is not
logged.
Raises
------
ValueError
If a joint entry of `grids` has a different number of fields and
offset grids.
Examples
--------
Fit the per-frame payload pointing of the ESIS flight-1 model to a
Level-1 frame.
.. code-block:: python
import numpy as np
import astropy.units as u
import esis
obs = esis.flights.f1.data.level_1()[dict(time=0)]
scene = ... # an AIA scene resampled to the frame's timestamp
instrument = esis.flights.f1.optics.distortion_fit(num_distribution=0)
fitted = esis.optics.fit_distortion_scan(
instrument=instrument,
scene=scene,
observation=obs.outputs.value,
grids=[
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,
),
],
axis_wavelength="velocity",
axis_field=("detector_x", "detector_y"),
sigma_psf=1.0,
)
"""
if parameters is None:
parameters = DistortionParameters.from_instrument(instrument)
parameters = copy.deepcopy(parameters)
path_log = None
if directory is not None:
directory = pathlib.Path(directory)
directory.mkdir(parents=True, exist_ok=True)
path_log = directory / "scan.log"
path_log.write_text(f"--- scan fit start: {datetime.datetime.now()} ---\n")
def log(message: str) -> None:
if path_log is not None:
with path_log.open("a") as f:
f.write(f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S} | {message}\n")
def evaluate(p: DistortionParameters) -> na.AbstractScalar:
return _correlation_model(
instrument=instrument,
parameters=p,
scene=scene,
observation=observation,
pupil=pupil,
axis_wavelength=axis_wavelength,
axis_field=axis_field,
axis_channel=axis_channel,
smoothing=smoothing,
sigma_psf=sigma_psf,
seed=seed,
)
def merit(corr: na.AbstractScalar) -> float:
if axis_channel is not None and axis_channel in na.shape(corr):
corr = corr.mean(axis_channel)
return float(na.value(corr).ndarray)
time_start = time.perf_counter()
nfev = 0
summary_rounds = []
corr = evaluate(parameters)
nfev += 1
merit_current = merit(corr)
log(f"start | correlation {merit_current:.6f}")
schedule = list(grids)
if tolerance is not None and len(schedule) > 0:
schedule = schedule + [schedule[-1]] * num_repeat
for index, grids_round in enumerate(schedule):
curves = {}
for key, offsets in grids_round.items():
fields = key if isinstance(key, tuple) else (key,)
if not isinstance(offsets, tuple):
offsets = (offsets,)
offsets = tuple(u.Quantity(o) for o in offsets)
if len(fields) != len(offsets):
raise ValueError(
f"the number of fields {fields} does not match the "
f"number of offset grids for round {index}"
)
shape_grid = tuple(len(o) for o in offsets)
values = []
for deltas in itertools.product(*offsets):
trial = copy.copy(parameters)
for field, delta in zip(fields, deltas):
setattr(trial, field, getattr(trial, field) + delta)
values.append(evaluate(trial))
nfev += 1
# one correlation curve per channel, or a single coherent curve
values = np.stack(
[np.atleast_1d(na.value(v).ndarray) for v in values],
)
num_curve = values.shape[~0]
values = values.reshape(shape_grid + (num_curve,))
values_read = values
if coherent:
values_read = values.mean(axis=~0, keepdims=True)
# locate the joint maximum of each curve, then refine every field
# with a parabola along its own axis through the maximum
peak = {field: [] for field in fields}
for i in range(values_read.shape[~0]):
v = values_read[..., i]
index_max = np.unravel_index(int(np.argmax(v)), v.shape)
for a, field in enumerate(fields):
section = v[
tuple(
slice(None) if b == a else index_max[b]
for b in range(len(fields))
)
]
peak[field].append(_peak_parabola(offsets[a].value, section))
for a, field in enumerate(fields):
if axis_channel is not None and len(peak[field]) > 1:
delta = na.ScalarArray(np.array(peak[field]), axes=axis_channel)
else:
delta = peak[field][0]
setattr(
parameters,
field,
getattr(parameters, field) + delta * offsets[a].unit,
)
name = " x ".join(fields)
curves[name] = dict(
fields=list(fields),
offsets=[[float(x) for x in o.value] for o in offsets],
unit=[str(o.unit) for o in offsets],
values=values.tolist(),
peak={f: [float(p) for p in peak[f]] for f in fields},
)
peak_round = {f: np.round(peak[f], 6) for f in fields}
log(f"round {index} | {name} | peak {peak_round}")
corr = evaluate(parameters)
nfev += 1
gain = merit(corr) - merit_current
merit_current = merit(corr)
log(
f"round {index} | correlation {merit_current:.6f} "
f"({gain:+.6f}) | nfev {nfev}"
)
summary_rounds.append(
dict(
index=index,
correlation=[float(x) for x in np.atleast_1d(na.value(corr).ndarray)],
gain=gain,
curves=curves,
)
)
if directory is not None:
summary = dict(
rounds=summary_rounds,
nfev=nfev,
seconds=time.perf_counter() - time_start,
)
with (directory / "scan.json").open("w") as f:
json.dump(summary, f, indent=2)
if tolerance is not None and index >= len(grids) and gain < tolerance:
log(f"converged | round gain {gain:+.6f} < {tolerance}")
break
return parameters
[docs]
def fit_distortion_series(
instrument: esis.optics.abc.AbstractInstrument,
scenes: Sequence[na.FunctionArray],
observations: Sequence[na.AbstractScalar],
grids: Sequence[dict[str, u.Quantity]],
parameters: None | DistortionParameters = None,
pupil: None | na.AbstractCartesian2dVectorArray = None,
axis_wavelength: None | str = None,
axis_field: None | tuple[str, str] = None,
axis_channel: None | str = None,
coherent: bool = False,
smoothing: None | int = None,
sigma_psf: None | float = 1.0,
seed: int = 0,
tolerance: None | float = None,
num_repeat: int = 8,
directory: None | pathlib.Path = None,
workers: int = 1,
) -> list[DistortionParameters]:
"""
Fit a time series of frames with :func:`fit_distortion_scan`.
Every frame is fit independently, starting from the same `parameters`
(for example the best fit of a reference frame) rather than warm-starting
from the previous frame: independent fits cannot accumulate drift from
one bad frame, make the per-frame results directly comparable, and
parallelize perfectly.
Parameters
----------
instrument
The instrument model to fit.
scenes
The per-frame scenes, one for each observation.
observations
The per-frame observed images, in time order.
grids
The scan schedule applied to every frame.
See :func:`fit_distortion_scan`.
parameters
The starting point shared by every frame. If :obj:`None`, the current
parameters of `instrument` are used.
pupil
The vertices of the pupil grid used to image the scenes.
axis_wavelength
The logical axis of the scenes corresponding to changing wavelength.
axis_field
The logical axes of the scenes corresponding to changing field position.
axis_channel
The logical axis of the observations corresponding to changing camera
channel. See :func:`fit_distortion_scan`.
coherent
If :obj:`True`, every channel receives the same offset (a
rigid-payload model). See :func:`fit_distortion_scan`.
smoothing
The width, in detector pixels, of a box filter applied to both images
before comparing.
sigma_psf
The standard deviation, in detector pixels, of a Gaussian point-spread
function convolved with the modeled image only.
See :attr:`DistortionResidual.sigma_psf`.
seed
The seed used to make each evaluation deterministic.
tolerance
If given, the last round of `grids` is repeated for each frame until
the mean correlation improves by less than this amount.
See :func:`fit_distortion_scan`.
num_repeat
The maximum number of extra polish rounds appended when `tolerance`
is given.
directory
A directory under which each frame's fit is logged in a ``frame_NNN``
subdirectory. If :obj:`None`, the fits are not logged.
workers
The number of processes used to fit frames concurrently.
A value of 1 fits the frames serially in the current process.
"""
if parameters is None:
parameters = DistortionParameters.from_instrument(instrument)
def kwargs_frame(i: int, scene, observation) -> dict[str, Any]:
directory_frame = None
if directory is not None:
directory_frame = pathlib.Path(directory) / f"frame_{i:03d}"
return dict(
instrument=instrument,
scene=scene,
observation=observation,
grids=grids,
parameters=parameters,
pupil=pupil,
axis_wavelength=axis_wavelength,
axis_field=axis_field,
axis_channel=axis_channel,
coherent=coherent,
smoothing=smoothing,
sigma_psf=sigma_psf,
seed=seed,
tolerance=tolerance,
num_repeat=num_repeat,
directory=directory_frame,
)
frames = list(zip(scenes, observations))
if workers > 1:
import concurrent.futures
import multiprocessing
# fork (the Linux default) is unsafe once numpy/numba thread pools
# exist in the parent, and it copies the parent's full memory into
# every worker; spawn starts clean interpreters on every platform
context = multiprocessing.get_context("spawn")
with concurrent.futures.ProcessPoolExecutor(
max_workers=workers,
mp_context=context,
) as pool:
futures = [
pool.submit(_fit_frame_scan, kwargs_frame(i, scene, observation))
for i, (scene, observation) in enumerate(frames)
]
return [future.result() for future in futures]
return [
_fit_frame_scan(kwargs_frame(i, scene, observation))
for i, (scene, observation) in enumerate(frames)
]
def _fit_frame_scan(kwargs: dict[str, Any]) -> DistortionParameters:
"""Fit a single frame; a picklable target for the process pool."""
return fit_distortion_scan(**kwargs)
[docs]
@dataclasses.dataclass(repr=False)
class ConvergenceLogger(
optika.mixins.Printable,
):
"""
A callback which logs the convergence of an optimization to disk.
Intended for :func:`scipy.optimize.differential_evolution`.
On every iteration, the best objective value and the standard deviation of
the population energies are appended to a CSV file, the best parameter
vector is appended to a text log, and a convergence plot is saved, so that
long-running fits can be monitored and post-mortemed.
"""
directory: pathlib.Path
"""The directory where the log files are saved."""
offset_energy: float = 1000.0
"""
An offset added to the objective values before plotting on a logarithmic
scale, chosen to keep the (typically negative) correlation objective of
:class:`DistortionObjective` positive.
"""
def __post_init__(self):
self.directory = pathlib.Path(self.directory)
self.directory.mkdir(parents=True, exist_ok=True)
self.iteration = 0
self.history_energy = []
self.history_deviation = []
self.path_data.write_text("iteration,energy_best,deviation_population\n")
self.path_log.write_text(
f"--- optimization start: {datetime.datetime.now()} ---\n"
)
@property
def path_data(self) -> pathlib.Path:
"""The path of the CSV file containing the convergence history."""
return self.directory / "convergence_data.csv"
@property
def path_log(self) -> pathlib.Path:
"""The path of the text file mirroring the console output."""
return self.directory / "full_output.log"
@property
def path_plot(self) -> pathlib.Path:
"""The path of the convergence plot."""
return self.directory / "convergence_plot.png"
[docs]
def log(self, message: str) -> None:
"""
Print a message to the console and append it to :attr:`path_log`.
Parameters
----------
message
The message to log.
"""
print(message)
with self.path_log.open("a") as f:
f.write(message + "\n")
def __call__(self, intermediate_result) -> None:
"""
Record the state of the optimizer after an iteration.
Parameters
----------
intermediate_result
The :class:`scipy.optimize.OptimizeResult` passed by
:func:`scipy.optimize.differential_evolution`, with at least the
``x``, ``fun``, and ``population_energies`` attributes.
"""
self.iteration += 1
energy = intermediate_result.fun
deviation = np.std(intermediate_result.population_energies)
self.history_energy.append(energy)
self.history_deviation.append(deviation)
with self.path_data.open("a") as f:
f.write(f"{self.iteration},{energy:.8e},{deviation:.4e}\n")
x = np.array2string(intermediate_result.x, precision=4, separator=", ")
self.log(f"iteration {self.iteration:03d} | energy: {energy:.6e} | x: {x}")
self.save_plot()
[docs]
def save_plot(self) -> None:
"""Save a plot of the convergence history to :attr:`path_plot`."""
fig, axs = plt.subplots(ncols=2, figsize=(10, 4), constrained_layout=True)
axs[0].plot(np.array(self.history_energy) + self.offset_energy)
axs[0].set_title("best energy")
axs[0].set_yscale("log")
axs[1].plot(self.history_deviation)
axs[1].set_title("population diversity (std. dev.)")
axs[1].set_yscale("log")
fig.savefig(self.path_plot)
plt.close(fig)