from pathlib import Path
import numpy as np
from astropy.io import fits
from cubegenpy import (
Background,
BuildConfig,
CubeBuilder,
FitsReadbackSource,
SyntheticSource,
read_product,
)
OUT = Path("products")Recalibrating a product
This is the user-time stream. Someone has a finished product and wants it calibrated differently. They do not refetch anything and they do not recompute geometry: the file already carries every input needed.
As it stands, recalibration changes one thing, the background offset. The calibration factor is reused verbatim, which is what calibration="stored" means.
1. Start from a finished product
Standing in for one the user downloaded.
original = CubeBuilder(
SyntheticSource(), config=BuildConfig(calibration="stored")
).build(OUT / "original")
print(original.fits_path)products/original/EUV2013_047_09_33_59.fits
WARNING: VerifyWarning: Card is too long, comment will be truncated. [astropy.io.fits.card]
2. Read it back
read_product reverses everything the writer did: the FITS axis order, the recoded null in RAW_COUNTS, the table shapes. What comes back is a ProductInputs, the same payload a source produces.
inputs = read_product(original.fits_path)
print("cube ", inputs.cube.shape, inputs.cube.dtype)
print("raw_counts ", inputs.raw_counts.shape, inputs.raw_counts.dtype)
print("cal_factor ", inputs.cal_factor.shape, inputs.cal_factor.dtype)
print("geometry ", {k: len(v) for k, v in inputs.geometry.items()})
print("header keys", len(inputs.header))cube (16, 8, 4) float32
raw_counts (16, 8, 4) uint16
cal_factor (16, 8) float32
geometry {'SC_GEOM': 24, 'BODY_GEOM': 12, 'GENERAL_GEOM': 3, 'RING_GEOM': 14}
header keys 25
3. Rebuild with a background subtraction
The radioisotope thermoelectric generator contributes a roughly constant dark rate. Background carries it as a value rather than a flag, which is why the rate itself can be stated here.
background = Background(mode="rtg", rtg_value=0.001)
recalibrated = CubeBuilder(
FitsReadbackSource(original.fits_path),
config=BuildConfig(calibration="stored", background=background),
).build(OUT / "recalibrated")
print(recalibrated.fits_path)products/recalibrated/EUV2013_047_09_33_59.fits
WARNING: VerifyWarning: Card is too long, comment will be truncated. [astropy.io.fits.card]
4. Check that only the science changed
This is the claim worth testing: the offset moves the calibrated cube by exactly -offset * cal_factor, and touches nothing else in the file.
after = read_product(recalibrated.fits_path)
delta = after.cube - inputs.cube
expected = np.nan_to_num(-background.rtg_value * inputs.cal_factor[:, :, np.newaxis])
print("cube moved by exactly -offset * cal_factor:",
np.allclose(delta, expected, atol=1e-5))cube moved by exactly -offset * cal_factor: True
print("raw_counts unchanged:", np.array_equal(after.raw_counts, inputs.raw_counts))
print("cal_factor unchanged:", np.array_equal(np.nan_to_num(after.cal_factor),
np.nan_to_num(inputs.cal_factor)))
print("wavelength unchanged:", np.array_equal(after.wavelength, inputs.wavelength))
print("kernels unchanged :", list(after.kernels) == list(inputs.kernels))
same_geometry = all(
np.array_equal(np.nan_to_num(np.asarray(after.geometry[hdu][column])),
np.nan_to_num(np.asarray(columns[column])))
for hdu, columns in inputs.geometry.items()
for column in columns
)
print("geometry unchanged :", same_geometry)raw_counts unchanged: True
cal_factor unchanged: True
wavelength unchanged: True
kernels unchanged : True
geometry unchanged : True
5. Why “stored” is the right choice here
The alternative would be to regenerate the calibration factor. That is a different question from “subtract a bit more background”, and it would change numbers the user did not ask about. Reusing the stored factor keeps the recalibration honest: one input changed, one output moved.