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
ctis code walkthrough
The MART tutorial step by step: grids, scene, instrument model, inversion and diagnostics, and what it takes to run it on your own data.
Roy T. Smart
pip install ctis
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}
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.
Coordinate grids give cell edges and values live in the cells: 21 wavelength vertices hold 20 spectral bins.
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
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
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.
Cartesian2dVectorLinearSpaceA 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
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}
wavelength, wavelength_rest and position components; .velocity is derived.doppler_optical.Step 4 of 15
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
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)
"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.vmin and vmax.na.plt.pcolormesh(..., axis_rgb=...).
Step 6 of 15
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)
stairs takes the 21 vertices and the 20 bin values directly.
Step 7 of 15
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
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.quantum_yield = 1 electron per photon, read_noise = 0 electrons.Under the hood
IdealInstrument computesdistortion()$\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.
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)noise=True)integrate=True), then add read noiseuncertainty=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
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=True | Default: Poisson per wavelength, plus read noise |
|---|---|
noise=False | The expected image |
uncertainty=True | Attach σ to the result |
integrate=False | Keep the wavelength axis: one image per spectral bin |
Step 9 of 15 · result

Each channel sees the scene rotated by its angle and dispersed along sensor $x$.
Step 10 of 15
mart = ctis.inverters.MartInverter(
instrument=instrument,
intermediate=True,
)
instrument | Any AbstractInstrument |
|---|---|
gamma | Learning rate $\gamma$. None means $2/N$, here 0.5. |
threshold_convergence | Stop when $\langle \chi^2 \rangle$ improves by less than this. Default $10^{-3}$. |
num_iteration | Maximum number of iterations. Default 100. |
intermediate | Keep every iterate in solutions. Default False; on here for the movie. |
Under the hood

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
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
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
na.plt.rgbmovie(label, wavelength, x, y, C=inversion.solutions.outputs, axis_time="iteration", axis_wavelength="wavelength", ...)
Step 13 of 15
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")

Step 14 of 15
inversion.plot_moments(scene, axis="wavelength")

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
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)

Adopting ctis
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)
mart() raises ValueError if the image positions differ from instrument.coordinates_sensor.Adopting ctis
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, channelaxis_channel, axis_wavelength, axis_scene_xy, axis_sensor_xy, num_channelEvery inverter works with every instrument.
Adopting ctis
import ctis | Including named-arrays, optika and astropy | 2.6 s |
|---|---|---|
instrument.weights | Forward weights, first access, then cached | 0.21 s |
instrument.weights_transpose | Transposed weights, first access, then cached | 0.04 s |
instrument.image(scene) | With shot noise | 89 ms |
instrument.image(scene, noise=False, uncertainty=True) | As called by MART | 25 ms |
instrument.backproject(images) | 12 ms | |
mart(images) | 23 iterations, 45 ms each | 1.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.
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).
pip install ctis