Radio imaging from visibilities: a simulated tutorial
Provisional
May change in any 0.4.x release without a deprecation cycle.
This tutorial takes a simulated ALMA-like observation from visibilities to an image: build a ComplexVisData, set up the calibration nuisances, start from CLEAN, then fit a pixel Image and compare it with the truth. Everything is synthetic and seeded, so it runs anywhere in about a minute on a laptop CPU. For real data, replace the simulation with ComplexVisData.read_ms; the radio interferometry guide and the CLEAN guide document each piece.
Imports and setup
import warnings
warnings.filterwarnings("ignore", category=SyntaxWarning) # a docstring in a dependency
warnings.filterwarnings("ignore", message="IProgress")
Imports and setup
import jax
import matplotlib.pyplot as plt
import numpy as onp
import numpyro.distributions as dist
from matplotlib.colors import PowerNorm
from virgil import CalibrationNuisance, ComplexVisData, Image, fit, scenes
from virgil._geometry import image_visibilities
from virgil.imaging import MaxEntropy
from virgil.radio_clean import clean, restore
jax.config.update("jax_enable_x64", True) # the Toeplitz χ² needs float64
Simulated data
The truth is a 65 × 65 image of 4 mas pixels: an inclined ring of radius 60 mas holding 0.8 Jy, and a compact clump to its north-east holding 0.2 Jy. The observation tracks 300 random baselines for six hours (east and north components each log-uniform between 100 m and 10 km) at 1.3 mm from a southern site, giving 60,000 uv points. The visibilities are the exact transform of the truth plus Gaussian noise of 0.05 Jy per real and imaginary part.
Code
N, S, SIG = 65, 4.0, 0.05 # pixels, mas per pixel, Jy per real/imaginary part
truth = onp.asarray(0.8 * scenes.ring(N, S, 60, 10, inc_deg=50, pa_deg=40) + 0.2 * scenes.gaussian_blob(N, S, 6, dra=90, ddec=60))
rng = onp.random.default_rng(1)
bl = onp.exp(rng.uniform(onp.log(100), onp.log(1e4), (300, 2))) * rng.choice([-1, 1], (300, 2)) # east, north (m)
H, dec, lat = onp.deg2rad(onp.linspace(-45, 45, 200)), onp.deg2rad(-37), onp.deg2rad(-23)
u = ((bl[:, :1] * onp.cos(H) - bl[:, 1:] * onp.sin(lat) * onp.sin(H)) / 1.3e-3).ravel()
v = ((bl[:, :1] * onp.sin(dec) * onp.sin(H) + bl[:, 1:] * (onp.sin(lat) * onp.sin(dec) * onp.cos(H) + onp.cos(lat) * onp.cos(dec))) / 1.3e-3).ravel()
vis = onp.asarray(image_visibilities(truth, u, v, S, ft="dft")) + SIG * (rng.normal(size=u.size) + 1j * rng.normal(size=u.size))
data = ComplexVisData.from_arrays(u, v, vis, 1 / SIG**2, 230e9) # u, v in wavelengths; weight = 1/σ²
print(f"{len(data)} visibilities, longest baseline {onp.hypot(u, v).max() / 1e6:.1f} Mλ")
60000 visibilities, longest baseline 9.9 Mλ
Calibration nuisances
CalibrationNuisance configures what is fitted besides the sky. Here there is one observation with known errors, so only the total flux cal.flux is fitted: no gains, offsets, spectral index or error scales.
nuis = CalibrationNuisance(data, ref_obs=0, alpha=False, offsets=False, sigmas=False)
priors = nuis.priors() # just cal.flux
A CLEAN start
radio_clean.clean gives a first image and its restoring beam (fit_beam fits the beam to the dirty beam's core). We compare reconstructions through that beam, which is what a perfect image would look like at this resolution.
sigma_img = 1 / onp.sqrt(onp.sum(onp.asarray(data.weight))) # thermal noise of the natural-weighted image
res = clean(data, N, S, deconvolver="hogbom", threshold=3 * sigma_img, usemask="auto-multithresh")
print(f"CLEAN stopped on '{res.stop}' after {res.iterations} iterations; beam {res.beam[0]:.0f} x {res.beam[1]:.0f} mas")
CLEAN stopped on 'threshold' after 563 iterations; beam 49 x 46 mas
Fitting an Image
The scene is a pixel Image (the pixels are softmax(log_brightness), scaled by cal.flux), started from the CLEAN model. MaxEntropy regularizes it weakly. chi2="auto" is the default: because the scene is one fixed Image, with no antenna gains, offsets or fitted spectral index, in float64, with enough points per pixel, fit evaluates the exact Toeplitz form of χ² (a few FFTs of the image instead of a transform of 60,000 points), and info["likelihood_forms"] reports which form ran.
scene = lambda values: Image(values["log_brightness"], pixel_scale_mas=S)
start = onp.clip(res.model, 0, None) / onp.clip(res.model, 0, None).sum()
init = nuis.defaults()
init["log_brightness"] = onp.log(start + 1e-4 * start.max())
init["cal.flux"] = float(res.model.sum())
priors["log_brightness"] = dist.Normal(0.0, 3.0)
term = data.term(scene, nuis, chi2="auto")
result = fit(lambda **values: scene(values), priors, (), likelihoods=[term], regularizers=[MaxEntropy(1e3)],
init=init, method="lbfgs", max_steps=5000)
print(result.info["likelihood_forms"], f"converged={result.info['converged']} in {result.info['steps']} steps")
['toeplitz'] converged=True in 2792 steps
The image against the truth
All panels are seen through the CLEAN beam, in Jy/beam. The last two are the CLEAN and fitted images minus the truth, on the same scale: signed residuals, with no uncertainties attached.
Plotting code
beam = res.beam
zeros = onp.zeros((N, N))
fit_image = onp.asarray(scene(result.values).brightness) * float(result.values["cal.flux"])
panels = {
"truth": restore(truth, zeros, beam, S),
"CLEAN": restore(res.model, zeros, beam, S),
"fit": restore(fit_image, zeros, beam, S),
}
half = N * S / 2
fig, axes = plt.subplots(2, 3, figsize=(13, 8.5))
for ax, (name, im) in zip(axes[0], panels.items()):
shown = ax.imshow(im, extent=[half, -half, -half, half], origin="upper", cmap="magma", norm=PowerNorm(0.5, vmin=0, vmax=panels["truth"].max()))
ax.set(title=name, xlabel="ΔRA (mas)", ylabel="ΔDec (mas)")
fig.colorbar(shown, ax=ax, label="Jy/beam")
lim = 0.2 * panels["truth"].max()
for ax, name in zip(axes[1, 1:], ("CLEAN", "fit")):
shown = ax.imshow(panels[name] - panels["truth"], extent=[half, -half, -half, half], origin="upper", cmap="RdBu_r", vmin=-lim, vmax=lim)
ax.set(title=f"{name} − truth", xlabel="ΔRA (mas)")
fig.colorbar(shown, ax=ax, label="Jy/beam")
axes[1, 0].axis("off")
fig.tight_layout()

Diagnostics
The fit statistic is the raw χ² per datum on the quoted errors, Σ w |V − M|² / (2N) (two real numbers per visibility), which should be near 1 for a model that fits noise-limited data; it is not rescaled. We also report the flux and the rms difference from the truth through the beam, relative to the truth's peak.
w = onp.asarray(data.weight)
def chi2_per_datum(model_vis):
return float(onp.sum(w * onp.abs(onp.asarray(data.vis) - model_vis) ** 2) / (2 * len(data)))
fit_vis = onp.asarray(data.model(scene(result.values), result.values, nuis))
peak = panels["truth"].max()
for name, model_vis, im, flux in (("CLEAN", res.model_visibilities(data), panels["CLEAN"], res.model.sum()),
("fit", fit_vis, panels["fit"], fit_image.sum())):
print(f"{name:5s} χ²/N = {chi2_per_datum(onp.asarray(model_vis)):.3f}, flux {flux:.3f} Jy (true {truth.sum():.3f}), "
f"rms − truth {onp.sqrt(onp.mean((im - panels['truth']) ** 2)) / peak:.3f} of peak")
print(f"fit reports χ² = {result.info['chi2'][0]:.0f} for {int(result.info['ndata'][0])} data")
CLEAN χ²/N = 1.005, flux 0.999 Jy (true 1.000), rms − truth 0.002 of peak
fit χ²/N = 1.003, flux 1.008 Jy (true 1.000), rms − truth 0.003 of peak
fit reports χ² = 120350 for 120000 data
Notes
The fit used the default exact path. Fitted phase-centre offsets would need offsets="spread" in ComplexVisData.toeplitz, an opt-in extension that is derived here, not published; antenna gains and a fitted spectral index fall back to the direct transform. The package is provisional, so details may change before it settles. See the radio interferometry guide for the model, the nuisances and the Toeplitz χ², and the CLEAN guide for clean.