From c748cd4ba0e28fcbfa81eb34b494efce8d6fdd5f Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 13 Apr 2026 22:59:03 -0600 Subject: [PATCH 01/55] Added `ctis.inverters.MartInverter`, an implementation of the Multiplicative Algebraic Reconstruction Technique (MART). --- ctis/__init__.py | 2 + ctis/inverters/__init__.py | 17 ++ ctis/inverters/_inverters.py | 48 +++++ ctis/inverters/_iterative/__init__.py | 8 + ctis/inverters/_iterative/_iterative.py | 47 +++++ ctis/inverters/_iterative/_mart/__init__.py | 5 + ctis/inverters/_iterative/_mart/_mart.py | 197 ++++++++++++++++++++ ctis/inverters/_results.py | 29 +++ 8 files changed, 353 insertions(+) create mode 100644 ctis/inverters/__init__.py create mode 100644 ctis/inverters/_inverters.py create mode 100644 ctis/inverters/_iterative/__init__.py create mode 100644 ctis/inverters/_iterative/_iterative.py create mode 100644 ctis/inverters/_iterative/_mart/__init__.py create mode 100644 ctis/inverters/_iterative/_mart/_mart.py create mode 100644 ctis/inverters/_results.py diff --git a/ctis/__init__.py b/ctis/__init__.py index fe5117f..2708fa6 100644 --- a/ctis/__init__.py +++ b/ctis/__init__.py @@ -5,8 +5,10 @@ from . import scenes from . import instruments +from . import inverters __all__ = [ "scenes", "instruments", + "inverters", ] diff --git a/ctis/inverters/__init__.py b/ctis/inverters/__init__.py new file mode 100644 index 0000000..db7bcda --- /dev/null +++ b/ctis/inverters/__init__.py @@ -0,0 +1,17 @@ +"""Inversion algorithms which can reconstruct scenes from observed images.""" + +from ._results import InversionResult +from ._inverters import AbstractInverter +from ._iterative import( + AbstractIterativeInverter, + MartInverter, + IterativeInversionResult, +) + +__all__ = [ + "AbstractInverter", + "AbstractIterativeInverter", + "MartInverter", + "InversionResult", + "IterativeInversionResult", +] diff --git a/ctis/inverters/_inverters.py b/ctis/inverters/_inverters.py new file mode 100644 index 0000000..8909d59 --- /dev/null +++ b/ctis/inverters/_inverters.py @@ -0,0 +1,48 @@ +import abc +import dataclasses +import named_arrays as na +import ctis +from ._results import InversionResult + +__all__ = [ + "AbstractInverter", +] + + +@dataclasses.dataclass +class AbstractInverter( + abc.ABC, +): + """ + An interface describing an algorithm which can invert CTIS observations + to yield a reconstruction of the observed scene. + """ + + @property + @abc.abstractmethod + def instrument(self) -> ctis.instruments.AbstractInstrument: + """ + A model of a CTIS instrument which transforms the radiance of an observed + scene to photons measured by the sensors. + """ + + @abc.abstractmethod + def __call__( + self, + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], + **kwargs, + )-> InversionResult: + """ + Reconstruct a scene using the observed images. + + Parameters + ---------- + images + The observed images used to calculate the reconstruction. + Must be evaluated on the same coordinates as + :attr:`~ctis.instruments.AbstractInstrument.coordinates_sensor` + attribute of :attr:`instrument`. + kwargs + Additional keyword arguments which can be used by subclass + implementations. + """ \ No newline at end of file diff --git a/ctis/inverters/_iterative/__init__.py b/ctis/inverters/_iterative/__init__.py new file mode 100644 index 0000000..3d5cdf5 --- /dev/null +++ b/ctis/inverters/_iterative/__init__.py @@ -0,0 +1,8 @@ +from ._iterative import AbstractIterativeInverter, IterativeInversionResult +from ._mart import MartInverter + +__all__ = [ + "AbstractIterativeInverter", + "IterativeInversionResult", + "MartInverter", +] \ No newline at end of file diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py new file mode 100644 index 0000000..0a823b3 --- /dev/null +++ b/ctis/inverters/_iterative/_iterative.py @@ -0,0 +1,47 @@ +import abc +import dataclasses +from .. import AbstractInverter, InversionResult + +__all__ = [ + "AbstractIterativeInverter", + "IterativeInversionResult", +] + + +@dataclasses.dataclass +class AbstractIterativeInverter( + AbstractInverter, +): + """ + An abstract inversion algorithm which reconstructs an observed scene + using iterative methods. + + These methods will apply some operation repeatedly until a specified + convergence criteria is met. + """ + + @property + @abc.abstractmethod + def num_iteration(self): + """ + The maximum number of iterations to perform. + + If convergence is not reached before this number is exceeded, + a warning is raised and an unsuccessful result is returned. + """ + + +@dataclasses.dataclass +class IterativeInversionResult( + InversionResult, +): + """The results of an iterative inversion attempt.""" + + num_iteration: int + """The number of iterations performed by the inverter.""" + + axis_intermediate: None | str = None + """ + The logical axis representing potential intermediate results. + If :obj:`None` (the default), there are no intermediate results. + """ diff --git a/ctis/inverters/_iterative/_mart/__init__.py b/ctis/inverters/_iterative/_mart/__init__.py new file mode 100644 index 0000000..0b70d70 --- /dev/null +++ b/ctis/inverters/_iterative/_mart/__init__.py @@ -0,0 +1,5 @@ +from ._mart import MartInverter + +__all__ = [ + "MartInverter", +] diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py new file mode 100644 index 0000000..5577b41 --- /dev/null +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -0,0 +1,197 @@ +from typing import ClassVar +import warnings +import dataclasses +import numpy as np +import astropy.units as u +import named_arrays as na +import ctis +from .. import AbstractIterativeInverter, IterativeInversionResult + +__all__ = [ + "MartInverter", +] + +@dataclasses.dataclass +class MartInverter( + AbstractIterativeInverter, +): + """ + An inversion routine based on the Richardson-Lucy algorithm + :cite:t:`Richardson1972,Lucy1974`. + """ + + instrument: ctis.instruments.AbstractInstrument = dataclasses.MISSING + """ + A model of a CTIS instrument which transforms the radiance of an observed + scene to photons measured by the sensors. + """ + + gamma: None | float = None + r""" + Contrast-enhancement factor, :math:`\gamma`. + + At every iteration, the current guess, :math:`G`, is replaced by + :math:`G^\gamma`. + + If :obj:`None`, :math:`\gamma = 2 / N`, where :math:`N` is the number of + channels. + """ + + num_iteration: int = 100 + """ + The maximum number of iterations to perform. + + If convergence is not reached before this number is exceeded, + a warning is raised and an unsuccessful result is returned. + """ + + axis_intermediate: ClassVar[str] = "_intermediate" + + intermediate: bool = False + """ + Whether to save intermediate solutions. + + This is set to :obj:`False` during normal operation, but can be useful for + debugging or demonstration purposes. + """ + + @property + def _gamma(self) -> float: + """Normalized version of :attr:`gamma`""" + gamma = self.gamma + if gamma is None: + gamma = 2 / self.instrument.num_channel + return gamma + + def __call__( + self, + images: na.ScalarArray, + guess: None | na.ScalarArray = None, + ) -> IterativeInversionResult: + """ + Reconstruct a scene using the observed images. + + Parameters + ---------- + images + The observed images used to calculate the reconstruction. + Must be evaluated on the same coordinates as + :attr:`~ctis.instruments.AbstractInstrument.coordinates_sensor` + attribute of :attr:`instrument`. + guess + The initial guess at the reconstructed scene. + Must be evaluated on the same coordinates as + :attr:`~ctis.instruments.AbstractInstrument.coordinates_scene` + attribute of :attr:`instrument`. + """ + + scene = guess.copy() + + instrument = self.instrument + + num_channel = instrument.num_channel + + gamma = self._gamma + + backprojected = instrument.backproject(images).outputs + + intermediate = [] + if self.intermediate: + intermediate.append(scene) + + chi_squared = self._mean_chi_squared(images, 0 * images.unit) + + for i in range(self.num_iteration): + + print(f"{i=}") + + images_new = instrument.image(scene, noise=False).outputs + + chi_squared_new = self._mean_chi_squared(images, images_new) + + print(f"{chi_squared_new=}") + + if (chi_squared - chi_squared_new) < 1e-2: + + # if self._converged(images, images_new): + message = "Achieved mean chi squared of less than 1." + success = True + break + + backprojected_new = instrument.backproject(images_new).outputs + + correction = backprojected / backprojected_new + + correction = np.nan_to_num( + x=correction, + nan=1, + posinf=1, + neginf=1, + ) + + correction = correction ** gamma + + correction = np.prod(correction, axis=instrument.axis_channel) + + correction = correction ** (1 / num_channel) + + if self.intermediate: + scene = scene * correction + intermediate.append(scene) + + else: + scene *= correction + + chi_squared = chi_squared_new + + print(f"{scene.sum().ndarray=}") + + else: + message = f"Max number of iterations ({self.num_iteration}) exceeded." + warnings.warn(message) + success = False + + if self.intermediate: + intermediate = na.stack(intermediate, axis=self.axis_intermediate) + solution = intermediate + axis_intermediate = self.axis_intermediate + else: + solution = scene + axis_intermediate = () + + return IterativeInversionResult( + solution=solution, + success=success, + images=images, + inverter=self, + message=message, + num_iteration=i, + axis_intermediate=axis_intermediate, + ) + + def _converged( + self, + images: na.ScalarArray, + images_new: na.ScalarArray, + ) -> bool: + r""" + Return true if :math:`\langle \chi^2 \rangle < 1` + """ + X2 = self._mean_chi_squared(images, images_new) + print(f"{X2=}") + return X2 < 1/2 + + def _mean_chi_squared( + self, + images: na.ScalarArray, + images_new: na.ScalarArray, + ): + r""" + Evaluated :math:`\langle \chi^2 \rangle < 1` normalized by uncertainty in each pixel. + """ + + uncertainty = self.instrument.uncertainty(images_new) + + uncertainty = np.maximum(uncertainty, 1 * u.photon) + + return np.mean(np.square((images_new - images) / uncertainty)) diff --git a/ctis/inverters/_results.py b/ctis/inverters/_results.py new file mode 100644 index 0000000..78c8ce6 --- /dev/null +++ b/ctis/inverters/_results.py @@ -0,0 +1,29 @@ +import abc +import dataclasses +import named_arrays as na +import ctis + +__all__ = [ + "InversionResult", +] + +@dataclasses.dataclass +class InversionResult: + """ + The results of an inversion attempt. + """ + + solution: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] + """The reconstructed scene found by the inversion.""" + + success: bool + """A boolean flag indicating whether the inversion was successful.""" + + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] + """The observed images on which the inversion was performed.""" + + inverter: 'ctis.inverters.AbstractInverter' + """The inversion algorithm that produced these results.""" + + message: str + """Any message from the inversion routine concerning the results.""" From ce54305293d48d16b051a4a5fd7edb2997dccc12 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 13 Apr 2026 23:01:11 -0600 Subject: [PATCH 02/55] added tutorial --- docs/index.rst | 1 + docs/tutorials/simple-mart.ipynb | 397 +++++++++++++++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 docs/tutorials/simple-mart.ipynb diff --git a/docs/index.rst b/docs/index.rst index e6f55f6..0810a8e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -26,6 +26,7 @@ Examples on how to use this package. :maxdepth: 1 tutorials/ideal-instrument + tutorials/simple-mart API Reference ============= diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb new file mode 100644 index 0000000..8f8782f --- /dev/null +++ b/docs/tutorials/simple-mart.ipynb @@ -0,0 +1,397 @@ +{ + "cells": [ + { + "cell_type": "code", + "id": "initial_id", + "metadata": { + "collapsed": true + }, + "source": [ + "import IPython.display\n", + "import matplotlib.pyplot as plt\n", + "import astropy.units as u\n", + "import astropy.visualization\n", + "import named_arrays as na\n", + "import ctis" + ], + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "velocity = na.linspace(-500, 500, axis=\"wavelength\", num=21) * u.km / u.s", + "id": "96b07d0db60fc9f4", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "wavelength_rest = 171 * u.AA", + "id": "5f22170dad0b55c2", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest))", + "id": "f6325e63d4740db", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "wavelength = velocity.to(**AA)", + "id": "65958436c451bf32", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "position_scene = na.Cartesian2dVectorLinearSpace(\n", + " start=-10 * u.arcsec,\n", + " stop=10 * u.arcsec,\n", + " axis=na.Cartesian2dVectorArray(\"scene_x\", \"scene_y\"),\n", + " num=na.Cartesian2dVectorArray(64, 64),\n", + ")" + ], + "id": "6fcb9cfddabb0628", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "position_sensor = na.Cartesian2dVectorArray(\n", + " x=na.arange(0, 64, axis=\"sensor_x\") * u.pix,\n", + " y=na.arange(0, 64, axis=\"sensor_y\") * u.pix,\n", + ")" + ], + "id": "b132ed6fa4b6c117", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "coordinates_scene = na.SpectralPositionalVectorArray(velocity, position_scene)\n", + "coordinates_sensor = na.SpectralPositionalVectorArray(wavelength, position_sensor)" + ], + "id": "12e91e591d860293", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "scene = ctis.scenes.gaussians(\n", + " inputs=coordinates_scene,\n", + " width=na.SpectralPositionalVectorArray(30 * u.km / u.s, 1 * u.arcsec),\n", + ")\n", + "scene = scene + .1 * scene.outputs.unit" + ], + "id": "b464c80a6fd9ecc8", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "coordinates_scene.wavelength = wavelength", + "id": "9016e7f953afae85", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, axs = plt.subplots(\n", + " ncols=2,\n", + " gridspec_kw=dict(width_ratios=[.9,.1]),\n", + " constrained_layout=True,\n", + " )\n", + " ax, cax = axs\n", + " colorbar = na.plt.rgbmesh(\n", + " C=scene,\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax,\n", + " vmin=0,\n", + " vmax=scene.outputs.max(),\n", + " )\n", + " na.plt.pcolormesh(\n", + " C=colorbar,\n", + " axis_rgb=\"wavelength\",\n", + " ax=cax,\n", + " )\n", + " ax.set_xlabel(f\"scene $x$ ({ax.get_xlabel()})\")\n", + " ax.set_ylabel(f\"scene $y$ ({ax.get_ylabel()})\")\n", + " cax.xaxis.set_ticks_position(\"top\")\n", + " cax.xaxis.set_label_position(\"top\")\n", + " cax.yaxis.tick_right()\n", + " cax.yaxis.set_label_position(\"right\")" + ], + "id": "2ffca52f5d82e9d3", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots(constrained_layout=True)\n", + " ax2 = ax.twiny()\n", + " na.plt.stairs(\n", + " velocity,\n", + " scene.outputs.mean((\"scene_x\", \"scene_y\")),\n", + " ax=ax,\n", + " )\n", + " na.plt.stairs(\n", + " wavelength,\n", + " scene.outputs.mean(((\"scene_x\", \"scene_y\"))),\n", + " ax=ax2\n", + " )\n", + " ax.set_xlabel(f\"Doppler velocity ({ax.get_xlabel()})\")\n", + " ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", + " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")" + ], + "id": "2e38fbd7c706789b", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "instrument = ctis.instruments.IdealInstrument(\n", + " area_effective=1 * u.cm ** 2,\n", + " timedelta_exposure=10 * u.s,\n", + " plate_scale=.4 * u.arcsec / u.pix,\n", + " dispersion=((100 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", + " angle=na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg + 45 * u.deg,\n", + " wavelength_ref=wavelength_rest,\n", + " position_ref=32 * u.pix,\n", + " coordinates_scene=coordinates_scene,\n", + " coordinates_sensor=coordinates_sensor,\n", + " axis_channel=\"channel\",\n", + " axis_wavelength=\"wavelength\",\n", + " axis_scene_xy=(\"scene_x\", \"scene_y\"),\n", + " axis_sensor_xy=(\"sensor_x\", \"sensor_y\"),\n", + ")" + ], + "id": "1d180d7c99e6066", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "image = instrument.image(scene.outputs, integrate=False)", + "id": "4a4c2b86dd9e4e24", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, axs = plt.subplots(\n", + " ncols=2,\n", + " gridspec_kw=dict(width_ratios=[.9,.1]),\n", + " constrained_layout=True,\n", + " )\n", + " ax, cax = axs\n", + " label = \"dispersion angle = \" + instrument.angle.to_string_array(\"%03d\")\n", + " ani, colorbar = na.plt.rgbmovie(\n", + " label,\n", + " image.inputs.wavelength,\n", + " image.inputs.position.x,\n", + " image.inputs.position.y,\n", + " C=image.outputs,\n", + " axis_time=\"channel\",\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax,\n", + " vmin=0,\n", + " vmax=image.outputs.max(),\n", + " )\n", + " na.plt.pcolormesh(\n", + " C=colorbar,\n", + " axis_rgb=\"wavelength\",\n", + " ax=cax,\n", + " )\n", + " ax.set_xlabel(f\"sensor $x$ ({image.inputs.position.x.unit})\")\n", + " ax.set_ylabel(f\"sensor $y$ ({image.inputs.position.y.unit})\")\n", + " cax.xaxis.set_ticks_position(\"top\")\n", + " cax.xaxis.set_label_position(\"top\")\n", + " cax.yaxis.tick_right()\n", + " cax.yaxis.set_label_position(\"right\")\n", + "\n", + "result = ani.to_jshtml(fps=10)\n", + "result = IPython.display.HTML(result)\n", + "\n", + "plt.close(ani._fig)\n", + "\n", + "result" + ], + "id": "a0481390b90587ba", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "images = instrument.image(scene.outputs)", + "id": "c995a39acadf48fd", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "mart = ctis.inverters.MartInverter(\n", + " instrument=instrument,\n", + " intermediate=True,\n", + ")" + ], + "id": "c50e70069ed0d2b2", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "inversion = mart(\n", + " images=images.outputs,\n", + " guess=0 * scene.outputs + 1 * scene.outputs.unit,\n", + ")" + ], + "id": "294e65726b0bd9ff", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, axs = plt.subplots(\n", + " ncols=3,\n", + " gridspec_kw=dict(width_ratios=[.45, .45, .1]),\n", + " constrained_layout=True,\n", + " figsize=(10, 5),\n", + " )\n", + " ax1, ax2, cax = axs\n", + " ax2.sharex(ax1)\n", + " ax2.sharey(ax1)\n", + " na.plt.rgbmesh(\n", + " C=scene,\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax1,\n", + " vmin=0,\n", + " vmax=scene.outputs.max(),\n", + " )\n", + " ani, colorbar = na.plt.rgbmovie(\n", + " na.arange(0, inversion.num_iteration, axis=inversion.axis_intermediate),\n", + " scene.inputs.wavelength,\n", + " scene.inputs.position.x,\n", + " scene.inputs.position.y,\n", + " C=inversion.solution,\n", + " axis_time=inversion.axis_intermediate,\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax2,\n", + " vmin=0,\n", + " vmax=scene.outputs.max(),\n", + " )\n", + " na.plt.pcolormesh(\n", + " C=colorbar,\n", + " axis_rgb=\"wavelength\",\n", + " ax=cax,\n", + " )\n", + " ax.set_xlabel(f\"sensor $x$ ({image.inputs.position.x.unit})\")\n", + " ax.set_ylabel(f\"sensor $y$ ({image.inputs.position.y.unit})\")\n", + " cax.xaxis.set_ticks_position(\"top\")\n", + " cax.xaxis.set_label_position(\"top\")\n", + " cax.yaxis.tick_right()\n", + " cax.yaxis.set_label_position(\"right\")\n", + "\n", + "result = ani.to_jshtml(fps=10)\n", + "result = IPython.display.HTML(result)\n", + "\n", + "plt.close(ani._fig)\n", + "\n", + "result" + ], + "id": "fd60140f76d94f44", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots(constrained_layout=True)\n", + " ax2 = ax.twiny()\n", + " na.plt.stairs(\n", + " velocity,\n", + " scene.outputs.mean((\"scene_x\", \"scene_y\")),\n", + " ax=ax,\n", + " )\n", + " na.plt.stairs(\n", + " wavelength,\n", + " inversion.solution.mean(((\"scene_x\", \"scene_y\")))[{inversion.axis_intermediate: ~0}],\n", + " ax=ax2,\n", + " color=\"tab:orange\",\n", + " )\n", + " ax.set_xlabel(f\"Doppler velocity ({ax.get_xlabel()})\")\n", + " ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", + " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")" + ], + "id": "ff4fcadc4acc604e", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": "", + "id": "7d1acbeb9b52a630", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 56f20032230ca62d64f7428e88b977bbfcc76ca2 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 11:04:38 -0600 Subject: [PATCH 03/55] added merit to results --- ctis/inverters/_iterative/_iterative.py | 27 +++++++++++++++++++----- ctis/inverters/_iterative/_mart/_mart.py | 20 ++++++------------ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index 0a823b3..0d783a3 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -1,5 +1,7 @@ +from typing import ClassVar import abc import dataclasses +import named_arrays as na from .. import AbstractInverter, InversionResult __all__ = [ @@ -20,6 +22,9 @@ class AbstractIterativeInverter( convergence criteria is met. """ + axis_iteration: ClassVar[str] = "iteration" + """The logical axis associated with changing iteration index.""" + @property @abc.abstractmethod def num_iteration(self): @@ -37,11 +42,23 @@ class IterativeInversionResult( ): """The results of an iterative inversion attempt.""" + inverter: AbstractIterativeInverter + num_iteration: int """The number of iterations performed by the inverter.""" - axis_intermediate: None | str = None - """ - The logical axis representing potential intermediate results. - If :obj:`None` (the default), there are no intermediate results. - """ + merit: na.ScalarArray + """The value of the merit function for each iteration.""" + + merit_name: str + """Human-readable name of the merit function.""" + + @property + def iteration(self) -> na.ScalarArray: + """The iteration value for each iteration.""" + return na.arange( + start=0, + stop=self.num_iteration, + axis=self.inverter.axis_iteration, + ) + diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index 5577b41..c19f3f7 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -45,8 +45,6 @@ class MartInverter( a warning is raised and an unsuccessful result is returned. """ - axis_intermediate: ClassVar[str] = "_intermediate" - intermediate: bool = False """ Whether to save intermediate solutions. @@ -101,18 +99,15 @@ def __call__( chi_squared = self._mean_chi_squared(images, 0 * images.unit) - for i in range(self.num_iteration): + merit = [] - print(f"{i=}") + for i in range(self.num_iteration): images_new = instrument.image(scene, noise=False).outputs chi_squared_new = self._mean_chi_squared(images, images_new) - print(f"{chi_squared_new=}") - if (chi_squared - chi_squared_new) < 1e-2: - # if self._converged(images, images_new): message = "Achieved mean chi squared of less than 1." success = True @@ -142,9 +137,9 @@ def __call__( else: scene *= correction - chi_squared = chi_squared_new + merit.append(chi_squared_new) - print(f"{scene.sum().ndarray=}") + chi_squared = chi_squared_new else: message = f"Max number of iterations ({self.num_iteration}) exceeded." @@ -152,12 +147,10 @@ def __call__( success = False if self.intermediate: - intermediate = na.stack(intermediate, axis=self.axis_intermediate) + intermediate = na.stack(intermediate, axis=self.axis_iteration) solution = intermediate - axis_intermediate = self.axis_intermediate else: solution = scene - axis_intermediate = () return IterativeInversionResult( solution=solution, @@ -166,7 +159,8 @@ def __call__( inverter=self, message=message, num_iteration=i, - axis_intermediate=axis_intermediate, + merit=na.stack(merit, axis=self.axis_iteration), + merit_name=r"$\langle \chi^2 \rangle$", ) def _converged( From f97500019ae33398dd05509493e2888ad7f27040 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 11:21:12 -0600 Subject: [PATCH 04/55] Updates to the tutorial --- docs/tutorials/simple-mart.ipynb | 337 ++++++++++++++++++++++--------- 1 file changed, 236 insertions(+), 101 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index 8f8782f..f0bd419 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -1,11 +1,33 @@ { "cells": [ + { + "cell_type": "raw", + "id": "2ae5f78e-5e62-447b-b8a9-12f809d1d765", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Invert a Synthetic Scene with MART\n", + "==================================\n", + "In this tutorial, we'll use the Multiplicative Algebraic Reconstruction Technique (MART) :cite:p:`Gordon1970` to invert a synthetic scene composed of randomly-placed Gaussians." + ] + }, { "cell_type": "code", + "execution_count": null, "id": "initial_id", "metadata": { - "collapsed": true + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.195004400Z", + "start_time": "2026-04-14T16:59:50.708004Z" + } }, + "outputs": [], "source": [ "import IPython.display\n", "import matplotlib.pyplot as plt\n", @@ -13,45 +35,79 @@ "import astropy.visualization\n", "import named_arrays as na\n", "import ctis" - ], - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", - "source": "velocity = na.linspace(-500, 500, axis=\"wavelength\", num=21) * u.km / u.s", + "execution_count": null, "id": "96b07d0db60fc9f4", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.235004Z", + "start_time": "2026-04-14T16:59:52.198003900Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "velocity = na.linspace(-500, 500, axis=\"wavelength\", num=21) * u.km / u.s" + ] }, { - "metadata": {}, "cell_type": "code", - "source": "wavelength_rest = 171 * u.AA", + "execution_count": null, "id": "5f22170dad0b55c2", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.244504300Z", + "start_time": "2026-04-14T16:59:52.236003700Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "wavelength_rest = 171 * u.AA" + ] }, { - "metadata": {}, "cell_type": "code", - "source": "AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest))", + "execution_count": null, "id": "f6325e63d4740db", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.254004700Z", + "start_time": "2026-04-14T16:59:52.246004800Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest))" + ] }, { - "metadata": {}, "cell_type": "code", - "source": "wavelength = velocity.to(**AA)", + "execution_count": null, "id": "65958436c451bf32", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.262504200Z", + "start_time": "2026-04-14T16:59:52.255004600Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "wavelength = velocity.to(**AA)" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "6fcb9cfddabb0628", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.271504100Z", + "start_time": "2026-04-14T16:59:52.263503800Z" + } + }, + "outputs": [], "source": [ "position_scene = na.Cartesian2dVectorLinearSpace(\n", " start=-10 * u.arcsec,\n", @@ -59,60 +115,87 @@ " axis=na.Cartesian2dVectorArray(\"scene_x\", \"scene_y\"),\n", " num=na.Cartesian2dVectorArray(64, 64),\n", ")" - ], - "id": "6fcb9cfddabb0628", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "b132ed6fa4b6c117", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.717505900Z", + "start_time": "2026-04-14T16:59:52.272503900Z" + } + }, + "outputs": [], "source": [ "position_sensor = na.Cartesian2dVectorArray(\n", - " x=na.arange(0, 64, axis=\"sensor_x\") * u.pix,\n", + " x=na.arange(0, 128, axis=\"sensor_x\") * u.pix,\n", " y=na.arange(0, 64, axis=\"sensor_y\") * u.pix,\n", ")" - ], - "id": "b132ed6fa4b6c117", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "12e91e591d860293", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.755004300Z", + "start_time": "2026-04-14T16:59:52.719504200Z" + } + }, + "outputs": [], "source": [ "coordinates_scene = na.SpectralPositionalVectorArray(velocity, position_scene)\n", "coordinates_sensor = na.SpectralPositionalVectorArray(wavelength, position_sensor)" - ], - "id": "12e91e591d860293", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "b464c80a6fd9ecc8", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.792005400Z", + "start_time": "2026-04-14T16:59:52.756005300Z" + } + }, + "outputs": [], "source": [ "scene = ctis.scenes.gaussians(\n", " inputs=coordinates_scene,\n", " width=na.SpectralPositionalVectorArray(30 * u.km / u.s, 1 * u.arcsec),\n", ")\n", - "scene = scene + .1 * scene.outputs.unit" - ], - "id": "b464c80a6fd9ecc8", - "outputs": [], - "execution_count": null + "scene = scene + scene.outputs.max() / 100" + ] }, { - "metadata": {}, "cell_type": "code", - "source": "coordinates_scene.wavelength = wavelength", + "execution_count": null, "id": "9016e7f953afae85", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:52.802005800Z", + "start_time": "2026-04-14T16:59:52.793504100Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "coordinates_scene.wavelength = wavelength" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "2ffca52f5d82e9d3", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:53.284503900Z", + "start_time": "2026-04-14T16:59:52.803504Z" + } + }, + "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, axs = plt.subplots(\n", @@ -139,14 +222,19 @@ " cax.xaxis.set_label_position(\"top\")\n", " cax.yaxis.tick_right()\n", " cax.yaxis.set_label_position(\"right\")" - ], - "id": "2ffca52f5d82e9d3", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "2e38fbd7c706789b", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:53.544004700Z", + "start_time": "2026-04-14T16:59:53.286504500Z" + } + }, + "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, ax = plt.subplots(constrained_layout=True)\n", @@ -164,23 +252,28 @@ " ax.set_xlabel(f\"Doppler velocity ({ax.get_xlabel()})\")\n", " ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")" - ], - "id": "2e38fbd7c706789b", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "1d180d7c99e6066", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:53.587505100Z", + "start_time": "2026-04-14T16:59:53.546005700Z" + } + }, + "outputs": [], "source": [ "instrument = ctis.instruments.IdealInstrument(\n", " area_effective=1 * u.cm ** 2,\n", - " timedelta_exposure=10 * u.s,\n", + " timedelta_exposure=20 * u.s,\n", " plate_scale=.4 * u.arcsec / u.pix,\n", - " dispersion=((100 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", - " angle=na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg + 45 * u.deg,\n", + " dispersion=((10 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", + " angle=na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg,\n", " wavelength_ref=wavelength_rest,\n", - " position_ref=32 * u.pix,\n", + " position_ref=na.Cartesian2dVectorArray(64, 32) * u.pix,\n", " coordinates_scene=coordinates_scene,\n", " coordinates_sensor=coordinates_sensor,\n", " axis_channel=\"channel\",\n", @@ -188,28 +281,41 @@ " axis_scene_xy=(\"scene_x\", \"scene_y\"),\n", " axis_sensor_xy=(\"sensor_x\", \"sensor_y\"),\n", ")" - ], - "id": "1d180d7c99e6066", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", - "source": "image = instrument.image(scene.outputs, integrate=False)", + "execution_count": null, "id": "4a4c2b86dd9e4e24", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:56.490502900Z", + "start_time": "2026-04-14T16:59:53.593004400Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "image = instrument.image(scene.outputs, integrate=False)" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "a0481390b90587ba", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:57.633004600Z", + "start_time": "2026-04-14T16:59:56.559002900Z" + } + }, + "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, axs = plt.subplots(\n", " ncols=2,\n", " gridspec_kw=dict(width_ratios=[.9,.1]),\n", " constrained_layout=True,\n", + " figsize=(9, 4),\n", " )\n", " ax, cax = axs\n", " label = \"dispersion angle = \" + instrument.angle.to_string_array(\"%03d\")\n", @@ -230,6 +336,7 @@ " axis_rgb=\"wavelength\",\n", " ax=cax,\n", " )\n", + " ax.set_aspect(\"equal\")\n", " ax.set_xlabel(f\"sensor $x$ ({image.inputs.position.x.unit})\")\n", " ax.set_ylabel(f\"sensor $y$ ({image.inputs.position.y.unit})\")\n", " cax.xaxis.set_ticks_position(\"top\")\n", @@ -243,48 +350,70 @@ "plt.close(ani._fig)\n", "\n", "result" - ], - "id": "a0481390b90587ba", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", - "source": "images = instrument.image(scene.outputs)", + "execution_count": null, "id": "c995a39acadf48fd", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:57.757004300Z", + "start_time": "2026-04-14T16:59:57.691004100Z" + } + }, "outputs": [], - "execution_count": null + "source": [ + "images = instrument.image(scene.outputs)" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "c50e70069ed0d2b2", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T16:59:57.816504800Z", + "start_time": "2026-04-14T16:59:57.789004100Z" + } + }, + "outputs": [], "source": [ "mart = ctis.inverters.MartInverter(\n", " instrument=instrument,\n", " intermediate=True,\n", ")" - ], - "id": "c50e70069ed0d2b2", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "294e65726b0bd9ff", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T17:00:01.134002900Z", + "start_time": "2026-04-14T16:59:57.819504400Z" + } + }, + "outputs": [], "source": [ "inversion = mart(\n", " images=images.outputs,\n", " guess=0 * scene.outputs + 1 * scene.outputs.unit,\n", ")" - ], - "id": "294e65726b0bd9ff", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "fd60140f76d94f44", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T17:01:34.810625800Z", + "start_time": "2026-04-14T17:01:24.085626800Z" + } + }, + "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, axs = plt.subplots(\n", @@ -303,13 +432,15 @@ " vmin=0,\n", " vmax=scene.outputs.max(),\n", " )\n", + " label = \"interation = \" + inversion.iteration.to_string_array(\"%d\")\n", + " label = label + f\"\\n{inversion.merit_name} = \" + inversion.merit.to_string_array()\n", " ani, colorbar = na.plt.rgbmovie(\n", - " na.arange(0, inversion.num_iteration, axis=inversion.axis_intermediate),\n", + " label,\n", " scene.inputs.wavelength,\n", " scene.inputs.position.x,\n", " scene.inputs.position.y,\n", " C=inversion.solution,\n", - " axis_time=inversion.axis_intermediate,\n", + " axis_time=inversion.inverter.axis_iteration,\n", " axis_wavelength=\"wavelength\",\n", " ax=ax2,\n", " vmin=0,\n", @@ -330,17 +461,24 @@ "result = ani.to_jshtml(fps=10)\n", "result = IPython.display.HTML(result)\n", "\n", + "ani.save(\"mart.gif\")\n", + "\n", "plt.close(ani._fig)\n", "\n", "result" - ], - "id": "fd60140f76d94f44", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "ff4fcadc4acc604e", + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-14T17:02:21.957753900Z", + "start_time": "2026-04-14T17:02:21.699754800Z" + } + }, + "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, ax = plt.subplots(constrained_layout=True)\n", @@ -352,44 +490,41 @@ " )\n", " na.plt.stairs(\n", " wavelength,\n", - " inversion.solution.mean(((\"scene_x\", \"scene_y\")))[{inversion.axis_intermediate: ~0}],\n", + " inversion.solution.mean(((\"scene_x\", \"scene_y\")))[{inversion.inverter.axis_iteration: ~0}],\n", " ax=ax2,\n", " color=\"tab:orange\",\n", " )\n", " ax.set_xlabel(f\"Doppler velocity ({ax.get_xlabel()})\")\n", " ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")" - ], - "id": "ff4fcadc4acc604e", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", - "source": "", + "execution_count": null, "id": "7d1acbeb9b52a630", + "metadata": {}, "outputs": [], - "execution_count": null + "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", - "version": 2 + "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.6" + "pygments_lexer": "ipython3", + "version": "3.13.3" } }, "nbformat": 4, From f44513d6f9055339ee22e68c451322bf4f1bb818 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 11:54:08 -0600 Subject: [PATCH 05/55] references --- docs/refs.bib | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/refs.bib b/docs/refs.bib index 2a23954..60ec505 100644 --- a/docs/refs.bib +++ b/docs/refs.bib @@ -36,5 +36,17 @@ @ARTICLE{Bertero2005 adsurl = {https://ui.adsabs.harvard.edu/abs/2005A&A...437..369B}, adsnote = {Provided by the SAO/NASA Astrophysics Data System} } - +@article{Gordon1970, + title = {Algebraic Reconstruction Techniques (ART) for three-dimensional electron microscopy and X-ray photography}, + journal = {Journal of Theoretical Biology}, + volume = {29}, + number = {3}, + pages = {471-481}, + year = {1970}, + issn = {0022-5193}, + doi = {https://doi.org/10.1016/0022-5193(70)90109-8}, + url = {https://www.sciencedirect.com/science/article/pii/0022519370901098}, + author = {Richard Gordon and Robert Bender and Gabor T. Herman}, + abstract = {We give a new method for direct reconstruction of three-dimensional objects from a few electron micrographs taken at angles which need not exceed a range of 60 degrees. The method works for totally asymmetric objects, and requires little computer time or storage. It is also applicable to X-ray photography, and may greatly reduce the exposure compared to current methods of body-section radiography.} +} From 995a310481b72eab18d085df7043ad8a9f760081 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 12:13:42 -0600 Subject: [PATCH 06/55] adding description to the tutorial --- docs/tutorials/simple-mart.ipynb | 184 ++++++++++++++++++++++++++++--- 1 file changed, 170 insertions(+), 14 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index f0bd419..3933ab3 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -14,7 +14,8 @@ "source": [ "Invert a Synthetic Scene with MART\n", "==================================\n", - "In this tutorial, we'll use the Multiplicative Algebraic Reconstruction Technique (MART) :cite:p:`Gordon1970` to invert a synthetic scene composed of randomly-placed Gaussians." + "In this tutorial, we'll use the Multiplicative Algebraic Reconstruction Technique (MART) :cite:p:`Gordon1970` to invert the :func:`~ctis.scenes.gaussians` sample scene developed by Amy R. Winebarger.\n", + "We will use a simple instrument with four projections to image this scene and then use the MART algorithm to reconstruct the original scene." ] }, { @@ -25,7 +26,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.195004400Z", "start_time": "2026-04-14T16:59:50.708004Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ @@ -37,6 +43,21 @@ "import ctis" ] }, + { + "cell_type": "raw", + "id": "c5e6a6a5-cc98-4d40-a367-fd0273a39691", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Start by defining a grid of Doppler velocities on which to reconstruct the scene." + ] + }, { "cell_type": "code", "execution_count": null, @@ -45,13 +66,33 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.235004Z", "start_time": "2026-04-14T16:59:52.198003900Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "velocity = na.linspace(-500, 500, axis=\"wavelength\", num=21) * u.km / u.s" ] }, + { + "cell_type": "raw", + "id": "b3639b74-86f0-45b5-a92b-6fe04f27e387", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Define the rest wavelength for converting between velocity and wavelength." + ] + }, { "cell_type": "code", "execution_count": null, @@ -60,13 +101,33 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.244504300Z", "start_time": "2026-04-14T16:59:52.236003700Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "wavelength_rest = 171 * u.AA" ] }, + { + "cell_type": "raw", + "id": "264f4e26-10b8-4309-bf25-4c47d77ba0d7", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Define a :mod:`astropy.units` equivalency for converting from Doppler velocity to wavelength." + ] + }, { "cell_type": "code", "execution_count": null, @@ -75,13 +136,33 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.254004700Z", "start_time": "2026-04-14T16:59:52.246004800Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest))" ] }, + { + "cell_type": "raw", + "id": "7a689dc1-675e-4031-8715-c07f22b5d6e9", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Convert the grid of velocities to wavelength units using our equivalency." + ] + }, { "cell_type": "code", "execution_count": null, @@ -90,13 +171,33 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.262504200Z", "start_time": "2026-04-14T16:59:52.255004600Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "wavelength = velocity.to(**AA)" ] }, + { + "cell_type": "raw", + "id": "e2e64b17-f21f-460d-9b41-33eff9f7a839", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Now define a grid of positions on which to reconstruct the scene," + ] + }, { "cell_type": "code", "execution_count": null, @@ -105,7 +206,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.271504100Z", "start_time": "2026-04-14T16:59:52.263503800Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ @@ -117,6 +223,21 @@ ")" ] }, + { + "cell_type": "raw", + "id": "9dc396e0-b38f-41aa-8819-580d94f86004", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "and a grid of positions on the sensor representing the vertices of each pixel." + ] + }, { "cell_type": "code", "execution_count": null, @@ -125,16 +246,36 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.717505900Z", "start_time": "2026-04-14T16:59:52.272503900Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "position_sensor = na.Cartesian2dVectorArray(\n", - " x=na.arange(0, 128, axis=\"sensor_x\") * u.pix,\n", - " y=na.arange(0, 64, axis=\"sensor_y\") * u.pix,\n", + " x=na.arange(0, 128 + 1, axis=\"sensor_x\") * u.pix,\n", + " y=na.arange(0, 64 + 1, axis=\"sensor_y\") * u.pix,\n", ")" ] }, + { + "cell_type": "raw", + "id": "3d892920-c0ae-4440-ac30-a9b3b5b30d53", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Combine the 1D velocity grid and the 2D position grid into a single 3D grid" + ] + }, { "cell_type": "code", "execution_count": null, @@ -143,12 +284,17 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.755004300Z", "start_time": "2026-04-14T16:59:52.719504200Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "coordinates_scene = na.SpectralPositionalVectorArray(velocity, position_scene)\n", - "coordinates_sensor = na.SpectralPositionalVectorArray(wavelength, position_sensor)" + "coordinates_sensor = na.SpectralPositionalVectorArray(velocity, position_sensor)" ] }, { @@ -159,7 +305,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.792005400Z", "start_time": "2026-04-14T16:59:52.756005300Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ @@ -178,7 +329,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:52.802005800Z", "start_time": "2026-04-14T16:59:52.793504100Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ From 4e1ca501b59df0662b6cacceb354cd2d732a2e40 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 15:03:49 -0600 Subject: [PATCH 07/55] More updates to tutorial --- docs/tutorials/simple-mart.ipynb | 67 ++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index 3933ab3..34ecb1d 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -273,7 +273,7 @@ "tags": [] }, "source": [ - "Combine the 1D velocity grid and the 2D position grid into a single 3D grid" + "Combine the 1D velocity grid and the 2D position grid into a single 3D grid for both the scene and sensor coordinates." ] }, { @@ -297,6 +297,21 @@ "coordinates_sensor = na.SpectralPositionalVectorArray(velocity, position_sensor)" ] }, + { + "cell_type": "raw", + "id": "3a9c7297-2fb9-4b54-b75a-f01ddfbe3bf0", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Create a synthetic scene composed of spatial/spectral 3D Gaussians with various Doppler shifts." + ] + }, { "cell_type": "code", "execution_count": null, @@ -317,10 +332,55 @@ "scene = ctis.scenes.gaussians(\n", " inputs=coordinates_scene,\n", " width=na.SpectralPositionalVectorArray(30 * u.km / u.s, 1 * u.arcsec),\n", - ")\n", + ")" + ] + }, + { + "cell_type": "raw", + "id": "b6c185a0-59bf-40e2-ba01-76a2a4cf40ef", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Add a small background equal to 1 percent of the maximum value of the scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4c1d896a-2723-4b01-8f74-481882b659ed", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ "scene = scene + scene.outputs.max() / 100" ] }, + { + "cell_type": "raw", + "id": "05a06503-35d8-483e-8ead-b599d8251c82", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Modify the 3D coordinates to use wavelength units to be compatible with the instrument forward model." + ] + }, { "cell_type": "code", "execution_count": null, @@ -338,7 +398,8 @@ }, "outputs": [], "source": [ - "coordinates_scene.wavelength = wavelength" + "coordinates_scene.wavelength = wavelength\n", + "coordinates_sensor.wavelength = wavelength" ] }, { From a05f43c637ac3587ab2509e9496c3fede67a4fa3 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 17:28:18 -0600 Subject: [PATCH 08/55] More updates to tutorial --- docs/tutorials/simple-mart.ipynb | 398 +++++++++++++++++++++++++------ 1 file changed, 327 insertions(+), 71 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index 34ecb1d..eace90f 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -402,6 +402,21 @@ "coordinates_sensor.wavelength = wavelength" ] }, + { + "cell_type": "raw", + "id": "6456f672-17e2-429a-9329-ed4b9f5873d0", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Display the scene as a false-color image." + ] + }, { "cell_type": "code", "execution_count": null, @@ -410,7 +425,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:53.284503900Z", "start_time": "2026-04-14T16:59:52.803504Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ @@ -433,6 +453,7 @@ " axis_rgb=\"wavelength\",\n", " ax=cax,\n", " )\n", + " ax.set_aspect(\"equal\")\n", " ax.set_xlabel(f\"scene $x$ ({ax.get_xlabel()})\")\n", " ax.set_ylabel(f\"scene $y$ ({ax.get_ylabel()})\")\n", " cax.xaxis.set_ticks_position(\"top\")\n", @@ -441,6 +462,52 @@ " cax.yaxis.set_label_position(\"right\")" ] }, + { + "cell_type": "raw", + "id": "a60b5a77-f347-43c7-a962-6dd66eceb0ed", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Compute the average spectrum of the scene" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54993dd9-75c5-4c6f-897c-390006e5816e", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "spectrum = scene.outputs.mean((\"scene_x\", \"scene_y\"))" + ] + }, + { + "cell_type": "raw", + "id": "0622e31c-4977-4343-96d0-88020288c77d", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Plot the average spectrum of the scene." + ] + }, { "cell_type": "code", "execution_count": null, @@ -449,7 +516,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:53.544004700Z", "start_time": "2026-04-14T16:59:53.286504500Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ @@ -458,12 +530,12 @@ " ax2 = ax.twiny()\n", " na.plt.stairs(\n", " velocity,\n", - " scene.outputs.mean((\"scene_x\", \"scene_y\")),\n", + " spectrum,\n", " ax=ax,\n", " )\n", " na.plt.stairs(\n", " wavelength,\n", - " scene.outputs.mean(((\"scene_x\", \"scene_y\"))),\n", + " spectrum,\n", " ax=ax2\n", " )\n", " ax.set_xlabel(f\"Doppler velocity ({ax.get_xlabel()})\")\n", @@ -471,6 +543,21 @@ " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")" ] }, + { + "cell_type": "raw", + "id": "6ffc256c-1813-442d-b343-8e9c187cdca8", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Define an ideal CTIS instrument with four projections, each separated by :math:`90^\\circ` degrees." + ] + }, { "cell_type": "code", "execution_count": null, @@ -479,7 +566,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:53.587505100Z", "start_time": "2026-04-14T16:59:53.546005700Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ @@ -500,6 +592,21 @@ ")" ] }, + { + "cell_type": "raw", + "id": "0a9c5297-896e-410a-acf5-9fde425a952e", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Apply the forward model of this instrument to the scene to calculate the observed images." + ] + }, { "cell_type": "code", "execution_count": null, @@ -508,11 +615,31 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:56.490502900Z", "start_time": "2026-04-14T16:59:53.593004400Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ - "image = instrument.image(scene.outputs, integrate=False)" + "images = instrument.image(scene.outputs)" + ] + }, + { + "cell_type": "raw", + "id": "63acbf81-4b74-4a5c-b4fc-5cce6653820c", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Display the images as an animation, where each frame represents a different channel / dispersion direction." ] }, { @@ -523,45 +650,50 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:57.633004600Z", "start_time": "2026-04-14T16:59:56.559002900Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", - " fig, axs = plt.subplots(\n", - " ncols=2,\n", - " gridspec_kw=dict(width_ratios=[.9,.1]),\n", + " fig, ax = plt.subplots(\n", " constrained_layout=True,\n", - " figsize=(9, 4),\n", + " figsize=(9.2, 4),\n", + " )\n", + " norm = plt.Normalize(\n", + " vmin=0,\n", + " vmax=images.outputs.value.ndarray.max(),\n", + " )\n", + " colorizer = plt.Colorizer(\n", + " cmap=\"gray\",\n", + " norm=norm,\n", " )\n", - " ax, cax = axs\n", " label = \"dispersion angle = \" + instrument.angle.to_string_array(\"%03d\")\n", - " ani, colorbar = na.plt.rgbmovie(\n", + " ani = na.plt.pcolormovie(\n", " label,\n", - " image.inputs.wavelength,\n", - " image.inputs.position.x,\n", - " image.inputs.position.y,\n", - " C=image.outputs,\n", + " images.inputs.position.x,\n", + " images.inputs.position.y,\n", + " C=images.outputs.value,\n", " axis_time=\"channel\",\n", - " axis_wavelength=\"wavelength\",\n", " ax=ax,\n", - " vmin=0,\n", - " vmax=image.outputs.max(),\n", + " kwargs_pcolormesh=dict(\n", + " colorizer=colorizer,\n", + " ),\n", " )\n", - " na.plt.pcolormesh(\n", - " C=colorbar,\n", - " axis_rgb=\"wavelength\",\n", - " ax=cax,\n", + " plt.colorbar(\n", + " mappable=plt.cm.ScalarMappable(colorizer=colorizer),\n", + " ax=ax,\n", + " label=f\"signal ({images.outputs.unit:latex_inline})\",\n", " )\n", " ax.set_aspect(\"equal\")\n", - " ax.set_xlabel(f\"sensor $x$ ({image.inputs.position.x.unit})\")\n", - " ax.set_ylabel(f\"sensor $y$ ({image.inputs.position.y.unit})\")\n", - " cax.xaxis.set_ticks_position(\"top\")\n", - " cax.xaxis.set_label_position(\"top\")\n", - " cax.yaxis.tick_right()\n", - " cax.yaxis.set_label_position(\"right\")\n", + " ax.set_xlabel(f\"sensor $x$ ({images.inputs.position.x.unit})\")\n", + " ax.set_ylabel(f\"sensor $y$ ({images.inputs.position.y.unit})\")\n", "\n", - "result = ani.to_jshtml(fps=10)\n", + "result = ani.to_jshtml(fps=2)\n", "result = IPython.display.HTML(result)\n", "\n", "plt.close(ani._fig)\n", @@ -570,18 +702,18 @@ ] }, { - "cell_type": "code", - "execution_count": null, - "id": "c995a39acadf48fd", + "cell_type": "raw", + "id": "14512eb5-c284-4022-a15f-a1df9f3a21b2", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:57.757004300Z", - "start_time": "2026-04-14T16:59:57.691004100Z" - } + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] }, - "outputs": [], "source": [ - "images = instrument.image(scene.outputs)" + "Initialize the MART inversion algorithm with the instrument model. We'll also enable saving intermediate results so that we can visualize the behavior of the algorithm." ] }, { @@ -592,7 +724,12 @@ "ExecuteTime": { "end_time": "2026-04-14T16:59:57.816504800Z", "start_time": "2026-04-14T16:59:57.789004100Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ @@ -602,6 +739,53 @@ ")" ] }, + { + "cell_type": "raw", + "id": "2eb1e79a-5d2b-4b1b-a03d-e1d3988c8e1e", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Make an initial guess for the solution.\n", + "In this case we will choose all ones for demonstration purposes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bfedd342-48cb-4792-9db5-bd7f5059e3da", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "guess = 0 * scene.outputs + 1 * scene.outputs.unit" + ] + }, + { + "cell_type": "raw", + "id": "c9c8d562-dd76-4cfe-8122-d74f22e4e5a4", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Invert the images using our instance of MART and the initial guess." + ] + }, { "cell_type": "code", "execution_count": null, @@ -610,16 +794,36 @@ "ExecuteTime": { "end_time": "2026-04-14T17:00:01.134002900Z", "start_time": "2026-04-14T16:59:57.819504400Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "inversion = mart(\n", " images=images.outputs,\n", - " guess=0 * scene.outputs + 1 * scene.outputs.unit,\n", + " guess=guess,\n", ")" ] }, + { + "cell_type": "raw", + "id": "870e9e44-a288-429b-a4ed-f97dba9a3db8", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Display the results as a false-color movie, where each frame represents subsequent iterations of the MART algorithm." + ] + }, { "cell_type": "code", "execution_count": null, @@ -628,20 +832,24 @@ "ExecuteTime": { "end_time": "2026-04-14T17:01:34.810625800Z", "start_time": "2026-04-14T17:01:24.085626800Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, axs = plt.subplots(\n", " ncols=3,\n", - " gridspec_kw=dict(width_ratios=[.45, .45, .1]),\n", + " gridspec_kw=dict(width_ratios=[.5, .5, .1]),\n", " constrained_layout=True,\n", - " figsize=(10, 5),\n", + " figsize=(10, 4.5),\n", " )\n", " ax1, ax2, cax = axs\n", - " ax2.sharex(ax1)\n", - " ax2.sharey(ax1)\n", + " ax2.set_yticklabels([])\n", " na.plt.rgbmesh(\n", " C=scene,\n", " axis_wavelength=\"wavelength\",\n", @@ -649,7 +857,7 @@ " vmin=0,\n", " vmax=scene.outputs.max(),\n", " )\n", - " label = \"interation = \" + inversion.iteration.to_string_array(\"%d\")\n", + " label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\")\n", " label = label + f\"\\n{inversion.merit_name} = \" + inversion.merit.to_string_array()\n", " ani, colorbar = na.plt.rgbmovie(\n", " label,\n", @@ -668,8 +876,13 @@ " axis_rgb=\"wavelength\",\n", " ax=cax,\n", " )\n", - " ax.set_xlabel(f\"sensor $x$ ({image.inputs.position.x.unit})\")\n", - " ax.set_ylabel(f\"sensor $y$ ({image.inputs.position.y.unit})\")\n", + " ax1.set_title(\"original\")\n", + " ax2.set_title(\"reconstructed\")\n", + " unit_x = scene.inputs.position.x.unit\n", + " unit_y = scene.inputs.position.y.unit\n", + " ax1.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + " ax2.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + " ax1.set_ylabel(f\"scene $y$ ({unit_y:latex_inline})\")\n", " cax.xaxis.set_ticks_position(\"top\")\n", " cax.xaxis.set_label_position(\"top\")\n", " cax.yaxis.tick_right()\n", @@ -678,13 +891,58 @@ "result = ani.to_jshtml(fps=10)\n", "result = IPython.display.HTML(result)\n", "\n", - "ani.save(\"mart.gif\")\n", - "\n", "plt.close(ani._fig)\n", "\n", "result" ] }, + { + "cell_type": "raw", + "id": "a2c7772a-631c-4f7f-a292-21b827b8fe3b", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Compute the average spectrum of the reconstructed scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2431aef0-c129-4502-8527-588e6d811a48", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "spectrum_inverted = inversion.solution.mean(((\"scene_x\", \"scene_y\")))\n", + "spectrum_inverted = spectrum_inverted[{inversion.inverter.axis_iteration: -1}]" + ] + }, + { + "cell_type": "raw", + "id": "7a4d8632-f903-4071-9377-5728f77d2dda", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Plot the average spectrum of the original scene vs. the average spectrum of the reconstructed scene." + ] + }, { "cell_type": "code", "execution_count": null, @@ -693,36 +951,34 @@ "ExecuteTime": { "end_time": "2026-04-14T17:02:21.957753900Z", "start_time": "2026-04-14T17:02:21.699754800Z" - } + }, + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] }, "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, ax = plt.subplots(constrained_layout=True)\n", - " ax2 = ax.twiny()\n", " na.plt.stairs(\n", - " velocity,\n", - " scene.outputs.mean((\"scene_x\", \"scene_y\")),\n", + " wavelength,\n", + " spectrum,\n", " ax=ax,\n", + " label=\"original\",\n", " )\n", " na.plt.stairs(\n", " wavelength,\n", - " inversion.solution.mean(((\"scene_x\", \"scene_y\")))[{inversion.inverter.axis_iteration: ~0}],\n", - " ax=ax2,\n", - " color=\"tab:orange\",\n", + " spectrum_inverted,\n", + " ax=ax,\n", + " label=\"reconstructed\",\n", " )\n", - " ax.set_xlabel(f\"Doppler velocity ({ax.get_xlabel()})\")\n", + " ax.set_xlabel(f\"wavelength ({ax.get_xlabel()})\")\n", " ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", - " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")" + " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")\n", + " ax.legend()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7d1acbeb9b52a630", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { From be8cb5ccb9d426d1992f4653517a0c07ce0112dd Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 19:37:56 -0600 Subject: [PATCH 09/55] black --- ctis/inverters/_iterative/_mart/_mart.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index c19f3f7..f1ab736 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -11,6 +11,7 @@ "MartInverter", ] + @dataclasses.dataclass class MartInverter( AbstractIterativeInverter, @@ -108,7 +109,7 @@ def __call__( chi_squared_new = self._mean_chi_squared(images, images_new) if (chi_squared - chi_squared_new) < 1e-2: - # if self._converged(images, images_new): + # if self._converged(images, images_new): message = "Achieved mean chi squared of less than 1." success = True break @@ -124,7 +125,7 @@ def __call__( neginf=1, ) - correction = correction ** gamma + correction = correction**gamma correction = np.prod(correction, axis=instrument.axis_channel) @@ -173,7 +174,7 @@ def _converged( """ X2 = self._mean_chi_squared(images, images_new) print(f"{X2=}") - return X2 < 1/2 + return X2 < 1 / 2 def _mean_chi_squared( self, @@ -186,6 +187,6 @@ def _mean_chi_squared( uncertainty = self.instrument.uncertainty(images_new) - uncertainty = np.maximum(uncertainty, 1 * u.photon) + uncertainty = np.maximum(uncertainty, 1 * u.photon) return np.mean(np.square((images_new - images) / uncertainty)) From ff852a5fa49abbd0cc74a11f3a69714f231424dc Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 19:39:47 -0600 Subject: [PATCH 10/55] ruff --- ctis/inverters/_iterative/_mart/_mart.py | 2 -- ctis/inverters/_results.py | 1 - 2 files changed, 3 deletions(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index f1ab736..878557e 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -1,4 +1,3 @@ -from typing import ClassVar import warnings import dataclasses import numpy as np @@ -173,7 +172,6 @@ def _converged( Return true if :math:`\langle \chi^2 \rangle < 1` """ X2 = self._mean_chi_squared(images, images_new) - print(f"{X2=}") return X2 < 1 / 2 def _mean_chi_squared( diff --git a/ctis/inverters/_results.py b/ctis/inverters/_results.py index 78c8ce6..56ffab9 100644 --- a/ctis/inverters/_results.py +++ b/ctis/inverters/_results.py @@ -1,4 +1,3 @@ -import abc import dataclasses import named_arrays as na import ctis From cfd4ede286f72f5deb5e67b6c2e0b3e4f7a6a97c Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 21:02:35 -0600 Subject: [PATCH 11/55] default guess --- ctis/inverters/_iterative/_mart/_mart.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index 878557e..204f176 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -83,10 +83,15 @@ def __call__( attribute of :attr:`instrument`. """ - scene = guess.copy() - instrument = self.instrument + if guess is None: + scene = instrument.backproject(images).outputs + scene = scene.mean(axis=instrument.axis_channel) + scene.ndarray[:] = scene.ndarray.mean() + else: + scene = guess.copy() + num_channel = instrument.num_channel gamma = self._gamma From 0995514a688ca9153e650fdf330558b3d7a881b0 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 21:33:07 -0600 Subject: [PATCH 12/55] added testing infrastructure --- ctis/inverters/_inverters_test.py | 26 ++++++++ ctis/inverters/_iterative/_iterative.py | 2 +- ctis/inverters/_iterative/_iterative_test.py | 12 ++++ ctis/inverters/_iterative/_mart/_mart_test.py | 59 +++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 ctis/inverters/_inverters_test.py create mode 100644 ctis/inverters/_iterative/_iterative_test.py create mode 100644 ctis/inverters/_iterative/_mart/_mart_test.py diff --git a/ctis/inverters/_inverters_test.py b/ctis/inverters/_inverters_test.py new file mode 100644 index 0000000..c475dad --- /dev/null +++ b/ctis/inverters/_inverters_test.py @@ -0,0 +1,26 @@ +import abc +import numpy as np +import named_arrays as na +import ctis + + +class AbstractTestAbstractInverter( + abc.ABC, +): + + def test_instrument(self, a: ctis.inverters.AbstractInverter): + result = a.instrument + assert isinstance(result, ctis.instruments.AbstractInstrument) + + def test__call__( + self, + a: ctis.inverters.AbstractInverter, + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], + ): + result = a(images) + + assert isinstance(result, ctis.inverters.InversionResult) + + images_new = a.instrument.image(result.solution) + + assert np.isclose(images, images_new) diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index 0d783a3..b6d7eab 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -27,7 +27,7 @@ class AbstractIterativeInverter( @property @abc.abstractmethod - def num_iteration(self): + def num_iteration(self) -> int: """ The maximum number of iterations to perform. diff --git a/ctis/inverters/_iterative/_iterative_test.py b/ctis/inverters/_iterative/_iterative_test.py new file mode 100644 index 0000000..4a4f83e --- /dev/null +++ b/ctis/inverters/_iterative/_iterative_test.py @@ -0,0 +1,12 @@ +import ctis +from .._inverters_test import AbstractTestAbstractInverter + + +class AbstractTestAbstractIterativeInverter( + AbstractTestAbstractInverter, +): + + def test_num_iteration(self, a: ctis.inverters.AbstractIterativeInverter): + result = a.num_iteration + assert isinstance(result, int) + assert result > 0 diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py new file mode 100644 index 0000000..0b3248d --- /dev/null +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -0,0 +1,59 @@ +import pytest +import astropy.units as u +import named_arrays as na +import ctis +from .._iterative_test import AbstractTestAbstractIterativeInverter + +velocity = na.linspace(-500, 500, axis="wavelength", num=21) * u.km / u.s + +wavelength_rest = 171 * u.AA + +AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest)) + +wavelength = velocity.to(**AA) + +position_scene = na.Cartesian2dVectorLinearSpace( + start=-10 * u.arcsec, + stop=10 * u.arcsec, + axis=na.Cartesian2dVectorArray("scene_x", "scene_y"), + num=na.Cartesian2dVectorArray(64, 64), +) + +position_sensor = na.Cartesian2dVectorArray( + x=na.arange(0, 128 + 1, axis="sensor_x") * u.pix, + y=na.arange(0, 64 + 1, axis="sensor_y") * u.pix, +) + +coordinates_scene = na.SpectralPositionalVectorArray(velocity, position_scene) +coordinates_sensor = na.SpectralPositionalVectorArray(velocity, position_sensor) + +scene = ctis.scenes.gaussians( + inputs=coordinates_scene, + width=na.SpectralPositionalVectorArray(30 * u.km / u.s, 1 * u.arcsec), +) + +coordinates_scene.wavelength = wavelength +coordinates_sensor.wavelength = wavelength + +instrument = ctis.instruments.IdealInstrument( + area_effective=1 * u.cm ** 2, + timedelta_exposure=20 * u.s, + plate_scale=.4 * u.arcsec / u.pix, + dispersion=((10 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix, + angle=na.linspace(0, 360, num=4, axis="channel", endpoint=False) * u.deg, + wavelength_ref=wavelength_rest, + position_ref=na.Cartesian2dVectorArray(64, 32) * u.pix, + coordinates_scene=coordinates_scene, + coordinates_sensor=coordinates_sensor, + axis_channel="channel", + axis_wavelength="wavelength", + axis_scene_xy=("scene_x", "scene_y"), + axis_sensor_xy=("sensor_x", "sensor_y"), +) + +images = instrument.image(scene.outputs) + + +class TestMartInverter: + + pass From 36caf1f8542d7439d21f291ebe5ad33968959267 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 21:35:58 -0600 Subject: [PATCH 13/55] tutorial --- docs/tutorials/simple-mart.ipynb | 37 +------------------------------- 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index eace90f..b81b134 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -739,38 +739,6 @@ ")" ] }, - { - "cell_type": "raw", - "id": "2eb1e79a-5d2b-4b1b-a03d-e1d3988c8e1e", - "metadata": { - "editable": true, - "raw_mimetype": "text/x-rst", - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "source": [ - "Make an initial guess for the solution.\n", - "In this case we will choose all ones for demonstration purposes." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bfedd342-48cb-4792-9db5-bd7f5059e3da", - "metadata": { - "editable": true, - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "outputs": [], - "source": [ - "guess = 0 * scene.outputs + 1 * scene.outputs.unit" - ] - }, { "cell_type": "raw", "id": "c9c8d562-dd76-4cfe-8122-d74f22e4e5a4", @@ -803,10 +771,7 @@ }, "outputs": [], "source": [ - "inversion = mart(\n", - " images=images.outputs,\n", - " guess=guess,\n", - ")" + "inversion = mart(images.outputs)" ] }, { From 8c2061e4610a5913ebfeca3654d1fe76bd515011 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 22:01:39 -0600 Subject: [PATCH 14/55] black --- ctis/inverters/__init__.py | 2 +- ctis/inverters/_inverters.py | 4 ++-- ctis/inverters/_iterative/_iterative.py | 1 - ctis/inverters/_iterative/_iterative_test.py | 2 +- ctis/inverters/_iterative/_mart/_mart_test.py | 4 ++-- ctis/inverters/_results.py | 3 ++- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ctis/inverters/__init__.py b/ctis/inverters/__init__.py index db7bcda..adaa329 100644 --- a/ctis/inverters/__init__.py +++ b/ctis/inverters/__init__.py @@ -2,7 +2,7 @@ from ._results import InversionResult from ._inverters import AbstractInverter -from ._iterative import( +from ._iterative import ( AbstractIterativeInverter, MartInverter, IterativeInversionResult, diff --git a/ctis/inverters/_inverters.py b/ctis/inverters/_inverters.py index 8909d59..0331b51 100644 --- a/ctis/inverters/_inverters.py +++ b/ctis/inverters/_inverters.py @@ -31,7 +31,7 @@ def __call__( self, images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], **kwargs, - )-> InversionResult: + ) -> InversionResult: """ Reconstruct a scene using the observed images. @@ -45,4 +45,4 @@ def __call__( kwargs Additional keyword arguments which can be used by subclass implementations. - """ \ No newline at end of file + """ diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index b6d7eab..b5751fa 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -61,4 +61,3 @@ def iteration(self) -> na.ScalarArray: stop=self.num_iteration, axis=self.inverter.axis_iteration, ) - diff --git a/ctis/inverters/_iterative/_iterative_test.py b/ctis/inverters/_iterative/_iterative_test.py index 4a4f83e..ec9f544 100644 --- a/ctis/inverters/_iterative/_iterative_test.py +++ b/ctis/inverters/_iterative/_iterative_test.py @@ -9,4 +9,4 @@ class AbstractTestAbstractIterativeInverter( def test_num_iteration(self, a: ctis.inverters.AbstractIterativeInverter): result = a.num_iteration assert isinstance(result, int) - assert result > 0 + assert result > 0 diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index 0b3248d..395b14a 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -36,9 +36,9 @@ coordinates_sensor.wavelength = wavelength instrument = ctis.instruments.IdealInstrument( - area_effective=1 * u.cm ** 2, + area_effective=1 * u.cm**2, timedelta_exposure=20 * u.s, - plate_scale=.4 * u.arcsec / u.pix, + plate_scale=0.4 * u.arcsec / u.pix, dispersion=((10 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix, angle=na.linspace(0, 360, num=4, axis="channel", endpoint=False) * u.deg, wavelength_ref=wavelength_rest, diff --git a/ctis/inverters/_results.py b/ctis/inverters/_results.py index 56ffab9..11da852 100644 --- a/ctis/inverters/_results.py +++ b/ctis/inverters/_results.py @@ -6,6 +6,7 @@ "InversionResult", ] + @dataclasses.dataclass class InversionResult: """ @@ -21,7 +22,7 @@ class InversionResult: images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] """The observed images on which the inversion was performed.""" - inverter: 'ctis.inverters.AbstractInverter' + inverter: "ctis.inverters.AbstractInverter" """The inversion algorithm that produced these results.""" message: str From 3084026db96bc85873972390e49e88cb5180aa41 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 22:31:58 -0600 Subject: [PATCH 15/55] testing --- ctis/inverters/_inverters_test.py | 8 +++++--- ctis/inverters/_iterative/_mart/_mart.py | 12 +++++++++++- ctis/inverters/_iterative/_mart/_mart_test.py | 19 +++++++++++++++++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ctis/inverters/_inverters_test.py b/ctis/inverters/_inverters_test.py index c475dad..59aaf9f 100644 --- a/ctis/inverters/_inverters_test.py +++ b/ctis/inverters/_inverters_test.py @@ -21,6 +21,8 @@ def test__call__( assert isinstance(result, ctis.inverters.InversionResult) - images_new = a.instrument.image(result.solution) - - assert np.isclose(images, images_new) + assert result.solution.sum() > 0 + assert result.success + assert isinstance(result.message, str) + assert np.all(result.images == images) + assert result.inverter == a diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index 204f176..ac5cd15 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -63,7 +63,7 @@ def _gamma(self) -> float: def __call__( self, - images: na.ScalarArray, + images: na.ScalarArray | na.FunctionArray, guess: None | na.ScalarArray = None, ) -> IterativeInversionResult: """ @@ -85,6 +85,16 @@ def __call__( instrument = self.instrument + if isinstance(images, na.AbstractFunctionArray): + position_images = images.inputs.position + position_sensor = instrument.coordinates_sensor.position + if not np.all(position_images == position_sensor): + raise ValueError( + "`images.inputs.position` and `self.coordinates_sensor.position` " + "are not equal." + ) + images = images.outputs + if guess is None: scene = instrument.backproject(images).outputs scene = scene.mean(axis=instrument.axis_channel) diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index 395b14a..5ca891d 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -53,7 +53,22 @@ images = instrument.image(scene.outputs) +inverter = ctis.inverters.MartInverter( + instrument=instrument, +) -class TestMartInverter: +@pytest.mark.parametrize("a", [inverter]) +class TestMartInverter( + AbstractTestAbstractIterativeInverter, +): - pass + @pytest.mark.parametrize("images", [images]) + def test__call__( + self, + a: ctis.inverters.AbstractInverter, + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], + ): + super().test__call__( + a=a, + images=images, + ) From c6259215bc5cb0b2e53067b068e1668709853cb8 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Tue, 14 Apr 2026 22:33:31 -0600 Subject: [PATCH 16/55] black --- ctis/inverters/_iterative/__init__.py | 2 +- ctis/inverters/_iterative/_mart/_mart_test.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ctis/inverters/_iterative/__init__.py b/ctis/inverters/_iterative/__init__.py index 3d5cdf5..e301a20 100644 --- a/ctis/inverters/_iterative/__init__.py +++ b/ctis/inverters/_iterative/__init__.py @@ -5,4 +5,4 @@ "AbstractIterativeInverter", "IterativeInversionResult", "MartInverter", -] \ No newline at end of file +] diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index 5ca891d..a44e114 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -57,6 +57,7 @@ instrument=instrument, ) + @pytest.mark.parametrize("a", [inverter]) class TestMartInverter( AbstractTestAbstractIterativeInverter, From 147efef5c7fd86e2b3e2cf00ecc4198b6e7a9a6f Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 10:07:36 -0600 Subject: [PATCH 17/55] Added beginnings of mart discussion --- docs/discussions/mart-discussion.rst | 33 ++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 34 insertions(+) create mode 100644 docs/discussions/mart-discussion.rst diff --git a/docs/discussions/mart-discussion.rst b/docs/discussions/mart-discussion.rst new file mode 100644 index 0000000..c486770 --- /dev/null +++ b/docs/discussions/mart-discussion.rst @@ -0,0 +1,33 @@ +Multiplicative Algebraic Reconstruction Technique (MART) +======================================================== + +Algebraic reconstruction techniques (ARTs) are a classic approach to solving the computed +tomography problem :cite:p:`Gordon1970`. +There are two possible types of this technique: additive and multiplicative. +For limited-angle tomography problems (such as reconstructing a scene using a CTIS), +the multiplicative method is generally preferred due to its positivity-preserving +properties. +The multiplicative algebraic reconstruction technique (MART) +has become the de-facto standard algorithm for reconstructing the +solar transition region using +the Multi-Order Solar EUV Spectrograph (MOSES) :cite:p:`Fox2010` +and the EUV Snapshot Imaging Spectrograph (ESIS) :cite:p:`Parker2022`. + +In this package, our implementation of MART will generally follow the version +described in :cite:t:`Parker2022`, with some slight adaptations to make it more +work on genereal, curvilinear meshes. + +Vanilla MART +------------ + +The basic version of MART starts with an initial guess at the solution, :math:`\hat{u}_0`, +which can be all ones, or some other informed choice. +Given this boundary condition, we then loop through the following steps until +a convergence criterion is reached: + +- Compute the images corresponding to the current guess, :math:`d_i = P \hat{u}_i`, + where :math:`P` is a projection operator corresponding to a forward model of + a CTIS instrument. +- Compute the mean chi squared, :math:`\langle \chi^2 \rangle = \biggl\langle \left( \frac{d_i - d}{\sigma} \right)^2 \biggr \rangle` + +Here, will describe how MART is implemented in this package diff --git a/docs/index.rst b/docs/index.rst index 0810a8e..064163c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,6 +15,7 @@ Some explanations of the theory behind inversion .. toctree:: :maxdepth: 1 + discussions/mart-discussion discussions/richardson-lucy-analogy/richardson-lucy-analogy Tutorials From d56c0a7842db2554896ecece095437998b3c4e56 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 12:02:43 -0600 Subject: [PATCH 18/55] update discussion. --- docs/discussions/mart-discussion.rst | 32 +++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/discussions/mart-discussion.rst b/docs/discussions/mart-discussion.rst index c486770..cf019e7 100644 --- a/docs/discussions/mart-discussion.rst +++ b/docs/discussions/mart-discussion.rst @@ -23,11 +23,33 @@ Vanilla MART The basic version of MART starts with an initial guess at the solution, :math:`\hat{u}_0`, which can be all ones, or some other informed choice. Given this boundary condition, we then loop through the following steps until -a convergence criterion is reached: +the convergence criterion is reached: - Compute the images corresponding to the current guess, :math:`d_i = P \hat{u}_i`, - where :math:`P` is a projection operator corresponding to a forward model of - a CTIS instrument. -- Compute the mean chi squared, :math:`\langle \chi^2 \rangle = \biggl\langle \left( \frac{d_i - d}{\sigma} \right)^2 \biggr \rangle` + where :math:`P` is a projection operator representing the forward model of + a CTIS instrument, and :math:`i` is the current iteration index. +- Compute the mean chi squared, + :math:`\langle \chi_i^2 \rangle = \biggl\langle \left( \frac{d_i - d}{\sigma} \right)^2 \biggr \rangle`, + where :math:`d` are the actual images measured by the CTIS, and :math:`\sigma` + is the uncertainty of the predicted images, :math:`d_i`. +- Check if the algorithm has converged by making sure + :math:`\langle \chi^2 \rangle` is still decreasing, + :math:`\langle \chi_{i}^2 \rangle - \langle \chi_{i-1}^2 \rangle < T`, + where :math:`T` is some threshold close to zero. +- If convergence has not been reached, compute the correction factor for each channel, + :math:`C_i = \frac{P^* d}{P^* d_i}`, + where :math:`P^*` is a deprojection operator, similar to :math:`P^T`, + which spreads the intensity gathered by each CTIS channel evenly along + the projection direction. +- Generate the actual correction factor for each channel, + :math:`C_i' = C_i^\gamma`, where :math:`\gamma` is the learning rate. +- Find the total correction factor, + :math:`\overline{C}_i` by taking the geometric average of each channel's + correction factor. +- Finally, generate a new guess by applying the correction factor to the current + guess, :math:`\hat{u}_{i+1} = \overline{C}_i \hat{u}_i` -Here, will describe how MART is implemented in this package +The main difference of this implementation from the one described in :cite:t:`Parker2022` +is that the correction factor is calculated in the coordinate system of the scene +instead of the sensors. +This is to allow us to conserve flux on both the forward and backward passes. From e91d1da6db87696f0b849f4158c14999e26c6934 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 12:09:13 -0600 Subject: [PATCH 19/55] more tweaks to the discussion. --- docs/discussions/mart-discussion.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/discussions/mart-discussion.rst b/docs/discussions/mart-discussion.rst index cf019e7..45c0ceb 100644 --- a/docs/discussions/mart-discussion.rst +++ b/docs/discussions/mart-discussion.rst @@ -50,6 +50,8 @@ the convergence criterion is reached: guess, :math:`\hat{u}_{i+1} = \overline{C}_i \hat{u}_i` The main difference of this implementation from the one described in :cite:t:`Parker2022` -is that the correction factor is calculated in the coordinate system of the scene +is that there is no contrast-enhancement filtering yet. +Another difference is that the correction factor is calculated in the coordinate system of the scene instead of the sensors. -This is to allow us to conserve flux on both the forward and backward passes. +This is to allow us to conserve flux on both the forward and backward passes, +potentially increasing the stability of the algorithm. From ca10157602246246f757c5bf5d88ad764537d6db Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 13:26:06 -0600 Subject: [PATCH 20/55] refs --- docs/refs.bib | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/refs.bib b/docs/refs.bib index 60ec505..ed02d46 100644 --- a/docs/refs.bib +++ b/docs/refs.bib @@ -49,4 +49,33 @@ @article{Gordon1970 author = {Richard Gordon and Robert Bender and Gabor T. Herman}, abstract = {We give a new method for direct reconstruction of three-dimensional objects from a few electron micrographs taken at angles which need not exceed a range of 60 degrees. The method works for totally asymmetric objects, and requires little computer time or storage. It is also applicable to X-ray photography, and may greatly reduce the exposure compared to current methods of body-section radiography.} } +@ARTICLE{Fox2010, + author = {{Fox}, J. Lewis and {Kankelborg}, Charles C. and {Thomas}, Roger J.}, + title = "{A Transition Region Explosive Event Observed in He II with the MOSES Sounding Rocket}", + journal = {\apj}, + keywords = {instrumentation: spectrographs, space vehicles: instruments, Sun: transition region, Sun: UV radiation, techniques: imaging spectroscopy}, + year = 2010, + month = aug, + volume = {719}, + number = {2}, + pages = {1132-1143}, + doi = {10.1088/0004-637X/719/2/1132}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2010ApJ...719.1132F}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} +@ARTICLE{Parker2022, + author = {{Parker}, Jacob D. and {Smart}, Roy T. and {Kankelborg}, Charles and {Winebarger}, Amy and {Goldsworth}, Nelson}, + title = "{First Flight of the EUV Snapshot Imaging Spectrograph (ESIS)}", + journal = {\apj}, + keywords = {Spectroscopy, 1558}, + year = 2022, + month = oct, + volume = {938}, + number = {2}, + eid = {116}, + pages = {116}, + doi = {10.3847/1538-4357/ac8eaa}, + adsurl = {https://ui.adsabs.harvard.edu/abs/2022ApJ...938..116P}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} +} From 453859a18bee206de479f5c05e48323436a74710 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 13:27:36 -0600 Subject: [PATCH 21/55] even more changes to discussion --- docs/discussions/mart-discussion.rst | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/discussions/mart-discussion.rst b/docs/discussions/mart-discussion.rst index 45c0ceb..24e4000 100644 --- a/docs/discussions/mart-discussion.rst +++ b/docs/discussions/mart-discussion.rst @@ -29,20 +29,20 @@ the convergence criterion is reached: where :math:`P` is a projection operator representing the forward model of a CTIS instrument, and :math:`i` is the current iteration index. - Compute the mean chi squared, - :math:`\langle \chi_i^2 \rangle = \biggl\langle \left( \frac{d_i - d}{\sigma} \right)^2 \biggr \rangle`, - where :math:`d` are the actual images measured by the CTIS, and :math:`\sigma` + :math:`\langle \chi_i^2 \rangle = \biggl\langle \left( \frac{d_i - d}{\sigma_i} \right)^2 \biggr \rangle`, + where :math:`d` are the actual images measured by the CTIS, and :math:`\sigma_i` is the uncertainty of the predicted images, :math:`d_i`. -- Check if the algorithm has converged by making sure - :math:`\langle \chi^2 \rangle` is still decreasing, - :math:`\langle \chi_{i}^2 \rangle - \langle \chi_{i-1}^2 \rangle < T`, +- Determine if the algorithm has converged by checking if + :math:`\langle \chi^2 \rangle` has stopped decreasing, + :math:`\langle \chi_{i-1}^2 \rangle - \langle \chi_{i}^2 \rangle < T`, where :math:`T` is some threshold close to zero. - If convergence has not been reached, compute the correction factor for each channel, :math:`C_i = \frac{P^* d}{P^* d_i}`, where :math:`P^*` is a deprojection operator, similar to :math:`P^T`, which spreads the intensity gathered by each CTIS channel evenly along the projection direction. -- Generate the actual correction factor for each channel, - :math:`C_i' = C_i^\gamma`, where :math:`\gamma` is the learning rate. +- Generate an effective correction factor for each channel, + :math:`C_i' = C_i^\gamma`, where :math:`0<\gamma<1` is the learning rate. - Find the total correction factor, :math:`\overline{C}_i` by taking the geometric average of each channel's correction factor. From de50aef4af5f91c8e608bf91e8fc1c4522373e81 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 22:17:06 -0600 Subject: [PATCH 22/55] compute correlation of residual with predicted images as a function of iteration. --- ctis/instruments/_instruments.py | 14 +- ctis/instruments/_instruments_test.py | 5 +- ctis/inverters/__init__.py | 2 + ctis/inverters/_inverters_test.py | 4 +- ctis/inverters/_iterative/_iterative.py | 63 +++- ctis/inverters/_iterative/_iterative_test.py | 20 ++ ctis/inverters/_iterative/_mart/_mart.py | 140 +++++---- ctis/inverters/_iterative/_mart/_mart_test.py | 2 +- ctis/inverters/merit/__init__.py | 11 + ctis/inverters/merit/_merit.py | 61 ++++ docs/tutorials/ideal-instrument.ipynb | 87 ++++-- docs/tutorials/simple-mart.ipynb | 281 ++++++++++-------- 12 files changed, 469 insertions(+), 221 deletions(-) create mode 100644 ctis/inverters/merit/__init__.py create mode 100644 ctis/inverters/merit/_merit.py diff --git a/ctis/instruments/_instruments.py b/ctis/instruments/_instruments.py index 748a579..3d2f549 100644 --- a/ctis/instruments/_instruments.py +++ b/ctis/instruments/_instruments.py @@ -114,12 +114,19 @@ def uncertainty(self) -> Callable[[na.ScalarArray], na.ScalarArray]: for a given number of photons. """ + @property + @abc.abstractmethod + def channel(self): + """ + Human-readable name of each independent CTIS channel. + """ + @property @abc.abstractmethod def axis_channel(self) -> str | tuple[str, ...]: """ The logical axis or axes of this instrument corresponding to - the different dispersion magnitudes and angles. + the different CTIS channels. """ @property @@ -391,6 +398,11 @@ class IdealInstrument( A grid of wavelength and position coordinates on the sensor plane. """ + channel: str | na.AbstractScalar = dataclasses.MISSING + """ + Human-readable name of each independent CTIS channel. + """ + axis_channel: str | tuple[str, ...] = dataclasses.MISSING """ The logical axis or axes of this instrument corresponding to diff --git a/ctis/instruments/_instruments_test.py b/ctis/instruments/_instruments_test.py index 0e06ff1..10522c5 100644 --- a/ctis/instruments/_instruments_test.py +++ b/ctis/instruments/_instruments_test.py @@ -40,16 +40,19 @@ dispersion = 200 * u.km / u.s dispersion = (dispersion.to(**AA) - wavelength_rest) / u.pix +angle = na.linspace(0, 360, axis="channel", num=3, endpoint=False) + instrument_ideal = ctis.instruments.IdealInstrument( area_effective=1 * u.cm**2, timedelta_exposure=10 * u.s, plate_scale=2 * u.arcsec / u.pix, dispersion=dispersion, - angle=na.linspace(0, 360, axis="channel", num=3, endpoint=False), + angle=angle, wavelength_ref=wavelength_rest, position_ref=32 * u.pix, coordinates_scene=coordinates_scene, coordinates_sensor=coordinates_sensor, + channel=angle, axis_channel="channel", axis_wavelength="wavelength", axis_scene_xy=("scene_x", "scene_y"), diff --git a/ctis/inverters/__init__.py b/ctis/inverters/__init__.py index adaa329..c227396 100644 --- a/ctis/inverters/__init__.py +++ b/ctis/inverters/__init__.py @@ -1,5 +1,6 @@ """Inversion algorithms which can reconstruct scenes from observed images.""" +from . import merit from ._results import InversionResult from ._inverters import AbstractInverter from ._iterative import ( @@ -9,6 +10,7 @@ ) __all__ = [ + "merit", "AbstractInverter", "AbstractIterativeInverter", "MartInverter", diff --git a/ctis/inverters/_inverters_test.py b/ctis/inverters/_inverters_test.py index 59aaf9f..23dc63b 100644 --- a/ctis/inverters/_inverters_test.py +++ b/ctis/inverters/_inverters_test.py @@ -16,7 +16,7 @@ def test__call__( self, a: ctis.inverters.AbstractInverter, images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], - ): + ) -> ctis.inverters.InversionResult: result = a(images) assert isinstance(result, ctis.inverters.InversionResult) @@ -26,3 +26,5 @@ def test__call__( assert isinstance(result.message, str) assert np.all(result.images == images) assert result.inverter == a + + return result diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index b5751fa..7b960f7 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -1,7 +1,10 @@ from typing import ClassVar import abc import dataclasses +import numpy as np +import astropy.units as u import named_arrays as na +import ctis from .. import AbstractInverter, InversionResult __all__ = [ @@ -35,6 +38,55 @@ def num_iteration(self) -> int: a warning is raised and an unsuccessful result is returned. """ + def mean_chi_squared( + self, + images_observed: na.ScalarArray, + images_predicted: na.ScalarArray, + ) -> na.ScalarArray: + r""" + Evaluate :math:`\langle \chi^2 \rangle` for each observed/predicted + image pair. + + Parameters + ---------- + images_observed + The actual measured images. + images_predicted + The images predicted by the inversion. + """ + + uncertainty = self.instrument.uncertainty(images_predicted) + + uncertainty = np.maximum(uncertainty, 1 * u.photon) + + return ctis.inverters.merit.mean_chi_squared( + observed=images_observed, + expected=images_predicted, + uncertainty=uncertainty, + axis=self.instrument.axis_sensor_xy, + ) + + def correlation_residual( + self, + images_observed: na.ScalarArray, + images_predicted: na.ScalarArray, + ) -> na.ScalarArray: + """ + Evaluate the correlation between the predicted images and the residual. + + Parameters + ---------- + images_observed + The actual measured images. + images_predicted + The images predicted by the inversion. + """ + return ctis.inverters.merit.correlation_residual( + observed=images_observed, + expected=images_predicted, + axis=self.instrument.axis_sensor_xy, + ) + @dataclasses.dataclass class IterativeInversionResult( @@ -47,11 +99,14 @@ class IterativeInversionResult( num_iteration: int """The number of iterations performed by the inverter.""" - merit: na.ScalarArray - """The value of the merit function for each iteration.""" + mean_chi_squared: na.ScalarArray + """The mean chi squared statistic for each iteration.""" - merit_name: str - """Human-readable name of the merit function.""" + correlation_residual: na.ScalarArray + """ + The correlation between the predicted images and the residuals + for each iteration. + """ @property def iteration(self) -> na.ScalarArray: diff --git a/ctis/inverters/_iterative/_iterative_test.py b/ctis/inverters/_iterative/_iterative_test.py index ec9f544..b858c14 100644 --- a/ctis/inverters/_iterative/_iterative_test.py +++ b/ctis/inverters/_iterative/_iterative_test.py @@ -1,4 +1,5 @@ import ctis +import named_arrays as na from .._inverters_test import AbstractTestAbstractInverter @@ -6,6 +7,25 @@ class AbstractTestAbstractIterativeInverter( AbstractTestAbstractInverter, ): + def test__call__( + self, + a: ctis.inverters.AbstractIterativeInverter, + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], + ) -> ctis.inverters.IterativeInversionResult: + + result = super().test__call__( + a=a, + images=images, + ) + + axis_iteration = result.inverter.axis_iteration + + assert result.iteration.size == result.num_iteration + assert result.mean_chi_squared.shape[axis_iteration] == result.num_iteration + assert result.correlation_residual.shape[axis_iteration] == result.num_iteration + + return result + def test_num_iteration(self, a: ctis.inverters.AbstractIterativeInverter): result = a.num_iteration assert isinstance(result, int) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index ac5cd15..b993a60 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -18,6 +18,8 @@ class MartInverter( """ An inversion routine based on the Richardson-Lucy algorithm :cite:t:`Richardson1972,Lucy1974`. + + For further information, see the discussion :doc:`discussions/mart-discussion`. """ instrument: ctis.instruments.AbstractInstrument = dataclasses.MISSING @@ -37,6 +39,14 @@ class MartInverter( channels. """ + threshold_convergence: float = 1e-3 + r""" + The convergence threshold, :math:`T`, which halts the iteration. + + If :math:`\langle \chi_{i}^2 \rangle - \langle \chi_{i-1}^2 \rangle < T`, + then the algorithm is considered to be converged. + """ + num_iteration: int = 100 """ The maximum number of iterations to perform. @@ -53,18 +63,16 @@ class MartInverter( debugging or demonstration purposes. """ - @property - def _gamma(self) -> float: - """Normalized version of :attr:`gamma`""" - gamma = self.gamma - if gamma is None: - gamma = 2 / self.instrument.num_channel - return gamma + def __post_init__(self): + + if self.gamma is None: + self.gamma = 2 / self.instrument.num_channel def __call__( self, - images: na.ScalarArray | na.FunctionArray, + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], guess: None | na.ScalarArray = None, + verbose: bool = False, ) -> IterativeInversionResult: """ Reconstruct a scene using the observed images. @@ -73,7 +81,7 @@ def __call__( ---------- images The observed images used to calculate the reconstruction. - Must be evaluated on the same coordinates as + Must be evaluated on the same position coordinates as :attr:`~ctis.instruments.AbstractInstrument.coordinates_sensor` attribute of :attr:`instrument`. guess @@ -85,51 +93,78 @@ def __call__( instrument = self.instrument - if isinstance(images, na.AbstractFunctionArray): - position_images = images.inputs.position - position_sensor = instrument.coordinates_sensor.position - if not np.all(position_images == position_sensor): - raise ValueError( - "`images.inputs.position` and `self.coordinates_sensor.position` " - "are not equal." - ) - images = images.outputs + axis_channel = instrument.axis_channel + + position_images = images.inputs.position + position_sensor = instrument.coordinates_sensor.position + if not np.all(position_images == position_sensor): + raise ValueError( + "`images.inputs.position` and `self.coordinates_sensor.position` " + "are not equal." + ) + images_inputs = images.inputs + images = images.outputs if guess is None: scene = instrument.backproject(images).outputs - scene = scene.mean(axis=instrument.axis_channel) + scene = scene.mean(axis_channel) scene.ndarray[:] = scene.ndarray.mean() else: scene = guess.copy() num_channel = instrument.num_channel - gamma = self._gamma + gamma = self.gamma backprojected = instrument.backproject(images).outputs + backprojected = np.maximum(backprojected, 0) + intermediate = [] if self.intermediate: intermediate.append(scene) - chi_squared = self._mean_chi_squared(images, 0 * images.unit) + chi2_old = np.inf + + chi2 = [] + correlation_residual = [] - merit = [] + for i in range(1, self.num_iteration): - for i in range(self.num_iteration): + if verbose: # pragma: nocover + print(f"{i=}") images_new = instrument.image(scene, noise=False).outputs - chi_squared_new = self._mean_chi_squared(images, images_new) - if (chi_squared - chi_squared_new) < 1e-2: - # if self._converged(images, images_new): + chi2_ij = self.mean_chi_squared(images, images_new) + r_ij = self.correlation_residual(images, images_new) + + chi2.append(chi2_ij) + correlation_residual.append(r_ij) + + chi2_i = chi2_ij.mean(axis_channel) + + if verbose: # pragma: nocover + print(f"mean chi squared: {chi2_ij}") + + if chi2_i > chi2_old: + message = "Failure: chi squared increasing." + success = False + num_iteration = i + warnings.warn(message) + break + + elif (chi2_old - chi2_i) < self.threshold_convergence: message = "Achieved mean chi squared of less than 1." success = True + num_iteration = i break backprojected_new = instrument.backproject(images_new).outputs + backprojected_new = np.maximum(backprojected_new, 0) + correction = backprojected / backprojected_new correction = np.nan_to_num( @@ -142,24 +177,23 @@ def __call__( correction = correction**gamma correction = np.prod(correction, axis=instrument.axis_channel) - correction = correction ** (1 / num_channel) if self.intermediate: scene = scene * correction - intermediate.append(scene) - else: scene *= correction - merit.append(chi_squared_new) + if self.intermediate: + intermediate.append(scene) - chi_squared = chi_squared_new + chi2_old = chi2_i else: message = f"Max number of iterations ({self.num_iteration}) exceeded." warnings.warn(message) success = False + num_iteration = self.num_iteration if self.intermediate: intermediate = na.stack(intermediate, axis=self.axis_iteration) @@ -167,39 +201,27 @@ def __call__( else: solution = scene + solution = na.FunctionArray( + inputs=self.instrument.coordinates_scene, + outputs=solution, + ) + + images = na.FunctionArray( + inputs=images_inputs, + outputs=images, + ) + + mean_chi_squared = na.stack(chi2, axis=self.axis_iteration) + correlation_residual = na.stack(correlation_residual, axis=self.axis_iteration) + return IterativeInversionResult( solution=solution, success=success, images=images, inverter=self, message=message, - num_iteration=i, - merit=na.stack(merit, axis=self.axis_iteration), - merit_name=r"$\langle \chi^2 \rangle$", + num_iteration=num_iteration, + mean_chi_squared=mean_chi_squared, + correlation_residual=correlation_residual, ) - def _converged( - self, - images: na.ScalarArray, - images_new: na.ScalarArray, - ) -> bool: - r""" - Return true if :math:`\langle \chi^2 \rangle < 1` - """ - X2 = self._mean_chi_squared(images, images_new) - return X2 < 1 / 2 - - def _mean_chi_squared( - self, - images: na.ScalarArray, - images_new: na.ScalarArray, - ): - r""" - Evaluated :math:`\langle \chi^2 \rangle < 1` normalized by uncertainty in each pixel. - """ - - uncertainty = self.instrument.uncertainty(images_new) - - uncertainty = np.maximum(uncertainty, 1 * u.photon) - - return np.mean(np.square((images_new - images) / uncertainty)) diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index a44e114..9ba84d5 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -51,7 +51,7 @@ axis_sensor_xy=("sensor_x", "sensor_y"), ) -images = instrument.image(scene.outputs) +images = instrument.image(scene) inverter = ctis.inverters.MartInverter( instrument=instrument, diff --git a/ctis/inverters/merit/__init__.py b/ctis/inverters/merit/__init__.py new file mode 100644 index 0000000..a2bccfb --- /dev/null +++ b/ctis/inverters/merit/__init__.py @@ -0,0 +1,11 @@ +"""Functions used to evaluate the quality of CTIS inversions.""" + +from ._merit import ( + mean_chi_squared, + correlation_residual, +) + +__all__ = [ + "mean_chi_squared", + "correlation_residual", +] \ No newline at end of file diff --git a/ctis/inverters/merit/_merit.py b/ctis/inverters/merit/_merit.py new file mode 100644 index 0000000..a6fdfa0 --- /dev/null +++ b/ctis/inverters/merit/_merit.py @@ -0,0 +1,61 @@ +from typing import Sequence +import numpy as np +import named_arrays as na + +__all__ = [ + "mean_chi_squared", + "correlation_residual", +] + +def mean_chi_squared( + observed: na.ScalarArray, + expected: na.ScalarArray, + uncertainty: na.ScalarArray, + axis: None | str | Sequence[str] = None, +) -> na.ScalarArray: + r""" + Compute :math:`\langle \chi^2 \rangle = \biggl\langle \left( \frac{O - E}{\sigma} \right)^2 \biggr \rangle` , + where :math:`O` is the observed value, + :math:`E` is the expected value, + and :math:`\sigma` denotes the standard deviation of the uncertainty. + + Parameters + ---------- + observed + The measured values. + expected + The values predicted by the model. + uncertainty + The uncertainty of the values predicted by the model. + axis + The logical axis or axes over which to average the result. + """ + chisq = np.square((observed - expected) / uncertainty) + + return np.mean(chisq, axis=axis) + + +def correlation_residual( + observed: na.ScalarArray, + expected: na.ScalarArray, + axis: None | str | Sequence[str] = None, +) -> na.ScalarArray: + """ + Compute the Spearman correlation coefficient between the expected values + and the residual. + + Parameters + ---------- + observed + The measured values. + expected + The values predicted by the model. + axis + The logical axis or axes over which to average the result. + """ + + residual = observed - expected + + r = na.stats.spearmanr(expected, residual, axis=axis) + + return r diff --git a/docs/tutorials/ideal-instrument.ipynb b/docs/tutorials/ideal-instrument.ipynb index 708ae7d..1714d1b 100644 --- a/docs/tutorials/ideal-instrument.ipynb +++ b/docs/tutorials/ideal-instrument.ipynb @@ -279,7 +279,7 @@ "tags": [] }, "source": [ - "Define an ideal CTIS instrument, an instrument characterized by an effective area, plate scale, dispersion magnitude/angle, and the coordinates of the scene/sensor.\n", + "Define the angle of dispersion for each channel of our CTIS instrument.\n", "In this case, we'll define an instrument with 36 dispersion angles to help visualize the behavior of this instrument. " ] }, @@ -295,17 +295,49 @@ "tags": [] }, "outputs": [], + "source": [ + "angle = na.linspace(0, 360, num=36, axis=\"channel\", endpoint=False) * u.deg" + ] + }, + { + "cell_type": "raw", + "id": "18", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Define an ideal CTIS instrument, an instrument characterized by an effective area, plate scale, dispersion magnitude/angle, and the coordinates of the scene/sensor." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], "source": [ "instrument = ctis.instruments.IdealInstrument(\n", " area_effective=(0.1 * u.mm**2).to(u.cm**2),\n", " timedelta_exposure=0.001 * u.s,\n", " plate_scale=.4 * u.arcsec / u.pix,\n", " dispersion=((20 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", - " angle=na.linspace(0, 360, num=36, axis=\"channel\", endpoint=False) * u.deg,\n", + " angle=angle,\n", " wavelength_ref=wavelength_rest,\n", " position_ref=32 * u.pix,\n", " coordinates_scene=coordinates_scene,\n", - " coordinates_sensor=coordinates_sensor,\n", + " coordinates_sensor=coordinates_sensor,\\\n", + " channel=\"dispersion angle = \" + angle.to_string_array(\"%03d\"),\n", " axis_channel=\"channel\",\n", " axis_wavelength=\"wavelength\",\n", " axis_scene_xy=(\"scene_x\", \"scene_y\"),\n", @@ -315,7 +347,7 @@ }, { "cell_type": "raw", - "id": "18", + "id": "20", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -334,7 +366,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "21", "metadata": { "editable": true, "slideshow": { @@ -373,7 +405,7 @@ }, { "cell_type": "raw", - "id": "20", + "id": "22", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -389,7 +421,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "23", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -429,7 +461,7 @@ }, { "cell_type": "raw", - "id": "22", + "id": "24", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -445,7 +477,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "25", "metadata": { "editable": true, "slideshow": { @@ -475,7 +507,7 @@ }, { "cell_type": "raw", - "id": "24", + "id": "26", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -492,7 +524,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "27", "metadata": { "editable": true, "slideshow": { @@ -501,11 +533,13 @@ "tags": [] }, "outputs": [], - "source": "image = instrument.image(scene, integrate=False)" + "source": [ + "image = instrument.image(scene, integrate=False)" + ] }, { "cell_type": "raw", - "id": "26", + "id": "28", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -522,7 +556,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "29", "metadata": { "editable": true, "slideshow": { @@ -539,9 +573,8 @@ " constrained_layout=True,\n", " )\n", " ax, cax = axs\n", - " label = \"dispersion angle = \" + instrument.angle.to_string_array(\"%03d\")\n", " ani, colorbar = na.plt.rgbmovie(\n", - " label,\n", + " instrument.channel,\n", " image.inputs.wavelength,\n", " image.inputs.position.x,\n", " image.inputs.position.y,\n", @@ -574,7 +607,7 @@ }, { "cell_type": "raw", - "id": "28", + "id": "30", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -591,7 +624,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "31", "metadata": { "editable": true, "slideshow": { @@ -600,11 +633,13 @@ "tags": [] }, "outputs": [], - "source": "image_sum = instrument.image(scene)" + "source": [ + "image_sum = instrument.image(scene)" + ] }, { "cell_type": "raw", - "id": "30", + "id": "32", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -620,7 +655,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "33", "metadata": { "editable": true, "slideshow": { @@ -629,11 +664,13 @@ "tags": [] }, "outputs": [], - "source": "backprojected = instrument.backproject(image_sum)" + "source": [ + "backprojected = instrument.backproject(image_sum)" + ] }, { "cell_type": "raw", - "id": "32", + "id": "34", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -649,7 +686,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "35", "metadata": { "editable": true, "raw_mimetype": "", @@ -668,7 +705,7 @@ " )\n", " ax, cax = axs\n", " ani, colorbar = na.plt.rgbmovie(\n", - " label,\n", + " instrument.channel,\n", " backprojected.inputs.wavelength,\n", " backprojected.inputs.position.x,\n", " backprojected.inputs.position.y,\n", diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index b81b134..66b1491 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "raw", - "id": "2ae5f78e-5e62-447b-b8a9-12f809d1d765", + "id": "0", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -21,12 +21,8 @@ { "cell_type": "code", "execution_count": null, - "id": "initial_id", + "id": "1", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.195004400Z", - "start_time": "2026-04-14T16:59:50.708004Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -45,7 +41,7 @@ }, { "cell_type": "raw", - "id": "c5e6a6a5-cc98-4d40-a367-fd0273a39691", + "id": "2", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -61,12 +57,8 @@ { "cell_type": "code", "execution_count": null, - "id": "96b07d0db60fc9f4", + "id": "3", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.235004Z", - "start_time": "2026-04-14T16:59:52.198003900Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -80,7 +72,7 @@ }, { "cell_type": "raw", - "id": "b3639b74-86f0-45b5-a92b-6fe04f27e387", + "id": "4", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -96,12 +88,8 @@ { "cell_type": "code", "execution_count": null, - "id": "5f22170dad0b55c2", + "id": "5", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.244504300Z", - "start_time": "2026-04-14T16:59:52.236003700Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -115,7 +103,7 @@ }, { "cell_type": "raw", - "id": "264f4e26-10b8-4309-bf25-4c47d77ba0d7", + "id": "6", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -131,12 +119,8 @@ { "cell_type": "code", "execution_count": null, - "id": "f6325e63d4740db", + "id": "7", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.254004700Z", - "start_time": "2026-04-14T16:59:52.246004800Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -150,7 +134,7 @@ }, { "cell_type": "raw", - "id": "7a689dc1-675e-4031-8715-c07f22b5d6e9", + "id": "8", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -166,12 +150,8 @@ { "cell_type": "code", "execution_count": null, - "id": "65958436c451bf32", + "id": "9", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.262504200Z", - "start_time": "2026-04-14T16:59:52.255004600Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -185,7 +165,7 @@ }, { "cell_type": "raw", - "id": "e2e64b17-f21f-460d-9b41-33eff9f7a839", + "id": "10", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -201,12 +181,8 @@ { "cell_type": "code", "execution_count": null, - "id": "6fcb9cfddabb0628", + "id": "11", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.271504100Z", - "start_time": "2026-04-14T16:59:52.263503800Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -225,7 +201,7 @@ }, { "cell_type": "raw", - "id": "9dc396e0-b38f-41aa-8819-580d94f86004", + "id": "12", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -241,12 +217,8 @@ { "cell_type": "code", "execution_count": null, - "id": "b132ed6fa4b6c117", + "id": "13", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.717505900Z", - "start_time": "2026-04-14T16:59:52.272503900Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -263,7 +235,7 @@ }, { "cell_type": "raw", - "id": "3d892920-c0ae-4440-ac30-a9b3b5b30d53", + "id": "14", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -279,12 +251,8 @@ { "cell_type": "code", "execution_count": null, - "id": "12e91e591d860293", + "id": "15", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.755004300Z", - "start_time": "2026-04-14T16:59:52.719504200Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -299,7 +267,7 @@ }, { "cell_type": "raw", - "id": "3a9c7297-2fb9-4b54-b75a-f01ddfbe3bf0", + "id": "16", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -315,12 +283,8 @@ { "cell_type": "code", "execution_count": null, - "id": "b464c80a6fd9ecc8", + "id": "17", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.792005400Z", - "start_time": "2026-04-14T16:59:52.756005300Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -337,7 +301,7 @@ }, { "cell_type": "raw", - "id": "b6c185a0-59bf-40e2-ba01-76a2a4cf40ef", + "id": "18", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -353,7 +317,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4c1d896a-2723-4b01-8f74-481882b659ed", + "id": "19", "metadata": { "editable": true, "slideshow": { @@ -363,12 +327,12 @@ }, "outputs": [], "source": [ - "scene = scene + scene.outputs.max() / 100" + "scene = scene + scene.max() / 100" ] }, { "cell_type": "raw", - "id": "05a06503-35d8-483e-8ead-b599d8251c82", + "id": "20", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -384,12 +348,8 @@ { "cell_type": "code", "execution_count": null, - "id": "9016e7f953afae85", + "id": "21", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:52.802005800Z", - "start_time": "2026-04-14T16:59:52.793504100Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -404,7 +364,7 @@ }, { "cell_type": "raw", - "id": "6456f672-17e2-429a-9329-ed4b9f5873d0", + "id": "22", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -420,12 +380,8 @@ { "cell_type": "code", "execution_count": null, - "id": "2ffca52f5d82e9d3", + "id": "23", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:53.284503900Z", - "start_time": "2026-04-14T16:59:52.803504Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -464,7 +420,7 @@ }, { "cell_type": "raw", - "id": "a60b5a77-f347-43c7-a962-6dd66eceb0ed", + "id": "24", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -480,7 +436,7 @@ { "cell_type": "code", "execution_count": null, - "id": "54993dd9-75c5-4c6f-897c-390006e5816e", + "id": "25", "metadata": { "editable": true, "slideshow": { @@ -495,7 +451,7 @@ }, { "cell_type": "raw", - "id": "0622e31c-4977-4343-96d0-88020288c77d", + "id": "26", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -511,12 +467,8 @@ { "cell_type": "code", "execution_count": null, - "id": "2e38fbd7c706789b", + "id": "27", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:53.544004700Z", - "start_time": "2026-04-14T16:59:53.286504500Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -545,7 +497,7 @@ }, { "cell_type": "raw", - "id": "6ffc256c-1813-442d-b343-8e9c187cdca8", + "id": "28", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -555,18 +507,46 @@ "tags": [] }, "source": [ - "Define an ideal CTIS instrument with four projections, each separated by :math:`90^\\circ` degrees." + "Define the dispersion angles for our instrument.\n", + "In this case we'll define four channels, each each separated by :math:`90^\\circ` degrees." ] }, { "cell_type": "code", "execution_count": null, - "id": "1d180d7c99e6066", + "id": "29", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:53.587505100Z", - "start_time": "2026-04-14T16:59:53.546005700Z" + "editable": true, + "slideshow": { + "slide_type": "" }, + "tags": [] + }, + "outputs": [], + "source": [ + "angle = na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg" + ] + }, + { + "cell_type": "raw", + "id": "30", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Create an ideal CTIS using these dispersion angles." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": { "editable": true, "slideshow": { "slide_type": "" @@ -580,11 +560,12 @@ " timedelta_exposure=20 * u.s,\n", " plate_scale=.4 * u.arcsec / u.pix,\n", " dispersion=((10 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", - " angle=na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg,\n", + " angle=angle,\n", " wavelength_ref=wavelength_rest,\n", " position_ref=na.Cartesian2dVectorArray(64, 32) * u.pix,\n", " coordinates_scene=coordinates_scene,\n", " coordinates_sensor=coordinates_sensor,\n", + " channel=\"dispersion angle = \" + angle.to_string_array(\"%03d\"),\n", " axis_channel=\"channel\",\n", " axis_wavelength=\"wavelength\",\n", " axis_scene_xy=(\"scene_x\", \"scene_y\"),\n", @@ -594,7 +575,7 @@ }, { "cell_type": "raw", - "id": "0a9c5297-896e-410a-acf5-9fde425a952e", + "id": "32", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -610,12 +591,8 @@ { "cell_type": "code", "execution_count": null, - "id": "4a4c2b86dd9e4e24", + "id": "33", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:56.490502900Z", - "start_time": "2026-04-14T16:59:53.593004400Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -624,12 +601,12 @@ }, "outputs": [], "source": [ - "images = instrument.image(scene.outputs)" + "images = instrument.image(scene)" ] }, { "cell_type": "raw", - "id": "63acbf81-4b74-4a5c-b4fc-5cce6653820c", + "id": "34", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -645,12 +622,8 @@ { "cell_type": "code", "execution_count": null, - "id": "a0481390b90587ba", + "id": "35", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:57.633004600Z", - "start_time": "2026-04-14T16:59:56.559002900Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -672,9 +645,8 @@ " cmap=\"gray\",\n", " norm=norm,\n", " )\n", - " label = \"dispersion angle = \" + instrument.angle.to_string_array(\"%03d\")\n", " ani = na.plt.pcolormovie(\n", - " label,\n", + " instrument.channel,\n", " images.inputs.position.x,\n", " images.inputs.position.y,\n", " C=images.outputs.value,\n", @@ -703,7 +675,7 @@ }, { "cell_type": "raw", - "id": "14512eb5-c284-4022-a15f-a1df9f3a21b2", + "id": "36", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -719,12 +691,8 @@ { "cell_type": "code", "execution_count": null, - "id": "c50e70069ed0d2b2", + "id": "37", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T16:59:57.816504800Z", - "start_time": "2026-04-14T16:59:57.789004100Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -741,7 +709,7 @@ }, { "cell_type": "raw", - "id": "c9c8d562-dd76-4cfe-8122-d74f22e4e5a4", + "id": "38", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -757,12 +725,8 @@ { "cell_type": "code", "execution_count": null, - "id": "294e65726b0bd9ff", + "id": "39", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T17:00:01.134002900Z", - "start_time": "2026-04-14T16:59:57.819504400Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -771,12 +735,12 @@ }, "outputs": [], "source": [ - "inversion = mart(images.outputs)" + "inversion = mart(images)" ] }, { "cell_type": "raw", - "id": "870e9e44-a288-429b-a4ed-f97dba9a3db8", + "id": "40", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -792,12 +756,8 @@ { "cell_type": "code", "execution_count": null, - "id": "fd60140f76d94f44", + "id": "41", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T17:01:34.810625800Z", - "start_time": "2026-04-14T17:01:24.085626800Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -823,13 +783,14 @@ " vmax=scene.outputs.max(),\n", " )\n", " label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\")\n", - " label = label + f\"\\n{inversion.merit_name} = \" + inversion.merit.to_string_array()\n", + " name = r\"$\\langle \\chi^2 \\rangle$\"\n", + " label = label + f\"{name} = \" + inversion.mean_chi_squared.mean(instrument.axis_channel).to_string_array()\n", " ani, colorbar = na.plt.rgbmovie(\n", " label,\n", " scene.inputs.wavelength,\n", " scene.inputs.position.x,\n", " scene.inputs.position.y,\n", - " C=inversion.solution,\n", + " C=inversion.solution.outputs,\n", " axis_time=inversion.inverter.axis_iteration,\n", " axis_wavelength=\"wavelength\",\n", " ax=ax2,\n", @@ -861,9 +822,75 @@ "result" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "42", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "difference = scene - inversion.solution[{inversion.inverter.axis_iteration: ~0}]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43", + "metadata": {}, + "outputs": [], + "source": [ + "difference.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots(\n", + " constrained_layout=True,\n", + " )\n", + " img = na.plt.pcolormesh(\n", + " difference.inputs.position,\n", + " C=difference.outputs.sum(\"wavelength\").value,\n", + " ax=ax,\n", + " cmap=\"gray\",\n", + " vmin=-difference.outputs.max().value,\n", + " vmax=difference.outputs.max().value,\n", + " )\n", + " plt.colorbar(\n", + " mappable=img.ndarray.item(),\n", + " ax=ax,\n", + " label=f\"wavelength-summed spectral radiance\\n({difference.outputs.unit:latex_inline})\",\n", + " )\n", + " ax.set_title(\"difference between original and reconstructed\")\n", + " ax.set_aspect(\"equal\")\n", + " ax.set_xlabel(f\"scene $x$ ({ax.get_xlabel()})\")\n", + " ax.set_ylabel(f\"scene $y$ ({ax.get_ylabel()})\")\n", + " cax.xaxis.set_ticks_position(\"top\")\n", + " cax.xaxis.set_label_position(\"top\")\n", + " cax.yaxis.tick_right()\n", + " cax.yaxis.set_label_position(\"right\")" + ] + }, { "cell_type": "raw", - "id": "a2c7772a-631c-4f7f-a292-21b827b8fe3b", + "id": "45", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -879,7 +906,7 @@ { "cell_type": "code", "execution_count": null, - "id": "2431aef0-c129-4502-8527-588e6d811a48", + "id": "46", "metadata": { "editable": true, "slideshow": { @@ -889,13 +916,13 @@ }, "outputs": [], "source": [ - "spectrum_inverted = inversion.solution.mean(((\"scene_x\", \"scene_y\")))\n", + "spectrum_inverted = inversion.solution.outputs.mean((\"scene_x\", \"scene_y\"))\n", "spectrum_inverted = spectrum_inverted[{inversion.inverter.axis_iteration: -1}]" ] }, { "cell_type": "raw", - "id": "7a4d8632-f903-4071-9377-5728f77d2dda", + "id": "47", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -911,12 +938,8 @@ { "cell_type": "code", "execution_count": null, - "id": "ff4fcadc4acc604e", + "id": "48", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T17:02:21.957753900Z", - "start_time": "2026-04-14T17:02:21.699754800Z" - }, "editable": true, "slideshow": { "slide_type": "" From 0820fc58a7be835a07628a05974f78cb3b917975 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 22:19:58 -0600 Subject: [PATCH 23/55] black --- ctis/inverters/_iterative/_mart/_mart.py | 2 -- ctis/inverters/merit/__init__.py | 2 +- ctis/inverters/merit/_merit.py | 1 + 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index b993a60..e9159a2 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -136,7 +136,6 @@ def __call__( images_new = instrument.image(scene, noise=False).outputs - chi2_ij = self.mean_chi_squared(images, images_new) r_ij = self.correlation_residual(images, images_new) @@ -224,4 +223,3 @@ def __call__( mean_chi_squared=mean_chi_squared, correlation_residual=correlation_residual, ) - diff --git a/ctis/inverters/merit/__init__.py b/ctis/inverters/merit/__init__.py index a2bccfb..901daf4 100644 --- a/ctis/inverters/merit/__init__.py +++ b/ctis/inverters/merit/__init__.py @@ -8,4 +8,4 @@ __all__ = [ "mean_chi_squared", "correlation_residual", -] \ No newline at end of file +] diff --git a/ctis/inverters/merit/_merit.py b/ctis/inverters/merit/_merit.py index a6fdfa0..4d57bd9 100644 --- a/ctis/inverters/merit/_merit.py +++ b/ctis/inverters/merit/_merit.py @@ -7,6 +7,7 @@ "correlation_residual", ] + def mean_chi_squared( observed: na.ScalarArray, expected: na.ScalarArray, From 013806839e208896c0d832bc8e31b98824e86886 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 22:21:00 -0600 Subject: [PATCH 24/55] ruff --- ctis/inverters/_iterative/_mart/_mart.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index e9159a2..28e7e07 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -1,7 +1,6 @@ import warnings import dataclasses import numpy as np -import astropy.units as u import named_arrays as na import ctis from .. import AbstractIterativeInverter, IterativeInversionResult From c6b94254f04a74b4c92bcf66d0ea84a5bec6ae90 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 22:22:21 -0600 Subject: [PATCH 25/55] tests --- ctis/inverters/_iterative/_mart/_mart_test.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index 9ba84d5..052ddb4 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -35,16 +35,19 @@ coordinates_scene.wavelength = wavelength coordinates_sensor.wavelength = wavelength +angle = na.linspace(0, 360, num=4, axis="channel", endpoint=False) * u.deg + instrument = ctis.instruments.IdealInstrument( area_effective=1 * u.cm**2, timedelta_exposure=20 * u.s, plate_scale=0.4 * u.arcsec / u.pix, dispersion=((10 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix, - angle=na.linspace(0, 360, num=4, axis="channel", endpoint=False) * u.deg, + angle=angle, wavelength_ref=wavelength_rest, position_ref=na.Cartesian2dVectorArray(64, 32) * u.pix, coordinates_scene=coordinates_scene, coordinates_sensor=coordinates_sensor, + channel=angle, axis_channel="channel", axis_wavelength="wavelength", axis_scene_xy=("scene_x", "scene_y"), From 63934f8a9ff84630ba692002ce91ffdc9391fe8b Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 24 Apr 2026 22:27:19 -0600 Subject: [PATCH 26/55] bump named-arrays version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cfbf262..0b9178d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] dependencies = [ "astropy", - "named-arrays~=1.1", + "named-arrays~=1.2", ] dynamic = ["version"] From 04982c73b1ac43cfb27b3f0c32a7facf48ca512b Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 13:53:15 -0600 Subject: [PATCH 27/55] coverage --- ctis/inverters/_inverters_test.py | 5 ++-- ctis/inverters/_iterative/_iterative_test.py | 2 ++ ctis/inverters/_iterative/_mart/_mart.py | 14 ++++----- ctis/inverters/_iterative/_mart/_mart_test.py | 29 ++++++++++++++++++- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/ctis/inverters/_inverters_test.py b/ctis/inverters/_inverters_test.py index 23dc63b..9d9628f 100644 --- a/ctis/inverters/_inverters_test.py +++ b/ctis/inverters/_inverters_test.py @@ -16,13 +16,14 @@ def test__call__( self, a: ctis.inverters.AbstractInverter, images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], + **kwargs, ) -> ctis.inverters.InversionResult: - result = a(images) + result = a(images, **kwargs) assert isinstance(result, ctis.inverters.InversionResult) assert result.solution.sum() > 0 - assert result.success + assert isinstance(result.success, bool) assert isinstance(result.message, str) assert np.all(result.images == images) assert result.inverter == a diff --git a/ctis/inverters/_iterative/_iterative_test.py b/ctis/inverters/_iterative/_iterative_test.py index b858c14..36b3c61 100644 --- a/ctis/inverters/_iterative/_iterative_test.py +++ b/ctis/inverters/_iterative/_iterative_test.py @@ -11,11 +11,13 @@ def test__call__( self, a: ctis.inverters.AbstractIterativeInverter, images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], + **kwargs ) -> ctis.inverters.IterativeInversionResult: result = super().test__call__( a=a, images=images, + **kwargs ) axis_iteration = result.inverter.axis_iteration diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index 28e7e07..c7e3649 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -120,15 +120,16 @@ def __call__( backprojected = np.maximum(backprojected, 0) intermediate = [] - if self.intermediate: - intermediate.append(scene) chi2_old = np.inf chi2 = [] correlation_residual = [] - for i in range(1, self.num_iteration): + for i in range(self.num_iteration): + + if self.intermediate: + intermediate.append(scene) if verbose: # pragma: nocover print(f"{i=}") @@ -149,14 +150,14 @@ def __call__( if chi2_i > chi2_old: message = "Failure: chi squared increasing." success = False - num_iteration = i + num_iteration = i + 1 warnings.warn(message) break elif (chi2_old - chi2_i) < self.threshold_convergence: message = "Achieved mean chi squared of less than 1." success = True - num_iteration = i + num_iteration = i + 1 break backprojected_new = instrument.backproject(images_new).outputs @@ -182,9 +183,6 @@ def __call__( else: scene *= correction - if self.intermediate: - intermediate.append(scene) - chi2_old = chi2_i else: diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index 052ddb4..a30ba2a 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -61,18 +61,45 @@ ) -@pytest.mark.parametrize("a", [inverter]) +@pytest.mark.parametrize( + argnames="a", + argvalues=[ + ctis.inverters.MartInverter( + instrument=instrument, + threshold_convergence=1e-2, + ), + ctis.inverters.MartInverter( + instrument=instrument, + num_iteration=2, + threshold_convergence=1e-2, + ), + ctis.inverters.MartInverter( + instrument=instrument, + intermediate=True, + threshold_convergence=1e-2, + ) + ], +) class TestMartInverter( AbstractTestAbstractIterativeInverter, ): @pytest.mark.parametrize("images", [images]) + @pytest.mark.parametrize( + argnames="guess", + argvalues=[ + None, + na.ScalarArray.ones(scene.outputs.shape) * scene.outputs.unit, + ] + ) def test__call__( self, a: ctis.inverters.AbstractInverter, images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], + guess: na.ScalarArray, ): super().test__call__( a=a, images=images, + guess=guess, ) From 030472d366eb07951205e717709cd4fa9d961e95 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:01:09 -0600 Subject: [PATCH 28/55] sphinx link --- ctis/inverters/_iterative/_mart/_mart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index c7e3649..f46868d 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -18,7 +18,7 @@ class MartInverter( An inversion routine based on the Richardson-Lucy algorithm :cite:t:`Richardson1972,Lucy1974`. - For further information, see the discussion :doc:`discussions/mart-discussion`. + For further information, see the discussion :doc:`../discussions/mart-discussion`. """ instrument: ctis.instruments.AbstractInstrument = dataclasses.MISSING From 6bb5ac4ceb2b118fd82825c2e581998b4ce80148 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:05:29 -0600 Subject: [PATCH 29/55] use Pearson's r for now --- ctis/inverters/merit/_merit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ctis/inverters/merit/_merit.py b/ctis/inverters/merit/_merit.py index 4d57bd9..8f77ab7 100644 --- a/ctis/inverters/merit/_merit.py +++ b/ctis/inverters/merit/_merit.py @@ -42,7 +42,7 @@ def correlation_residual( axis: None | str | Sequence[str] = None, ) -> na.ScalarArray: """ - Compute the Spearman correlation coefficient between the expected values + Compute Pearson's correlation coefficient between the expected values and the residual. Parameters @@ -57,6 +57,6 @@ def correlation_residual( residual = observed - expected - r = na.stats.spearmanr(expected, residual, axis=axis) + r = na.stats.pearsonr(expected, residual, axis=axis) return r From 44741c7a5ab7f992d669653466bfdcd8566a88c8 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:05:39 -0600 Subject: [PATCH 30/55] coverage --- ctis/inverters/_iterative/_mart/_mart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index f46868d..b74d13c 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -147,7 +147,7 @@ def __call__( if verbose: # pragma: nocover print(f"mean chi squared: {chi2_ij}") - if chi2_i > chi2_old: + if chi2_i > chi2_old: # pragma: nocover message = "Failure: chi squared increasing." success = False num_iteration = i + 1 From ead610e74ce3edfd2b273e5556395ff6574d32a7 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:08:43 -0600 Subject: [PATCH 31/55] black --- ctis/inverters/_iterative/_iterative_test.py | 4 ++-- ctis/inverters/_iterative/_mart/_mart_test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ctis/inverters/_iterative/_iterative_test.py b/ctis/inverters/_iterative/_iterative_test.py index 36b3c61..43fa9a6 100644 --- a/ctis/inverters/_iterative/_iterative_test.py +++ b/ctis/inverters/_iterative/_iterative_test.py @@ -11,13 +11,13 @@ def test__call__( self, a: ctis.inverters.AbstractIterativeInverter, images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], - **kwargs + **kwargs, ) -> ctis.inverters.IterativeInversionResult: result = super().test__call__( a=a, images=images, - **kwargs + **kwargs, ) axis_iteration = result.inverter.axis_iteration diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index a30ba2a..8bb25d4 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -77,7 +77,7 @@ instrument=instrument, intermediate=True, threshold_convergence=1e-2, - ) + ), ], ) class TestMartInverter( @@ -90,7 +90,7 @@ class TestMartInverter( argvalues=[ None, na.ScalarArray.ones(scene.outputs.shape) * scene.outputs.unit, - ] + ], ) def test__call__( self, From 658a931842fed2a12b2dc88015185ee09d6d49f7 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:17:06 -0600 Subject: [PATCH 32/55] doc tweaks --- ctis/inverters/_iterative/_mart/_mart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index b74d13c..bed832d 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -42,7 +42,7 @@ class MartInverter( r""" The convergence threshold, :math:`T`, which halts the iteration. - If :math:`\langle \chi_{i}^2 \rangle - \langle \chi_{i-1}^2 \rangle < T`, + If :math:`\langle \chi_{i-1}^2 \rangle - \langle \chi_{i}^2 \rangle < T`, then the algorithm is considered to be converged. """ From 6a5e0489634b1d39c7f4f05efaaeace47f0052bd Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:19:55 -0600 Subject: [PATCH 33/55] docs --- ctis/inverters/_iterative/_mart/_mart.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index bed832d..cb9a8f5 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -29,10 +29,10 @@ class MartInverter( gamma: None | float = None r""" - Contrast-enhancement factor, :math:`\gamma`. + Learning rate, :math:`\gamma`. - At every iteration, the current guess, :math:`G`, is replaced by - :math:`G^\gamma`. + At every iteration, the current correction, :math:`C`, is replaced by + :math:`C^\gamma`. If :obj:`None`, :math:`\gamma = 2 / N`, where :math:`N` is the number of channels. From a84170f693588686c259bea122bce6ed96dfec23 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:21:07 -0600 Subject: [PATCH 34/55] docs --- ctis/inverters/_iterative/_mart/_mart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index cb9a8f5..5c77434 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -15,8 +15,8 @@ class MartInverter( AbstractIterativeInverter, ): """ - An inversion routine based on the Richardson-Lucy algorithm - :cite:t:`Richardson1972,Lucy1974`. + An inversion routine based on the multiplicative algebraic reconstruction + technique (MART) :cite:t:`Gordon1970`. For further information, see the discussion :doc:`../discussions/mart-discussion`. """ From 0f570fa14dfc2bd2c5a9d70f4e8d44bae78e6523 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:21:53 -0600 Subject: [PATCH 35/55] added an iris tutorial --- docs/tutorials/mart-iris.ipynb | 1014 ++++++++++++++++++++++++++++++++ 1 file changed, 1014 insertions(+) create mode 100644 docs/tutorials/mart-iris.ipynb diff --git a/docs/tutorials/mart-iris.ipynb b/docs/tutorials/mart-iris.ipynb new file mode 100644 index 0000000..1e4c0d1 --- /dev/null +++ b/docs/tutorials/mart-iris.ipynb @@ -0,0 +1,1014 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "0", + "metadata": {}, + "outputs": [], + "source": [ + "%reload_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import IPython.display\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import astropy.units as u\n", + "import astropy.visualization\n", + "import named_arrays as na\n", + "import iris\n", + "import ctis" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2", + "metadata": {}, + "outputs": [], + "source": [ + "plt.rcParams[\"animation.embed_limit\"] = 100" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "# scene = iris.sg.open(\"2013-10-22 11:30\")\n", + "# scene = iris.sg.open(\"2014-07-05 23:00\")\n", + "scene = iris.sg.open(\"2014-07-04 11:40\")\n", + "# scene = iris.sg.open(\"2014-10-07 18:16\")\n", + "# scene = iris.sg.open(\"2014-10-08 17:01\")\n", + "# obs = iris.sg.open(\"2015-02-24 19:03\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "scene = scene[{scene.axis_time: 0}]\n", + "scene.timedelta = scene.timedelta[{scene.axis_time: 0}]\n", + "scene.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "scene = na.despike(scene, axis=(scene.axis_wavelength, scene.axis_detector_y))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "scene.outputs = np.nan_to_num(scene.outputs)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "scene.outputs[scene.outputs < 0] = 0\n", + "scene.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "scene.inputs.position = scene.inputs.position - scene.inputs.position.mean()\n", + "scene.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "scene = scene.radiance\n", + "scene.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "velocity_thresh = 150 * u.km / u.s\n", + "\n", + "velocity_centers = scene.velocity_doppler.cell_centers()\n", + "\n", + "index_lower = np.argmax(-velocity_thresh < velocity_centers)[scene.axis_wavelength]\n", + "index_upper = np.argmax(velocity_thresh < velocity_centers)[scene.axis_wavelength]\n", + "\n", + "crop_wavelength = {scene.axis_wavelength: slice(index_lower.ndarray, index_upper.ndarray)}\n", + "\n", + "scene = scene[crop_wavelength]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "scene.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "scene.show();" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "wavelength_rest = scene.wavelength_center" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "coordinates_scene = scene.inputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "position_sensor = na.Cartesian2dVectorArray(\n", + " x=na.arange(0, 512 + 1, axis=\"sensor_x\") * u.pix,\n", + " y=na.arange(0, 512 + 1, axis=\"sensor_y\") * u.pix,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "coordinates_sensor = na.SpectralPositionalVectorArray(\n", + " wavelength=scene.inputs.wavelength,\n", + " position=position_sensor,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18", + "metadata": {}, + "outputs": [], + "source": [ + "angle = na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "channel = \"dispersion angle = \" + angle.to_string_array(\"%03d\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "instrument = ctis.instruments.IdealInstrument(\n", + " area_effective=1 * u.mm ** 2,\n", + " timedelta_exposure=10 * u.s,\n", + " plate_scale=0.4 * u.arcsec / u.pix,\n", + " dispersion=((5 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", + " angle=angle,\n", + " wavelength_ref=wavelength_rest,\n", + " position_ref=na.Cartesian2dVectorArray(256, 256) * u.pix,\n", + " coordinates_scene=coordinates_scene,\n", + " coordinates_sensor=coordinates_sensor,\n", + " channel=channel,\n", + " axis_channel=\"channel\",\n", + " axis_wavelength=scene.axis_wavelength,\n", + " axis_scene_xy=(scene.axis_detector_x, scene.axis_detector_y),\n", + " axis_sensor_xy=(\"sensor_x\", \"sensor_y\"),\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "images = instrument.image(scene)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "images = images" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots(\n", + " constrained_layout=True,\n", + " figsize=(5, 4),\n", + " )\n", + " norm = plt.Normalize(\n", + " vmin=0,\n", + " vmax=500,\n", + " # vmax=images.outputs.value.ndarray.max(),\n", + " )\n", + " colorizer = plt.Colorizer(\n", + " cmap=\"gray\",\n", + " norm=norm,\n", + " )\n", + " ani = na.plt.pcolormovie(\n", + " channel,\n", + " images.inputs.position.x,\n", + " images.inputs.position.y,\n", + " C=images.outputs.value,\n", + " axis_time=\"channel\",\n", + " ax=ax,\n", + " kwargs_pcolormesh=dict(\n", + " colorizer=colorizer,\n", + " ),\n", + " )\n", + " plt.colorbar(\n", + " mappable=plt.cm.ScalarMappable(colorizer=colorizer),\n", + " ax=ax,\n", + " label=f\"signal ({images.outputs.unit:latex_inline})\",\n", + " )\n", + " ax.set_aspect(\"equal\")\n", + " ax.set_xlabel(f\"sensor $x$ ({images.inputs.position.x.unit})\")\n", + " ax.set_ylabel(f\"sensor $y$ ({images.inputs.position.y.unit})\")\n", + "\n", + "result = ani.to_jshtml(fps=2)\n", + "result = IPython.display.HTML(result)\n", + "\n", + "plt.close(ani._fig)\n", + "\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "24", + "metadata": {}, + "outputs": [], + "source": [ + "import dataclasses" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25", + "metadata": {}, + "outputs": [], + "source": [ + "coords_scene_single = coordinates_scene.replace(wavelength=images.inputs.wavelength)\n", + "coords_sensor_single = images.inputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "instrument_single = dataclasses.replace(\n", + " instrument,\n", + " coordinates_scene=coords_scene_single,\n", + " coordinates_sensor=coords_sensor_single,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27", + "metadata": {}, + "outputs": [], + "source": [ + "s = instrument_single.backproject(images) * scene.outputs.shape[\"wavelength\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "28", + "metadata": {}, + "outputs": [], + "source": [ + "smin = np.min(s, axis=\"channel\")[dict(channel=0)]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29", + "metadata": {}, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots()\n", + " na.plt.pcolormesh(\n", + " smin.inputs.position.x,\n", + " smin.inputs.position.y,\n", + " C=smin.outputs[dict(channel=0, wavelength=0)].value,\n", + " vmin=0,\n", + " vmax=np.percentile(smin.outputs, 99.5).value,\n", + " )\n", + " ax.set_aspect(\"equal\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30", + "metadata": {}, + "outputs": [], + "source": [ + "mart = ctis.inverters.MartInverter(\n", + " instrument=instrument,\n", + " intermediate=True,\n", + " # num_iteration=10,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31", + "metadata": {}, + "outputs": [], + "source": [ + "spectrum_avg = scene.outputs.mean((scene.axis_detector_x, scene.axis_detector_y))\n", + "spectrum_avg = spectrum_avg / spectrum_avg.sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32", + "metadata": {}, + "outputs": [], + "source": [ + "guess_spatial = smin.outputs\n", + "# guess_spatial = guess_spatial.broadcast_to(scene.outputs.shape)\n", + "guess_spatial = guess_spatial / guess_spatial.sum()\n", + "guess_spatial.sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33", + "metadata": {}, + "outputs": [], + "source": [ + "guess = spectrum_avg * guess_spatial * scene.outputs.sum()\n", + "# guess = spectrum_avg * scene.outputs.sum()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "inversion = mart(images, guess=guess, verbose=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35", + "metadata": {}, + "outputs": [], + "source": [ + "axis_iter = inversion.inverter.axis_iteration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36", + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(\n", + " nrows=2,\n", + " sharex=True,\n", + " constrained_layout=True,\n", + ")\n", + "na.plt.plot(\n", + " inversion.iteration,\n", + " inversion.mean_chi_squared,\n", + " ax=ax[0],\n", + " axis=axis_iter,\n", + " label=channel,\n", + ")\n", + "na.plt.plot(\n", + " inversion.iteration,\n", + " inversion.correlation_residual,\n", + " ax=ax[1],\n", + " axis=axis_iter,\n", + " label=channel,\n", + ")\n", + "ax[0].set_yscale(\"log\")\n", + "ax[0].legend();" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37", + "metadata": {}, + "outputs": [], + "source": [ + "solution = inversion.solution[{axis_iter: ~0}]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38", + "metadata": {}, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots(constrained_layout=True)\n", + " na.plt.stairs(\n", + " scene.inputs.wavelength,\n", + " scene.outputs.mean((scene.axis_detector_x, scene.axis_detector_y)),\n", + " ax=ax,\n", + " label=\"original\",\n", + " )\n", + " na.plt.stairs(\n", + " solution.inputs.wavelength,\n", + " solution.outputs.mean((scene.axis_detector_x, scene.axis_detector_y)),\n", + " ax=ax,\n", + " label=\"reconstructed\",\n", + " )\n", + " ax.set_xlabel(f\"wavelength ({ax.get_xlabel()})\")\n", + " # ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", + " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")\n", + " ax.legend()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39", + "metadata": {}, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, axs = plt.subplots(\n", + " ncols=3,\n", + " gridspec_kw=dict(width_ratios=[.5, .5, .1]),\n", + " constrained_layout=True,\n", + " figsize=(10, 6),\n", + " )\n", + " ax1, ax2, cax = axs\n", + " ax2.set_yticklabels([])\n", + " vmax = np.nanpercentile(\n", + " a=scene.outputs,\n", + " q=99.5,\n", + " \n", + " axis=(scene.axis_detector_x, scene.axis_detector_y),\n", + " )\n", + "\n", + " na.plt.rgbmesh(\n", + " C=scene,\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax1,\n", + " vmin=0,\n", + " vmax=vmax,\n", + " )\n", + " colorbar = na.plt.rgbmesh(\n", + " scene.velocity_doppler,\n", + " scene.inputs.position.x,\n", + " scene.inputs.position.y,\n", + " C=solution.outputs,\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax2,\n", + " vmin=0,\n", + " vmax=vmax,\n", + " )\n", + " na.plt.pcolormesh(\n", + " C=colorbar,\n", + " axis_rgb=\"wavelength\",\n", + " ax=cax,\n", + " )\n", + " ax1.set_title(\"original\")\n", + " ax2.set_title(\"reconstructed\")\n", + " unit_x = scene.inputs.position.x.unit\n", + " unit_y = scene.inputs.position.y.unit\n", + " ax1.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + " ax2.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + " ax1.set_ylabel(f\"scene $y$ ({unit_y:latex_inline})\")\n", + " cax.xaxis.set_ticks_position(\"top\")\n", + " cax.xaxis.set_label_position(\"top\")\n", + " cax.yaxis.tick_right()\n", + " cax.yaxis.set_label_position(\"right\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40", + "metadata": {}, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, ax = plt.subplots(\n", + " ncols=2,\n", + " constrained_layout=True,\n", + " sharex=True,\n", + " sharey=True,\n", + " # figsize=(10, 5),\n", + " )\n", + " i = {scene.axis_detector_x: 248}\n", + " na.plt.pcolormesh(\n", + " scene.inputs.wavelength,\n", + " scene.inputs.position.y[i],\n", + " C=np.sqrt(scene.outputs[i].value),\n", + " ax=ax[0],\n", + " vmin=0,\n", + " vmax=1000,\n", + " )\n", + " ani = na.plt.pcolormovie(\n", + " inversion.iteration,\n", + " inversion.solution.inputs.wavelength,\n", + " inversion.solution.inputs.position.y[i],\n", + " C=np.sqrt(inversion.solution.outputs[i].value),\n", + " ax=ax[1],\n", + " vmin=0,\n", + " vmax=1000,\n", + " axis_time=inversion.inverter.axis_iteration,\n", + " )\n", + " ax[0].set_title(inversion.solution.inputs.position.x[i].ndarray.mean())\n", + "\n", + "result = ani.to_jshtml(fps=10)\n", + "result = IPython.display.HTML(result)\n", + "\n", + "ani.save(\"mart-iris.gif\")\n", + "\n", + "plt.close(ani._fig)\n", + "\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "41", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "with astropy.visualization.quantity_support():\n", + " fig, axs = plt.subplots(\n", + " ncols=3,\n", + " gridspec_kw=dict(width_ratios=[.5, .5, .1]),\n", + " constrained_layout=True,\n", + " figsize=(10, 6),\n", + " )\n", + " ax1, ax2, cax = axs\n", + " ax2.set_yticklabels([])\n", + " vmax = np.nanpercentile(\n", + " a=scene.outputs,\n", + " q=99.5,\n", + " \n", + " axis=(scene.axis_detector_x, scene.axis_detector_y),\n", + " )\n", + "\n", + " na.plt.rgbmesh(\n", + " C=scene,\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax1,\n", + " vmin=0,\n", + " vmax=vmax,\n", + " )\n", + " label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\")\n", + " chisq_str = r\"$\\langle \\chi^2 \\rangle$\"\n", + " label = label + f\"\\n{chisq_str} = \" + inversion.mean_chi_squared.mean(\"channel\").to_string_array(\"%.03f\")\n", + " ani, colorbar = na.plt.rgbmovie(\n", + " label,\n", + " scene.velocity_doppler,\n", + " scene.inputs.position.x,\n", + " scene.inputs.position.y,\n", + " C=inversion.solution.outputs,\n", + " axis_time=inversion.inverter.axis_iteration,\n", + " axis_wavelength=\"wavelength\",\n", + " ax=ax2,\n", + " vmin=0,\n", + " vmax=vmax,\n", + " )\n", + " na.plt.pcolormesh(\n", + " C=colorbar,\n", + " axis_rgb=\"wavelength\",\n", + " ax=cax,\n", + " )\n", + " ax1.set_title(\"original\")\n", + " ax2.set_title(\"reconstructed\")\n", + " unit_x = scene.inputs.position.x.unit\n", + " unit_y = scene.inputs.position.y.unit\n", + " ax1.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + " ax2.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + " ax1.set_ylabel(f\"scene $y$ ({unit_y:latex_inline})\")\n", + " cax.xaxis.set_ticks_position(\"top\")\n", + " cax.xaxis.set_label_position(\"top\")\n", + " cax.yaxis.tick_right()\n", + " cax.yaxis.set_label_position(\"right\")\n", + "\n", + "result = ani.to_jshtml(fps=10)\n", + "result = IPython.display.HTML(result)\n", + "\n", + "ani.save(\"mart-iris.gif\")\n", + "\n", + "plt.close(ani._fig)\n", + "\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42", + "metadata": {}, + "outputs": [], + "source": [ + "velocity = scene.velocity_doppler" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "43", + "metadata": {}, + "outputs": [], + "source": [ + "sum_scene = scene.outputs.sum(scene.axis_wavelength)\n", + "sum_recon = solution.outputs.sum(scene.axis_wavelength).to(scene.outputs.unit)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44", + "metadata": {}, + "outputs": [], + "source": [ + "median_scene = na.pdf.median(velocity, scene.outputs, axis=scene.axis_wavelength)\n", + "median_recon = na.pdf.median(velocity, solution.outputs, axis=scene.axis_wavelength)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45", + "metadata": {}, + "outputs": [], + "source": [ + "iqr_scene = na.pdf.iqr(velocity, scene.outputs, axis=scene.axis_wavelength)\n", + "iqr_recon = na.pdf.iqr(velocity, solution.outputs, axis=scene.axis_wavelength)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46", + "metadata": {}, + "outputs": [], + "source": [ + "bins = dict(true=50, reconstructed=50)\n", + "hist_sum = na.histogram2d(sum_scene, sum_recon, bins=bins, min=0 * solution.outputs.unit, max=0.2e8 * solution.outputs.unit)\n", + "hist_median = na.histogram2d(median_scene, median_recon, bins=bins, min=-50 * u.km / u.s, max=50 * u.km / u.s)\n", + "hist_iqr = na.histogram2d(iqr_scene, iqr_recon, bins=bins, min=0 * u.km / u.s, max=100 * u.km / u.s)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47", + "metadata": {}, + "outputs": [], + "source": [ + "hist_sum = hist_sum / hist_sum.sum(\"reconstructed\")\n", + "hist_median = hist_median / hist_median.sum(\"reconstructed\")\n", + "hist_iqr = hist_iqr / hist_iqr.sum(\"reconstructed\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.colors" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49", + "metadata": {}, + "outputs": [], + "source": [ + "with astropy.visualization.quantity_support():\n", + " fig, axs = plt.subplots(\n", + " constrained_layout=True,\n", + " figsize=(10, 4),\n", + " ncols=3,\n", + " )\n", + " ax_sum, ax_median, ax_iqr = axs\n", + " na.plt.pcolormesh(\n", + " C=hist_sum,\n", + " ax=ax_sum,\n", + " norm=matplotlib.colors.LogNorm(),\n", + " )\n", + " na.plt.pcolormesh(\n", + " C=hist_median,\n", + " ax=ax_median,\n", + " norm=matplotlib.colors.LogNorm(),\n", + " )\n", + " na.plt.pcolormesh(\n", + " C=hist_iqr,\n", + " ax=ax_iqr,\n", + " norm=matplotlib.colors.LogNorm(),\n", + " )\n", + " ax_sum.set_aspect(\"equal\")\n", + " ax_median.set_aspect(\"equal\")\n", + " ax_iqr.set_aspect(\"equal\")\n", + " ax_sum.set_title(\"sum\")\n", + " ax_median.set_title(\"median\")\n", + " ax_iqr.set_title(\"interquartile range\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50", + "metadata": {}, + "outputs": [], + "source": [ + "import scipy.stats" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "51", + "metadata": {}, + "outputs": [], + "source": [ + "where_finite = np.isfinite(sum_scene) & np.isfinite(median_scene) & np.isfinite(median_recon)\n", + "where_finite.sum() / where_finite.size" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52", + "metadata": {}, + "outputs": [], + "source": [ + "float(scipy.stats.spearmanr(sum_scene[where_finite].ndarray, sum_recon[where_finite].ndarray).statistic)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53", + "metadata": {}, + "outputs": [], + "source": [ + "float(scipy.stats.spearmanr(median_scene[where_finite].ndarray, median_recon[where_finite].ndarray).statistic)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54", + "metadata": {}, + "outputs": [], + "source": [ + "float(scipy.stats.spearmanr(iqr_scene[where_finite].ndarray, iqr_recon[where_finite].ndarray).statistic)" + ] + }, + { + "cell_type": "markdown", + "id": "55", + "metadata": {}, + "source": [ + " - signal-correlated residuals (spearman)\n", + " - mean-chi squared of each channel\n", + " - " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "predictions = instrument.image(solution, noise=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57", + "metadata": {}, + "outputs": [], + "source": [ + "residual = images - predictions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58", + "metadata": {}, + "outputs": [], + "source": [ + "fig, ax = na.plt.subplots(\n", + " axis_rows=\"rows\",\n", + " axis_cols=\"cols\",\n", + " nrows=2,\n", + " ncols=2,\n", + " constrained_layout=True,\n", + " figsize=(8,7),\n", + " sharex=True,\n", + " sharey=True,\n", + ")\n", + "ax = ax.reshape(dict(channel=-1))\n", + "vmin = -35\n", + "vmax = +35\n", + "img = na.plt.pcolormesh(\n", + " residual.inputs.position.x,\n", + " residual.inputs.position.y,\n", + " C=residual.outputs.value,\n", + " ax=ax,\n", + " vmin=vmin,\n", + " vmax=vmax,\n", + " cmap=\"gray\",\n", + ")\n", + "na.plt.set_title\n", + "na.plt.set_aspect(\"equal\", ax=ax)\n", + "plt.colorbar(\n", + " ax=ax.ndarray,\n", + " mappable=img.ndarray[0],\n", + " label=f\"residual ({residual.outputs.unit:latex_inline})\",\n", + ");" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59", + "metadata": {}, + "outputs": [], + "source": [ + "float(scipy.stats.spearmanr(predictions.outputs.ndarray.reshape(-1), residual.outputs.ndarray.reshape(-1)).statistic)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "60", + "metadata": {}, + "outputs": [], + "source": [ + "float(scipy.stats.pearsonr(predictions.outputs.ndarray.reshape(-1), residual.outputs.ndarray.reshape(-1)).statistic)" + ] + }, + { + "cell_type": "markdown", + "id": "61", + "metadata": {}, + "source": [ + " - mulitply counts by 10 to see how it changes the results\n", + " - smooth the outputs before taking the residual (and computing the moments)\n", + " - negative correlation coefficient implies that we went a little too far (crossed zero)" + ] + }, + { + "cell_type": "markdown", + "id": "62", + "metadata": {}, + "source": [ + "$d \\chi = \\frac{d' - d}{\\sqrt{d}}$ contribution of the residual to the total chi square (SNR-weighted residual)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From e71aa25a883b4d7cf1e5afb933094538a2512e4c Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 14:43:09 -0600 Subject: [PATCH 36/55] add iris to deps --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0b9178d..2202301 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ test = [ doc = [ "pytest", "matplotlib", + "interface-region-imaging-spectrograph", "graphviz", "sphinx-autodoc-typehints", "sphinxcontrib-bibtex", From b59ae2fcb072c2cc714e3bd19314af256448c4c0 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 15:56:51 -0600 Subject: [PATCH 37/55] try with smaller obs --- docs/tutorials/mart-iris.ipynb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/mart-iris.ipynb b/docs/tutorials/mart-iris.ipynb index 1e4c0d1..3464b77 100644 --- a/docs/tutorials/mart-iris.ipynb +++ b/docs/tutorials/mart-iris.ipynb @@ -4,7 +4,12 @@ "cell_type": "code", "execution_count": null, "id": "0", - "metadata": {}, + "metadata": { + "ExecuteTime": { + "end_time": "2026-04-25T20:56:39.795626900Z", + "start_time": "2026-04-25T20:56:37.522100900Z" + } + }, "outputs": [], "source": [ "%reload_ext autoreload\n", @@ -47,7 +52,8 @@ "source": [ "# scene = iris.sg.open(\"2013-10-22 11:30\")\n", "# scene = iris.sg.open(\"2014-07-05 23:00\")\n", - "scene = iris.sg.open(\"2014-07-04 11:40\")\n", + "# scene = iris.sg.open(\"2014-07-04 11:40\")\n", + "scene = iris.sg.open(\"2014-10-13 04:11\") #\n", "# scene = iris.sg.open(\"2014-10-07 18:16\")\n", "# scene = iris.sg.open(\"2014-10-08 17:01\")\n", "# obs = iris.sg.open(\"2015-02-24 19:03\")" @@ -612,7 +618,7 @@ " sharey=True,\n", " # figsize=(10, 5),\n", " )\n", - " i = {scene.axis_detector_x: 248}\n", + " i = {scene.axis_detector_x: 190}\n", " na.plt.pcolormesh(\n", " scene.inputs.wavelength,\n", " scene.inputs.position.y[i],\n", @@ -709,7 +715,7 @@ "result = ani.to_jshtml(fps=10)\n", "result = IPython.display.HTML(result)\n", "\n", - "ani.save(\"mart-iris.gif\")\n", + "# ani.save(\"mart-iris.gif\")\n", "\n", "plt.close(ani._fig)\n", "\n", From 7c9a7adb6f22b6b55f4dab4331f8b03a57656403 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Sat, 25 Apr 2026 22:30:18 -0600 Subject: [PATCH 38/55] try to use less memory --- docs/tutorials/mart-iris.ipynb | 195 +++++++++++++++++---------------- 1 file changed, 100 insertions(+), 95 deletions(-) diff --git a/docs/tutorials/mart-iris.ipynb b/docs/tutorials/mart-iris.ipynb index 3464b77..200b42f 100644 --- a/docs/tutorials/mart-iris.ipynb +++ b/docs/tutorials/mart-iris.ipynb @@ -417,7 +417,7 @@ "source": [ "mart = ctis.inverters.MartInverter(\n", " instrument=instrument,\n", - " intermediate=True,\n", + " # intermediate=True,\n", " # num_iteration=10,\n", ")" ] @@ -515,7 +515,8 @@ "metadata": {}, "outputs": [], "source": [ - "solution = inversion.solution[{axis_iter: ~0}]" + "# solution = inversion.solution[{axis_iter: ~0}]\n", + "solution = inversion.solution" ] }, { @@ -561,10 +562,14 @@ " )\n", " ax1, ax2, cax = axs\n", " ax2.set_yticklabels([])\n", + " vmin = np.nanpercentile(\n", + " a=scene.outputs,\n", + " q=0.5,\n", + " axis=(scene.axis_detector_x, scene.axis_detector_y),\n", + " )\n", " vmax = np.nanpercentile(\n", " a=scene.outputs,\n", " q=99.5,\n", - " \n", " axis=(scene.axis_detector_x, scene.axis_detector_y),\n", " )\n", "\n", @@ -572,7 +577,7 @@ " C=scene,\n", " axis_wavelength=\"wavelength\",\n", " ax=ax1,\n", - " vmin=0,\n", + " vmin=vmin,\n", " vmax=vmax,\n", " )\n", " colorbar = na.plt.rgbmesh(\n", @@ -582,7 +587,7 @@ " C=solution.outputs,\n", " axis_wavelength=\"wavelength\",\n", " ax=ax2,\n", - " vmin=0,\n", + " vmin=vmin,\n", " vmax=vmax,\n", " )\n", " na.plt.pcolormesh(\n", @@ -610,43 +615,43 @@ "metadata": {}, "outputs": [], "source": [ - "with astropy.visualization.quantity_support():\n", - " fig, ax = plt.subplots(\n", - " ncols=2,\n", - " constrained_layout=True,\n", - " sharex=True,\n", - " sharey=True,\n", - " # figsize=(10, 5),\n", - " )\n", - " i = {scene.axis_detector_x: 190}\n", - " na.plt.pcolormesh(\n", - " scene.inputs.wavelength,\n", - " scene.inputs.position.y[i],\n", - " C=np.sqrt(scene.outputs[i].value),\n", - " ax=ax[0],\n", - " vmin=0,\n", - " vmax=1000,\n", - " )\n", - " ani = na.plt.pcolormovie(\n", - " inversion.iteration,\n", - " inversion.solution.inputs.wavelength,\n", - " inversion.solution.inputs.position.y[i],\n", - " C=np.sqrt(inversion.solution.outputs[i].value),\n", - " ax=ax[1],\n", - " vmin=0,\n", - " vmax=1000,\n", - " axis_time=inversion.inverter.axis_iteration,\n", - " )\n", - " ax[0].set_title(inversion.solution.inputs.position.x[i].ndarray.mean())\n", + "# with astropy.visualization.quantity_support():\n", + "# fig, ax = plt.subplots(\n", + "# ncols=2,\n", + "# constrained_layout=True,\n", + "# sharex=True,\n", + "# sharey=True,\n", + "# # figsize=(10, 5),\n", + "# )\n", + "# i = {scene.axis_detector_x: 190}\n", + "# na.plt.pcolormesh(\n", + "# scene.inputs.wavelength,\n", + "# scene.inputs.position.y[i],\n", + "# C=np.sqrt(scene.outputs[i].value),\n", + "# ax=ax[0],\n", + "# vmin=0,\n", + "# vmax=1000,\n", + "# )\n", + "# ani = na.plt.pcolormovie(\n", + "# inversion.iteration,\n", + "# inversion.solution.inputs.wavelength,\n", + "# inversion.solution.inputs.position.y[i],\n", + "# C=np.sqrt(inversion.solution.outputs[i].value),\n", + "# ax=ax[1],\n", + "# vmin=0,\n", + "# vmax=1000,\n", + "# axis_time=inversion.inverter.axis_iteration,\n", + "# )\n", + "# ax[0].set_title(inversion.solution.inputs.position.x[i].ndarray.mean())\n", "\n", - "result = ani.to_jshtml(fps=10)\n", - "result = IPython.display.HTML(result)\n", + "# result = ani.to_jshtml(fps=10)\n", + "# result = IPython.display.HTML(result)\n", "\n", - "ani.save(\"mart-iris.gif\")\n", + "# ani.save(\"mart-iris.gif\")\n", "\n", - "plt.close(ani._fig)\n", + "# plt.close(ani._fig)\n", "\n", - "result" + "# result" ] }, { @@ -657,69 +662,69 @@ "outputs": [], "source": [ "\n", - "with astropy.visualization.quantity_support():\n", - " fig, axs = plt.subplots(\n", - " ncols=3,\n", - " gridspec_kw=dict(width_ratios=[.5, .5, .1]),\n", - " constrained_layout=True,\n", - " figsize=(10, 6),\n", - " )\n", - " ax1, ax2, cax = axs\n", - " ax2.set_yticklabels([])\n", - " vmax = np.nanpercentile(\n", - " a=scene.outputs,\n", - " q=99.5,\n", + "# with astropy.visualization.quantity_support():\n", + "# fig, axs = plt.subplots(\n", + "# ncols=3,\n", + "# gridspec_kw=dict(width_ratios=[.5, .5, .1]),\n", + "# constrained_layout=True,\n", + "# figsize=(10, 6),\n", + "# )\n", + "# ax1, ax2, cax = axs\n", + "# ax2.set_yticklabels([])\n", + "# vmax = np.nanpercentile(\n", + "# a=scene.outputs,\n", + "# q=99.5,\n", " \n", - " axis=(scene.axis_detector_x, scene.axis_detector_y),\n", - " )\n", + "# axis=(scene.axis_detector_x, scene.axis_detector_y),\n", + "# )\n", "\n", - " na.plt.rgbmesh(\n", - " C=scene,\n", - " axis_wavelength=\"wavelength\",\n", - " ax=ax1,\n", - " vmin=0,\n", - " vmax=vmax,\n", - " )\n", - " label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\")\n", - " chisq_str = r\"$\\langle \\chi^2 \\rangle$\"\n", - " label = label + f\"\\n{chisq_str} = \" + inversion.mean_chi_squared.mean(\"channel\").to_string_array(\"%.03f\")\n", - " ani, colorbar = na.plt.rgbmovie(\n", - " label,\n", - " scene.velocity_doppler,\n", - " scene.inputs.position.x,\n", - " scene.inputs.position.y,\n", - " C=inversion.solution.outputs,\n", - " axis_time=inversion.inverter.axis_iteration,\n", - " axis_wavelength=\"wavelength\",\n", - " ax=ax2,\n", - " vmin=0,\n", - " vmax=vmax,\n", - " )\n", - " na.plt.pcolormesh(\n", - " C=colorbar,\n", - " axis_rgb=\"wavelength\",\n", - " ax=cax,\n", - " )\n", - " ax1.set_title(\"original\")\n", - " ax2.set_title(\"reconstructed\")\n", - " unit_x = scene.inputs.position.x.unit\n", - " unit_y = scene.inputs.position.y.unit\n", - " ax1.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", - " ax2.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", - " ax1.set_ylabel(f\"scene $y$ ({unit_y:latex_inline})\")\n", - " cax.xaxis.set_ticks_position(\"top\")\n", - " cax.xaxis.set_label_position(\"top\")\n", - " cax.yaxis.tick_right()\n", - " cax.yaxis.set_label_position(\"right\")\n", + "# na.plt.rgbmesh(\n", + "# C=scene,\n", + "# axis_wavelength=\"wavelength\",\n", + "# ax=ax1,\n", + "# vmin=0,\n", + "# vmax=vmax,\n", + "# )\n", + "# label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\")\n", + "# chisq_str = r\"$\\langle \\chi^2 \\rangle$\"\n", + "# label = label + f\"\\n{chisq_str} = \" + inversion.mean_chi_squared.mean(\"channel\").to_string_array(\"%.03f\")\n", + "# ani, colorbar = na.plt.rgbmovie(\n", + "# label,\n", + "# scene.velocity_doppler,\n", + "# scene.inputs.position.x,\n", + "# scene.inputs.position.y,\n", + "# C=inversion.solution.outputs,\n", + "# axis_time=inversion.inverter.axis_iteration,\n", + "# axis_wavelength=\"wavelength\",\n", + "# ax=ax2,\n", + "# vmin=0,\n", + "# vmax=vmax,\n", + "# )\n", + "# na.plt.pcolormesh(\n", + "# C=colorbar,\n", + "# axis_rgb=\"wavelength\",\n", + "# ax=cax,\n", + "# )\n", + "# ax1.set_title(\"original\")\n", + "# ax2.set_title(\"reconstructed\")\n", + "# unit_x = scene.inputs.position.x.unit\n", + "# unit_y = scene.inputs.position.y.unit\n", + "# ax1.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + "# ax2.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", + "# ax1.set_ylabel(f\"scene $y$ ({unit_y:latex_inline})\")\n", + "# cax.xaxis.set_ticks_position(\"top\")\n", + "# cax.xaxis.set_label_position(\"top\")\n", + "# cax.yaxis.tick_right()\n", + "# cax.yaxis.set_label_position(\"right\")\n", "\n", - "result = ani.to_jshtml(fps=10)\n", - "result = IPython.display.HTML(result)\n", + "# result = ani.to_jshtml(fps=10)\n", + "# result = IPython.display.HTML(result)\n", "\n", - "# ani.save(\"mart-iris.gif\")\n", + "# # ani.save(\"mart-iris.gif\")\n", "\n", - "plt.close(ani._fig)\n", + "# plt.close(ani._fig)\n", "\n", - "result" + "# result" ] }, { From a78c9a1885f74fc3b7c91a16a003b966e779fe67 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Fri, 22 May 2026 16:29:25 -0600 Subject: [PATCH 39/55] lots of fixes --- ctis/instruments/_instruments.py | 16 +- ctis/inverters/__init__.py | 3 +- ctis/inverters/_inverters_test.py | 4 +- ctis/inverters/_iterative/_iterative.py | 63 +- ctis/inverters/_iterative/_mart/_mart.py | 42 +- ctis/inverters/_iterative/_mart/_mart_test.py | 26 +- ctis/inverters/_results.py | 251 +++- ctis/scenes/_gaussians.py | 45 +- docs/tutorials/mart-iris.ipynb | 1025 ----------------- pyproject.toml | 2 +- 10 files changed, 371 insertions(+), 1106 deletions(-) delete mode 100644 docs/tutorials/mart-iris.ipynb diff --git a/ctis/instruments/_instruments.py b/ctis/instruments/_instruments.py index 3d2f549..f65ae17 100644 --- a/ctis/instruments/_instruments.py +++ b/ctis/instruments/_instruments.py @@ -504,10 +504,6 @@ def _coordinates_output(self) -> na.AbstractSpectralPositionalVectorArray: coordinates_output = coordinates_output.cell_centers(self.axis_wavelength) - p = coordinates_output.position - coordinates_output.position.x = na.random.normal(p.x, 1e-3 * u.pix) - coordinates_output.position.y = na.random.normal(p.y, 1e-3 * u.pix) - return coordinates_output @functools.cached_property @@ -530,12 +526,12 @@ def weights_transpose(self): coordinates_input = self._coordinates_input coordinates_output = self._coordinates_output - return na.regridding.weights( - coordinates_input=coordinates_output.position, - coordinates_output=coordinates_input.position, - axis_input=self.axis_sensor_xy, - axis_output=self.axis_scene_xy, - method="conservative", + return na.regridding.transpose_weights_conservative( + weights=self.weights, + coordinates_input=coordinates_input.position, + coordinates_output=coordinates_output.position, + axis_input=self.axis_scene_xy, + axis_output=self.axis_sensor_xy, ) def image( diff --git a/ctis/inverters/__init__.py b/ctis/inverters/__init__.py index c227396..f2f8514 100644 --- a/ctis/inverters/__init__.py +++ b/ctis/inverters/__init__.py @@ -1,7 +1,7 @@ """Inversion algorithms which can reconstruct scenes from observed images.""" from . import merit -from ._results import InversionResult +from ._results import AbstractInversionResult, InversionResult from ._inverters import AbstractInverter from ._iterative import ( AbstractIterativeInverter, @@ -14,6 +14,7 @@ "AbstractInverter", "AbstractIterativeInverter", "MartInverter", + "AbstractInversionResult", "InversionResult", "IterativeInversionResult", ] diff --git a/ctis/inverters/_inverters_test.py b/ctis/inverters/_inverters_test.py index 9d9628f..b7232a4 100644 --- a/ctis/inverters/_inverters_test.py +++ b/ctis/inverters/_inverters_test.py @@ -17,10 +17,10 @@ def test__call__( a: ctis.inverters.AbstractInverter, images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], **kwargs, - ) -> ctis.inverters.InversionResult: + ) -> ctis.inverters.AbstractInversionResult: result = a(images, **kwargs) - assert isinstance(result, ctis.inverters.InversionResult) + assert isinstance(result, ctis.inverters.AbstractInversionResult) assert result.solution.sum() > 0 assert isinstance(result.success, bool) diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index 7b960f7..aa0867e 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -1,11 +1,10 @@ from typing import ClassVar -import abc import dataclasses import numpy as np import astropy.units as u import named_arrays as na import ctis -from .. import AbstractInverter, InversionResult +from .. import AbstractInverter, AbstractInversionResult __all__ = [ "AbstractIterativeInverter", @@ -28,15 +27,21 @@ class AbstractIterativeInverter( axis_iteration: ClassVar[str] = "iteration" """The logical axis associated with changing iteration index.""" - @property - @abc.abstractmethod - def num_iteration(self) -> int: - """ - The maximum number of iterations to perform. + num_iteration: int = dataclasses.field(default=100, kw_only=True) + """ + The maximum number of iterations to perform. - If convergence is not reached before this number is exceeded, - a warning is raised and an unsuccessful result is returned. - """ + If convergence is not reached before this number is exceeded, + a warning is raised and an unsuccessful result is returned. + """ + + intermediate: bool = dataclasses.field(default=False, kw_only=True) + """ + Whether to save intermediate solutions. + + This is set to :obj:`False` during normal operation, but can be useful for + debugging or demonstration purposes. + """ def mean_chi_squared( self, @@ -90,24 +95,52 @@ def correlation_residual( @dataclasses.dataclass class IterativeInversionResult( - InversionResult, + AbstractInversionResult, ): """The results of an iterative inversion attempt.""" - inverter: AbstractIterativeInverter + solutions: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] + """ + Intermediate solutions from each iteration. + + If :attr:`AbstractIterativeInverter.intermediate` is set to :obj:`True`, + this has up to :attr:`~AbstractIterativeInverter.num_iteration` elements + along the :attr:`~AbstractIterativeInverter.axis_iteration` logical axis. + Otherwise this has only one element along the + :attr:`~AbstractIterativeInverter.axis_iteration` axis. + """ + + success: bool = dataclasses.MISSING + """A boolean flag indicating whether the inversion was successful.""" + + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] = dataclasses.MISSING + """The observed images on which the inversion was performed.""" + + inverter: "ctis.inverters.AbstractInverter" = dataclasses.MISSING + """The inversion algorithm instance that produced these results.""" - num_iteration: int + message: str = dataclasses.MISSING + """Any message from the inversion routine concerning the results.""" + + num_iteration: int = dataclasses.MISSING """The number of iterations performed by the inverter.""" - mean_chi_squared: na.ScalarArray + mean_chi_squared: na.ScalarArray = dataclasses.MISSING """The mean chi squared statistic for each iteration.""" - correlation_residual: na.ScalarArray + correlation_residual: na.ScalarArray = dataclasses.MISSING """ The correlation between the predicted images and the residuals for each iteration. """ + @property + def solution( + self, + ) -> na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray]: + axis_iteration = self.inverter.axis_iteration + return self.solutions[{axis_iteration: ~0}] + @property def iteration(self) -> na.ScalarArray: """The iteration value for each iteration.""" diff --git a/ctis/inverters/_iterative/_mart/_mart.py b/ctis/inverters/_iterative/_mart/_mart.py index 5c77434..2124913 100644 --- a/ctis/inverters/_iterative/_mart/_mart.py +++ b/ctis/inverters/_iterative/_mart/_mart.py @@ -46,22 +46,6 @@ class MartInverter( then the algorithm is considered to be converged. """ - num_iteration: int = 100 - """ - The maximum number of iterations to perform. - - If convergence is not reached before this number is exceeded, - a warning is raised and an unsuccessful result is returned. - """ - - intermediate: bool = False - """ - Whether to save intermediate solutions. - - This is set to :obj:`False` during normal operation, but can be useful for - debugging or demonstration purposes. - """ - def __post_init__(self): if self.gamma is None: @@ -121,7 +105,7 @@ def __call__( intermediate = [] - chi2_old = np.inf + merit_old = np.inf chi2 = [] correlation_residual = [] @@ -142,20 +126,20 @@ def __call__( chi2.append(chi2_ij) correlation_residual.append(r_ij) - chi2_i = chi2_ij.mean(axis_channel) + merit = chi2_ij.mean(axis_channel) if verbose: # pragma: nocover - print(f"mean chi squared: {chi2_ij}") + print(f"merit: {merit}") - if chi2_i > chi2_old: # pragma: nocover - message = "Failure: chi squared increasing." + if merit > merit_old: # pragma: nocover + message = "Failure: merit increasing." success = False num_iteration = i + 1 warnings.warn(message) break - elif (chi2_old - chi2_i) < self.threshold_convergence: - message = "Achieved mean chi squared of less than 1." + elif (merit_old - merit) < self.threshold_convergence: + message = f"Achieved merit less than {self.threshold_convergence}." success = True num_iteration = i + 1 break @@ -183,7 +167,7 @@ def __call__( else: scene *= correction - chi2_old = chi2_i + merit_old = merit else: message = f"Max number of iterations ({self.num_iteration}) exceeded." @@ -193,13 +177,13 @@ def __call__( if self.intermediate: intermediate = na.stack(intermediate, axis=self.axis_iteration) - solution = intermediate + solutions = intermediate else: - solution = scene + solutions = scene.add_axes(self.axis_iteration) - solution = na.FunctionArray( + solutions = na.FunctionArray( inputs=self.instrument.coordinates_scene, - outputs=solution, + outputs=solutions, ) images = na.FunctionArray( @@ -211,7 +195,7 @@ def __call__( correlation_residual = na.stack(correlation_residual, axis=self.axis_iteration) return IterativeInversionResult( - solution=solution, + solutions=solutions, success=success, images=images, inverter=self, diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index 8bb25d4..4f93052 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -1,3 +1,4 @@ +import matplotlib.pyplot as plt import pytest import astropy.units as u import named_arrays as na @@ -24,14 +25,19 @@ y=na.arange(0, 64 + 1, axis="sensor_y") * u.pix, ) -coordinates_scene = na.SpectralPositionalVectorArray(velocity, position_scene) -coordinates_sensor = na.SpectralPositionalVectorArray(velocity, position_sensor) - -scene = ctis.scenes.gaussians( - inputs=coordinates_scene, - width=na.SpectralPositionalVectorArray(30 * u.km / u.s, 1 * u.arcsec), +coordinates_scene = na.DopplerPositionalVectorArray.from_velocity( + velocity=velocity, + wavelength_rest=wavelength_rest, + position=position_scene, +) +coordinates_sensor = na.DopplerPositionalVectorArray.from_velocity( + velocity=velocity, + wavelength_rest=wavelength_rest, + position=position_sensor, ) +scene = ctis.scenes.gaussians(coordinates_scene) + coordinates_scene.wavelength = wavelength coordinates_sensor.wavelength = wavelength @@ -98,8 +104,14 @@ def test__call__( images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray], guess: na.ScalarArray, ): - super().test__call__( + result = super().test__call__( a=a, images=images, guess=guess, ) + + fig, axs = result.plot_moments(scene) + + assert isinstance(fig, plt.Figure) + for ax in axs: + assert isinstance(ax, plt.Axes) \ No newline at end of file diff --git a/ctis/inverters/_results.py b/ctis/inverters/_results.py index 11da852..7b93e45 100644 --- a/ctis/inverters/_results.py +++ b/ctis/inverters/_results.py @@ -1,4 +1,9 @@ +import abc import dataclasses +import numpy as np +import matplotlib.pyplot as plt +import astropy.units as u +import astropy.visualization import named_arrays as na import ctis @@ -8,22 +13,254 @@ @dataclasses.dataclass -class InversionResult: +class AbstractInversionResult( + abc.ABC, +): + """An interface describing the results of an inversion attempt.""" + + @property + @abc.abstractmethod + def solution( + self, + ) -> na.FunctionArray[na.AbstractDopplerPositionalVectorArray, na.ScalarArray]: + """The reconstructed scene found by the inversion.""" + + @property + @abc.abstractmethod + def success(self) -> bool: + """Whether the inversion was successful.""" + + @property + @abc.abstractmethod + def images( + self, + ) -> na.FunctionArray[ + na.AbstractDopplerPositionalVectorArray, + na.ScalarArray, + ]: + """The observed images used to calculate the inversion.""" + + @property + @abc.abstractmethod + def inverter(self) -> "ctis.inverters.AbstractInverter": + """The inversion algorithm instance that produced these results.""" + + @property + @abc.abstractmethod + def message(self) -> str: + """Any message from the inverter regarding these results.""" + + def plot_moments( + self, + truth: na.FunctionArray[ + na.AbstractDopplerPositionalVectorArray, + na.ScalarArray, + ], + num_bins: int = 50, + range_radiance: None | tuple[u.Quantity, u.Quantity] = None, + range_median: None | tuple[u.Quantity, u.Quantity] = None, + range_iqr: None | tuple[u.Quantity, u.Quantity] = None, + ) -> tuple[plt.Figure, np.ndarray]: + recon = self.solution + + axis_wavelength = self.inverter.instrument.axis_wavelength + + wavelength_truth = truth.inputs.wavelength + wavelength_recon = recon.inputs.wavelength + + dw_truth = wavelength_truth.volume_cell(axis_wavelength) + dw_recon = wavelength_recon.volume_cell(axis_wavelength) + + radiance_truth = (truth.outputs * dw_truth).sum(axis_wavelength) + radiance_recon = (recon.outputs * dw_recon).sum(axis_wavelength) + + median_truth = na.pdf.median( + x=truth.inputs.velocity, + f=truth.outputs, + axis="wavelength", + ) + median_recon = na.pdf.median( + x=recon.inputs.velocity, + f=recon.outputs, + axis="wavelength", + ) + + iqr_truth = na.pdf.iqr( + x=truth.inputs.velocity, + f=truth.outputs, + axis="wavelength", + ) + iqr_recon = na.pdf.iqr( + x=recon.inputs.velocity, + f=recon.outputs, + axis="wavelength", + ) + + bins = dict(true=num_bins, reconstructed=num_bins) + + if range_radiance is not None: + min_radiance, max_radiance = range_radiance + else: + min_radiance = 0 * radiance_truth.unit + max_radiance = radiance_truth.max() + + if range_median is not None: + min_median, max_median = range_median + else: + min_median = np.nanmin(median_truth) + max_median = np.nanmax(median_truth) + + if range_iqr is not None: + min_iqr, max_iqr = range_iqr + else: + min_iqr = 0 * iqr_truth.unit + max_iqr = iqr_truth.max() + + hist_radiance = na.histogram2d( + radiance_truth, + radiance_recon, + bins=bins, + min=min_radiance, + max=max_radiance, + ) + hist_median = na.histogram2d( + median_truth, + median_recon, + bins=bins, + min=min_median, + max=max_median, + ) + hist_iqr = na.histogram2d( + iqr_truth, + iqr_recon, + bins=bins, + min=min_iqr, + max=max_iqr, + ) + + hist_radiance = hist_radiance / hist_radiance.sum("reconstructed") + hist_median = hist_median / hist_median.sum("reconstructed") + hist_iqr = hist_iqr / hist_iqr.sum("reconstructed") + + hist_radiance.outputs = np.nan_to_num( + x=hist_radiance.outputs, + posinf=0, + neginf=0, + ) + hist_median.outputs = np.nan_to_num(hist_median.outputs) + hist_iqr.outputs = np.nan_to_num(hist_iqr.outputs) + + with astropy.visualization.quantity_support(): + fig, axs = plt.subplots( + constrained_layout=True, + figsize=(10, 4), + ncols=3, + ) + ax_radiance, ax_median, ax_iqr = axs + img_radiance = na.plt.pcolormesh( + C=hist_radiance, + ax=ax_radiance, + vmax=np.nanpercentile(hist_radiance.outputs, 99.5), + ) + img_median = na.plt.pcolormesh( + C=hist_median, + ax=ax_median, + vmax=np.nanpercentile(hist_median.outputs, 99.5), + ) + img_iqr = na.plt.pcolormesh( + C=hist_iqr, + ax=ax_iqr, + vmax=np.nanpercentile(hist_iqr.outputs, 99.5), + ) + + pt_radiance = np.nanmean(radiance_truth).ndarray.value + pt_median = np.nanmean(median_truth).ndarray.value + pt_iqr = np.nanmean(iqr_truth).ndarray.value + + ax_radiance.axline( + (pt_radiance, pt_radiance), + slope=1, + color="tab:red", + linestyle="dashed", + ) + ax_median.axline( + (pt_median, pt_median), + slope=1, + color="tab:red", + linestyle="dashed", + ) + ax_iqr.axline( + (pt_iqr, pt_iqr), + slope=1, + color="tab:red", + linestyle="dashed", + ) + plt.colorbar( + img_radiance.ndarray.item(), + ax=ax_radiance, + location="top", + label="probability", + ) + plt.colorbar( + img_median.ndarray.item(), + ax=ax_median, + location="top", + label="probability", + ) + plt.colorbar( + img_iqr.ndarray.item(), + ax=ax_iqr, + location="top", + label="probability", + ) + ax_radiance.set_xlabel( + f"true radiance ({ax_radiance.get_xlabel()})", + ) + ax_radiance.set_ylabel( + f"reconstructed radiance ({ax_radiance.get_ylabel()})", + ) + ax_median.set_xlabel( + f"true median ({ax_median.get_xlabel()})", + ) + ax_median.set_ylabel( + f"reconstructed median ({ax_median.get_ylabel()})", + ) + ax_iqr.set_xlabel( + f"true IQR ({ax_iqr.get_xlabel()})", + ) + ax_iqr.set_ylabel( + f"reconstructed IQR ({ax_iqr.get_ylabel()})", + ) + + ax_radiance.set_aspect("equal") + ax_median.set_aspect("equal") + ax_iqr.set_aspect("equal") + + return fig, axs + + +@dataclasses.dataclass +class InversionResult( + AbstractInversionResult, +): """ The results of an inversion attempt. """ - solution: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] + solution: na.FunctionArray[ + na.AbstractDopplerPositionalVectorArray, + na.ScalarArray + ] = dataclasses.MISSING """The reconstructed scene found by the inversion.""" - success: bool + success: bool = dataclasses.MISSING """A boolean flag indicating whether the inversion was successful.""" - images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] = dataclasses.MISSING """The observed images on which the inversion was performed.""" - inverter: "ctis.inverters.AbstractInverter" - """The inversion algorithm that produced these results.""" + inverter: "ctis.inverters.AbstractInverter" = dataclasses.MISSING + """The inversion algorithm instance that produced these results.""" - message: str + message: str = dataclasses.MISSING """Any message from the inversion routine concerning the results.""" diff --git a/ctis/scenes/_gaussians.py b/ctis/scenes/_gaussians.py index ddd910c..5ea3245 100644 --- a/ctis/scenes/_gaussians.py +++ b/ctis/scenes/_gaussians.py @@ -14,10 +14,10 @@ def _gaussian( - inputs: na.AbstractSpectralPositionalVectorArray, + inputs: na.AbstractDopplerPositionalVectorArray, amplitude: u.Quantity | na.AbstractScalar, - center: na.AbstractSpectralPositionalVectorArray, - width: na.AbstractSpectralPositionalVectorArray, + center: na.AbstractDopplerPositionalVectorArray, + width: na.AbstractDopplerPositionalVectorArray, ) -> na.AbstractScalar: """ Compute a Gaussian kernel. @@ -41,14 +41,30 @@ def _gaussian( inputs = inputs.cell_centers() + inputs = na.SpectralPositionalVectorArray( + wavelength=inputs.velocity, + position=inputs.position, + ) + + center = na.SpectralPositionalVectorArray( + wavelength=center.velocity, + position=center.position + ) + + width = na.SpectralPositionalVectorArray( + wavelength=width.velocity, + position=width.position + ) + arg = -np.square(((inputs - center) / width).length) / 2 + return amplitude * np.exp(arg) def gaussians( - inputs: SpectralPositionalVectorT, - width: na.AbstractSpectralPositionalVectorArray, -) -> na.FunctionArray[SpectralPositionalVectorT, na.ScalarArray]: + inputs: na.DopplerPositionalVectorArray, + width: None | na.DopplerPositionalVectorArray = None, +) -> na.FunctionArray[na.DopplerPositionalVectorArray, na.ScalarArray]: r""" A scene with eight randomly-positioned Gaussian kernels originally prepared by Amy R. Winebarger. @@ -59,6 +75,9 @@ def gaussians( The grid of wavelengths and positions on which to evaluate the scene. width The standard deviation of the Gaussian kernels. + If :obj:`None` (the default), the width will be :math:`30\,\text{km/s} + in the wavelength direction and :math:`1\,\text{arcsec}` in the spatial + directions. Examples -------- @@ -174,7 +193,7 @@ def gaussians( center_y = np.array(center_y) center_v = np.array(center_v) - scale = 1 * u.erg / (u.cm**2 * u.sr * u.mAA * u.s) + scale = 1000 * u.erg / (u.cm**2 * u.sr * u.AA * u.s) amplitude = amplitude * scale axis = "event" @@ -184,11 +203,19 @@ def gaussians( center_y = na.ScalarArray(center_y, axis) << u.arcsec center_v = na.ScalarArray(center_v, axis) << (u.km / u.s) - center = na.SpectralPositionalVectorArray( - wavelength=center_v, + center = na.DopplerPositionalVectorArray.from_velocity( + velocity=center_v, + wavelength_rest=inputs.wavelength_rest, position=na.Cartesian2dVectorArray(center_x, center_y), ) + if width is None: + width = na.DopplerPositionalVectorArray.from_velocity( + velocity=30 * u.km / u.s, + wavelength_rest=inputs.wavelength_rest, + position=1 * u.arcsec, + ) + outputs = _gaussian( inputs=inputs, amplitude=amplitude, diff --git a/docs/tutorials/mart-iris.ipynb b/docs/tutorials/mart-iris.ipynb deleted file mode 100644 index 200b42f..0000000 --- a/docs/tutorials/mart-iris.ipynb +++ /dev/null @@ -1,1025 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "0", - "metadata": { - "ExecuteTime": { - "end_time": "2026-04-25T20:56:39.795626900Z", - "start_time": "2026-04-25T20:56:37.522100900Z" - } - }, - "outputs": [], - "source": [ - "%reload_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1", - "metadata": {}, - "outputs": [], - "source": [ - "import IPython.display\n", - "import numpy as np\n", - "import matplotlib.pyplot as plt\n", - "import astropy.units as u\n", - "import astropy.visualization\n", - "import named_arrays as na\n", - "import iris\n", - "import ctis" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2", - "metadata": {}, - "outputs": [], - "source": [ - "plt.rcParams[\"animation.embed_limit\"] = 100" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3", - "metadata": {}, - "outputs": [], - "source": [ - "# scene = iris.sg.open(\"2013-10-22 11:30\")\n", - "# scene = iris.sg.open(\"2014-07-05 23:00\")\n", - "# scene = iris.sg.open(\"2014-07-04 11:40\")\n", - "scene = iris.sg.open(\"2014-10-13 04:11\") #\n", - "# scene = iris.sg.open(\"2014-10-07 18:16\")\n", - "# scene = iris.sg.open(\"2014-10-08 17:01\")\n", - "# obs = iris.sg.open(\"2015-02-24 19:03\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4", - "metadata": {}, - "outputs": [], - "source": [ - "scene = scene[{scene.axis_time: 0}]\n", - "scene.timedelta = scene.timedelta[{scene.axis_time: 0}]\n", - "scene.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5", - "metadata": {}, - "outputs": [], - "source": [ - "scene = na.despike(scene, axis=(scene.axis_wavelength, scene.axis_detector_y))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6", - "metadata": {}, - "outputs": [], - "source": [ - "scene.outputs = np.nan_to_num(scene.outputs)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": {}, - "outputs": [], - "source": [ - "scene.outputs[scene.outputs < 0] = 0\n", - "scene.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8", - "metadata": {}, - "outputs": [], - "source": [ - "scene.inputs.position = scene.inputs.position - scene.inputs.position.mean()\n", - "scene.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": {}, - "outputs": [], - "source": [ - "scene = scene.radiance\n", - "scene.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "10", - "metadata": {}, - "outputs": [], - "source": [ - "velocity_thresh = 150 * u.km / u.s\n", - "\n", - "velocity_centers = scene.velocity_doppler.cell_centers()\n", - "\n", - "index_lower = np.argmax(-velocity_thresh < velocity_centers)[scene.axis_wavelength]\n", - "index_upper = np.argmax(velocity_thresh < velocity_centers)[scene.axis_wavelength]\n", - "\n", - "crop_wavelength = {scene.axis_wavelength: slice(index_lower.ndarray, index_upper.ndarray)}\n", - "\n", - "scene = scene[crop_wavelength]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "11", - "metadata": {}, - "outputs": [], - "source": [ - "scene.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], - "source": [ - "scene.show();" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13", - "metadata": {}, - "outputs": [], - "source": [ - "wavelength_rest = scene.wavelength_center" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "14", - "metadata": {}, - "outputs": [], - "source": [ - "AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "15", - "metadata": {}, - "outputs": [], - "source": [ - "coordinates_scene = scene.inputs" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ - "position_sensor = na.Cartesian2dVectorArray(\n", - " x=na.arange(0, 512 + 1, axis=\"sensor_x\") * u.pix,\n", - " y=na.arange(0, 512 + 1, axis=\"sensor_y\") * u.pix,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "17", - "metadata": {}, - "outputs": [], - "source": [ - "coordinates_sensor = na.SpectralPositionalVectorArray(\n", - " wavelength=scene.inputs.wavelength,\n", - " position=position_sensor,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18", - "metadata": {}, - "outputs": [], - "source": [ - "angle = na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], - "source": [ - "channel = \"dispersion angle = \" + angle.to_string_array(\"%03d\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "instrument = ctis.instruments.IdealInstrument(\n", - " area_effective=1 * u.mm ** 2,\n", - " timedelta_exposure=10 * u.s,\n", - " plate_scale=0.4 * u.arcsec / u.pix,\n", - " dispersion=((5 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", - " angle=angle,\n", - " wavelength_ref=wavelength_rest,\n", - " position_ref=na.Cartesian2dVectorArray(256, 256) * u.pix,\n", - " coordinates_scene=coordinates_scene,\n", - " coordinates_sensor=coordinates_sensor,\n", - " channel=channel,\n", - " axis_channel=\"channel\",\n", - " axis_wavelength=scene.axis_wavelength,\n", - " axis_scene_xy=(scene.axis_detector_x, scene.axis_detector_y),\n", - " axis_sensor_xy=(\"sensor_x\", \"sensor_y\"),\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21", - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "images = instrument.image(scene)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "22", - "metadata": {}, - "outputs": [], - "source": [ - "images = images" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23", - "metadata": {}, - "outputs": [], - "source": [ - "with astropy.visualization.quantity_support():\n", - " fig, ax = plt.subplots(\n", - " constrained_layout=True,\n", - " figsize=(5, 4),\n", - " )\n", - " norm = plt.Normalize(\n", - " vmin=0,\n", - " vmax=500,\n", - " # vmax=images.outputs.value.ndarray.max(),\n", - " )\n", - " colorizer = plt.Colorizer(\n", - " cmap=\"gray\",\n", - " norm=norm,\n", - " )\n", - " ani = na.plt.pcolormovie(\n", - " channel,\n", - " images.inputs.position.x,\n", - " images.inputs.position.y,\n", - " C=images.outputs.value,\n", - " axis_time=\"channel\",\n", - " ax=ax,\n", - " kwargs_pcolormesh=dict(\n", - " colorizer=colorizer,\n", - " ),\n", - " )\n", - " plt.colorbar(\n", - " mappable=plt.cm.ScalarMappable(colorizer=colorizer),\n", - " ax=ax,\n", - " label=f\"signal ({images.outputs.unit:latex_inline})\",\n", - " )\n", - " ax.set_aspect(\"equal\")\n", - " ax.set_xlabel(f\"sensor $x$ ({images.inputs.position.x.unit})\")\n", - " ax.set_ylabel(f\"sensor $y$ ({images.inputs.position.y.unit})\")\n", - "\n", - "result = ani.to_jshtml(fps=2)\n", - "result = IPython.display.HTML(result)\n", - "\n", - "plt.close(ani._fig)\n", - "\n", - "result" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "24", - "metadata": {}, - "outputs": [], - "source": [ - "import dataclasses" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "25", - "metadata": {}, - "outputs": [], - "source": [ - "coords_scene_single = coordinates_scene.replace(wavelength=images.inputs.wavelength)\n", - "coords_sensor_single = images.inputs" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "26", - "metadata": {}, - "outputs": [], - "source": [ - "instrument_single = dataclasses.replace(\n", - " instrument,\n", - " coordinates_scene=coords_scene_single,\n", - " coordinates_sensor=coords_sensor_single,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "27", - "metadata": {}, - "outputs": [], - "source": [ - "s = instrument_single.backproject(images) * scene.outputs.shape[\"wavelength\"]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "28", - "metadata": {}, - "outputs": [], - "source": [ - "smin = np.min(s, axis=\"channel\")[dict(channel=0)]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "29", - "metadata": {}, - "outputs": [], - "source": [ - "with astropy.visualization.quantity_support():\n", - " fig, ax = plt.subplots()\n", - " na.plt.pcolormesh(\n", - " smin.inputs.position.x,\n", - " smin.inputs.position.y,\n", - " C=smin.outputs[dict(channel=0, wavelength=0)].value,\n", - " vmin=0,\n", - " vmax=np.percentile(smin.outputs, 99.5).value,\n", - " )\n", - " ax.set_aspect(\"equal\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30", - "metadata": {}, - "outputs": [], - "source": [ - "mart = ctis.inverters.MartInverter(\n", - " instrument=instrument,\n", - " # intermediate=True,\n", - " # num_iteration=10,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "31", - "metadata": {}, - "outputs": [], - "source": [ - "spectrum_avg = scene.outputs.mean((scene.axis_detector_x, scene.axis_detector_y))\n", - "spectrum_avg = spectrum_avg / spectrum_avg.sum()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "32", - "metadata": {}, - "outputs": [], - "source": [ - "guess_spatial = smin.outputs\n", - "# guess_spatial = guess_spatial.broadcast_to(scene.outputs.shape)\n", - "guess_spatial = guess_spatial / guess_spatial.sum()\n", - "guess_spatial.sum()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "33", - "metadata": {}, - "outputs": [], - "source": [ - "guess = spectrum_avg * guess_spatial * scene.outputs.sum()\n", - "# guess = spectrum_avg * scene.outputs.sum()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34", - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "inversion = mart(images, guess=guess, verbose=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "35", - "metadata": {}, - "outputs": [], - "source": [ - "axis_iter = inversion.inverter.axis_iteration" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "36", - "metadata": {}, - "outputs": [], - "source": [ - "fig, ax = plt.subplots(\n", - " nrows=2,\n", - " sharex=True,\n", - " constrained_layout=True,\n", - ")\n", - "na.plt.plot(\n", - " inversion.iteration,\n", - " inversion.mean_chi_squared,\n", - " ax=ax[0],\n", - " axis=axis_iter,\n", - " label=channel,\n", - ")\n", - "na.plt.plot(\n", - " inversion.iteration,\n", - " inversion.correlation_residual,\n", - " ax=ax[1],\n", - " axis=axis_iter,\n", - " label=channel,\n", - ")\n", - "ax[0].set_yscale(\"log\")\n", - "ax[0].legend();" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "37", - "metadata": {}, - "outputs": [], - "source": [ - "# solution = inversion.solution[{axis_iter: ~0}]\n", - "solution = inversion.solution" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "38", - "metadata": {}, - "outputs": [], - "source": [ - "with astropy.visualization.quantity_support():\n", - " fig, ax = plt.subplots(constrained_layout=True)\n", - " na.plt.stairs(\n", - " scene.inputs.wavelength,\n", - " scene.outputs.mean((scene.axis_detector_x, scene.axis_detector_y)),\n", - " ax=ax,\n", - " label=\"original\",\n", - " )\n", - " na.plt.stairs(\n", - " solution.inputs.wavelength,\n", - " solution.outputs.mean((scene.axis_detector_x, scene.axis_detector_y)),\n", - " ax=ax,\n", - " label=\"reconstructed\",\n", - " )\n", - " ax.set_xlabel(f\"wavelength ({ax.get_xlabel()})\")\n", - " # ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", - " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")\n", - " ax.legend()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "39", - "metadata": {}, - "outputs": [], - "source": [ - "with astropy.visualization.quantity_support():\n", - " fig, axs = plt.subplots(\n", - " ncols=3,\n", - " gridspec_kw=dict(width_ratios=[.5, .5, .1]),\n", - " constrained_layout=True,\n", - " figsize=(10, 6),\n", - " )\n", - " ax1, ax2, cax = axs\n", - " ax2.set_yticklabels([])\n", - " vmin = np.nanpercentile(\n", - " a=scene.outputs,\n", - " q=0.5,\n", - " axis=(scene.axis_detector_x, scene.axis_detector_y),\n", - " )\n", - " vmax = np.nanpercentile(\n", - " a=scene.outputs,\n", - " q=99.5,\n", - " axis=(scene.axis_detector_x, scene.axis_detector_y),\n", - " )\n", - "\n", - " na.plt.rgbmesh(\n", - " C=scene,\n", - " axis_wavelength=\"wavelength\",\n", - " ax=ax1,\n", - " vmin=vmin,\n", - " vmax=vmax,\n", - " )\n", - " colorbar = na.plt.rgbmesh(\n", - " scene.velocity_doppler,\n", - " scene.inputs.position.x,\n", - " scene.inputs.position.y,\n", - " C=solution.outputs,\n", - " axis_wavelength=\"wavelength\",\n", - " ax=ax2,\n", - " vmin=vmin,\n", - " vmax=vmax,\n", - " )\n", - " na.plt.pcolormesh(\n", - " C=colorbar,\n", - " axis_rgb=\"wavelength\",\n", - " ax=cax,\n", - " )\n", - " ax1.set_title(\"original\")\n", - " ax2.set_title(\"reconstructed\")\n", - " unit_x = scene.inputs.position.x.unit\n", - " unit_y = scene.inputs.position.y.unit\n", - " ax1.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", - " ax2.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", - " ax1.set_ylabel(f\"scene $y$ ({unit_y:latex_inline})\")\n", - " cax.xaxis.set_ticks_position(\"top\")\n", - " cax.xaxis.set_label_position(\"top\")\n", - " cax.yaxis.tick_right()\n", - " cax.yaxis.set_label_position(\"right\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "40", - "metadata": {}, - "outputs": [], - "source": [ - "# with astropy.visualization.quantity_support():\n", - "# fig, ax = plt.subplots(\n", - "# ncols=2,\n", - "# constrained_layout=True,\n", - "# sharex=True,\n", - "# sharey=True,\n", - "# # figsize=(10, 5),\n", - "# )\n", - "# i = {scene.axis_detector_x: 190}\n", - "# na.plt.pcolormesh(\n", - "# scene.inputs.wavelength,\n", - "# scene.inputs.position.y[i],\n", - "# C=np.sqrt(scene.outputs[i].value),\n", - "# ax=ax[0],\n", - "# vmin=0,\n", - "# vmax=1000,\n", - "# )\n", - "# ani = na.plt.pcolormovie(\n", - "# inversion.iteration,\n", - "# inversion.solution.inputs.wavelength,\n", - "# inversion.solution.inputs.position.y[i],\n", - "# C=np.sqrt(inversion.solution.outputs[i].value),\n", - "# ax=ax[1],\n", - "# vmin=0,\n", - "# vmax=1000,\n", - "# axis_time=inversion.inverter.axis_iteration,\n", - "# )\n", - "# ax[0].set_title(inversion.solution.inputs.position.x[i].ndarray.mean())\n", - "\n", - "# result = ani.to_jshtml(fps=10)\n", - "# result = IPython.display.HTML(result)\n", - "\n", - "# ani.save(\"mart-iris.gif\")\n", - "\n", - "# plt.close(ani._fig)\n", - "\n", - "# result" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "41", - "metadata": {}, - "outputs": [], - "source": [ - "\n", - "# with astropy.visualization.quantity_support():\n", - "# fig, axs = plt.subplots(\n", - "# ncols=3,\n", - "# gridspec_kw=dict(width_ratios=[.5, .5, .1]),\n", - "# constrained_layout=True,\n", - "# figsize=(10, 6),\n", - "# )\n", - "# ax1, ax2, cax = axs\n", - "# ax2.set_yticklabels([])\n", - "# vmax = np.nanpercentile(\n", - "# a=scene.outputs,\n", - "# q=99.5,\n", - " \n", - "# axis=(scene.axis_detector_x, scene.axis_detector_y),\n", - "# )\n", - "\n", - "# na.plt.rgbmesh(\n", - "# C=scene,\n", - "# axis_wavelength=\"wavelength\",\n", - "# ax=ax1,\n", - "# vmin=0,\n", - "# vmax=vmax,\n", - "# )\n", - "# label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\")\n", - "# chisq_str = r\"$\\langle \\chi^2 \\rangle$\"\n", - "# label = label + f\"\\n{chisq_str} = \" + inversion.mean_chi_squared.mean(\"channel\").to_string_array(\"%.03f\")\n", - "# ani, colorbar = na.plt.rgbmovie(\n", - "# label,\n", - "# scene.velocity_doppler,\n", - "# scene.inputs.position.x,\n", - "# scene.inputs.position.y,\n", - "# C=inversion.solution.outputs,\n", - "# axis_time=inversion.inverter.axis_iteration,\n", - "# axis_wavelength=\"wavelength\",\n", - "# ax=ax2,\n", - "# vmin=0,\n", - "# vmax=vmax,\n", - "# )\n", - "# na.plt.pcolormesh(\n", - "# C=colorbar,\n", - "# axis_rgb=\"wavelength\",\n", - "# ax=cax,\n", - "# )\n", - "# ax1.set_title(\"original\")\n", - "# ax2.set_title(\"reconstructed\")\n", - "# unit_x = scene.inputs.position.x.unit\n", - "# unit_y = scene.inputs.position.y.unit\n", - "# ax1.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", - "# ax2.set_xlabel(f\"scene $x$ ({unit_x:latex_inline})\")\n", - "# ax1.set_ylabel(f\"scene $y$ ({unit_y:latex_inline})\")\n", - "# cax.xaxis.set_ticks_position(\"top\")\n", - "# cax.xaxis.set_label_position(\"top\")\n", - "# cax.yaxis.tick_right()\n", - "# cax.yaxis.set_label_position(\"right\")\n", - "\n", - "# result = ani.to_jshtml(fps=10)\n", - "# result = IPython.display.HTML(result)\n", - "\n", - "# # ani.save(\"mart-iris.gif\")\n", - "\n", - "# plt.close(ani._fig)\n", - "\n", - "# result" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "42", - "metadata": {}, - "outputs": [], - "source": [ - "velocity = scene.velocity_doppler" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "43", - "metadata": {}, - "outputs": [], - "source": [ - "sum_scene = scene.outputs.sum(scene.axis_wavelength)\n", - "sum_recon = solution.outputs.sum(scene.axis_wavelength).to(scene.outputs.unit)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "44", - "metadata": {}, - "outputs": [], - "source": [ - "median_scene = na.pdf.median(velocity, scene.outputs, axis=scene.axis_wavelength)\n", - "median_recon = na.pdf.median(velocity, solution.outputs, axis=scene.axis_wavelength)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "45", - "metadata": {}, - "outputs": [], - "source": [ - "iqr_scene = na.pdf.iqr(velocity, scene.outputs, axis=scene.axis_wavelength)\n", - "iqr_recon = na.pdf.iqr(velocity, solution.outputs, axis=scene.axis_wavelength)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "46", - "metadata": {}, - "outputs": [], - "source": [ - "bins = dict(true=50, reconstructed=50)\n", - "hist_sum = na.histogram2d(sum_scene, sum_recon, bins=bins, min=0 * solution.outputs.unit, max=0.2e8 * solution.outputs.unit)\n", - "hist_median = na.histogram2d(median_scene, median_recon, bins=bins, min=-50 * u.km / u.s, max=50 * u.km / u.s)\n", - "hist_iqr = na.histogram2d(iqr_scene, iqr_recon, bins=bins, min=0 * u.km / u.s, max=100 * u.km / u.s)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "47", - "metadata": {}, - "outputs": [], - "source": [ - "hist_sum = hist_sum / hist_sum.sum(\"reconstructed\")\n", - "hist_median = hist_median / hist_median.sum(\"reconstructed\")\n", - "hist_iqr = hist_iqr / hist_iqr.sum(\"reconstructed\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "48", - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.colors" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "49", - "metadata": {}, - "outputs": [], - "source": [ - "with astropy.visualization.quantity_support():\n", - " fig, axs = plt.subplots(\n", - " constrained_layout=True,\n", - " figsize=(10, 4),\n", - " ncols=3,\n", - " )\n", - " ax_sum, ax_median, ax_iqr = axs\n", - " na.plt.pcolormesh(\n", - " C=hist_sum,\n", - " ax=ax_sum,\n", - " norm=matplotlib.colors.LogNorm(),\n", - " )\n", - " na.plt.pcolormesh(\n", - " C=hist_median,\n", - " ax=ax_median,\n", - " norm=matplotlib.colors.LogNorm(),\n", - " )\n", - " na.plt.pcolormesh(\n", - " C=hist_iqr,\n", - " ax=ax_iqr,\n", - " norm=matplotlib.colors.LogNorm(),\n", - " )\n", - " ax_sum.set_aspect(\"equal\")\n", - " ax_median.set_aspect(\"equal\")\n", - " ax_iqr.set_aspect(\"equal\")\n", - " ax_sum.set_title(\"sum\")\n", - " ax_median.set_title(\"median\")\n", - " ax_iqr.set_title(\"interquartile range\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "50", - "metadata": {}, - "outputs": [], - "source": [ - "import scipy.stats" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "51", - "metadata": {}, - "outputs": [], - "source": [ - "where_finite = np.isfinite(sum_scene) & np.isfinite(median_scene) & np.isfinite(median_recon)\n", - "where_finite.sum() / where_finite.size" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "52", - "metadata": {}, - "outputs": [], - "source": [ - "float(scipy.stats.spearmanr(sum_scene[where_finite].ndarray, sum_recon[where_finite].ndarray).statistic)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "53", - "metadata": {}, - "outputs": [], - "source": [ - "float(scipy.stats.spearmanr(median_scene[where_finite].ndarray, median_recon[where_finite].ndarray).statistic)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "54", - "metadata": {}, - "outputs": [], - "source": [ - "float(scipy.stats.spearmanr(iqr_scene[where_finite].ndarray, iqr_recon[where_finite].ndarray).statistic)" - ] - }, - { - "cell_type": "markdown", - "id": "55", - "metadata": {}, - "source": [ - " - signal-correlated residuals (spearman)\n", - " - mean-chi squared of each channel\n", - " - " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "56", - "metadata": {}, - "outputs": [], - "source": [ - "%%time\n", - "predictions = instrument.image(solution, noise=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "57", - "metadata": {}, - "outputs": [], - "source": [ - "residual = images - predictions" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "58", - "metadata": {}, - "outputs": [], - "source": [ - "fig, ax = na.plt.subplots(\n", - " axis_rows=\"rows\",\n", - " axis_cols=\"cols\",\n", - " nrows=2,\n", - " ncols=2,\n", - " constrained_layout=True,\n", - " figsize=(8,7),\n", - " sharex=True,\n", - " sharey=True,\n", - ")\n", - "ax = ax.reshape(dict(channel=-1))\n", - "vmin = -35\n", - "vmax = +35\n", - "img = na.plt.pcolormesh(\n", - " residual.inputs.position.x,\n", - " residual.inputs.position.y,\n", - " C=residual.outputs.value,\n", - " ax=ax,\n", - " vmin=vmin,\n", - " vmax=vmax,\n", - " cmap=\"gray\",\n", - ")\n", - "na.plt.set_title\n", - "na.plt.set_aspect(\"equal\", ax=ax)\n", - "plt.colorbar(\n", - " ax=ax.ndarray,\n", - " mappable=img.ndarray[0],\n", - " label=f\"residual ({residual.outputs.unit:latex_inline})\",\n", - ");" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "59", - "metadata": {}, - "outputs": [], - "source": [ - "float(scipy.stats.spearmanr(predictions.outputs.ndarray.reshape(-1), residual.outputs.ndarray.reshape(-1)).statistic)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "60", - "metadata": {}, - "outputs": [], - "source": [ - "float(scipy.stats.pearsonr(predictions.outputs.ndarray.reshape(-1), residual.outputs.ndarray.reshape(-1)).statistic)" - ] - }, - { - "cell_type": "markdown", - "id": "61", - "metadata": {}, - "source": [ - " - mulitply counts by 10 to see how it changes the results\n", - " - smooth the outputs before taking the residual (and computing the moments)\n", - " - negative correlation coefficient implies that we went a little too far (crossed zero)" - ] - }, - { - "cell_type": "markdown", - "id": "62", - "metadata": {}, - "source": [ - "$d \\chi = \\frac{d' - d}{\\sqrt{d}}$ contribution of the residual to the total chi square (SNR-weighted residual)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/pyproject.toml b/pyproject.toml index 2202301..851640b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] dependencies = [ "astropy", - "named-arrays~=1.2", + "named-arrays~=1.4", ] dynamic = ["version"] From dfaae02939c97b978637c2e12758142baf1880b8 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 08:51:53 -0600 Subject: [PATCH 40/55] fix tutorial --- docs/tutorials/simple-mart.ipynb | 310 ++++++++++++++++--------------- 1 file changed, 159 insertions(+), 151 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index 66b1491..c35895f 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -23,6 +23,10 @@ "execution_count": null, "id": "1", "metadata": { + "ExecuteTime": { + "end_time": "2026-05-16T01:09:40.699749400Z", + "start_time": "2026-05-16T01:09:38.955748600Z" + }, "editable": true, "slideshow": { "slide_type": "" @@ -101,68 +105,6 @@ "wavelength_rest = 171 * u.AA" ] }, - { - "cell_type": "raw", - "id": "6", - "metadata": { - "editable": true, - "raw_mimetype": "text/x-rst", - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "source": [ - "Define a :mod:`astropy.units` equivalency for converting from Doppler velocity to wavelength." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": { - "editable": true, - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "outputs": [], - "source": [ - "AA = dict(unit=u.AA, equivalencies=u.doppler_optical(wavelength_rest))" - ] - }, - { - "cell_type": "raw", - "id": "8", - "metadata": { - "editable": true, - "raw_mimetype": "text/x-rst", - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "source": [ - "Convert the grid of velocities to wavelength units using our equivalency." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9", - "metadata": { - "editable": true, - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "outputs": [], - "source": [ - "wavelength = velocity.to(**AA)" - ] - }, { "cell_type": "raw", "id": "10", @@ -195,7 +137,7 @@ " start=-10 * u.arcsec,\n", " stop=10 * u.arcsec,\n", " axis=na.Cartesian2dVectorArray(\"scene_x\", \"scene_y\"),\n", - " num=na.Cartesian2dVectorArray(64, 64),\n", + " num=na.Cartesian2dVectorArray(256, 256),\n", ")" ] }, @@ -261,47 +203,30 @@ }, "outputs": [], "source": [ - "coordinates_scene = na.SpectralPositionalVectorArray(velocity, position_scene)\n", - "coordinates_sensor = na.SpectralPositionalVectorArray(velocity, position_sensor)" - ] - }, - { - "cell_type": "raw", - "id": "16", - "metadata": { - "editable": true, - "raw_mimetype": "text/x-rst", - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, - "source": [ - "Create a synthetic scene composed of spatial/spectral 3D Gaussians with various Doppler shifts." + "coordinates_scene = na.DopplerPositionalVectorArray.from_velocity(\n", + " velocity=velocity,\n", + " wavelength_rest=wavelength_rest,\n", + " position=position_scene,\n", + ")" ] }, { "cell_type": "code", "execution_count": null, - "id": "17", - "metadata": { - "editable": true, - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, + "id": "e1a32653-a215-4017-b87b-dc6d55ce9153", + "metadata": {}, "outputs": [], "source": [ - "scene = ctis.scenes.gaussians(\n", - " inputs=coordinates_scene,\n", - " width=na.SpectralPositionalVectorArray(30 * u.km / u.s, 1 * u.arcsec),\n", + "coordinates_sensor = na.DopplerPositionalVectorArray.from_velocity(\n", + " velocity=velocity,\n", + " wavelength_rest=wavelength_rest,\n", + " position=position_sensor,\n", ")" ] }, { "cell_type": "raw", - "id": "18", + "id": "16", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -311,13 +236,13 @@ "tags": [] }, "source": [ - "Add a small background equal to 1 percent of the maximum value of the scene." + "Create a synthetic scene composed of spatial/spectral 3D Gaussians with various Doppler shifts." ] }, { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "17", "metadata": { "editable": true, "slideshow": { @@ -327,12 +252,12 @@ }, "outputs": [], "source": [ - "scene = scene + scene.max() / 100" + "scene = ctis.scenes.gaussians(coordinates_scene)" ] }, { "cell_type": "raw", - "id": "20", + "id": "18", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -342,13 +267,13 @@ "tags": [] }, "source": [ - "Modify the 3D coordinates to use wavelength units to be compatible with the instrument forward model." + "Add a small background equal to 1 percent of the maximum value of the scene." ] }, { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "19", "metadata": { "editable": true, "slideshow": { @@ -358,8 +283,7 @@ }, "outputs": [], "source": [ - "coordinates_scene.wavelength = wavelength\n", - "coordinates_sensor.wavelength = wavelength" + "scene = scene + scene.max() / 100" ] }, { @@ -486,7 +410,7 @@ " ax=ax,\n", " )\n", " na.plt.stairs(\n", - " wavelength,\n", + " scene.inputs.wavelength,\n", " spectrum,\n", " ax=ax2\n", " )\n", @@ -524,7 +448,41 @@ }, "outputs": [], "source": [ - "angle = na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg" + "angle = na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg + 5.64 * u.deg" + ] + }, + { + "cell_type": "raw", + "id": "f35428cc-4e1f-4ec0-a351-8e407d1f940e", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Define the magnitude of dispersion for our instrument in terms of Doppler velocity and then convert to wavelength units." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e2021eef-32d8-420d-947b-b59855340b38", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "dispersion = 10 * u.km / u.s\n", + "dispersion = dispersion.to(u.AA, equivalencies=u.doppler_optical(wavelength_rest))\n", + "dispersion = (dispersion - wavelength_rest) / u.pix\n", + "dispersion.to(u.mAA / u.pix)" ] }, { @@ -539,7 +497,7 @@ "tags": [] }, "source": [ - "Create an ideal CTIS using these dispersion angles." + "Create an ideal CTIS using the dispersion magnitude and angles." ] }, { @@ -559,7 +517,7 @@ " area_effective=1 * u.cm ** 2,\n", " timedelta_exposure=20 * u.s,\n", " plate_scale=.4 * u.arcsec / u.pix,\n", - " dispersion=((10 * u.km / u.s).to(**AA) - wavelength_rest) / u.pix,\n", + " dispersion=dispersion,\n", " angle=angle,\n", " wavelength_ref=wavelength_rest,\n", " position_ref=na.Cartesian2dVectorArray(64, 32) * u.pix,\n", @@ -601,6 +559,7 @@ }, "outputs": [], "source": [ + "%%time\n", "images = instrument.image(scene)" ] }, @@ -782,7 +741,7 @@ " vmin=0,\n", " vmax=scene.outputs.max(),\n", " )\n", - " label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\")\n", + " label = \"iteration = \" + inversion.iteration.to_string_array(\"%d\") + \"\\n\"\n", " name = r\"$\\langle \\chi^2 \\rangle$\"\n", " label = label + f\"{name} = \" + inversion.mean_chi_squared.mean(instrument.axis_channel).to_string_array()\n", " ani, colorbar = na.plt.rgbmovie(\n", @@ -790,7 +749,7 @@ " scene.inputs.wavelength,\n", " scene.inputs.position.x,\n", " scene.inputs.position.y,\n", - " C=inversion.solution.outputs,\n", + " C=inversion.solutions.outputs,\n", " axis_time=inversion.inverter.axis_iteration,\n", " axis_wavelength=\"wavelength\",\n", " ax=ax2,\n", @@ -823,35 +782,24 @@ ] }, { - "cell_type": "code", - "execution_count": null, - "id": "42", + "cell_type": "raw", + "id": "b477e497-b168-4ba5-8dae-1c822874a25c", "metadata": { "editable": true, + "raw_mimetype": "text/x-rst", "slideshow": { "slide_type": "" }, "tags": [] }, - "outputs": [], "source": [ - "difference = scene - inversion.solution[{inversion.inverter.axis_iteration: ~0}]" + "Isolate the solution array from the inversion result object." ] }, { "cell_type": "code", "execution_count": null, - "id": "43", - "metadata": {}, - "outputs": [], - "source": [ - "difference.shape" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "44", + "id": "83648e87-c2a1-488a-9f99-a7b704704b04", "metadata": { "editable": true, "slideshow": { @@ -861,31 +809,7 @@ }, "outputs": [], "source": [ - "with astropy.visualization.quantity_support():\n", - " fig, ax = plt.subplots(\n", - " constrained_layout=True,\n", - " )\n", - " img = na.plt.pcolormesh(\n", - " difference.inputs.position,\n", - " C=difference.outputs.sum(\"wavelength\").value,\n", - " ax=ax,\n", - " cmap=\"gray\",\n", - " vmin=-difference.outputs.max().value,\n", - " vmax=difference.outputs.max().value,\n", - " )\n", - " plt.colorbar(\n", - " mappable=img.ndarray.item(),\n", - " ax=ax,\n", - " label=f\"wavelength-summed spectral radiance\\n({difference.outputs.unit:latex_inline})\",\n", - " )\n", - " ax.set_title(\"difference between original and reconstructed\")\n", - " ax.set_aspect(\"equal\")\n", - " ax.set_xlabel(f\"scene $x$ ({ax.get_xlabel()})\")\n", - " ax.set_ylabel(f\"scene $y$ ({ax.get_ylabel()})\")\n", - " cax.xaxis.set_ticks_position(\"top\")\n", - " cax.xaxis.set_label_position(\"top\")\n", - " cax.yaxis.tick_right()\n", - " cax.yaxis.set_label_position(\"right\")" + "solution = inversion.solution" ] }, { @@ -916,8 +840,7 @@ }, "outputs": [], "source": [ - "spectrum_inverted = inversion.solution.outputs.mean((\"scene_x\", \"scene_y\"))\n", - "spectrum_inverted = spectrum_inverted[{inversion.inverter.axis_iteration: -1}]" + "spectrum_inverted = solution.outputs.mean((\"scene_x\", \"scene_y\"))" ] }, { @@ -951,13 +874,13 @@ "with astropy.visualization.quantity_support():\n", " fig, ax = plt.subplots(constrained_layout=True)\n", " na.plt.stairs(\n", - " wavelength,\n", + " scene.inputs.wavelength,\n", " spectrum,\n", " ax=ax,\n", " label=\"original\",\n", " )\n", " na.plt.stairs(\n", - " wavelength,\n", + " scene.inputs.wavelength,\n", " spectrum_inverted,\n", " ax=ax,\n", " label=\"reconstructed\",\n", @@ -967,6 +890,91 @@ " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")\n", " ax.legend()" ] + }, + { + "cell_type": "raw", + "id": "bfc2bff4-324d-4eb6-a85c-5c6fe552e322", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Plot 2D histograms of the true vs. reconstructed value of the total radiance, median (Doppler shift), and interquartile range (Doppler width) for every pixel in the scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26a04c3a-aa38-448b-9e9f-deb1e9cfce34", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "inversion.plot_moments(scene);" + ] + }, + { + "cell_type": "raw", + "id": "bbdf379d-373f-4077-a08f-bf1177d3e0c1", + "metadata": { + "editable": true, + "raw_mimetype": "text/x-rst", + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "Plot :math:`\\langle \\chi^2 \\rangle` and the signal-correlated residual as a function of iteration." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20cb4673-06ac-4513-aa40-be984fe4c7d8", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(\n", + " nrows=2,\n", + " sharex=True,\n", + " constrained_layout=True,\n", + ")\n", + "na.plt.plot(\n", + " inversion.iteration,\n", + " inversion.mean_chi_squared,\n", + " ax=ax[0],\n", + " axis=inversion.inverter.axis_iteration,\n", + " label=instrument.channel,\n", + ")\n", + "na.plt.plot(\n", + " inversion.iteration,\n", + " inversion.correlation_residual,\n", + " ax=ax[1],\n", + " axis=inversion.inverter.axis_iteration,\n", + " label=instrument.channel,\n", + ")\n", + "ax[0].set_ylabel(r\"$\\langle \\chi^2 \\rangle$\")\n", + "ax[1].set_xlabel(\"iteration\")\n", + "ax[1].set_ylabel(\"signal-correlated residual\")\n", + "ax[0].set_yscale(\"log\")\n", + "ax[0].legend();" + ] } ], "metadata": { From 412b689cbd53ddd753f4172edd9523c784ba5b50 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 11:44:48 -0600 Subject: [PATCH 41/55] fix tests --- ctis/instruments/_instruments_test.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/ctis/instruments/_instruments_test.py b/ctis/instruments/_instruments_test.py index 10522c5..9f1ad33 100644 --- a/ctis/instruments/_instruments_test.py +++ b/ctis/instruments/_instruments_test.py @@ -7,6 +7,8 @@ velocity = na.linspace(-500, 500, axis="wavelength", num=21) * u.km / u.s +wavelength_rest = 171 * u.AA + position_scene = na.Cartesian2dVectorLinearSpace( start=-20 * u.arcsec, stop=20 * u.arcsec, @@ -19,15 +21,18 @@ y=na.arange(0, 64, axis="sensor_y") * u.pix, ) -coordinates_scene = na.SpectralPositionalVectorArray(velocity, position_scene) -coordinates_sensor = na.SpectralPositionalVectorArray(velocity, position_sensor) - -gaussians = ctis.scenes.gaussians( - inputs=coordinates_scene, - width=na.SpectralPositionalVectorArray(30 * u.km / u.s, 1 * u.arcsec), +coordinates_scene = na.DopplerPositionalVectorArray.from_velocity( + velocity=velocity, + wavelength_rest=wavelength_rest, + position=position_scene, +) +coordinates_sensor = na.DopplerPositionalVectorArray.from_velocity( + velocity=velocity, + wavelength_rest=wavelength_rest, + position=position_sensor, ) -wavelength_rest = 171 * u.AA +gaussians = ctis.scenes.gaussians(coordinates_scene) AA = dict( unit=u.AA, From 7d5c99b3881d0b3d128f06a329e1623ac3a64260 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 11:47:44 -0600 Subject: [PATCH 42/55] black --- ctis/inverters/_iterative/_iterative.py | 5 ++++- ctis/inverters/_iterative/_mart/_mart_test.py | 3 +-- ctis/inverters/_results.py | 12 ++++++++---- ctis/scenes/_gaussians.py | 4 ++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index aa0867e..3231f93 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -113,7 +113,10 @@ class IterativeInversionResult( success: bool = dataclasses.MISSING """A boolean flag indicating whether the inversion was successful.""" - images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] = dataclasses.MISSING + images: na.FunctionArray[ + na.SpectralPositionalVectorArray, + na.ScalarArray, + ] = dataclasses.MISSING """The observed images on which the inversion was performed.""" inverter: "ctis.inverters.AbstractInverter" = dataclasses.MISSING diff --git a/ctis/inverters/_iterative/_mart/_mart_test.py b/ctis/inverters/_iterative/_mart/_mart_test.py index 4f93052..02d6d0e 100644 --- a/ctis/inverters/_iterative/_mart/_mart_test.py +++ b/ctis/inverters/_iterative/_mart/_mart_test.py @@ -89,7 +89,6 @@ class TestMartInverter( AbstractTestAbstractIterativeInverter, ): - @pytest.mark.parametrize("images", [images]) @pytest.mark.parametrize( argnames="guess", @@ -114,4 +113,4 @@ def test__call__( assert isinstance(fig, plt.Figure) for ax in axs: - assert isinstance(ax, plt.Axes) \ No newline at end of file + assert isinstance(ax, plt.Axes) diff --git a/ctis/inverters/_results.py b/ctis/inverters/_results.py index 7b93e45..3b66b6a 100644 --- a/ctis/inverters/_results.py +++ b/ctis/inverters/_results.py @@ -22,7 +22,10 @@ class AbstractInversionResult( @abc.abstractmethod def solution( self, - ) -> na.FunctionArray[na.AbstractDopplerPositionalVectorArray, na.ScalarArray]: + ) -> na.FunctionArray[ + na.AbstractDopplerPositionalVectorArray, + na.ScalarArray, + ]: """The reconstructed scene found by the inversion.""" @property @@ -248,15 +251,16 @@ class InversionResult( """ solution: na.FunctionArray[ - na.AbstractDopplerPositionalVectorArray, - na.ScalarArray + na.AbstractDopplerPositionalVectorArray, na.ScalarArray ] = dataclasses.MISSING """The reconstructed scene found by the inversion.""" success: bool = dataclasses.MISSING """A boolean flag indicating whether the inversion was successful.""" - images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] = dataclasses.MISSING + images: na.FunctionArray[na.SpectralPositionalVectorArray, na.ScalarArray] = ( + dataclasses.MISSING + ) """The observed images on which the inversion was performed.""" inverter: "ctis.inverters.AbstractInverter" = dataclasses.MISSING diff --git a/ctis/scenes/_gaussians.py b/ctis/scenes/_gaussians.py index 5ea3245..508179a 100644 --- a/ctis/scenes/_gaussians.py +++ b/ctis/scenes/_gaussians.py @@ -48,12 +48,12 @@ def _gaussian( center = na.SpectralPositionalVectorArray( wavelength=center.velocity, - position=center.position + position=center.position, ) width = na.SpectralPositionalVectorArray( wavelength=width.velocity, - position=width.position + position=width.position, ) arg = -np.square(((inputs - center) / width).length) / 2 From fbb7576f6b9c81a806fcdc96e758df6971944fa9 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 11:49:05 -0600 Subject: [PATCH 43/55] docs --- ctis/inverters/merit/_merit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctis/inverters/merit/_merit.py b/ctis/inverters/merit/_merit.py index 8f77ab7..3c34539 100644 --- a/ctis/inverters/merit/_merit.py +++ b/ctis/inverters/merit/_merit.py @@ -18,7 +18,7 @@ def mean_chi_squared( Compute :math:`\langle \chi^2 \rangle = \biggl\langle \left( \frac{O - E}{\sigma} \right)^2 \biggr \rangle` , where :math:`O` is the observed value, :math:`E` is the expected value, - and :math:`\sigma` denotes the standard deviation of the uncertainty. + and :math:`\sigma` denotes the standard deviation of the uncertainty. Parameters ---------- From 97341afe817012cbebc6a027f653a59332d0800d Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 11:51:01 -0600 Subject: [PATCH 44/55] nbstripout --- docs/tutorials/simple-mart.ipynb | 90 +++++++++++++++----------------- 1 file changed, 43 insertions(+), 47 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index c35895f..31bbd82 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -23,10 +23,6 @@ "execution_count": null, "id": "1", "metadata": { - "ExecuteTime": { - "end_time": "2026-05-16T01:09:40.699749400Z", - "start_time": "2026-05-16T01:09:38.955748600Z" - }, "editable": true, "slideshow": { "slide_type": "" @@ -107,7 +103,7 @@ }, { "cell_type": "raw", - "id": "10", + "id": "6", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -123,7 +119,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11", + "id": "7", "metadata": { "editable": true, "slideshow": { @@ -143,7 +139,7 @@ }, { "cell_type": "raw", - "id": "12", + "id": "8", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -159,7 +155,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "9", "metadata": { "editable": true, "slideshow": { @@ -177,7 +173,7 @@ }, { "cell_type": "raw", - "id": "14", + "id": "10", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -193,7 +189,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "11", "metadata": { "editable": true, "slideshow": { @@ -213,7 +209,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e1a32653-a215-4017-b87b-dc6d55ce9153", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -226,7 +222,7 @@ }, { "cell_type": "raw", - "id": "16", + "id": "13", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -242,7 +238,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "14", "metadata": { "editable": true, "slideshow": { @@ -257,7 +253,7 @@ }, { "cell_type": "raw", - "id": "18", + "id": "15", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -273,7 +269,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "16", "metadata": { "editable": true, "slideshow": { @@ -288,7 +284,7 @@ }, { "cell_type": "raw", - "id": "22", + "id": "17", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -304,7 +300,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "18", "metadata": { "editable": true, "slideshow": { @@ -344,7 +340,7 @@ }, { "cell_type": "raw", - "id": "24", + "id": "19", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -360,7 +356,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "20", "metadata": { "editable": true, "slideshow": { @@ -375,7 +371,7 @@ }, { "cell_type": "raw", - "id": "26", + "id": "21", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -391,7 +387,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "22", "metadata": { "editable": true, "slideshow": { @@ -421,7 +417,7 @@ }, { "cell_type": "raw", - "id": "28", + "id": "23", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -438,7 +434,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "24", "metadata": { "editable": true, "slideshow": { @@ -453,7 +449,7 @@ }, { "cell_type": "raw", - "id": "f35428cc-4e1f-4ec0-a351-8e407d1f940e", + "id": "25", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -469,7 +465,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e2021eef-32d8-420d-947b-b59855340b38", + "id": "26", "metadata": { "editable": true, "slideshow": { @@ -487,7 +483,7 @@ }, { "cell_type": "raw", - "id": "30", + "id": "27", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -503,7 +499,7 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "28", "metadata": { "editable": true, "slideshow": { @@ -533,7 +529,7 @@ }, { "cell_type": "raw", - "id": "32", + "id": "29", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -549,7 +545,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "30", "metadata": { "editable": true, "slideshow": { @@ -565,7 +561,7 @@ }, { "cell_type": "raw", - "id": "34", + "id": "31", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -581,7 +577,7 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "32", "metadata": { "editable": true, "slideshow": { @@ -634,7 +630,7 @@ }, { "cell_type": "raw", - "id": "36", + "id": "33", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -650,7 +646,7 @@ { "cell_type": "code", "execution_count": null, - "id": "37", + "id": "34", "metadata": { "editable": true, "slideshow": { @@ -668,7 +664,7 @@ }, { "cell_type": "raw", - "id": "38", + "id": "35", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -684,7 +680,7 @@ { "cell_type": "code", "execution_count": null, - "id": "39", + "id": "36", "metadata": { "editable": true, "slideshow": { @@ -699,7 +695,7 @@ }, { "cell_type": "raw", - "id": "40", + "id": "37", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -715,7 +711,7 @@ { "cell_type": "code", "execution_count": null, - "id": "41", + "id": "38", "metadata": { "editable": true, "slideshow": { @@ -783,7 +779,7 @@ }, { "cell_type": "raw", - "id": "b477e497-b168-4ba5-8dae-1c822874a25c", + "id": "39", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -799,7 +795,7 @@ { "cell_type": "code", "execution_count": null, - "id": "83648e87-c2a1-488a-9f99-a7b704704b04", + "id": "40", "metadata": { "editable": true, "slideshow": { @@ -814,7 +810,7 @@ }, { "cell_type": "raw", - "id": "45", + "id": "41", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -830,7 +826,7 @@ { "cell_type": "code", "execution_count": null, - "id": "46", + "id": "42", "metadata": { "editable": true, "slideshow": { @@ -845,7 +841,7 @@ }, { "cell_type": "raw", - "id": "47", + "id": "43", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -861,7 +857,7 @@ { "cell_type": "code", "execution_count": null, - "id": "48", + "id": "44", "metadata": { "editable": true, "slideshow": { @@ -893,7 +889,7 @@ }, { "cell_type": "raw", - "id": "bfc2bff4-324d-4eb6-a85c-5c6fe552e322", + "id": "45", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -909,7 +905,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26a04c3a-aa38-448b-9e9f-deb1e9cfce34", + "id": "46", "metadata": { "editable": true, "slideshow": { @@ -924,7 +920,7 @@ }, { "cell_type": "raw", - "id": "bbdf379d-373f-4077-a08f-bf1177d3e0c1", + "id": "47", "metadata": { "editable": true, "raw_mimetype": "text/x-rst", @@ -940,7 +936,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20cb4673-06ac-4513-aa40-be984fe4c7d8", + "id": "48", "metadata": { "editable": true, "slideshow": { From 3bb69cab36192043a9cfa3e57bb647bd889ea2db Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 11:54:07 -0600 Subject: [PATCH 45/55] tests --- ctis/scenes/_gaussians_test.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ctis/scenes/_gaussians_test.py b/ctis/scenes/_gaussians_test.py index 30efefd..5034778 100644 --- a/ctis/scenes/_gaussians_test.py +++ b/ctis/scenes/_gaussians_test.py @@ -8,24 +8,27 @@ @pytest.mark.parametrize( argnames="inputs", argvalues=[ - na.SpectralPositionalVectorArray( - wavelength=na.linspace(-500, 500, axis="wavelength", num=101) * u.km / u.s, + na.DopplerPositionalVectorArray.from_velocity( + velocity=na.linspace(-500, 500, axis="wavelength", num=101) * u.km / u.s, + wavelength_rest=171 * u.AA, position=na.Cartesian2dVectorLinearSpace( start=-10 * u.arcsec, stop=10 * u.arcsec, axis=na.Cartesian2dVectorArray("x", "y"), num=41, ), - ) + ), ], ) @pytest.mark.parametrize( argnames="width", argvalues=[ - na.SpectralPositionalVectorArray( - wavelength=30 * u.km / u.s, - position=1 * u.arcsec, - ) + None, + na.DopplerPositionalVectorArray.from_velocity( + velocity=30 * u.km / u.s, + wavelength_rest=304 * u.AA, + position=2 * u.arcsec, + ), ], ) def test_gaussians( From 24b0687cc2b242d516b2e79ad2f1599612cb92bc Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 11:57:36 -0600 Subject: [PATCH 46/55] docs --- ctis/scenes/_gaussians.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/ctis/scenes/_gaussians.py b/ctis/scenes/_gaussians.py index 508179a..5a0760c 100644 --- a/ctis/scenes/_gaussians.py +++ b/ctis/scenes/_gaussians.py @@ -98,8 +98,9 @@ def gaussians( # Define the grid of positions and velocities on which to evaluate the # test pattern - inputs = na.SpectralPositionalVectorArray( - wavelength=na.linspace(-500, 500, axis="wavelength", num=21) * u.km / u.s, + inputs = na.DopplerPositionalVectorArray.from_velocity( + velocityu=na.linspace(-500, 500, axis="wavelength", num=21) * u.km / u.s, + wavelength_rest=171 * u.AA, position=na.Cartesian2dVectorLinearSpace( start=-20 * platescale * u.pix, stop=20 * platescale * u.pix, @@ -108,18 +109,9 @@ def gaussians( ), ) - # Define the standard deviations of the Gaussians in space and velocity - width = na.SpectralPositionalVectorArray( - wavelength=27 * u.km / u.s, - position=2.4 / 2.35 * u.arcsec, - ) - # Compute the scene of random Gaussians for the # given input grid and standard deviations - scene = ctis.scenes.gaussians( - inputs=inputs, - width=width, - ) + scene = ctis.scenes.gaussians(inputs) # Plot the result with astropy.visualization.quantity_support(): From 6d261b951ffc054f9c17a17a2a849e8e2f64bf16 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 12:04:03 -0600 Subject: [PATCH 47/55] docs --- ctis/scenes/_gaussians.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ctis/scenes/_gaussians.py b/ctis/scenes/_gaussians.py index 5a0760c..5eff2ac 100644 --- a/ctis/scenes/_gaussians.py +++ b/ctis/scenes/_gaussians.py @@ -99,7 +99,7 @@ def gaussians( # Define the grid of positions and velocities on which to evaluate the # test pattern inputs = na.DopplerPositionalVectorArray.from_velocity( - velocityu=na.linspace(-500, 500, axis="wavelength", num=21) * u.km / u.s, + velocity=na.linspace(-500, 500, axis="wavelength", num=21) * u.km / u.s, wavelength_rest=171 * u.AA, position=na.Cartesian2dVectorLinearSpace( start=-20 * platescale * u.pix, From 597a4755b956b2eeca4e968dec21dbd1108778ce Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 12:04:54 -0600 Subject: [PATCH 48/55] tests --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a7d43b9..a7bfe48 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,6 +32,8 @@ jobs: pip install setuptools wheel pip install -e .[test] - name: Test with pytest + env: + MPLBACKEND: "agg" run: | pip install pytest pytest-cov pytest --cov=. --cov-report=xml From 155ab57d432f5805080073157c22478b557fbe96 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 12:10:34 -0600 Subject: [PATCH 49/55] coverage --- ctis/inverters/_results.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/ctis/inverters/_results.py b/ctis/inverters/_results.py index 3b66b6a..e84eb09 100644 --- a/ctis/inverters/_results.py +++ b/ctis/inverters/_results.py @@ -101,22 +101,32 @@ def plot_moments( bins = dict(true=num_bins, reconstructed=num_bins) - if range_radiance is not None: - min_radiance, max_radiance = range_radiance - else: + if range_radiance is None: + range_radiance = (None, None) + + if range_median is None: + range_median = (None, None) + + if range_iqr is None: + range_iqr = (None, None) + + min_radiance, max_radiance = range_radiance + min_median, max_median = range_median + min_iqr, max_iqr = range_iqr + + if min_radiance is None: min_radiance = 0 * radiance_truth.unit + if max_radiance is None: max_radiance = radiance_truth.max() - if range_median is not None: - min_median, max_median = range_median - else: + if min_median is None: min_median = np.nanmin(median_truth) + if max_median is None: max_median = np.nanmax(median_truth) - if range_iqr is not None: - min_iqr, max_iqr = range_iqr - else: + if min_iqr is None: min_iqr = 0 * iqr_truth.unit + if max_iqr is None: max_iqr = iqr_truth.max() hist_radiance = na.histogram2d( From 4419a506f27c919acf0219ebcac0400da32d8a33 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 12:12:12 -0600 Subject: [PATCH 50/55] black --- ctis/inverters/_results.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ctis/inverters/_results.py b/ctis/inverters/_results.py index e84eb09..c5378ad 100644 --- a/ctis/inverters/_results.py +++ b/ctis/inverters/_results.py @@ -103,17 +103,17 @@ def plot_moments( if range_radiance is None: range_radiance = (None, None) - + if range_median is None: range_median = (None, None) - + if range_iqr is None: range_iqr = (None, None) min_radiance, max_radiance = range_radiance min_median, max_median = range_median min_iqr, max_iqr = range_iqr - + if min_radiance is None: min_radiance = 0 * radiance_truth.unit if max_radiance is None: From 495d69cfc632596162bd2b67575b19ee76655a11 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 12:16:32 -0600 Subject: [PATCH 51/55] memory --- docs/tutorials/simple-mart.ipynb | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index 31bbd82..2862f57 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -117,25 +117,19 @@ ] }, { + "metadata": {}, "cell_type": "code", - "execution_count": null, - "id": "7", - "metadata": { - "editable": true, - "slideshow": { - "slide_type": "" - }, - "tags": [] - }, "outputs": [], + "execution_count": null, "source": [ "position_scene = na.Cartesian2dVectorLinearSpace(\n", " start=-10 * u.arcsec,\n", " stop=10 * u.arcsec,\n", " axis=na.Cartesian2dVectorArray(\"scene_x\", \"scene_y\"),\n", - " num=na.Cartesian2dVectorArray(256, 256),\n", + " num=na.Cartesian2dVectorArray(64 + 1, 64 + 1),\n", ")" - ] + ], + "id": "c8e366397f7ec7a2" }, { "cell_type": "raw", From fe8b82f9c49dae18f6e478e9fe057564cebfc5af Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 12:19:02 -0600 Subject: [PATCH 52/55] nbstripout --- docs/tutorials/simple-mart.ipynb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index 2862f57..2c06696 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -117,10 +117,11 @@ ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], "source": [ "position_scene = na.Cartesian2dVectorLinearSpace(\n", " start=-10 * u.arcsec,\n", @@ -128,8 +129,7 @@ " axis=na.Cartesian2dVectorArray(\"scene_x\", \"scene_y\"),\n", " num=na.Cartesian2dVectorArray(64 + 1, 64 + 1),\n", ")" - ], - "id": "c8e366397f7ec7a2" + ] }, { "cell_type": "raw", From 13bcd97d132c79f0314c934d15bdc1a3831c83db Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 18:51:07 -0600 Subject: [PATCH 53/55] fixed mean-chi squared calculation --- ctis/inverters/_iterative/_iterative.py | 2 -- ctis/inverters/merit/_merit.py | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index 3231f93..d28b86f 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -62,8 +62,6 @@ def mean_chi_squared( uncertainty = self.instrument.uncertainty(images_predicted) - uncertainty = np.maximum(uncertainty, 1 * u.photon) - return ctis.inverters.merit.mean_chi_squared( observed=images_observed, expected=images_predicted, diff --git a/ctis/inverters/merit/_merit.py b/ctis/inverters/merit/_merit.py index 3c34539..5c73a7f 100644 --- a/ctis/inverters/merit/_merit.py +++ b/ctis/inverters/merit/_merit.py @@ -33,7 +33,13 @@ def mean_chi_squared( """ chisq = np.square((observed - expected) / uncertainty) - return np.mean(chisq, axis=axis) + where = uncertainty != 0 + + return np.mean( + a=chisq, + axis=axis, + where=where, + ) def correlation_residual( From 0ca65463e7db541155df5dfb8715d07669a936b0 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 18:51:54 -0600 Subject: [PATCH 54/55] tweak notebook --- docs/tutorials/simple-mart.ipynb | 153 +++++++++++++++---------------- 1 file changed, 74 insertions(+), 79 deletions(-) diff --git a/docs/tutorials/simple-mart.ipynb b/docs/tutorials/simple-mart.ipynb index 2c06696..51420d4 100644 --- a/docs/tutorials/simple-mart.ipynb +++ b/docs/tutorials/simple-mart.ipynb @@ -20,7 +20,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "1", "metadata": { "editable": true, @@ -29,7 +28,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "import IPython.display\n", "import matplotlib.pyplot as plt\n", @@ -37,7 +35,9 @@ "import astropy.visualization\n", "import named_arrays as na\n", "import ctis" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -56,7 +56,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "3", "metadata": { "editable": true, @@ -65,10 +64,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "velocity = na.linspace(-500, 500, axis=\"wavelength\", num=21) * u.km / u.s" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -87,7 +87,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "5", "metadata": { "editable": true, @@ -96,10 +95,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "wavelength_rest = 171 * u.AA" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -118,10 +118,8 @@ }, { "cell_type": "code", - "execution_count": null, "id": "7", "metadata": {}, - "outputs": [], "source": [ "position_scene = na.Cartesian2dVectorLinearSpace(\n", " start=-10 * u.arcsec,\n", @@ -129,7 +127,9 @@ " axis=na.Cartesian2dVectorArray(\"scene_x\", \"scene_y\"),\n", " num=na.Cartesian2dVectorArray(64 + 1, 64 + 1),\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -148,7 +148,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "9", "metadata": { "editable": true, @@ -157,13 +156,14 @@ }, "tags": [] }, - "outputs": [], "source": [ "position_sensor = na.Cartesian2dVectorArray(\n", " x=na.arange(0, 128 + 1, axis=\"sensor_x\") * u.pix,\n", " y=na.arange(0, 64 + 1, axis=\"sensor_y\") * u.pix,\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -182,7 +182,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "11", "metadata": { "editable": true, @@ -191,28 +190,29 @@ }, "tags": [] }, - "outputs": [], "source": [ "coordinates_scene = na.DopplerPositionalVectorArray.from_velocity(\n", " velocity=velocity,\n", " wavelength_rest=wavelength_rest,\n", " position=position_scene,\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "code", - "execution_count": null, "id": "12", "metadata": {}, - "outputs": [], "source": [ "coordinates_sensor = na.DopplerPositionalVectorArray.from_velocity(\n", " velocity=velocity,\n", " wavelength_rest=wavelength_rest,\n", " position=position_sensor,\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -231,7 +231,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "14", "metadata": { "editable": true, @@ -240,10 +239,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "scene = ctis.scenes.gaussians(coordinates_scene)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -262,7 +262,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "16", "metadata": { "editable": true, @@ -271,10 +270,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "scene = scene + scene.max() / 100" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -293,7 +293,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "18", "metadata": { "editable": true, @@ -302,7 +301,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, axs = plt.subplots(\n", @@ -330,7 +328,9 @@ " cax.xaxis.set_label_position(\"top\")\n", " cax.yaxis.tick_right()\n", " cax.yaxis.set_label_position(\"right\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -349,7 +349,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "20", "metadata": { "editable": true, @@ -358,10 +357,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "spectrum = scene.outputs.mean((\"scene_x\", \"scene_y\"))" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -380,7 +380,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "22", "metadata": { "editable": true, @@ -389,7 +388,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, ax = plt.subplots(constrained_layout=True)\n", @@ -407,7 +405,9 @@ " ax.set_xlabel(f\"Doppler velocity ({ax.get_xlabel()})\")\n", " ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -427,7 +427,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "24", "metadata": { "editable": true, @@ -436,10 +435,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "angle = na.linspace(0, 360, num=4, axis=\"channel\", endpoint=False) * u.deg + 5.64 * u.deg" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -458,7 +458,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "26", "metadata": { "editable": true, @@ -467,13 +466,14 @@ }, "tags": [] }, - "outputs": [], "source": [ "dispersion = 10 * u.km / u.s\n", "dispersion = dispersion.to(u.AA, equivalencies=u.doppler_optical(wavelength_rest))\n", "dispersion = (dispersion - wavelength_rest) / u.pix\n", "dispersion.to(u.mAA / u.pix)" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -492,7 +492,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "28", "metadata": { "editable": true, @@ -501,7 +500,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "instrument = ctis.instruments.IdealInstrument(\n", " area_effective=1 * u.cm ** 2,\n", @@ -519,7 +517,9 @@ " axis_scene_xy=(\"scene_x\", \"scene_y\"),\n", " axis_sensor_xy=(\"sensor_x\", \"sensor_y\"),\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -538,7 +538,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "30", "metadata": { "editable": true, @@ -547,11 +546,9 @@ }, "tags": [] }, + "source": "images = instrument.image(scene)", "outputs": [], - "source": [ - "%%time\n", - "images = instrument.image(scene)" - ] + "execution_count": null }, { "cell_type": "raw", @@ -570,7 +567,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "32", "metadata": { "editable": true, @@ -579,7 +575,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, ax = plt.subplots(\n", @@ -620,7 +615,9 @@ "plt.close(ani._fig)\n", "\n", "result" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -639,7 +636,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "34", "metadata": { "editable": true, @@ -648,13 +644,14 @@ }, "tags": [] }, - "outputs": [], "source": [ "mart = ctis.inverters.MartInverter(\n", " instrument=instrument,\n", " intermediate=True,\n", ")" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -673,7 +670,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "36", "metadata": { "editable": true, @@ -682,10 +678,9 @@ }, "tags": [] }, + "source": "inversion = mart(images)", "outputs": [], - "source": [ - "inversion = mart(images)" - ] + "execution_count": null }, { "cell_type": "raw", @@ -704,7 +699,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "38", "metadata": { "editable": true, @@ -713,7 +707,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, axs = plt.subplots(\n", @@ -763,13 +756,15 @@ " cax.yaxis.tick_right()\n", " cax.yaxis.set_label_position(\"right\")\n", "\n", - "result = ani.to_jshtml(fps=10)\n", + "result = ani.to_jshtml(fps=20)\n", "result = IPython.display.HTML(result)\n", "\n", "plt.close(ani._fig)\n", "\n", "result" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -788,7 +783,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "40", "metadata": { "editable": true, @@ -797,10 +791,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "solution = inversion.solution" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -819,7 +814,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "42", "metadata": { "editable": true, @@ -828,10 +822,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "spectrum_inverted = solution.outputs.mean((\"scene_x\", \"scene_y\"))" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -850,7 +845,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "44", "metadata": { "editable": true, @@ -859,7 +853,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "with astropy.visualization.quantity_support():\n", " fig, ax = plt.subplots(constrained_layout=True)\n", @@ -879,7 +872,9 @@ " ax2.set_xlabel(f\"wavelength ({ax2.get_xlabel()})\")\n", " ax.set_ylabel(f\"average radiance ({ax.get_ylabel()})\")\n", " ax.legend()" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -898,7 +893,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "46", "metadata": { "editable": true, @@ -907,10 +901,11 @@ }, "tags": [] }, - "outputs": [], "source": [ "inversion.plot_moments(scene);" - ] + ], + "outputs": [], + "execution_count": null }, { "cell_type": "raw", @@ -929,7 +924,6 @@ }, { "cell_type": "code", - "execution_count": null, "id": "48", "metadata": { "editable": true, @@ -938,7 +932,6 @@ }, "tags": [] }, - "outputs": [], "source": [ "fig, ax = plt.subplots(\n", " nrows=2,\n", @@ -964,7 +957,9 @@ "ax[1].set_ylabel(\"signal-correlated residual\")\n", "ax[0].set_yscale(\"log\")\n", "ax[0].legend();" - ] + ], + "outputs": [], + "execution_count": null } ], "metadata": { From cf1b449477f12e19cbe573f0df9a396c3700bdb5 Mon Sep 17 00:00:00 2001 From: Roy Smart Date: Mon, 25 May 2026 18:55:53 -0600 Subject: [PATCH 55/55] ruff --- ctis/inverters/_iterative/_iterative.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ctis/inverters/_iterative/_iterative.py b/ctis/inverters/_iterative/_iterative.py index d28b86f..9d3a0f5 100644 --- a/ctis/inverters/_iterative/_iterative.py +++ b/ctis/inverters/_iterative/_iterative.py @@ -1,7 +1,5 @@ from typing import ClassVar import dataclasses -import numpy as np -import astropy.units as u import named_arrays as na import ctis from .. import AbstractInverter, AbstractInversionResult