Measurement Sets and Calibration Tables
Everything that knows the Measurement Set format. Named for the format rather than generically, so that a second input format becomes a sibling module with its own name.
Reading declarations rather than assuming them
An MS records several properties that are easy to assume and expensive to get wrong. TABASCAL reads them from the file:
Correlations. The POLARIZATION subtable’s CORR_TYPE lists which
correlations the MS actually holds, as CASA Stokes codes.
resolve_correlation() matches the configured data.corr
against it by identity, not by position, so yy selects YY whether the MS
holds all four correlations or only that one. A fixed {xx: 0, xy: 1, yx: 2,
yy: 3} table only works for a full four-correlation MS: a single-correlation
MS has a length-1 correlation axis whatever polarisation it holds, and a
two-correlation (XX, YY) MS puts YY at index 1. Requesting a correlation the
MS does not hold is an error naming what it does hold, rather than an index
error or a silent read of the wrong polarisation.
Time scale. The TIME column’s MEASINFO record declares the scale its
values are on, almost always UTC. read_time_scale()
returns it, and it is carried in the read_ms result as time_scale. The
scales differ by enough to matter — reading a UTC epoch as TAI shifts it by the
accumulated leap seconds, 37 s since 2017, or roughly 285 km along a LEO
satellite’s ground track — and none of these mismatches raise.
The declaration is honoured by normalising to UTC once, in read_ms:
to_utc_jd() moves the declared Julian Dates onto UTC and
the result is returned as times_jd. Everything past the reader works on that
— skyfield through skyfield_time()’s default, sgp4jax’s
propagator, which has no scale concept to be told otherwise, and the TLE epoch
checks — so a single conversion covers all of them and no scale argument is
threaded through the trajectory maths. A UTC-declared MS goes through no
arithmetic at all and reads bit-identically.
An epoch reference TABASCAL cannot place on a time line — a sidereal angle such
as GAST, or a relativistic scale skyfield offers no constructor for — stops
the read, before the visibilities are touched, rather than being guessed at.
The preflight observation-epoch helper — which runs before the reader, and
which every TLE age comparison and nearest-record decision is measured from —
reads the same MEASINFO record through casacore and normalises the same way,
so it lands on the same instant. That keeps it physically right and keeps
check_epoch_agreement comparing like with like, both sides on UTC.
times_mjd stays as declared beside times_jd: it is the MS’s own TIME
column in days — the numbers the file stores, converted in unit only — and
ms_integration_times_mjd() reports that same column
the same way, so the two stay comparable. It is kept as read rather than
recovered from times_jd, which loses ~1e-10 days on the round trip. Nothing
writes it back to the MS: the results writer leaves the original TIME column
untouched.
Time unit. The same column’s QuantumUnits keyword declares whether its
values are seconds or days; TABASCAL works in MJD days.
read_time_unit() returns the declaration and
times_to_mjd() applies it, falling back — for the columns
that carry no declaration — on the magnitude of the times: an MJD day number is
at most ~1e5 in any plausible observing era against ~1e9 for the same instant in
seconds, and no observation falls between. What is compared is the median of
the finite magnitudes, so no single entry decides the column — a row casacore
added and never filled leaves TIME at zero, and the smallest magnitude would
read a column of seconds as days on the strength of it. The threshold is strict:
a magnitude of exactly 1e5 reads as days.
The spacing of consecutive samples used to decide this instead, on the reasoning
that an integration is seconds long and so a gap above half a day could only be
seconds. It could not. The threshold was strict, so an integration of exactly
0.5 s — a common correlator dump time — read as days, and so did anything
shorter; that is issue #208, which showed up as an OverflowError out of the
preflight TLE epoch check, times of ~5e9 having been taken for day numbers. And
in the other direction a day-numbered column stepping past the half-day
threshold read as seconds: MJD 60676 came back as MJD 0.7. The rule compared the
two smallest distinct times rather than a representative cadence, so this
needed every sample to be at least half a day from the next — a column carrying
one row per day, not merely an observation spread over several.
Magnitude parts every column the spacing rule parted correctly inside the era
the threshold already assumes, so the rule is gone rather than repaired: for a
spacing test to decide anything magnitude does not, a column stored in seconds
would need a typical |TIME| of 1e5 or less, putting the observation within
1.16 days of the MJD epoch of 1858-11-17. Outside the era bound — a day-numbered
column past 2132, whose |TIME| exceeds 1e5 — spacing was the better of the
two, but a heuristic whose constant is an era bound has conceded that case
already.
read_ms(), the results writer’s observation grid and the
preflight observation-epoch helper all convert here, so the heuristic cannot
classify one MS two ways — including for an MS whose timestep blocks do not
ascend, since a median does not depend on the order its values arrive in. The
preflight helper deduplicates and the other two do not, but it converts before
it deduplicates, and on any MS ms_layout() accepts the three
are handed the same multiset anyway: n_time is the number of distinct times
and every block must hold one constant time, so the reader’s slice holds nothing
for np.unique to remove. Their scopes still differ — the preflight helper
reads the whole main table and the reader one partition — which weighing the
times by frequency is what makes survivable.
A declared unit cannot part them either. The preflight helper reads
QuantumUnits from the same getcolkeywords call it takes MEASINFO
from, so all three converters honour a declaration and the heuristic is what is
left for an MS carrying none. It deliberately did not, once, so that both paths
would share the heuristic — but the reader honours a declaration, so leaving it
unread was what created the divergence rather than what closed it: an MS
declaring seconds while storing day numbers was read on the declaration by the
run and on the magnitudes by the TLE age checks.
CASA calibration tables
Gains are exchanged in CASA’s own format rather than as ad-hoc .npz files, so
that standard tooling — applycal, CARAcal, stimela — can consume what
TABASCAL solves for. write_caltable() emits a B Jones
table laid out exactly as casatasks.gaincal emits one: one row per
(time, antenna), time-major, with CPARAM of shape (n_chan, n_pol).
B Jones rather than G Jones because the gains are frequency dependent,
which a scalar G table cannot carry.
CASA identifies a caltable by its table INFO record, not by its keywords:
without type='Calibration' applycal rejects the table outright. The MS’s
ANTENNA, FIELD, SPECTRAL_WINDOW, OBSERVATION and HISTORY
subtables are copied in beside it, as CASA does, which is what lets
read_caltable() return the channel frequencies the gains
belong to without being handed the MS again.
The convention is CASA’s: V_obs = g_p conj(g_q) V_true, so calibrating
divides that out and the noise follows the data,
sigma_cal = sigma / |g_p conj(g_q)|.
apply_gains_to_data() is that one statement in code.
Extra keywords are written beside the table’s own, for what the solver knows
and the format has no field for: TABASCAL records the correlation it fitted as
FittedCorr, since a single-solution table otherwise cannot say, and applying
an xx solution to yx data is a silent mistake. The names the table needs
for itself — the four CASA identifies it by, and the one per subtable — are
refused rather than overwritten, and so are values casacore cannot be relied on
to encode: a keyword value is a string, bool, int or float, or a non-empty list
of one single one of those. read_caltable() returns the
caller’s keywords, and only those, so a round trip needs no casacore.
time_ref is the epoch reference the TIME column declares, and it must be
the MS’s own. The times are a copy of the MS’s column and nothing shifts them,
so a table declaring UTC over a TAI-declared observation has moved every
timestamp by the accumulated leap seconds for anything that reads the
declaration. It is validated against TIME_SCALES, and
read back as time_ref.
remove_caltable() is the other end: it deletes a table whose
solution has been superseded — a rerun that fits no gains has none to overwrite
the previous one with, and a stale table under the current name reads as the
current calibration. It applies the same overlap guard as the writer, and the
same test of what it is about to delete.
What may be deleted
Both calls that remove something — remove_caltable, and write_caltable
clearing an existing output under overwrite=True — first ask whether the path
holds a table this module could have written. Three checks, each catching what
the others miss: casacore’s marker files, which a caller’s own directory and an
ordinary file do not have; an INFO record declaring Type = Calibration, which
is the only thing separating a caltable from a Measurement Set, since an MS
carries the same markers; and tableexists, casacore’s read-only structural
check, which catches a directory dressed up with hand-written marker files and a
table.dat that has been truncated or replaced.
That is a check on the format and not a guarantee of integrity — a table that
opens cleanly can still hold a solution that is wrong, or for another
observation — but it does guarantee that what is removed was a casacore
calibration table rather than a caller’s data. Anything else at the path is a
ValueError from the writer and a False from the remover; a damaged table
is refused on the same rule and has to be cleared by hand. This tightens the
original overwrite contract, which removed the destination on sight: every
legitimate overwrite target is a previous solution, so anything else there is a
path pointing somewhere the caller did not mean, and the cost of reading it the
other way is a deletion that cannot be undone.
The solution TABASCAL fits is exported this way after every run; see Output from TABASCAL.
A gain that is zero or non-finite carries no solution, and both halves of that
are written: FLAG is set and CPARAM is NaN, so a reader going by the
flag and one going by the value reach the same conclusion. Calibrating with one
gives NaN rather than an infinity — every kind of dead gain arrives as the same
NaN, so a caller flagging on isnan catches all of them.
Scope: one spectral window, one correlation
That is what TABASCAL fits, and it is checked rather than assumed. Every row is
written with SPECTRAL_WINDOW_ID = 0 and the frequencies are read back from
window 0, so an MS or caltable describing more than one spectral window is
rejected instead of having one window’s gains labelled with another’s channels.
write_caltable duplicates its single solution across the polarisation axis,
so collapsing that axis on the way back in is a no-op for TABASCAL’s own tables.
A caltable from CASA can hold a genuinely different Jones term per polarisation,
and averaging those would return a gain that calibrates neither — so
read_caltable() requires the unflagged polarisations to agree
and raises otherwise. Per-polarisation reading is tracked by issue #151. A
flagged polarisation is treated as missing rather than as zero: where one
polarisation holds a solution and the other does not, the surviving one is
returned.
What a failed write leaves behind
overwrite=True removes a calibration that took a run to produce, so
write_caltable checks every argument before it touches anything on disk —
including the checks that would otherwise only fail deep in the write, such as a
non-numeric gains array reaching np.isfinite, and overwrite itself,
which is required to be a genuine boolean rather than taken on its truthiness:
overwrite="False" reads as a refusal and would delete the very table the
caller was trying to protect. The gains are also
cross-checked against the MS they claim to belong to: the caltable carries a copy
of the MS’s ANTENNA and SPECTRAL_WINDOW, and its own rows index those
copies, so gains of the wrong antenna or channel width would produce a table
that disagrees with the MS inside itself. A mismatch names both counts.
The output path is also required not to overlap the MS. Writing the caltable to the MS, or to a directory containing it, would delete the observation before its subtables could be copied out — and writing it inside the MS means writing into the very directories being copied from. All three are rejected up front.
The check asks the filesystem rather than comparing paths as text, because one
directory has many spellings: a symlink, a .., and — on a case-insensitive
filesystem such as APFS or NTFS — a different case. realpath hands back
whichever spelling it was given, so X.ms and x.ms resolve to strings that
differ while naming one directory; identity and containment are settled by
(st_dev, st_ino) instead, walking a path’s ancestors rather than testing it
as a prefix. A sibling named x.ms2 is therefore not mistaken for a child of
x.ms, and a case-variant alias cannot spell its way past the guard.
That gives two guarantees, which are deliberately different:
A caller’s mistake costs nothing — the call raises before the removal, and an existing table is left exactly as it was.
An I/O failure part-way through the write cannot put the old table back. The partial output is then removed on a best-effort basis before the error is re-raised, so a half-written table can only survive a failure that also prevents its own removal. The original error always propagates — nothing raised while clearing up replaces it.
Warning
Do not rely on applycal to set the weights for a frequency-dependent
gain. applycal(calwt=True) applies a single per-row weight factor,
constant across channels, even when WEIGHT_SPECTRUM exists — it collapses
the frequency axis rather than scaling each channel by its own
|g_ch|**2. TABASCAL therefore computes WEIGHT_SPECTRUM itself when it
writes results.
Reading Measurement Sets, and the calibration tables that go beside them.
Everything that knows the MS format lives here. Named for the format rather than
generically (io.py) so that a second input format becomes a sibling module
with its own name, instead of accreting into one file the way MS reading
accreted into tab_tools.py.
The CASA caltable block at the end is the one place that knows the calibration
table format, so that gains can be exchanged with standard tooling (applycal,
CASA/CARAcal/stimela) instead of ad-hoc .npz files. It sits here because a
caltable is an MS-shaped thing that only means anything beside its MS, and is
kept a self-contained block so it can become its own module unchanged.
Its scope is the scope tabascal solves for: one spectral window and one correlation. Both are checked rather than assumed – a multi-window table would label one window’s gains with another’s channels, and a table holding a different Jones term per polarisation cannot be collapsed to the single gain tabascal fits.
Gain convention (CASA’s, and the only one used here)
The gain multiplies the model to give the observed visibility:
V_obs[p, q] = g_p * conj(g_q) * V_true[p, q]
so calibrating divides it out, and the noise follows the data:
V_cal = V_obs / (g_p conj(g_q))
sigma_cal = sigma / |g_p conj(g_q)|
weight_cal = weight * |g_p conj(g_q)|**2 (weight == 1 / sigma**2)
A scalar flux scale V_cal = k * V_obs is therefore the antenna-independent
gain g = k ** -0.5.
- tabascal.ms.CORR_TYPES = {'i': 1, 'll': 8, 'lr': 7, 'q': 2, 'rl': 6, 'rr': 5, 'u': 3, 'v': 4, 'xx': 9, 'xy': 10, 'yx': 11, 'yy': 12}
CASA Stokes enumeration (casacore
Stokes.h), for the correlations that can be selected by name. The MS records which of these it holds inPOLARIZATION::CORR_TYPE, so a correlation is identified by its code rather than by where it sits on the data axis.
- tabascal.ms.DEFAULT_TIME_SCALE = 'utc'
Time scale assumed when an MS does not say which one its
TIMEcolumn uses.
- class tabascal.ms.MSLayout(n_time: int, n_bl: int, a1: ndarray, a2: ndarray)[source]
How an MS partition’s rows map onto tabascal’s
(n_time, n_bl)grid.a1/a2are the antenna pairs of one timestep’s block, in row order.- a1: ndarray
Alias for field number 2
- a2: ndarray
Alias for field number 3
- n_bl: int
Alias for field number 1
- n_time: int
Alias for field number 0
- tabascal.ms.TIME_UNITS = {'d': 'd', 'day': 'd', 'days': 'd', 's': 's', 'sec': 's', 'second': 's', 'seconds': 's', 'secs': 's'}
Units a
TIMEcolumn can declare inQuantumUnits, mapped to the two tabascal distinguishes. casacore writes's'; the longer spellings are accepted for the same reason the scale names are – the point is to read whatever the MS declares, not to insist on one spelling of it.
- tabascal.ms.apply_gains_to_data(vis: NDArray, gains: NDArray, a1: NDArray, a2: NDArray, sigma: NDArray | float | None = None)[source]
Divide the gains out of the data (and carry the noise with it).
This is the whole convention in one place – see the module docstring.
visis(n_bl, n_freq, n_time);gainsis(n_ant, n_freq, n_time);sigmais anything broadcastable againstvis(a scalar, or(n_bl, 1, 1)).Returns
(vis_cal, sigma_cal);sigma_calisNoneif nosigmawas given. A baseline whose gain is dead – flagged, zero or non-finite – has no calibrated value, and comes back as NaN in both; such visibilities must be flagged by the caller.
- tabascal.ms.fitted_correlation(ms_path: str, zarr_corr, corr, n_corr: int, pol_id: int = 0) int[source]
Index on the MS’s correlation axis that the results belong to.
tabascal fits one correlation. Its name comes from the
corrargument if given, else from thecorrattribute the run recorded on the results zarr, and is resolved to an index by identity, not by position – a single-polarisation MS holds one correlation whatever it is, soyyis index 0 there.pol_idis thePOLARIZATIONrow the data partition actually uses, the same oneread_msresolved throughDATA_DESCRIPTION. Row 0 is only a convention: a partition on another row may order its correlations differently, or hold fewer of them, and resolving against the wrong row would put the results in the wrong polarisation without a word.A zarr written before that attribute existed carries no name. With one correlation there is only one answer; with more, guessing would silently write the results into the wrong polarisation, so it is an error.
- tabascal.ms.grid_to_rows(arr, n_freq: int, n_corr: int = 1)[source]
tabascal’s
(bl, freq, time)back to MS(row, chan, corr).The exact inverse of
rows_to_grid(), and the reason they live next to each other: a transpose written once in each direction cannot drift out of step the way two independent ones can.
- tabascal.ms.infer_time_unit(times) str[source]
"s"or"d"for aTIMEcolumn that declares no unit.Split out from
times_to_mjd()so that a caller which wants to reduce one array and convert another can.orbit_config._integration_times_mjdis the one that does: the unit has to be read from every row, because the reduction weighs the times by how often they occur, while the values it returns are the distinct ones. Converting first and deduplicating after would do both, but it scales the whole column to do it – 2.3x its size against 1.3x, on a main table that can run to gigabytes – and division is not injective in binary64, so two raw timestamps can land on one MJD and leave the mean epoch weighted differently.Works in place on its own copy of the finite times, which no caller shares.
- tabascal.ms.into_corr(col, corr_idx: int, n_corr: int, fill)[source]
Place a one-correlation result on the MS’s correlation axis.
Results are
(row, chan, 1)while the MS column may be(row, chan, 4). The fitted correlation takes the result; the others takefill– zero for the model columns, and the data column itself for the data-frame columns, which is what “no gain applied and nothing subtracted” means there.Works on the raw arrays because xarray will not broadcast a length-1
corrdimension against a length-4 one; the caller re-wraps.
- tabascal.ms.ms_layout(xds) MSLayout[source]
Derive and validate the row layout of one MS partition.
tabascal reads every visibility column as
(n_time, n_bl), so the rows have to be time-major:n_blconsecutive rows holding one timestep of a fixed baseline sequence, repeated per timestep. That was assumed everywhere the reshape appears; it is checked once here instead, for the reader and the results writer alike.Three distinct ways an MS can break the reshape, each silent on its own: a baseline-major store repeats one pair down the first rows; a per-timestep reshuffle keeps the row count right while moving each baseline’s data; and rows that cycle through baselines and times together satisfy both of those while landing every visibility on the wrong timestamp.
Nothing the size of a column is ever held in memory. The only values read whole are the
n_timedistinct times and the first block’sn_blantenna pairs; the checks over the full columns are reductions, computed together in one pass, chunk by chunk, on dask-backed input.
- tabascal.ms.ms_row_chunks(row_chunk=None)[source]
Leave dask-ms defaults intact unless a positive row chunk is requested.
- tabascal.ms.partition_noise(xds, n_time: int, n_bl: int, n_freq: int, corr_idx: int = 0, chans=None, n_chan_ms: int | None = None) ndarray | None[source]
The noise on one MS partition’s visibilities, as resolved as the MS allows.
Most specific column first:
SIGMA_SPECTRUM(row, chan, corr)Noise per (baseline, channel), shape
(n_bl, n_freq). The default, because a bandpass is not flat and an MS that has measured that says so.SIGMA(row, corr)Per-baseline noise, shape
(n_bl,)– the band-averaged version of the same measurement, and what most MSs carry.
Either column keeps its time axis if it has one to keep: a column whose rows genuinely change over the observation comes back as
(n_bl, n_freq, n_time), or(n_bl, 1, n_time)fromSIGMA. Seetabascal.noise.A
SIGMA_SPECTRUMthat is absent, that holds no positive finite value anywhere (a column that was never filled in), or that describes a different set of channels from the ones being read is not an error: the read falls through toSIGMA. A column that contradicts the row layout is an error – that is the reader and the MS disagreeing about the grid, which reading another column instead would only bury.Nothing is invented: if neither column is usable this returns
None, after saying why, because a made-up noise scale silently re-weights the entire fit.Nonerather than an exception becausedata.noiseis read after the MS – an override is exactly the answer to an MS with no noise in it, and raising here would take its turn away.TabConfig.set_noise()is where a still-unset noise becomes the error that stops the run.chansnarrowsSIGMA_SPECTRUMto the channels being read, and must be the same selection the data went through: the noise divides those visibilities cell by cell, so a noise left on the full band would weight every channel by another channel’s.SIGMAhas no channel axis to narrow.n_chan_msis the MS’s own channel count, which the column is validated against; it defaults ton_freq, i.e. no selection was made.
- tabascal.ms.partition_polarization(ms_path: str, xds) int[source]
The
POLARIZATIONrow the partitionxdsuses.The half of
partition_setup()a caller needs when it is placing correlations rather than reading channel frequencies.
- tabascal.ms.partition_setup(ms_path: str, xds) tuple[source]
(spectral_window_id, polarization_id)for the partitionxds.xds_from_mspartitions by(FIELD_ID, DATA_DESC_ID)and records the id in each partition’s attrs, so a partition can say which subtable rows its data is described by rather than assuming row 0.
- tabascal.ms.read_caltable(path: str) dict[source]
Read a caltable written by
write_caltable()(or by CASA).Returns a dict with
gains(n_ant, n_freq, n_time)complex – flagged solutions set to NaN – plustimes,ant_idx,freqs(Noneif the table carries noSPECTRAL_WINDOW) andviscal.time_refis the scale theTIMEcolumn declares, as written, orNonefor a table that declares none.keywordsholds the caller’s keywords – whatever the solver recorded, such as tabascal’sFittedCorr– with the names the format claims for itself left out, so that reading back what was written needs no casacore.Reads the single-correlation, single-spectral-window tables tabascal fits. A table whose polarisations carry genuinely different solutions, or which describes more than one spectral window, is an error rather than a silent collapse – see
_collapse_pols()and_single_spw_chan_freq().
- tabascal.ms.read_time_scale(column_keywords: dict, column: str = 'TIME') str[source]
Time scale declared by an MS column, from its
MEASINFOrecord.A Measurement Set records the scale its times are on rather than leaving it to convention: the
TIMEcolumn carriesMEASINFO {'type': 'epoch', 'Ref': 'UTC'}.UTCis overwhelmingly the common case, but it is a declaration to be read, not a property to be assumed – an MS may legitimately declareTAIor another scale, and the difference is the accumulated leap seconds, 37 s since 2017, which is ~285 km along a LEO satellite’s ground track.Read by
read_ms(), which normalises the times it returns onto UTC, and byorbit_config’s preflight epoch helper, which normalises the same way.- Parameters:
column_keywords (dict) – Per-column keyword mapping, as returned by
xds_from_ms(path, column_keywords=True)[1].column (str, optional) – Column to read the scale from. Defaults to
"TIME".
- Returns:
The declared scale, lower-cased, or
DEFAULT_TIME_SCALEwhen the MS does not declare one.- Return type:
str
- Warns:
UserWarning – If the column declares no scale and the default is assumed. An assumed scale is worth saying out loud, and worth being able to filter and assert on, which a bare print is not – as in
read_time_unit().
- tabascal.ms.read_time_unit(column_keywords: dict, column: str = 'TIME') str | None[source]
Unit an MS column declares its times in, from its
QuantumUnitskeyword.The MS format leaves the unit of
TIMEto the column: casacore writes seconds and declaresQuantumUnits ['s'], but days are equally legal. Reading the declaration makes it authoritative and leavestimes_to_mjd()’s heuristic as the fallback for the columns that carry no declaration.- Parameters:
column_keywords (dict) – Per-column keyword mapping, as returned by
xds_from_ms(path, column_keywords=True)[1].column (str, optional) – Column to read the unit from. Defaults to
"TIME".
- Returns:
"s"or"d", orNonewhen the MS declares nothing usable – which is not an error, only an absence for the caller to infer around.- Return type:
str or None
- Warns:
UserWarning – If the column declares a unit that is neither seconds nor days. An ignored declaration is worth saying out loud, and worth being able to filter and assert on, which a bare print is not.
- tabascal.ms.remove_caltable(path: str, ms_path: str) bool[source]
Delete the calibration table at path, if that is what is there.
For a solution that has been superseded. A run that fits no gains has no table to overwrite the previous run’s with, and one left standing beside the new results reads as the current calibration – the failure mode a stale file always has, and a calibration is a bad thing to be wrong about.
Returns whether a table was removed; nothing there is not an error.
This is a
rmtreeon a path a caller named, so it is the one call here that has to be sure. Both checks refuse rather than delete: the path must hold a casacore table declaring itself a calibration (_is_caltable()), and it must not be, contain, or sit inside the MS – the same guardwrite_caltable()applies before it removes an existing output, and for the same reason.
- tabascal.ms.resolve_correlation(ms_path: str, corr: str, pol_id: int = 0) int[source]
Index of
corron the MS’s correlation axis.Resolved by identity, not by position: the requested correlation is mapped to its CASA Stokes code and located in
POLARIZATION::CORR_TYPE.A full 4-correlation MS lays its correlations out in the conventional order, so a fixed
{xx: 0, xy: 1, yx: 2, yy: 3}table happens to work there. It does not generalise: an MS written with a single polarisation holds only that one, so its correlation axis has length 1 whatever the polarisation is, andyymeans index 0 rather than 3. A 2-correlation (XX, YY) MS breaks the same table in a different way. ReadingCORR_TYPEcovers all three, and turns a request for an absent correlation into an error rather than either an index error or a silent read of the wrong polarisation.- Parameters:
ms_path (str) – Path to the Measurement Set.
corr (str) – Correlation name, e.g.
"xx". Case-insensitive.pol_id (int, optional) – Row of
POLARIZATIONdescribing the data being read, fromresolve_data_description(). Defaults to 0.
- Returns:
Position of
corron the data’s correlation axis.- Return type:
int
- Raises:
ValueError – If
corris not a recognised name, or the MS does not contain it.
- tabascal.ms.resolve_data_description(ms_path: str, data_desc_id: int = 0)[source]
(spectral_window_id, polarization_id)for aDATA_DESC_ID.An MS does not tie its data to row 0 of
SPECTRAL_WINDOWandPOLARIZATION. It carries aDATA_DESC_IDper row, and theDATA_DESCRIPTIONsubtable maps that to the spectral window and polarization setups the data actually uses. Those ids are 0 in the common single-setup case, which is why assuming 0 usually works – and why an MS with several setups would silently read another one’s channel frequencies or correlation layout.xds_from_mspartitions by(FIELD_ID, DATA_DESC_ID)and records the id in each partition’s attrs, so the caller can say which partition it is reading.Falls back to
(0, 0)with a warning ifDATA_DESCRIPTIONcannot be read, which keeps a malformed store loadable.
- tabascal.ms.rows_to_grid(col, n_time: int, n_bl: int, n_freq: int)[source]
MS
(row, chan)to tabascal’s(bl, freq, time).The rows are time-major –
n_blconsecutive rows per timestep, in a fixed baseline order, asms_layout()checks – so the row axis unfolds into(n_time, n_bl)and the time axis then moves to the back.Method calls rather than
np./jnp./da.functions, so the reader can pass jax arrays and the writer dask ones through the same mapping.
- tabascal.ms.times_to_mjd(times, unit: str | None = None) ndarray[source]
An MS
TIMEcolumn as Modified Julian Dates in days.tabascal works in MJD days throughout, while an MS stores
TIMEin the unit its column declares – seconds, as casacore writes it. Pass that declaration (read_time_unit()) and it is honoured; passNoneand the unit is inferred, because not every writer fills the keyword in.The inference reads the magnitude of the times, and nothing else. An MJD day number is at most ~1e5 in any plausible observing era – 1e5 is the year 2132 and -1e5 is 1585 – while the same instant in seconds is ~1e9. No MS can plausibly sit between the two, so one comparison settles it, for a single integration and a full observation alike. The test is strict: a magnitude of exactly 1e5 reads as days.
The value compared is the median of the finite magnitudes, which is a statement about the column rather than about any one of its entries. A single row can be neither: casacore leaves
TIMEat zero in a row that was added and never filled, and one such row in a column of seconds drags the smallest magnitude to zero – read as days, the observation then overflows the preflight epoch check. The largest magnitude is no better: a stray value in the other unit, a seconds timestamp left in a column of days, reaches it just as easily. Half the column has to be wrong before a median is, and a median is indifferent to the order its values arrive in, which no caller here guarantees.Non-finite entries are dropped rather than ranked, so an
infcannot decide the unit from either end. A column with no finite time at all reads as days, so it goes downstream exactly as it arrived – the same nothing, not a nothing scaled by 86400.The reduction is over the times as given, duplicates included, so that frequency counts – which is what “half the column” means, and what makes a handful of corrupt rows survivable. The alternative, reducing over the distinct values, buys nothing here and costs that: two sentinel rows at 0 and 1 beside any number of real ones are three distinct values whose median is 1, so a column of seconds would read as days.
That leaves the callers to agree by construction rather than by construction of the statistic. Within one partition they do, for every MS
ms_layout()accepts: it derivesn_timeas the number of distinct times and requires the rows to be that many blocks each holding one constant time, so two blocks cannot share one and theTIME.reshape(n_time, n_bl)[:, 0]thatread_ms()andwrite._observation_gridpass holds no duplicates fororbit_config._integration_times_mjd’snp.uniqueto remove. A column whose blocks repeat a time can part them, but its reshape has already stopped landing on block boundaries, so the unit is the least of it.The scopes still differ: the preflight helper reads the whole main table through casacore while
read_ms()takes onexds_from_mspartition, so a multi-field or multi-SPW MS reaches them as different columns entirely. Weighing by frequency is what makes that survivable – the rows of another field are a minority of a column, not half of it. The values come back in the order they were given: only the decision reduces them.The spacing of consecutive samples used to decide the unit instead, until issue #208;
docs/api/ms.rstrecords why it could not. In short, an integration of exactly 0.5 s read as days, and so did anything shorter.One rule for every caller, declared or inferred. All three pass what
read_time_unit()gave them –read_ms(),write._observation_gridand the preflight epoch check – so a declaration settles the unit for all of them and this reduction is what is left for an MS that declares nothing. Every caller honouring the declaration is what keeps an MS whoseQuantumUnitscontradicts its magnitudes from reading one way to the run and another to the TLE age checks.
- tabascal.ms.write_caltable(path: str, gains: NDArray, times: NDArray, ms_path: str, interval: float = 0.0, n_pol: int = 2, viscal: str = 'B Jones', overwrite: bool = True, keywords: Mapping | None = None, time_ref: str = 'UTC') str[source]
Write an
applycal-compatible calibration table.The layout mirrors exactly what
casatasks.gaincalemits (verified against a reference table it produced): one row per (time, antenna), time-major, withCPARAMof shape(n_chan, n_pol).B Jonesrather thanG Jonesbecause the gains here are frequency dependent, which a scalar G table cannot represent.Verified against CASA:
applycalaccepts the table, and itsCORRECTED_DATAreproducesV / (g_p conj(g_q))to 6e-7 relative (float32 round-off) over 6.7e6 visibilities.Do not rely on applycal to set the weights for a frequency-dependent gain.
applycal(calwt=True)was measured to apply a single per-row weight factor, constant across channels (within-row CV of the applied factor = 0.0000), even whenWEIGHT_SPECTRUMexists – it collapses the frequency axis rather than scaling each channel by its own|g_ch|**2. For a channel-constant gain that is exact; for a frequency-dependent one it is an approximation. tabascal therefore computesWEIGHT_SPECTRUM = 1 / sigma_cal**2per channel itself when it writes results.A gain that is zero or non-finite carries no solution, and both halves of that are written:
FLAGis set andCPARAMis NaN. A reader going by the flag and one going by the value have to reach the same conclusion – a zero left inCPARAMreads as a solution that calibrates to infinity, and an Inf reads as a number too.One spectral window only. Every row is written with
SPECTRAL_WINDOW_ID = 0, so an MS with more than one window is rejected rather than having one window’s gains filed under another’s id.- Parameters:
path (str) – Output caltable path.
gains (NDArray) –
(n_ant, n_freq, n_time)complex –g_p, in theV_obs = g_p conj(g_q) V_trueconvention of the module docstring.times (NDArray) –
(n_time,)MSTIMEvalues in seconds (MJD seconds, as in the MS), on the scaletime_refnames.ms_path (str) – The MS these gains belong to; its
ANTENNA,FIELD,SPECTRAL_WINDOW,OBSERVATIONandHISTORYsubtables are copied into the caltable, as CASA does. Subtables it does not have are skipped.n_pol (int, optional) – CASA writes 2 polarisations even for a single-correlation MS, so the gain is duplicated across the pol axis by default.
keywords (dict, optional) – Extra table keywords, written beside the table’s own. For what the solver knows and the format has no field for – the correlation tabascal fitted, say – so that the table still says it when it is read back somewhere else. The names the table needs for itself (
_RESERVED_KEYWORDS) are refused rather than overwritten, and so are values casacore cannot be relied on to encode: a string, bool, int or float, or a non-empty list of one of those.time_ref (str, optional) – Epoch reference the
TIMEcolumn declares, which must be the MS’s own:timesis a copy of its column and nothing here shifts it, so declaring UTC over a TAI-declared MS moves every timestamp by the accumulated leap seconds for anything that reads the declaration. Validated againstTIME_SCALES– the scales tabascal can interpret – and written in casacore’s upper case.
- Returns:
str – The caltable path.
The gains are checked against the MS they claim to belong to before any of
this happens, since the copied subtables are what the table’s own rows index
ANTENNA1intoANTENNA, andCPARAM’s channel axis ontoCHAN_FREQ. Gains of the wrong width would produce a table that disagreeswith the copy of the MS inside itself.
``path`` may not be, contain, or sit inside ``ms_path`` (the output is)
removed before the subtables are copied out of the MS, so an overlapping
path would destroy the observation. That is rejected before any of it
happens, which is also what keeps the clean-up on the failure path from
reaching anything but the caltable’s own directory.
**``overwrite=True`` replaces a calibration table and nothing else.** An
existing output that is not one – a caller’s directory, an ordinary file, a
Measurement Set, a table too damaged for casacore to open (see
_is_caltable()) – is aValueErrornaming the path and what wasfound, rather than a
rmtree. Every legitimate overwrite target is aprevious solution, so anything else there is a path pointing somewhere the
caller did not mean; the cost of that reading is a deletion that cannot be
undone. A damaged caltable is refused on the same rule and has to be removed
by hand.
- Raises:
ValueError – If the arguments do not describe one single-spectral-window solution set for this MS. Every check on the caller’s arguments runs before an existing table is removed:
overwrite=Truedeletes a calibration that took a run to produce, and a caller’s mistake must not cost them that. An I/O failure part-way through the write cannot put the old table back; the partial output is then removed on a best-effort basis before the error is re-raised, so a half-written table can only survive a failure that also prevents its removal. The original exception always propagates – nothing raised during the clean-up replaces it. Also ifpathexists,overwriteis true, and what is there is not a calibration table this function could have written.FileExistsError – If
pathexists andoverwriteis false.