from pathlib import Path
import numpy as np
from astropy.io import fits
from cubegenpy import BuildConfig, CubeBuilder, SyntheticSource, read_product
OUT = Path("products")Building a product
This is the archive-creation stream, end to end: science arrays plus metadata and backplanes go in, one PDS4-shaped FITS product comes out.
The science arrays here are synthetic. The real stream reads them from a Cassini UVIS PDS product, and the metadata and backplanes arrive already computed from Showalter’s pipeline. Nothing below changes when that happens: only the Source is swapped. That is the whole point of the seam.
1. The source
A Source produces one ProductInputs, the single payload the rest of the pipeline moves around. Its field names match the writer’s arguments one for one.
source = SyntheticSource()
inputs = source.load()
for name in ("raw_counts", "cal_factor", "wavelength", "cube"):
value = getattr(inputs, name)
shape = "None (the calibrator fills it)" if value is None else f"{value.shape} {value.dtype}"
print(f"{name:12} {shape}")
print()
print("dims ", inputs.dims)
print("geometry ", {k: len(v) for k, v in inputs.geometry.items()})raw_counts (16, 8, 4) uint16
cal_factor (16, 8) float32
wavelength (16,) float32
cube None (the calibrator fills it)
dims Dims(NX=16, NY=8, NZ=4, NT=3)
geometry {'SC_GEOM': 20, 'BODY_GEOM': 10, 'GENERAL_GEOM': 3, 'RING_GEOM': 12}
cube is the only field a source may leave empty. Everything else is input.
Note the conventions: raw_counts is uint16 in the PDS3 sense, where 65535 is the null rather than a count, and wavelength is in Angstrom even though pyuvis hands back nanometres.
2. Configure the build
BuildConfig carries the two knobs that matter: where the calibration factor comes from, and how much background to subtract first.
Here the synthetic source already carries a CAL_FACTOR, so "stored" reuses it verbatim. The archive stream will use "pyuvis" once the real reader lands.
config = BuildConfig(calibration="stored")
print(config.summary())
print("background mode:", config.background.mode)cubegenpy[cal=stored, bg=none, v1.0]
background mode: none
3. Build
product = CubeBuilder(source, config=config).build(OUT)
print("fits :", product.fits_path)
print("label:", product.label_path)fits : products/EUV2013_047_09_33_59.fits
label: products/EUV2013_047_09_33_59.xml
WARNING: VerifyWarning: Card is too long, comment will be truncated. [astropy.io.fits.card]
4. What landed
with fits.open(product.fits_path) as hdul:
hdul.info()Filename: products/EUV2013_047_09_33_59.fits
No. Name Ver Type Cards Dimensions Format
0 PRIMARY 1 PrimaryHDU 33 (16, 8, 4) float32
1 RAW_COUNTS 1 ImageHDU 11 (16, 8, 4) int16
2 CAL_FACTOR 1 ImageHDU 9 (16, 8) float32
3 BACKGROUND 1 ImageHDU 9 (16, 8) float32
4 SC_GEOM 1 BinTableHDU 102 2R x 25C [12A, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, 12E, E, E, E, L]
5 BODY_GEOM 1 BinTableHDU 57 1R x 13C [12A, 480E, 480E, 480E, 480E, 480E, 480E, 480E, 480E, 480E, 480E, 480L, 480L]
6 GENERAL_GEOM 1 BinTableHDU 21 1R x 3C [12D, 480E, 480E]
7 RING_GEOM 1 BinTableHDU 63 1R x 14C [480E, 480E, 480E, 480E, 480E, 480E, 480E, 480E, 480E, 480E, 480L, 480L, 480E, 480E]
8 KERNELS 1 TableHDU 15 3R x 2C [A32, A4]
9 WAVELENGTH 1 ImageHDU 8 (16,) float32
with fits.open(product.fits_path) as hdul:
header = hdul[0].header
for keyword in ("PROD_ID", "MISSION", "INSTRUME", "CHANNEL", "TARGET", "OBS_UTC", "INT_TIME"):
print(f"{keyword:9} = {header[keyword]!r}")PROD_ID = 'EUV2013_047_09_33_59'
MISSION = 'Cassini'
INSTRUME = 'UVIS'
CHANNEL = 'EUV'
TARGET = 'TITAN'
OBS_UTC = '2013-02-16T09:34:00.519'
INT_TIME = 240.0
5. The calibration, in the open
The whole formula is one line, and it runs entirely in counts per second:
calibrated = (raw_counts / int_time - background_offset) * cal_factor
Worth reproducing by hand once, so nothing about the written product is mysterious. Undefined calibration entries arrive as NaN and are zeroed on the way out, which is what nan_to_num below mirrors.
written = read_product(product.fits_path)
int_time = float(inputs.header["INT_TIME"])
counts_per_sec = inputs.raw_counts.astype(np.float64) / int_time
expected = np.nan_to_num(counts_per_sec * inputs.cal_factor[:, :, np.newaxis]).astype(np.float32)
print("shapes match :", written.cube.shape == expected.shape)
print("values match :", np.array_equal(written.cube, expected))shapes match : True
values match : True
What changes with real data
Only the first cell of section 1. SyntheticSource() becomes a source that reads the science arrays from a PDS product and merges in the externally computed metadata and backplanes, and calibration="stored" becomes "pyuvis". Sections 3 through 5 stay exactly as they are.