Spectro-interferometric observables
Spectro-interferometers such as VLTI/GRAVITY and MATISSE record more than
squared visibilities and closure phases: a spectrum of the target (OI_FLUX),
visibility amplitudes or correlated fluxes (OI_VIS VISAMP), triple
amplitudes (OI_T3 T3AMP) and differential phases (OI_VIS VISPHI). virgil
reads them on request as extra observables, which follow the visibilities
and phases in the data vector, so every likelihood, fit and grid uses them
through the same whitened_residuals.
The API is virgil.observables.
Reading them
By default read_oifits and OIData read only V² (or |V|) and closure
phases; name the extras you want. A GRAVITY file holds fringe-tracker tables
(GRAVITY_FT) beside the science channel (GRAVITY_SC), measuring the same
baselines, so choose one with insname=. OIData accepts the record that
read_oifits returns as well as a path:
from virgil.oidata import OIData
from virgil.oifits import read_oifits
record = read_oifits(
"gravity.fits",
insname="GRAVITY_SC",
extras=("flux", "t3amp", "visphi"),
)
data = OIData(record)
# State the grey scale's prior, in the data's units (here Jy): required for
# "flux" and correlated fluxes, never taken from the data.
data = data.with_flux_scale(scale=(2.0, 2.0))
[block.kind for block in data.extras]
The example leaves out "visamp": GRAVITY's VIS2 and VISAMP come from the same frames, so reading both would count the visibility information twice.
extras |
Table and column | Model |
|---|---|---|
"flux" |
OI_FLUX FLUXDATA (or FLUX) |
k Σ fᵢ(λ), k marginalized |
"nflux" |
the same, as a normalized spectrum | Σ fᵢ(λ) / continuum, a scale near 1 marginalized |
"visamp" |
OI_VIS VISAMP, AMPTYP='absolute' |
|V| |
"visamp" |
OI_VIS VISAMP, AMPTYP='correlated flux' |
k |Σ fᵢ(λ) Vᵢ|, k marginalized (kind "corrflux") |
"t3amp" |
OI_T3 T3AMP |
|V_ab V_bc V_ac| |
"visphi" |
OI_VIS VISPHI, any PHITYP |
continuum-normalized arg V (below) |
Choose one of "flux" and "nflux", two readings of the same table. Read
V² and |V| together only when the pipeline measured them separately.
Choosing channels and observables
OIData.select restricts data to wavelength
windows and to some observables, so you never need to flag a read_oifits
record by hand. Wavelengths are in metres and ranges are inclusive:
data.select(2.05e-6, 2.40e-6) # one window
data.select(ranges=[(2.05e-6, 2.10e-6), (2.20e-6, 2.40e-6)])
data.select(exclude=[(2.162e-6, 2.170e-6)]) # drop a line
data.select(2.05e-6, 2.18e-6, observables="phi") # closure phases only
data.select(observables=("phi", "visphi")) # no V², no spectrum
observables names what to keep: "vis", "phi" and the extras kinds in
the table above; the rest are dropped at every wavelength. Closure triangles
are kept whole, since their legs share a wavelength, and calibration gains
(with_gains) go with the visibilities.
When the closure phases are dropped, the differential phases keep their
closure part (below), since it is then counted only once. Projected
observables (kernel phases, DISCOs) cannot be selected, and closure-phase
offsets must be added after selecting.
To use different windows for different observables, make them separate datasets of one fit. V² and closure phases are independent, so nothing is counted twice. Here a line window is left out of the V² while the closure phases are used everywhere:
import numpyro.distributions as dist
from virgil.fitting import fit
from virgil.models import PointSource, System
from virgil.spectra import GaussianLine, PowerLaw, Sum
# A star and a fainter companion with a Brγ emission line.
model = System(
star=PointSource(),
blob=PointSource(
dra=0.5,
ddec=0.2,
flux=Sum(
cont=PowerLaw(0.1, 0.0, wavel0=2.166e-6),
brg=GaussianLine(0.5, line_wavel=2.166e-6, fwhm=3e-9),
),
),
)
priors = {
"blob.dra": dist.Uniform(-5.0, 5.0),
"blob.ddec": dist.Uniform(-5.0, 5.0),
"blob.flux.cont.ratio": dist.LogUniform(1e-3, 1.0),
}
line = [(2.162e-6, 2.170e-6)]
datasets = [
data.select(exclude=line, observables="vis"),
data.select(observables="phi"),
]
result = fit(model, priors, datasets)
best_model = result.model
Spectra and the grey scale
Visibilities constrain only flux ratios: multiplying every component's
spectrum by a common g(λ) leaves V unchanged. A measured spectrum breaks the
degeneracy through
total_spectrum, the sum of the
components' fluxes. Its absolute level (calibration, fibre injection) is
unknown, so the model is k Σ fᵢ(λ) with a grey scale k.
The scale is linear, so virgil integrates it out analytically rather than
fitting it, under a Gaussian prior that you state in the data's units
with with_flux_scale(scale=(mean, sd)) (Luger, Foreman-Mackey & Hogg 2017).
The prior never comes from the data: for "flux" and correlated fluxes the
likelihood raises until you give one. Normalized spectra ("nflux") default
to (1, 0.1). This is the same low-rank marginalization, and code, as the
calibration gains of OIData.with_gains. The likelihood keeps the
log-determinant, which depends on the model's spectral shape.
The Gaussian is a proposal: k is a positive scale, whose Jeffreys prior is 1/k on stated bounds, and for a well-measured spectrum the two differ by about σ_k/k. For the Jeffreys posterior, reweight samples of k from its conditional posterior by 1/(k N(k; mean, sd²)) within the bounds, or sample log k directly. Make no evidence claims that depend on the Gaussian's width: the Jeffreys prior is improper, so an evidence needs finite positive bounds \([k_{\min}, k_{\max}]\) and the density \(1/(k\ln(k_{\max}/k_{\min}))\). Marginalizing in log k is not linear and is not done here. After a fit, report the scale's conditional posterior:
from virgil.likelihood import flux_scale_posterior
post = flux_scale_posterior(best_model, data)["flux"]
post["scale"] # data ≈ scale × total_spectrum, per group
post["mean"], post["cov"] # the weights, in template units
Change the prior and grouping with
with_flux_scale. Use per="row"
(one scale per spectrum) or per="station" (per telescope) when injection
differs between telescopes and exposures, as for uncalibrated GRAVITY
spectra. poly_order=1 also marginalizes a slope in λ times the spectrum,
for a chromatic calibration error.
"nflux" divides the model spectrum by its per-row continuum fit over the
ranges set with with_continuum.
The same helper,
continuum_operator, normalizes
the differential phases. A small scale with a 10% prior width absorbs the
difference between the pipeline's continuum and ours.
The degeneracy without OI_FLUX. If you fit no spectrum, the reference
component's spectral shape is unmeasured, and every other component's
spectral index or temperature is measured relative to it. Give the
reference its own PowerLaw or BlackBody with a prior from a model
atmosphere; that prior sets the systematic error on every other component's
colour.
Differential phases
A pipeline's differential phase is arg V with a continuum fit removed per baseline and frame: a mean and a slope in wavenumber, that is, an offset and a delay. virgil applies the same operator, N = I − L, to the exact model phase arg V(λ), unwrapped along wavelength, with covariance N D Nᵀ. If our basis contains the pipeline's, then N N_pipe = N, so re-normalizing the data does no harm.
continuum = [(2.150e-6, 2.162e-6), (2.170e-6, 2.180e-6)]
# `line` is the window defined with the fit above.
data = data.with_continuum(
continuum=continuum,
lines=line,
order=1, # offset and delay; 0 removes the mean only
)
Either range defaults to the complement of the other. With neither, every channel serves as both continuum and line, which is right for data without closure phases.
Do not use the photocentre formula for resolved systems. The
approximation φ ≈ −2π u·Δp(λ)/λ holds only while the line-emitting
structure is unresolved (|Δp| ≪ λ/B). For a binary much wider than λ/B with a
line in one star, it is wrong by far more than typical errors. virgil always
uses arg V (tested in tests/test_observables.py).
Projection is marginalization. Removing an offset and delay per baseline and frame is the flat-prior limit of marginalizing those nuisances, and the likelihood of N d, with covariance N D Nᵀ, does not depend on which projection with that null space is used (restricted maximum likelihood). A finite-prior version is an option:
data = data.with_continuum(continuum, lines=line, prior_width=(0.5, 2.0))
It marginalizes each baseline and frame's offset (and slope, per unit of wavenumber scaled to span 1 across the channels) under Gaussian priors of these widths in radians, keeping every channel of both windows. It works whether or not the pipeline has already normalized the data, since that subtraction only shifts the marginalized offset and slope, and it reuses the low-rank whitening of the 6d gains. As the widths grow, its χ² tends to the projection's when every channel is used (tested). The projection stays the default because it matches what the pipeline did.
Closure phases and differential phases together
When VISPHI and T3PHI come from one frame, the closure of the differential phases is the continuum-normalized closure phase, so fitting both would count it twice. By default virgil uses:
- closure phases in every channel;
- in the line windows only (both windows with
prior_width), the part of the differential phase with no closure: per frame, the baseline phases are projected onto the telescope-differenced subspace φ_ab = a_a − a_b, which is orthogonal to every closure, leaving N − 1 combinations per channel for N telescopes.
Each line channel then has (N − 1)(N − 2)/2 closure phases plus N − 1 closure-free differential-phase combinations, not one per baseline. The projection's propagated covariance is whitened as one dense block per frame.
What is approximated. The cross-covariance between the projected differential phases and the closure phases is neglected. It is exactly zero only when a frame and channel's baseline errors are equal; otherwise the independent-block likelihood is not exact, and the joint covariance is preferable where available. The residuals are treated as linear (Gaussian), which holds while differential phases stay well below π. A channel flagged on any baseline of a frame is dropped from that frame.
Error floors
with_error_floor sets a minimum
uncertainty per observable, like PMOIRED's min error and min relative
error:
import numpy as np
data = data.with_error_floor(
absolute={"phi": np.deg2rad(0.5), "visphi": np.deg2rad(0.3)},
relative={"vis": 0.02, "flux": 0.01},
)
Each σ becomes max(σ, absolute, relative × |data|), a fixed change to the
data. The fitted counterpart is the noise= terms (vis_scale,
vis_error_rel, vis_error, phi_error, …), which add in quadrature
relative to the model. Both use one rule, _utils.inflate_errors, exposed by
inflated_errors with where= and
combine=. Closure phases from four or more telescopes are floored before
whitening, and their periodic penalty rows keep the effective error 1/√(2π),
so the floor never enters the normalization twice. The noise= terms act on
V² and closure phases only; extra observables take fixed floors.
Bias of low-S/N amplitudes
Visibility amplitudes measured at low S/N are biased high, because noise adds
in quadrature: E|V̂| ≈ √(|V|² + σ²). The bias is about σ / (2 S/N), a sixth
of σ at S/N = 3 and 5% of σ at S/N = 10. virgil does not debias VISAMP. The
bias matters when the fit averages many channels, since it does not average
down. For low-S/N data, either fit V² (unbiased once the pipeline has
subtracted the noise power), or debias the amplitudes before reading, as
eht-imaging's debias does: |V| → √max(|V|² − σ², 0).
Bandwidth smearing
OIData(path, bandwidth=True) keeps the channel widths
(OI_WAVELENGTH.EFF_BAND) as OIData.bandwidth, and
data.with_bandwidth(dlambda) sets them for simulated data; by default they
are ignored. Models are then evaluated with each component's offset phasor
multiplied by the boxcar-channel envelope
sinc(π Δλ (u dra + v ddec) / λ²), applied to a System's components before
they are weighted and summed. An extended component is smeared as if it sat
at its centre offset. See virgil.models.smeared_model.
Supported models are Systems of components (nested to any depth),
BinaryModelCartesian and BinaryModelAngular (smeared as their
to_system()), Resolved pieces (zero visibility, so nothing to smear) and
Rotated scenes of these. Image and other models raise a TypeError
naming them; an OrbitalBinary is smeared through its at(mjd) snapshot,
which OIData evaluates for each sample.
Not yet
virgil does not yet model the spectral-resolution kernel, the primary beam
(fibre coupling), differential visibility amplitudes
(AMPTYP='differential'), or fitted noise= terms for the extra
observables. The wavelength scale (wavel_scale, from
OIData.with_wavelength_scale) does not yet rescale the OI_FLUX wavelengths.