Skip to content

Commit 24f830e

Browse files
committed
More robust error handling, use ImageHDU for cutout data
1 parent a097671 commit 24f830e

File tree

3 files changed

+107
-36
lines changed

3 files changed

+107
-36
lines changed

CHANGES.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Unreleased
77
writing multiple cutouts into a single ZIP archive. [#167]
88
- Added ``get_tess_sectors`` function to return TESS sector information for sectors whose footprints overlap with
99
the given sky coordinates and cutout size. [#168]
10-
- Cutouts of ASDF data in FITS format now include embedded ASDF metadata and cutout data in an "ASDF" extension within the FITS file for
10+
- Cutouts of ASDF data in FITS format now include embedded ASDF metadata in an "ASDF" extension within the FITS file for
1111
Python versions greater than or equal to 3.11. [#170]
1212

1313
Breaking Changes
@@ -32,6 +32,9 @@ Breaking Changes
3232
- New (no unit - pixels assumed): ``..._83.4063100_-62.4897710_64-x-64_astrocut.fits``
3333
- New (with units): ``..._83.4063100_-62.4897710_5arcmin-x-4arcmin_astrocut.fits``
3434

35+
- ASDF cutouts in FITS format now include cutout data in an ``ImageHDU`` extension called "CUTOUT". Code that reads ASDF cutouts from FITS files
36+
should be updated to access the "CUTOUT" extension for cutout data rather than the "PRIMARY" extension. [#170]
37+
3538

3639
1.1.0 (2025-09-15)
3740
------------------

astrocut/asdf_cutout.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
from time import monotonic
66
from typing import List, Tuple, Union, Optional
7+
from datetime import date
78

89
import asdf
910
import gwcs
@@ -85,7 +86,7 @@ def __init__(self, input_files: List[Union[str, Path, S3Path]], coordinates: Uni
8586
super().__init__(input_files, coordinates, cutout_size, fill_value, verbose=verbose)
8687

8788
# Must be using Python 3.11 or higher to support stdatamodels and ASDF-in-FITS embedding
88-
self._supports_stdatamodels = sys.version_info >= (3, 11)
89+
self._py311_or_higher = sys.version_info >= (3, 11)
8990

9091
# Assign AWS credential attributes
9192
self._key = key
@@ -110,32 +111,62 @@ def fits_cutouts(self) -> List[fits.HDUList]:
110111
"""
111112
if not self._fits_cutouts:
112113
# Try to import stdatamodels for ASDF-in-FITS embedding
113-
if self._supports_stdatamodels:
114+
if self._py311_or_higher:
114115
try:
115-
from stdatamodels import asdf_in_fits
116-
except ModuleNotFoundError:
117-
warnings.warn('The `stdatamodels` package is required for ASDF-in-FITS embedding. '
118-
'Skipping embedding for these cutouts. To install astrocut with optional '
119-
'`stdatamodels` support, run: pip install "astrocut[all]"', ModuleWarning)
120-
self._supports_stdatamodels = False
116+
# Check version of stdatamodels
117+
from stdatamodels import __version__ as stdata_version, asdf_in_fits
118+
if stdata_version < '4.1.0':
119+
warnings.warn(
120+
'The `stdatamodels` package is not available in the correct version (>=4.1.0); '
121+
'ASDF-in-FITS embedding will be skipped for these cutouts. Install the optional '
122+
'dependency with: pip install "astrocut[all]" or pip install stdatamodels>=4.1.0',
123+
ModuleWarning
124+
)
125+
self._can_embed_asdf_in_fits = False
126+
else:
127+
self._can_embed_asdf_in_fits = True
128+
except ImportError:
129+
warnings.warn(
130+
'The `stdatamodels` package cannot be imported; ASDF-in-FITS embedding will be '
131+
'skipped for these cutouts. Install the optional dependency with: '
132+
'pip install "astrocut[all]" or pip install stdatamodels>=4.1.0',
133+
ModuleWarning
134+
)
135+
self._can_embed_asdf_in_fits = False
121136
else:
122137
warnings.warn('ASDF-in-FITS embedding requires Python 3.11 or higher. '
123138
'Skipping embedding for these cutouts.', ModuleWarning)
139+
self._can_embed_asdf_in_fits = False
124140

125141
fits_cutouts = []
126142
for i, (file, cutouts) in enumerate(self.cutouts_by_file.items()):
127143
cutout = cutouts[0]
128144
if self._lite:
129-
tree = self._get_lite_tree(str(file), cutout, self._gwcs_objects[i])
145+
tree = {
146+
# Tree should only include sliced WCS and original filename
147+
self._mission_kwd: {
148+
'meta': {'wcs': self._slice_gwcs(cutout, self._gwcs_objects[i]),
149+
'orig_file': str(file)}
150+
}
151+
}
130152
else:
131153
tree = self._asdf_trees[i]
132-
133-
# Create a primary FITS header to hold data and WCS
134-
primary_hdu = fits.PrimaryHDU(data=cutout.data, header=cutout.wcs.to_header(relax=True))
135-
primary_hdu.header['ORIG_FLE'] = str(file) # Add original file to header
136-
hdul = fits.HDUList([primary_hdu])
137-
138-
if self._supports_stdatamodels:
154+
# Tree should only include meta
155+
tree[self._mission_kwd] = {'meta': tree[self._mission_kwd]['meta']}
156+
157+
# Build the PrimaryHDU with keywords
158+
primary_hdu = fits.PrimaryHDU()
159+
primary_hdu.header.extend([('ORIGIN', 'STScI/MAST', 'institution responsible for creating this file'),
160+
('DATE', str(date.today()), 'file creation date'),
161+
('PROCVER', __version__, 'software version')])
162+
163+
# Build ImageHDU with cutout data and WCS
164+
image_hdu = fits.ImageHDU(data=cutout.data, header=cutout.wcs.to_header(relax=True))
165+
image_hdu.header['ORIG_FLE'] = str(file) # Add original file to header
166+
image_hdu.header['EXTNAME'] = 'CUTOUT'
167+
hdul = fits.HDUList([primary_hdu, image_hdu])
168+
169+
if self._can_embed_asdf_in_fits:
139170
hdul_embed = asdf_in_fits.to_hdulist(tree, hdul)
140171
else:
141172
hdul_embed = hdul

astrocut/tests/test_asdf_cutout.py

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import zipfile
55
import io
66
import importlib.util
7+
from unittest.mock import MagicMock, patch
78

89
import asdf
910
from astropy import coordinates as coord
@@ -17,7 +18,7 @@
1718
from PIL import Image
1819

1920
from astrocut.asdf_cutout import ASDFCutout, asdf_cut, get_center_pixel
20-
from astrocut.exceptions import DataWarning, InvalidInputError, InvalidQueryError
21+
from astrocut.exceptions import DataWarning, InvalidInputError, InvalidQueryError, ModuleWarning
2122

2223

2324
def make_wcs(xsize, ysize, ra=30., dec=45.):
@@ -159,20 +160,22 @@ def test_asdf_cutout(test_images, center_coord, cutout_size):
159160

160161

161162
def test_asdf_cutout_write_to_file(test_images, center_coord, cutout_size, tmpdir):
162-
def check_asdf_metadata(af, original_file, cutout_data):
163+
def check_asdf_metadata(af, original_file, cutout_data, meta_only=False):
163164
"""Check that ASDF file contains correct metadata"""
164165
assert 'roman' in af
165166
assert 'meta' in af['roman']
166-
# Check cutout data and metadata
167-
for key in ['data', 'dq', 'err', 'context']:
168-
assert key in af['roman']
169-
assert np.all(af['roman'][key] == cutout_data)
170167
meta = af['roman']['meta']
171168
assert meta['wcs'].pixel_shape == (10, 10)
172169
assert meta['product_type'] == 'l2'
173170
assert meta['file_date'] == Time('2023-10-01T00:00:00', format='isot')
174171
assert meta['origin'] == 'STSCI/SOC'
175172
assert meta['orig_file'] == original_file.as_posix()
173+
174+
if not meta_only:
175+
# Check cutout data and metadata
176+
for key in ['data', 'dq', 'err', 'context']:
177+
assert key in af['roman']
178+
assert np.all(af['roman'][key] == cutout_data)
176179

177180
# Write cutouts to ASDF files on disk
178181
cutout = ASDFCutout(test_images, center_coord, cutout_size)
@@ -190,17 +193,19 @@ def check_asdf_metadata(af, original_file, cutout_data):
190193
assert len(fits_files) == 3
191194
for i, fits_file in enumerate(fits_files):
192195
with fits.open(fits_file) as hdul:
193-
assert np.all(hdul[0].data == cutout.cutouts[i].data)
194-
assert hdul[0].header['NAXIS1'] == 10
195-
assert hdul[0].header['NAXIS2'] == 10
196-
assert hdul[0].header['ORIG_FLE'] == test_images[i].as_posix()
196+
assert hdul[0].name == 'PRIMARY'
197+
assert hdul[1].name == 'CUTOUT'
198+
assert np.all(hdul[1].data == cutout.cutouts[i].data)
199+
assert hdul[1].header['NAXIS1'] == 10
200+
assert hdul[1].header['NAXIS2'] == 10
201+
assert hdul[1].header['ORIG_FLE'] == test_images[i].as_posix()
197202
assert Path(fits_file).stat().st_size < Path(test_images[i]).stat().st_size
198203

199204
# Check ASDF extension contents (stdatamodels optional)
200205
if importlib.util.find_spec('stdatamodels') is not None:
201206
from stdatamodels import asdf_in_fits
202207
with asdf_in_fits.open(fits_file) as af:
203-
check_asdf_metadata(af, test_images[i], cutout.cutouts[i].data)
208+
check_asdf_metadata(af, test_images[i], cutout.cutouts[i].data, meta_only=True)
204209

205210

206211
@pytest.mark.parametrize('output_format', ['.asdf', '.fits'])
@@ -226,8 +231,8 @@ def test_asdf_cutout_write_to_zip(tmpdir, test_images, center_coord, cutout_size
226231
else:
227232
with fits.open(io.BytesIO(data)) as hdul:
228233
assert isinstance(hdul, fits.HDUList)
229-
assert len(hdul) == 2 if importlib.util.find_spec('stdatamodels') is not None else 1
230-
assert hdul[0].data.shape == (cutout_size, cutout_size)
234+
assert len(hdul) == 3 if importlib.util.find_spec('stdatamodels') is not None else 2
235+
assert hdul[1].data.shape == (cutout_size, cutout_size)
231236

232237

233238
def test_asdf_cutout_write_to_zip_invalid_format(tmpdir, test_images, center_coord, cutout_size):
@@ -238,15 +243,16 @@ def test_asdf_cutout_write_to_zip_invalid_format(tmpdir, test_images, center_coo
238243

239244

240245
def test_asdf_cutout_lite(test_images, center_coord, cutout_size):
241-
def check_lite_metadata(af):
246+
def check_lite_metadata(af, meta_only=False):
242247
"""Check that ASDF file contains only lite metadata"""
243248
assert 'roman' in af
244-
assert 'data' in af['roman']
245249
assert 'meta' in af['roman']
246250
assert 'wcs' in af['roman']['meta']
247251
assert 'orig_file' in af['roman']['meta']
248-
assert len(af['roman']) == 2 # only data and meta
252+
assert len(af['roman']) == (1 if meta_only else 2)
249253
assert len(af['roman']['meta']) == 2 # only wcs and original filename
254+
if not meta_only:
255+
assert 'data' in af['roman']
250256

251257
# Write cutouts to ASDF objects in lite mode
252258
cutout = ASDFCutout(test_images, center_coord, cutout_size, lite=True)
@@ -257,15 +263,16 @@ def check_lite_metadata(af):
257263
cutout = ASDFCutout(test_images, center_coord, cutout_size, lite=True)
258264
for hdul in cutout.fits_cutouts:
259265
has_stdatamodels = importlib.util.find_spec('stdatamodels') is not None
260-
assert len(hdul) == 2 if has_stdatamodels else 1 # primary HDU + embedded ASDF extension
266+
assert len(hdul) == 3 if has_stdatamodels else 2 # primary HDU + cutout HDU + embedded ASDF extension
261267
assert hdul[0].name == 'PRIMARY'
268+
assert hdul[1].name == 'CUTOUT'
262269

263270
# Check ASDF extension contents (stdatamodels optional)
264271
if has_stdatamodels:
265-
assert hdul[1].name == 'ASDF'
272+
assert hdul[2].name == 'ASDF'
266273
from stdatamodels import asdf_in_fits
267274
with asdf_in_fits.open(hdul) as af:
268-
check_lite_metadata(af)
275+
check_lite_metadata(af, meta_only=True)
269276

270277

271278
def test_asdf_cutout_partial(test_images, center_coord, cutout_size):
@@ -404,6 +411,36 @@ def test_asdf_cutout_gwcs(test_images, center_coord):
404411
assert gwcs.bounding_box.intervals[1].upper == 39
405412

406413

414+
@pytest.mark.parametrize(('is_installed', 'warn_msg'),
415+
[(True, 'not available in the correct version'), (False, 'package cannot be imported')])
416+
def test_asdf_cutout_stdatamodels(test_images, center_coord, cutout_size, is_installed, warn_msg):
417+
""" Test that warning is emitted about ASDF-in-FITS embedding for stdatamodels issues """
418+
mock_stdatamodels = None
419+
if is_installed:
420+
mock_stdatamodels = MagicMock()
421+
mock_stdatamodels.__version__ = '1.0.0'
422+
mock_stdatamodels.asdf_in_fits = MagicMock()
423+
patch_dict = {'stdatamodels': mock_stdatamodels}
424+
425+
with patch.dict('sys.modules', patch_dict):
426+
with pytest.warns(ModuleWarning, match=warn_msg):
427+
cutout = ASDFCutout(test_images, center_coord, cutout_size)
428+
fits_cutouts = cutout.fits_cutouts
429+
assert cutout._can_embed_asdf_in_fits is False
430+
assert len(fits_cutouts[0]) == 2 # primary + cutout HDU only
431+
432+
433+
def test_asdf_cutout_python_version(test_images, center_coord, cutout_size):
434+
""" Test that warning is emitted about ASDF-in-FITS embedding for Python <3.11 """
435+
with patch('sys.version_info', (3, 10, 0)):
436+
with pytest.warns(ModuleWarning, match='requires Python 3.11 or higher'):
437+
cutout = ASDFCutout(test_images, center_coord, cutout_size)
438+
fits_cutouts = cutout.fits_cutouts
439+
assert cutout._py311_or_higher is False
440+
assert cutout._can_embed_asdf_in_fits is False
441+
assert len(fits_cutouts[0]) == 2 # primary + cutout HDU only
442+
443+
407444
def test_get_center_pixel(fakedata):
408445
""" Test get_center_pixel function """
409446
# Get the fake data

0 commit comments

Comments
 (0)