from pathlib import Path
from time import monotonic
from typing import List, Union, Tuple, Literal
import warnings
import astropy.units as u
import numpy as np
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.time import Time
from astropy.utils.decorators import deprecated_renamed_argument
from astropy.wcs import WCS
from s3path import S3Path
from . import __version__, log
from .cube_cutout import CubeCutout
from .exceptions import DataWarning, InvalidInputError, InvalidQueryError
[docs]class TessCubeCutout(CubeCutout):
"""
Class for creating cutouts from TESS full frame image cubes.
Parameters
----------
input_files : list
List of input image files.
coordinates : str | `~astropy.coordinates.SkyCoord`
Coordinates of the center of the cutout.
cutout_size : int | array | list | tuple | `~astropy.units.Quantity`
Size of the cutout array.
fill_value : int | float
Value to fill the cutout with if the cutout is outside the image.
limit_rounding_method : str
Method to use for rounding the cutout limits. Options are 'round', 'ceil', and 'floor'.
threads : int | 'auto'
The number of threads to use for making the cutouts. If 'auto', the number of threads will be set to the number
of available CPUs.
product : str
.. deprecated:: 1.1.0
This parameter is deprecated and will be removed in a future release.
Only "SPOC" products will be supported.
verbose : bool
If True, log messages are printed to the console.
Attributes
----------
cutouts_by_file : dict
Dictionary where each key is an input cube filename and its corresponding value is the resulting cutout as a
``CubeCutoutInstance`` object.
cutouts : list
List of cutout objects.
tpf_cutouts_by_file : dict
Dictionary where each key is an input cube filename and its corresponding value is the resulting cutout target
pixel file object.
tpf_cutouts : list
List of cutout target pixel file objects.
Methods
-------
write_as_tpf(output_dir, output_file)
Write the cutouts to target pixel files.
"""
@deprecated_renamed_argument('product', None, since='1.1.0', message='The `product` argument is deprecated and '
'will be removed in a future version. Astrocut will only support cutouts from '
'SPOC products.')
def __init__(self, input_files: List[Union[str, Path, S3Path]], coordinates: Union[SkyCoord, str],
cutout_size: Union[int, np.ndarray, u.Quantity, List[int], Tuple[int]] = 25,
fill_value: Union[int, float] = np.nan, limit_rounding_method: str = 'round',
threads: Union[int, Literal['auto']] = 1, product: str = 'SPOC', verbose: bool = False):
super().__init__(input_files, coordinates, cutout_size, fill_value, limit_rounding_method, threads, verbose)
# Validate and set the product
if product.upper() not in ['SPOC', 'TICA']:
raise InvalidInputError('Product for TESS cube cutouts must be either "SPOC" or "TICA".')
self._product = product.upper()
# Whether the cube has uncertainty data
self._has_uncertainty = self._product == 'SPOC'
# Keyword corresponding to WCS axis
self._wcs_axes_keyword = 'CTYPE2'
# Expected value for the WCS axis keyword
self._wcs_axes_value = 'DEC--TAN-SIP'
# Keywords to skip when adding to the cutout WCS header
# These are TICA-specific image keywords that are specific to a single FFI or just not helpful
self._skip_kwds = ['TIME', 'EXPTIME', 'FILTER']
# Extra keywords from the FFI image headers in SPOC (TESS-specific)
# These are applied to both SPOC and TICA cutouts for consistency.
self._img_kwds = {
'BACKAPP': [None, 'background is subtracted'],
'CDPP0_5': [None, 'RMS CDPP on 0.5-hr time scales'],
'CDPP1_0': [None, 'RMS CDPP on 1.0-hr time scales'],
'CDPP2_0': [None, 'RMS CDPP on 2.0-hr time scales'],
'CROWDSAP': [None, 'Ratio of target flux to total flux in op. ap.'],
'DEADAPP': [None, 'deadtime applied'],
'DEADC': [None, 'deadtime correction'],
'EXPOSURE': [None, '[d] time on source'],
'FLFRCSAP': [None, 'Frac. of target flux w/in the op. aperture'],
'FRAMETIM': [None, '[s] frame time [INT_TIME + READTIME]'],
'FXDOFF': [None, 'compression fixed offset'],
'GAINA': [None, '[electrons/count] CCD output A gain'],
'GAINB': [None, '[electrons/count] CCD output B gain'],
'GAINC': [None, '[electrons/count] CCD output C gain'],
'GAIND': [None, '[electrons/count] CCD output D gain'],
'INT_TIME': [None, '[s] photon accumulation time per frame'],
'LIVETIME': [None, '[d] TELAPSE multiplied by DEADC'],
'MEANBLCA': [None, '[count] FSW mean black level CCD output A'],
'MEANBLCB': [None, '[count] FSW mean black level CCD output B'],
'MEANBLCC': [None, '[count] FSW mean black level CCD output C'],
'MEANBLCD': [None, '[count] FSW mean black level CCD output D'],
'NREADOUT': [None, 'number of read per cadence'],
'NUM_FRM': [None, 'number of frames per time stamp'],
'READNOIA': [None, '[electrons] read noise CCD output A'],
'READNOIB': [None, '[electrons] read noise CCD output B'],
'READNOIC': [None, '[electrons] read noise CCD output C'],
'READNOID': [None, '[electrons] read noise CCD output D'],
'READTIME': [None, '[s] readout time per frame'],
'TIERRELA': [None, '[d] relative time error'],
'TIMEDEL': [None, '[d] time resolution of data'],
'TIMEPIXR': [None, 'bin time beginning=0 middle=0.5 end=1'],
'TMOFST11': [None, '(s) readout delay for camera 1 and ccd 1'],
'VIGNAPP': [None, 'vignetting or collimator correction applied'],
}
# Dictionary to hold TPF objects by filename
self.tpf_cutouts_by_file = {}
# Make the cutouts upon initialization
self.cutout()
@property
def tpf_cutouts(self):
"""
Return the cutouts as a list of `astropy.io.fits.HDUList` target pixel file objects.
"""
return list(self.tpf_cutouts_by_file.values())
def _get_cutout_wcs_dict(self, cutout_wcs: WCS, cutout_lims: np.ndarray) -> dict:
"""
Create a dictionary of WCS keywords for the cutout.
Adds the physical keywords for transformation back from cutout to location on FFI.
This is a TESS-specific function.
Parameters
----------
cutout_wcs : `~astropy.wcs.WCS`
The WCS object for the cutout.
cutout_lims : `~numpy.ndarray`
The limits of the cutout in pixel coordinates.
Returns
-------
cutout_wcs_dict : dict
Cutout WCS column header keywords as dictionary of
``{<kwd format string>: [value, desc]} pairs.``
"""
wcs_header = cutout_wcs.to_header()
wcs_axes = wcs_header.get('WCSAXES', 2) # Default to 2 if missing
# Create the cutout WCS dictionary
cutout_wcs_dict = {
# Number of axes
'WCAX{}': [wcs_axes, 'Number of WCS axes'],
# Celestial WCS Keywords
'1CTYP{}': [wcs_header['CTYPE1'], 'right ascension coordinate type'],
'2CTYP{}': [wcs_header['CTYPE2'], 'declination coordinate type'],
'1CRPX{}': [wcs_header['CRPIX1'], '[pixel] reference pixel along image axis 1'],
'2CRPX{}': [wcs_header['CRPIX2'], '[pixel] reference pixel along image axis 2'],
'1CRVL{}': [wcs_header['CRVAL1'], '[deg] right ascension at reference pixel'],
'2CRVL{}': [wcs_header['CRVAL2'], '[deg] declination at reference pixel'],
'1CUNI{}': [wcs_header['CUNIT1'], 'physical unit in column dimension'],
'2CUNI{}': [wcs_header['CUNIT2'], 'physical unit in row dimension'],
'1CDLT{}': [wcs_header['CDELT1'], '[deg] pixel scale in RA dimension'],
'2CDLT{}': [wcs_header['CDELT1'], '[deg] pixel scale in DEC dimension'],
# Coordinate transformation matrix (PC) keywords
'11PC{}': [wcs_header['PC1_1'], 'Coordinate transformation matrix element'],
'12PC{}': [wcs_header['PC1_2'], 'Coordinate transformation matrix element'],
'21PC{}': [wcs_header['PC2_1'], 'Coordinate transformation matrix element'],
'22PC{}': [wcs_header['PC2_2'], 'Coordinate transformation matrix element'],
# Physical keywords
'WCSN{}P': ['PHYSICAL', 'table column WCS name'],
'WCAX{}P': [2, 'table column physical WCS dimensions'],
'1CTY{}P': ['RAWX', 'table column physical WCS axis 1 type, CCD col'],
'2CTY{}P': ['RAWY', 'table column physical WCS axis 2 type, CCD row'],
'1CUN{}P': ['PIXEL', 'table column physical WCS axis 1 unit'],
'2CUN{}P': ['PIXEL', 'table column physical WCS axis 2 unit'],
'1CRV{}P': [cutout_lims[0, 0] + 1, 'table column physical WCS ax 1 ref value'],
'2CRV{}P': [cutout_lims[1, 0] + 1, 'table column physical WCS ax 2 ref value'],
# TODO: can we calculate these? or are they fixed?
'1CDL{}P': [1.0, 'table column physical WCS a1 step'],
'2CDL{}P': [1.0, 'table column physical WCS a2 step'],
'1CRP{}P': [1, 'table column physical WCS a1 reference'],
'2CRP{}P': [1, 'table column physical WCS a2 reference'],
}
return cutout_wcs_dict
def _update_primary_header(self, primary_header: fits.Header):
"""
Updates the primary header of the cutout target pixel file.
This function sets the object's RA and Dec to the central coordinates of the cutout.
Since other TESS target pixel file keywords are not available, they are set to 0 or empty strings
as placeholders.
This is a TESS-specific function.
Parameters
----------
primary_header : `~astropy.io.fits.Header`
The primary header from the cube file that will be modified in place for the cutout.
"""
# Add cutout-specific metadata
primary_header.update({
'CREATOR': ('astrocut', 'software used to produce this file'),
'PROCVER': (__version__, 'software version'),
'FFI_TYPE': (self._product, 'the FFI type used to make the cutouts'),
'RA_OBJ': (self._coordinates.ra.deg, '[deg] right ascension'),
'DEC_OBJ': (self._coordinates.dec.deg, '[deg] declination'),
'TIMEREF': ('SOLARSYSTEM' if self._product == 'SPOC' else None,
'barycentric correction applied to times'),
'TASSIGN': ('SPACECRAFT' if self._product == 'SPOC' else None,
'where time is assigned'),
'TIMESYS': ('TDB', 'time system is Barycentric Dynamical Time (TDB)'),
'BJDREFI': (2457000, 'integer part of BTJD reference date'),
'BJDREFF': (0.00000000, 'fraction of the day in BTJD reference date'),
'TIMEUNIT': ('d', 'time unit for TIME, TSTART, and TSTOP')
})
# TODO : The name of FIRST_FFI (and LAST_FFI) is too long to be a header kwd value.
# Find a way to include these in the headers without breaking astropy (maybe abbreviate?).
# primary_header['FIRST_FFI'] = (self.first_ffi, 'the FFI used for the primary header
# keyword values, except TSTOP')
# primary_header['LAST_FFI'] = (self.last_ffi, 'the FFI used for the TSTOP keyword value')
if self._product == 'TICA':
# Adding some missing keywords for TICA cutouts
primary_header.update({
'EXTVER': ('1', 'extension version number (not format version)'),
'SIMDATA': (False, 'file is based on simulated data'),
'NEXTEND': ('2', 'number of standard extensions'),
'TSTART': (primary_header['STARTTJD'], 'observation start time in TJD of first FFI'),
'TSTOP': (primary_header['ENDTJD'], 'observation stop time in TJD of last FFI'),
'CAMERA': (primary_header['CAMNUM'], 'Camera number'),
'CCD': (primary_header['CCDNUM'], 'CCD chip number'),
'ASTATE': (None, 'archive state F indicates single orbit processing'),
'CRMITEN': (primary_header['CRM'], 'spacecraft cosmic ray mitigation enabled'),
'CRBLKSZ': (None, '[exposures] s/c cosmic ray mitigation block siz'),
'FFIINDEX': (primary_header['CADENCE'], 'number of FFI cadence interval of first FFI'),
'DATA_REL': (None, 'data release version number'),
'FILEVER': (None, 'file format version'),
'RADESYS': (None, 'reference frame of celestial coordinates'),
'SCCONFIG': (None, 'spacecraft configuration ID'),
'TIMVERSN': (None, 'OGIP memo number for file format')
})
date_obs = Time(primary_header['TSTART'] + primary_header['BJDREFI'], format='jd').iso
date_end = Time(primary_header['TSTOP'] + primary_header['BJDREFI'], format='jd').iso
primary_header.update({
'DATE-OBS': (date_obs, 'TSTART as Julian Date of first FFI'),
'DATE-END': (date_end, 'TSTOP as Julian Date of last FFI'),
})
# Remove unnecessary keywords
# Bulk removal with wildcards. Most of these should only live in EXT 1 header.
delete_kwds_wildcards = ['SC_*', 'RMS*', 'A_*', 'AP_*', 'B_*', 'BP*', 'GAIN*', 'TESS_*', 'CD*',
'CT*', 'CRPIX*', 'CRVAL*', 'MJD*']
# Removal of specific kwds not relevant for cutouts. Most likely these describe a single FFI, and not
# the whole cube, which is misleading because we are working with entire stacks of FFIs. Other keywords
# are analogs to ones that have already been added to the primary header in the lines above.
delete_kwds = ['COMMENT', 'FILTER', 'TIME', 'EXPTIME', 'ACS_MODE', 'DEC_TARG', 'FLXWIN', 'RA_TARG',
'CCDNUM', 'CAMNUM', 'WCSGDF', 'UNITS', 'CADENCE', 'SCIPIXS', 'INT_TIME', 'PIX_CAT',
'REQUANT', 'DIFF_HUF', 'PRIM_HUF', 'QUAL_BIT', 'SPM', 'STARTTJD', 'ENDTJD', 'CRM',
'TJD_ZERO', 'CRM_N', 'ORBIT_ID', 'MIDTJD']
for kwd in delete_kwds_wildcards + delete_kwds:
primary_header.pop(kwd, None)
# Compute and update TELAPSE keyword
telapse = primary_header.get('TSTOP', 0) - primary_header.get('TSTART', 0)
primary_header['TELAPSE '] = (telapse, '[d] TSTOP - TSTART')
# Update DATE comment to be more explicit
primary_header['DATE'] = (primary_header['DATE'], 'FFI cube creation date')
# Specifying that some of these headers keyword values are inherited from the first FFI
if self._product == 'SPOC':
primary_header.update({
'TSTART': (primary_header['TSTART'], 'observation start time in TJD of first FFI'),
'TSTOP': (primary_header['TSTOP'], 'observation stop time in TJD of last FFI'),
'DATE-OBS': (primary_header['DATE-OBS'], 'TSTART as UTC calendar date of first FFI'),
'DATE-END': (primary_header['DATE-END'], 'TSTOP as UTC calendar date of last FFI'),
'FFIINDEX': (primary_header['FFIINDEX'], 'number of FFI cadence interval of first FFI')
})
# Add missing target-specific metadata
primary_header.update({
'OBJECT': ('', 'string version of target ID'),
'TCID': (0, 'unique TESS target identifier'),
'PXTABLE': (0, 'pixel table ID'),
'PMRA': (0.0, '[mas/yr] RA proper motion'),
'PMDEC': (0.0, '[mas/yr] Dec proper motion'),
'PMTOTAL': (0.0, '[mas/yr] Total proper motion'),
'TESSMAG': (0.0, '[mag] TESS magnitude'),
'TEFF': (0.0, '[K] Effective temperature'),
'LOGG': (0.0, '[cm/s2] log10 surface gravity'),
'MH': (0.0, '[log10([M/H])] Metallicity'),
'RADIUS': (0.0, '[solar radii] Stellar radius'),
'TICVER': (0, 'TIC version'),
'TICID': (None, 'unique TESS target identifier')
})
def _build_tpf(self, cube_fits: fits.HDUList, cutout: CubeCutout.CubeCutoutInstance) -> fits.HDUList:
"""
Build the cutout target pixel file (TPF) and format it to match TESS pipeline TPFs.
Parameters
---------
cube_fits : `~astropy.io.fits.hdu.hdulist.HDUList`
The cube hdu list.
cutout : `CubeCutout.CubeCutoutInstance`
The cutout object.
Returns
-------
cutout_hdu_list : `~astropy.io.fits.HDUList`
Target pixel file HDUList
"""
# Copy the primary HDU and update its header
primary_hdu = cube_fits[0].copy()
self._update_primary_header(primary_hdu.header)
cols = [] # list to store FITS table columns
img_cube = cutout.data
empty_arr = np.zeros_like(img_cube) # precompute empty array for missing data
empty_single = empty_arr[:, 0, 0] # extract single-value empty array for time & position
# Define FITS column format and dimension
pixel_format = f'{img_cube[0].size}E'
pixel_dim = f'{img_cube[0].shape[::-1]}'
# Definte time-related keywords based on product type
start, stop = ("TSTART", "TSTOP") if self._product == "SPOC" else ("STARTTJD", "ENDTJD")
cols.append(fits.Column(name='TIME', format='D', unit='BJD - 2457000, days', disp='D14.7',
array=(cube_fits[2].columns[start].array + cube_fits[2].columns[stop].array) / 2))
if self._product == 'SPOC':
cols.append(fits.Column(name='TIMECORR', format='E', unit='d', disp='E14.7',
array=cube_fits[2].columns['BARYCORR'].array))
# Define cadence number (zero-filled for SPOC)
cadence_array = empty_single if self._product == 'SPOC' else cube_fits[2].columns['CADENCE'].array
cols.append(fits.Column(name='CADENCENO', format='J', disp='I10', array=cadence_array))
# Define flux-related columns
pixel_unit = 'e-/s' if self._product == 'SPOC' else 'e-'
flux_err_array = cutout.uncertainty if self._product == 'SPOC' else empty_arr
cols.extend([
fits.Column(name='RAW_CNTS', format=pixel_format.replace('E', 'J'), unit='count',
dim=pixel_dim, disp='I8', array=empty_arr - 1, null=-1),
fits.Column(name='FLUX', format=pixel_format, dim=pixel_dim, unit=pixel_unit,
disp='E14.7', array=img_cube),
fits.Column(name='FLUX_ERR', format=pixel_format, dim=pixel_dim, unit=pixel_unit,
disp='E14.7', array=flux_err_array)
])
# Add background columns (identical zero arrays)
for col_name in ['FLUX_BKG', 'FLUX_BKG_ERR']:
cols.append(fits.Column(name=col_name, format=pixel_format, dim=pixel_dim,
unit=pixel_unit, disp='E14.7', array=empty_arr))
# Add the quality flags
data_quality = 'DQUALITY' if self._product == 'SPOC' else 'QUAL_BIT'
cols.append(fits.Column(name='QUALITY', format='J', disp='B16.16',
array=cube_fits[2].columns[data_quality].array))
# Add position correction info (zero-filled)
for col_name in ['POS_CORR1', 'POS_CORR2']:
cols.append(fits.Column(name=col_name, format='E', unit='pixel', disp='E14.7', array=empty_single))
# Add the FFI_FILE column (not in the pipeline tpfs)
cols.append(fits.Column(name='FFI_FILE', format='38A', unit='pixel',
array=cube_fits[2].columns['FFI_FILE'].array))
# Create the table HDU
table_hdu = fits.BinTableHDU.from_columns(cols)
table_hdu.header['EXTNAME'] = 'PIXELS'
table_hdu.header['INHERIT'] = True
# Add the WCS keywords to the columns and remove from the header
self._add_column_wcs(table_hdu.header, self._get_cutout_wcs_dict(cutout.wcs, cutout.cutout_lims))
# Add the extra image keywords
self._add_img_kwds(table_hdu.header)
# Build the aperture HDU
aperture_hdu = fits.ImageHDU(data=cutout.aperture)
aperture_hdu.header['EXTNAME'] = 'APERTURE'
# Copy WCS headers
for kwd, val, cmt in cutout.wcs.to_header().cards:
aperture_hdu.header.set(kwd, val, cmt)
# Add extra aperture keywords (TESS specific)
aperture_hdu.header.set('NPIXMISS', None, 'Number of op. aperture pixels not collected')
aperture_hdu.header.set('NPIXSAP', None, 'Number of pixels in optimal aperture')
# Add goodness-of-fit keywords
for key, val in cutout.wcs_fit.items():
aperture_hdu.header[key] = tuple(val)
aperture_hdu.header['INHERIT'] = True
# Assemble the HDUList
cutout_hdu_list = fits.HDUList([primary_hdu, table_hdu, aperture_hdu])
# Apply header inheritance
self._apply_header_inherit(cutout_hdu_list)
# Store the cutout TPF with the input filename
self.tpf_cutouts_by_file[cutout.cube_filename] = cutout_hdu_list
return cutout_hdu_list
def _cutout_file(self, file: Union[str, Path, S3Path]):
"""
Make a cutout from a single cube file.
Parameters
----------
file : str, Path, or S3Path
The path to the cube file.
"""
# Read in file data
cube = self._load_file_data(file)
# Parse table info
cube_wcs = self._parse_table_info(cube[2].data)
# Get cutouts
try:
cutout = self.CubeCutoutInstance(cube, file, cube_wcs, self._has_uncertainty, self)
except InvalidQueryError:
warnings.warn(f'Cutout footprint does not overlap with data in {file}, skipping...', DataWarning)
cube.close()
return
# Build TPF
self._build_tpf(cube, cutout)
cube.close()
# Log coordinates
log.debug('Cutout center coordinate: %s, %s', self._coordinates.ra.deg, self._coordinates.dec.deg)
# Get cutout WCS info
max_dist, sigma = cutout.wcs_fit['WCS_MSEP'][0], cutout.wcs_fit['WCS_SIG'][0]
log.debug('Maximum distance between approximate and true location: %s', max_dist)
log.debug('Error in approximate WCS (sigma): %.4f', sigma)
# Store cutouts with filename
self.cutouts_by_file[file] = cutout
[docs] def write_as_tpf(self, output_dir: Union[str, Path] = '.', output_file: str = None):
"""
Write the cutouts to disk as target pixel files.
Parameters
----------
output_dir : str | Path
The output directory where the cutout files will be saved.
output_file : str
The output filename. If not provided or more than one cutout is created, the filename
will be generated based on the input filename. This parameter is included so that
``CubeCutout`` is backwards compatible with ``CutoutFactory.cube_cut``.
Returns
-------
cutouts : list
List of file paths to cutout target pixel files.
"""
# Track write time
write_time = monotonic()
# Ensure that output directory exists
Path(output_dir).mkdir(parents=True, exist_ok=True)
cutout_paths = []
for file, cutout in self.cutouts_by_file.items():
# Determine file name
if not output_file or len(self._input_files) > 1:
filename = self._make_cutout_filename(Path(file).stem.rstrip('-cube'))
else:
filename = output_file
# Write the cutout TPF
cutout_path = Path(output_dir) / filename
# Get the corresponding cutout TPF
tpf = self.tpf_cutouts_by_file[file]
with warnings.catch_warnings():
# Suppress FITS verification warnings
warnings.simplefilter('ignore', fits.verify.VerifyWarning)
tpf.writeto(cutout_path, overwrite=True, checksum=True)
# Log file path
log.debug('Target pixel file path: %s', cutout_path)
# Append file path or memory object
cutout_paths.append(cutout_path.as_posix())
log.debug('Write time: %.2f sec', (monotonic() - write_time))
return cutout_paths
[docs] def write_as_zip(self, output_dir: Union[str, Path] = '.', filename: Union[str, Path, None] = None) -> str:
"""
Package the cutout TPF files into a zip archive without writing intermediate files.
Parameters
----------
output_dir : str | Path, optional
Directory where the zip will be created. Default '.'.
filename : str | Path | None, optional
Name (or path) of the output zip file. If not provided, defaults to
'astrocut_{ra}_{dec}_{size}.zip'. If provided without a '.zip' suffix,
the suffix is added automatically.
Returns
-------
str
Path to the created zip file.
"""
def build_entries():
for file, tpf in self.tpf_cutouts_by_file.items():
arcname = self._make_cutout_filename(Path(file).stem.rstrip('-cube'))
yield arcname, tpf
return self._write_cutouts_to_zip(output_dir=output_dir, filename=filename, build_entries=build_entries)
class CubeCutoutInstance(CubeCutout.CubeCutoutInstance):
"""
Represents an individual cutout with its own data, uncertainty, and aperture arrays.
Extension of the `CubeCutout.CubeCutoutInstance` class to include TESS-specific method to adjust the full
FFI WCS for the cutout WCS.
"""
def _get_full_cutout_wcs(self, cube_wcs: WCS, cube_table_header: fits.Header) -> WCS:
"""
Adjust the full FFI WCS for the cutout WCS. Modifies CRPIX values and adds physical WCS keywords.
Parameters
----------
cube_wcs : `~astropy.wcs.WCS`
The WCS for the input cube.
cube_table_header : `~astropy.io.fits.Header`
The FFI cube header for the data table extension. This allows the cutout WCS information
to more closely match the mission TPF format.
Returns
--------
response : `~astropy.wcs.WCS`
The cutout WCS object including SIP distortions.
"""
wcs_header = cube_wcs.to_header(relax=True)
# Using table comments if available
for kwd in wcs_header:
if cube_table_header.get(kwd, None):
wcs_header.comments[kwd] = cube_table_header[kwd]
# Adjusting the CRPIX values based on cutout limits
wcs_header['CRPIX1'] -= self.cutout_lims[0, 0]
wcs_header['CRPIX2'] -= self.cutout_lims[1, 0]
# Add physical WCS keywords
physical_wcs_keys = {
'WCSNAMEP': ('PHYSICAL', 'name of world coordinate system alternate P'),
'WCSAXESP': (2, 'mumber of WCS physical axes'),
'CTYPE1P': ('RAWX', 'physical WCS axis 1 type (CCD column)'),
'CUNIT1P': ('PIXEL', 'physical WCS axis 1 unit'),
'CRPIX1P': (1, 'reference CCD column'),
'CRVAL1P': (self.cutout_lims[0, 0] + 1, 'value at reference CCD column'),
'CDELT1P': (1.0, 'physical WCS axis 1 step'),
'CTYPE2P': ('RAWY', 'physical WCS axis 2 type (CCD row)'),
'CUNIT2P': ('PIXEL', 'physical WCS axis 2 unit'),
'CRPIX2P': (1, 'reference CCD row'),
'CRVAL2P': (self.cutout_lims[1, 0] + 1, 'value at reference CCD row'),
'CDELT2P': (1.0, 'physical WCS axis 2 step'),
}
for key, (value, comment) in physical_wcs_keys.items():
wcs_header[key] = (value, comment)
return WCS(wcs_header)