ctis code walkthrough

Simulating and Inverting a CTIS Observation

The MART tutorial step by step: grids, scene, instrument model, inversion and diagnostics, and what it takes to run it on your own data.

pip install ctis

False-color image of the synthetic test scene

Conventions used throughout

Named axes

A ScalarArray is a NumPy array plus a name for each axis. Arrays broadcast and reduce by name; there is no positional indexing.


a = na.ScalarArray(
    ndarray=np.random.random((64, 32)),
    axes=("scene_x", "scene_y"),
)
b = na.ScalarArray(
    ndarray=np.linspace(0, 1, 20),
    axes="wavelength",
)
        
a.mean("scene_x").shape
{'scene_y': 32}
(a * b).shape
{'scene_x': 64, 'scene_y': 32, 'wavelength': 20}

Units everywhere

Multiply any array by an astropy unit. The instrument converts radiance in $\mathrm{erg\,cm^{-2}\,sr^{-1}\,\mathring{A}^{-1}\,s^{-1}}$ to electrons, and back.

Vertices, not centers

Coordinate grids give cell edges and values live in the cells: 21 wavelength vertices hold 20 spectral bins.

Functions on grids

na.FunctionArray(inputs=..., outputs=...) pairs a grid of coordinates with a ScalarArray of values. Scenes, images and solutions are all function arrays, and ctis checks that their grids match the instrument's.

Step 1 of 15

Define the spectral grid


velocity = na.linspace(-500, 500, axis="wavelength", num=21) * u.km / u.s

wavelength_rest = 171 * u.AA
    
velocity.shape
{'wavelength': 21}

21 vertices bound 20 spectral bins of 50 km/s.

The axis is named "wavelength" because it becomes the wavelength axis of the coordinates in step 3.

The rest wavelength converts velocity to wavelength in step 3.

Step 2 of 15

Define the scene and sensor grids


position_scene = na.Cartesian2dVectorLinearSpace(
    start=-10 * u.arcsec,
    stop=10 * u.arcsec,
    axis=na.Cartesian2dVectorArray("scene_x", "scene_y"),
    num=na.Cartesian2dVectorArray(64 + 1, 64 + 1),
)

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,
)
        
position_scene.x.shape
{'scene_x': 65}
position_scene.shape
{'scene_x': 65, 'scene_y': 65}
position_sensor.shape
{'sensor_x': 129, 'sensor_y': 65}

The scene has 64 × 64 cells across 20″, a 0.3125″ pitch; the plate scale in step 8 is 0.4″ per pixel.

Cartesian2dVectorArray(x, y)

A 2D vector whose components are arrays. The shape is the components' broadcast shape: x varies along "sensor_x" and y along "sensor_y", so position_sensor is a 129 × 65 grid of vectors.

Cartesian2dVectorLinearSpace

A vector linspace. Each argument is either a scalar shared by both components or a vector with one value per component. Here x runs from −10″ to 10″ along "scene_x" and y along "scene_y".

It is evaluated lazily; .explicit returns the equivalent Cartesian2dVectorArray.

Step 3 of 15

Combine them into spectral–spatial coordinates


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,
)
        
coordinates_scene.shape
{'wavelength': 21, 'scene_x': 65, 'scene_y': 65}
coordinates_sensor.shape
{'wavelength': 21, 'sensor_x': 129, 'sensor_y': 65}
  • A vector with wavelength, wavelength_rest and position components; .velocity is derived.
  • $\lambda = (1 + v/c)\,\lambda_0$, the same convention as astropy's doppler_optical.
  • The sensor grid carries the wavelength axis too: the forward model works one wavelength bin at a time before summing.

Step 4 of 15

Create a synthetic scene


scene = ctis.scenes.gaussians(coordinates_scene)

scene = scene + scene.max() / 100
    
scene.outputs.shape
{'wavelength': 20, 'scene_x': 64, 'scene_y': 64}
scene.outputs.unit
erg / (Angstrom s sr cm2)

Eight randomly placed Gaussians with σ = 30 km/s and 1″, prepared by Amy R. Winebarger.

A uniform background of 1% of the peak.

scene is a FunctionArray: scene.inputs is coordinates_scene and scene.outputs is the radiance on the 20 × 64 × 64 cells.

Step 5 of 15

Visualize the cube with rgbmesh


colorbar = na.plt.rgbmesh(
    C=scene,
    axis_wavelength="wavelength",
    ax=ax,
    vmin=0,
    vmax=scene.outputs.max(),
)
na.plt.pcolormesh(C=colorbar, axis_rgb="wavelength", ax=cax)
        
  • Collapses the "wavelength" axis into color with colorsynth: each pixel's spectrum is stretched onto the visible range and weighted by the CIE 1931 color-matching functions.
  • Hue shows the shape of the spectrum, here the Doppler shift; brightness shows the radiance between vmin and vmax.
  • Returns a 2D colorbar, color against radiance and wavelength, drawn with na.plt.pcolormesh(..., axis_rgb=...).
False-color image of the test scene, colored by Doppler velocity

Step 6 of 15

Inspect the average spectrum


spectrum = scene.outputs.mean(("scene_x", "scene_y"))

ax2 = ax.twiny()
na.plt.stairs(velocity, spectrum, ax=ax)
na.plt.stairs(scene.inputs.wavelength, spectrum, ax=ax2)
        
  • Reduce over the spatial axes by name.
  • stairs takes the 21 vertices and the 20 bin values directly.
  • Two components, about ±125 km/s from rest, on the 1% background.
The average spectrum of the test scene

Step 7 of 15

Choose the dispersion


angle = na.linspace(0, 360, num=4, axis="channel", endpoint=False) * u.deg + 5.64 * u.deg

dispersion = 10 * u.km / u.s
dispersion = dispersion.to(u.AA, equivalencies=u.doppler_optical(wavelength_rest))
dispersion = (dispersion - wavelength_rest) / u.pix
dispersion.to(u.mAA / u.pix)
    
<Quantity 5.70394603 mAngstrom / pix>

A new named axis, "channel", is all it takes to model four channels: everything downstream broadcasts over it.

Channels at 5.64° + 0°, 90°, 180° and 270°.

10 km/s per pixel becomes 5.70 mÅ per pixel at 171 Å.

Step 8 of 15

Build the instrument model


instrument = ctis.instruments.IdealInstrument(
    area_effective=1 * u.cm ** 2,
    timedelta_exposure=20 * u.s,
    plate_scale=.4 * u.arcsec / u.pix,
    dispersion=dispersion,
    angle=angle,
    wavelength_ref=wavelength_rest,
    position_ref=na.Cartesian2dVectorArray(64, 32) * u.pix,
    coordinates_scene=coordinates_scene,
    coordinates_sensor=coordinates_sensor,
    channel="dispersion angle = " + angle.to_string_array("%03d"),
    axis_channel="channel",
    axis_wavelength="wavelength",
    axis_scene_xy=("scene_x", "scene_y"),
    axis_sensor_xy=("sensor_x", "sensor_y"),
)
        
  • wavelength_ref, position_ref: the center of the field lands on pixel (64, 32) at 171 Å.
  • channel: labels for plots and legends.
  • axis_*: which named axes are channel, wavelength, scene and sensor.
  • Defaults not shown: quantum_yield = 1 electron per photon, read_noise = 0 electrons.

Under the hood

What IdealInstrument computes

Geometry: distortion()

$\mathbf{s} = \mathbf{s}_\text{ref} + \frac{1}{p}\,R(\theta)\,\mathbf{x} + \begin{pmatrix} (\lambda - \lambda_\text{ref}) / D \\ 0 \end{pmatrix}$

Rotate the scene by the channel angle, scale by the plate scale $p$, and disperse along sensor $x$. Every channel disperses along its own detector's $x$ axis.

Weights

Conservative regridding of each scene cell onto the pixel grid, for every (wavelength, channel) pair: na.regridding.weights(..., method="conservative"). Computed once and cached; backproject() uses the conservative transpose.

image(scene)

  1. Radiance × $A_\text{eff}$ × $t$ × voxel volume ÷ $hc/\lambda$ → photons per voxel
  2. Apply the weights → photons per pixel, per wavelength
  3. Poisson noise per wavelength (noise=True)
  4. × quantum yield → electrons
  5. Sum over wavelength (integrate=True), then add read noise

uncertainty=True attaches σ as a NormalUncertainScalarArray: shot noise per wavelength summed in quadrature, plus read noise.

backproject(images) divides by the quantum yield, spreads each pixel evenly over the wavelength bins and applies the transposed weights, returning radiance on the scene grid.

Step 9 of 15

Simulate the observation


images = instrument.image(scene)
        
images.outputs.shape
{'channel': 4, 'sensor_x': 128, 'sensor_y': 64}
images.outputs.unit
electron

81,920 unknowns from 32,768 measurements: 2.5× underdetermined.

noise=TrueDefault: Poisson per wavelength, plus read noise
noise=FalseThe expected image
uncertainty=TrueAttach σ to the result
integrate=FalseKeep the wavelength axis: one image per spectral bin

Step 9 of 15 · result

The four channel images

The four simulated channel images

Each channel sees the scene rotated by its angle and dispersed along sensor $x$.

Step 10 of 15

Configure MART


mart = ctis.inverters.MartInverter(
    instrument=instrument,
    intermediate=True,
)
        
instrumentAny AbstractInstrument
gammaLearning rate $\gamma$. None means $2/N$, here 0.5.
threshold_convergenceStop when $\langle \chi^2 \rangle$ improves by less than this. Default $10^{-3}$.
num_iterationMaximum number of iterations. Default 100.
intermediateKeep every iterate in solutions. Default False; on here for the movie.

Under the hood

Backprojection is the conservative transpose

A single bright cell, the same cell regridded onto a rotated grid, and the result regridded back with the transposed weights

A single bright cell, $\mathbf{u}$.

Forward: $\mathbf{d} = W \mathbf{u}$, where $W_{ij}$ is the fraction of input cell $j$ that falls in output cell $i$.

Backward: $\mathbf{u}' = W^{*} \mathbf{d}$, where $W^{*}_{ji}$ is the fraction of output cell $i$ that falls in input cell $j$. The total is conserved, but the cell is not recovered.

In ctis, weights_transpose is na.regridding.transpose_weights_conservative(weights, ...). backproject() also spreads each pixel evenly over the wavelength bins and converts photons back to radiance. It is the $P^{*}$ in MART's correction, $P^{*} d \,/\, P^{*} d_i$.

Under the hood

Inside MartInverter.__call__


backprojected = np.maximum(instrument.backproject(images).outputs, 0)
scene = instrument.backproject(images).outputs.mean(axis_channel)
scene.ndarray[:] = scene.ndarray.mean()              # flat start, unless mart(images, guess=...)

for i in range(self.num_iteration):
    predicted = instrument.image(scene, noise=False, uncertainty=True).outputs
    chi2 = self.mean_chi_squared(images, predicted.nominal, predicted.width)
    merit = chi2.mean(axis_channel)
    if (merit_old - merit) < self.threshold_convergence:
        break
    backprojected_new = np.maximum(instrument.backproject(predicted.nominal).outputs, 0)
    correction = np.nan_to_num(backprojected / backprojected_new, nan=1, posinf=1, neginf=1)
    correction = np.prod(correction ** gamma, axis=axis_channel) ** (1 / num_channel)
    scene = scene * correction
    merit_old = merit
    

The correction is a ratio of back-projections, computed in scene space, so both passes conserve flux.

Channels combine by geometric mean, each tempered by $\gamma$.

No contrast enhancement yet (Parker et al. 2022).

Step 11 of 15

Run the inversion


inversion = mart(images)
    
inversion.num_iteration            23
inversion.success                  True
inversion.message                  Achieved merit less than 0.001.
inversion.solutions.outputs.shape  {'iteration': 23, 'wavelength': 20, 'scene_x': 64, 'scene_y': 64}
inversion.mean_chi_squared.shape   {'iteration': 23, 'channel': 4}
inversion.solution.outputs.unit    erg / (Angstrom s arcsec2 cm2)

About 1.0 s for 23 iterations (45 ms each) on a 24-core Threadripper 3960X.

inversion.solution is the last iterate. Its unit is per square arcsecond, not per steradian: use .to(scene.outputs.unit) before comparing numbers.

mart(images, guess=...) starts from your own initial guess; verbose=True prints the merit each iteration.

Step 12 of 15

Watch the iterations

na.plt.rgbmovie(label, wavelength, x, y, C=inversion.solutions.outputs, axis_time="iteration", axis_wavelength="wavelength", ...)

Step 13 of 15

Compare the average spectra


solution = inversion.solution
spectrum_inverted = solution.outputs.mean(
    ("scene_x", "scene_y")
)

na.plt.stairs(scene.inputs.wavelength, spectrum,
              ax=ax, label="original")
na.plt.stairs(scene.inputs.wavelength,
              spectrum_inverted,
              ax=ax, label="reconstructed")
        
  • The peaks come back 17% and 14% low.
  • The dip between the components comes back 65% high.
  • The missing flux goes into the line wings.
Original and reconstructed average spectra

Step 14 of 15

Compare line moments, pixel by pixel


inversion.plot_moments(scene, axis="wavelength")
    
Histograms of true against reconstructed radiance, median velocity and interquartile range

Column-normalized 2D histograms of radiance, median velocity and interquartile range, with Pearson's $r$. Options: num_bins, range_*, and percentile_radiance to exclude faint pixels.

Step 15 of 15

Check convergence


na.plt.plot(inversion.iteration,
            inversion.mean_chi_squared,
            ax=ax[0],
            axis=inversion.inverter.axis_iteration,
            label=instrument.channel)
na.plt.plot(inversion.iteration,
            inversion.correlation_residual,
            ax=ax[1],
            axis=inversion.inverter.axis_iteration,
            label=instrument.channel)
        
  • $\langle \chi^2 \rangle$ per channel uses σ of the predicted image; the stopping rule uses its mean over channels.
  • The residual's correlation with the predicted image tracks structure left in the residual.
  • $\langle \chi^2 \rangle$ ends at 0.33: below 1, so the later iterations fit noise.
Mean chi-squared and signal-correlated residual against iteration

Adopting ctis

Inverting your own images


images = na.FunctionArray(
    inputs=instrument.coordinates_sensor,
    outputs=na.ScalarArray(
        ndarray=data * u.electron,      # numpy array, (4, 128, 64)
        axes=("channel", "sensor_x", "sensor_y"),
    ),
)

inversion = mart(images)
        
  • Wrap a plain NumPy array by naming its axes and giving it units; this runs unchanged.
  • Images are in electrons, one per channel, on the instrument's sensor grid.
  • mart() raises ValueError if the image positions differ from instrument.coordinates_sensor.

Adopting ctis

Using your own instrument model

Option A · real optics


instrument = ctis.instruments.OptikaInstrument(
    system=system,   # optika linear system
    coordinates_scene=coordinates_scene,
    channel=channel,
    axis_channel="channel",
    axis_wavelength="wavelength",
    axis_scene_xy=("scene_x", "scene_y"),
)
        

optika supplies distortion, effective area, vignetting and the sensor response. The sensor grid and its axes come from system.

Option B · your forward model

Subclass AbstractInstrument and implement:

  • image(scene, integrate, noise, uncertainty)
  • backproject(image, integrate, unit)
  • coordinates_scene, coordinates_sensor, channel
  • axis_channel, axis_wavelength, axis_scene_xy, axis_sensor_xy, num_channel

Every inverter works with every instrument.

Adopting ctis

Performance at tutorial scale

import ctisIncluding named-arrays, optika and astropy2.6 s
instrument.weightsForward weights, first access, then cached0.21 s
instrument.weights_transposeTransposed weights, first access, then cached0.04 s
instrument.image(scene)With shot noise89 ms
instrument.image(scene, noise=False, uncertainty=True)As called by MART25 ms
instrument.backproject(images)12 ms
mart(images)23 iterations, 45 ms each1.0 s

A 64 × 64 × 20 scene and 4 × 128 × 64 images. AMD Threadripper 3960X (24 cores), Windows, Python 3.13, measured on 2026-09-30 with the numba cache warm.

Status and roadmap

Released · v0.3.1

Electron-based instrument interface with exact noise propagation (uncertainty=True).

OptikaInstrument for forward models from optika.

In review

ParametricInverter fits a spectral line profile to every pixel (#23).

Optional smoothness regularization for MART (#25). No gain on this synthetic scene; to be judged on ESIS data.

Not yet

Contrast enhancement from Parker et al. (2022).