Data handling#
All image and visibility cubes in ps_eor.datacube store brightness
temperature in Kelvin. Frequencies are stored in Hz, angular quantities
in radians, and Cartesian UV coordinates in wavelengths unless a method says
otherwise.
Cartesian visibility cubes#
CartDataCube stores a complex array shaped
(frequency, spatial mode) together with its frequency and UV coordinates,
image metadata, and optional weights. Saving a cube to HDF5 preserves this
information:
from ps_eor import datacube
cube = datacube.CartDataCube.load("visibilities.h5")
cube.save("visibilities-copy.h5")
Selecting frequencies and UV range#
Use get_slice to select an inclusive frequency interval, or
get_slice_from_idx for a boolean mask, index array, or Python slice.
Both return a new cube. filter_uvrange selects a baseline interval in
place:
selected = cube.get_slice(120e6, 130e6) # frequencies in Hz
selected = selected.get_slice_from_idx(selected.freqs != 125e6)
selected.filter_uvrange(60, 200) # UV distance in wavelengths
Apply the same selection to related data, noise, and model cubes so their
coordinates remain aligned. Methods named filter_* generally modify their
cube; use cube.copy() first when the original sampling must be retained.
Creating a cube from FITS images#
For frequency-dependent images in Jy/PSF, the preferred path supplies a
matching PSF FITS file for every image. The measured PSF gives a more accurate
Jy/PSF-to-Kelvin normalization and visibility weight than inferring the PSF
area from image headers alone. Each PSF header must also contain a visibility
weight/normalization key: WSCENVIS (preferred), WSCNVIS, or
WEIGHT. Without one of these keys a weight cube cannot be constructed.
from pathlib import Path
import numpy as np
from ps_eor import datacube, psutil
image_files = psutil.sort_by_fits_key(
[str(path) for path in Path("fits/images").glob("*.fits")],
"CRVAL3",
)
psf_files = psutil.sort_by_fits_key(
[str(path) for path in Path("fits/psfs").glob("*.fits")],
"CRVAL3",
)
cube = datacube.CartDataCube.load_from_fits_image_and_psf(
image_files,
psf_files,
umin=50,
umax=250,
theta_fov=np.deg2rad(4),
int_time=10,
total_time=10 * 3600,
)
Here UV limits are in wavelengths, theta_fov is in radians, and observing
times are in seconds. If separate PSF images are unavailable,
load_from_fits_image() can infer the PSF
normalization from image metadata, but does not provide the same measured PSF
weight information.
If the FITS images already contain Kelvin values, disable the Jy/PSF conversion explicitly:
cube = datacube.CartDataCube.load_from_fits_image(
image_files,
umin=50,
umax=250,
theta_fov=np.deg2rad(4),
convert_jy2k=False,
)
Creating a cube from an in-memory Kelvin image cube#
An array shaped (frequency, x, y) can be wrapped as a
CartImageCube and Fourier transformed to a
visibility cube. The input array is already expected to be in Kelvin:
from ps_eor.datacube import CartImageCube, ImageMetaData
# image_data_k has shape (n_freq, nx, ny); freqs_hz has shape (n_freq,).
meta = ImageMetaData.from_res(
res=np.deg2rad(1.5 / 60),
shape=image_data_k.shape[-2:],
)
meta.wcs.wcs.cdelt[2] = freqs_hz[1] - freqs_hz[0]
image_cube = CartImageCube(image_data_k, freqs_hz, meta)
cube = image_cube.ft(umin=50, umax=250)
Transforming, changing the field of view, and windowing#
image(), regrid(), and ft() connect the sparse visibility and
image representations:
gridded = cube.regrid() # sparse UV samples -> regular UV grid
image_cube = gridded.image() # regular UV grid -> Kelvin sky images
round_trip = image_cube.ft(umin=50, umax=250)
For convenience, cube.image() performs the regridding and imaging in one
call. To restrict the image-plane field of view or apply a taper while
remaining in visibility space:
smaller = cube.reduce_fov(np.deg2rad(3), umin=50, umax=250)
tapered = smaller.apply_window_function(
datacube.WindowFunction("blackmanharris"),
umin=50,
umax=250,
)
Both methods return new cubes and propagate the weights when present.
reduce_fov updates the WCS and image shape. apply_window_function
records the window name and parameters in the output metadata by default, so
the information survives save() and can
be used in later power-spectrum normalization. Pass add_to_meta=False
only when that bookkeeping is intentionally unwanted.
Stored metadata#
Every image or visibility cube has an
ImageMetaData object available as cube.meta.
Its properties provide the main observing and image information without
requiring direct access to the FITS header:
Property |
Information |
Obtained from |
|---|---|---|
|
Angular pixel size in radians. |
Image WCS |
|
Image field of view in radians. |
Image shape and pixel size |
|
Phase-centre right ascension and declination in degrees. |
Image WCS |
|
Spacing between frequency samples in Hz. |
Frequency WCS |
|
Physical channel width in Hz. |
Channel metadata, or frequency spacing if it is unavailable |
|
Integration time and accumulated observing time in seconds. |
FITS metadata or values supplied when creating the cube |
|
Observation reference time as an MJD. |
Image WCS |
|
Applied image window and its normalizations. |
Window metadata; a boxcar window is assumed if none was recorded |
This information is copied when new cubes are created and saved with the cube. Operations such as trimming the field of view or applying a window update the corresponding metadata automatically.
Plotting and cube arithmetic#
cube.plot() displays an image-plane view of one channel (or a reduction
over channels), while cube.plot_uv() scatters the real visibility values
on the UV plane:
cube.plot(fmhz=125, theta_lines=[1, 2])
cube.plot_uv(fmhz=125, uv_lines=[50, 100, 200])
cube.plot_uv(action_fct=np.mean)
Compatible cubes support addition and subtraction, and a cube can be scaled by a number. These operations return a new cube and preserve its geometry:
residual = stokes_i - foreground_model
average = 0.5 * (split_a + split_b)
The operands must have the same frequency channels (and, in practice, the same spatial grid). Cube weights are propagated according to the relevant cube type.
Weights and noise#
A cube’s weights describe sampling or relative statistical weight; they are not a noise realization. Missing or rejected samples should be represented consistently by filtering them from all associated cubes or by assigning zero weight.
When a measured noise realization or noise proxy has weights, it can estimate the system-equivalent flux density (SEFD):
noise_proxy = stokes_v.make_diff_cube()
sefd = noise_proxy.estimate_sefd()
sefd_by_frequency = noise_proxy.estimate_freqs_sefd(
sefd_poly_fit_deg=3,
)
sefd_by_uv = noise_proxy.estimate_uv_sefd()
estimate_sefd returns one overall value, estimate_freqs_sefd an array
over frequency, and estimate_uv_sefd a cube over spatial modes. Values are
in Jy by default; pass sefd_jansky=False for Kelvin. The same estimators
are available from the weight cube as weights.estimate_*sefd(noise_proxy)
when the weights and noise cube are held separately.
Given an SEFD, a CartWeightCube can calculate the
expected noise level or draw a realization with the radiometer equation:
total_time = 100 * 3600 # seconds
noise_std = data_cube.weights.get_noise_std_cube(
sefd_by_frequency,
time=total_time,
)
simulated_noise = data_cube.weights.simulate_noise(
sefd_by_frequency,
time=total_time,
)
NoiseStdCube stores the full complex thermal-noise
standard deviation,
sqrt(E[abs(noise)**2]). In the example above, noise_std is a
NoiseStdCube obtained directly from the visibility weights, a measured
SEFD, and the requested accumulated time. Its values are real and
non-negative. Use
generate_noise_cube() only when an
analysis needs a random realization rather than the expected noise level.
It is equivalent in purpose to drawing with weights.simulate_noise after
the standard-deviation cube has already been constructed.
Combining observations#
DataCubeCombiner incrementally combines observations
on their common coordinates and records the accumulated weights and observing
time:
from ps_eor.datacube import DataCubeCombiner
combiner = DataCubeCombiner(
umin=50,
umax=250,
weighting_mode="full",
)
for night_id, cube in observations.items():
selected = cube.copy()
selected.filter_uvrange(50, 250)
combiner.add(selected, night_id)
combined = combiner.get(min_n_nights=3)
combined.save("combined_I.h5")
weighting_mode="full" uses the frequency- and UV-dependent sample
weights. The alternatives are "uv" for one weight per spatial mode,
"global" for one weight per cube, and "none" for an unweighted mean.
Set inhomogeneous=True when observations do not all contain the same
frequency and UV samples; min_n_nights can then discard channels with
insufficient coverage.
Use separate combiners for Stokes I, Stokes V, and time-difference cubes, but feed them the same observation order and quality selections so their output grids remain compatible.