Skip to content

Commit db9a1ea

Browse files
authored
New function to get TESS sectors for a position and cutout size (#168)
* get_tess_sectors function * docstrings, changelog * Add note about private functions in docstring * More tests, better handling for invalid coordinates * Fix test * Fix table creation for other versions
1 parent cfcd623 commit db9a1ea

File tree

5 files changed

+199
-117
lines changed

5 files changed

+199
-117
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Unreleased
33

44
- Added support in `ra_dec_crossmatch` for a cutout size of zero, enabling single-point matching to FFIs that contain
55
the specified coordinates. [#166]
6+
- Added ``get_tess_sectors`` function to return TESS sector information for sectors whose footprints overlap with
7+
the given sky coordinates and cutout size. [#168]
68

79

810
1.1.0 (2025-09-15)

astrocut/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@
3939
from .asdf_cutout import ASDFCutout, asdf_cut, get_center_pixel # noqa
4040
from .tess_cube_cutout import TessCubeCutout # noqa
4141
from .footprint_cutout import ra_dec_crossmatch # noqa
42-
from .tess_footprint_cutout import TessFootprintCutout, cube_cut_from_footprint # noqa
42+
from .tess_footprint_cutout import TessFootprintCutout, cube_cut_from_footprint, get_tess_sectors # noqa

astrocut/footprint_cutout.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from spherical_geometry.vector import radec_to_vector
1414

1515
from .cutout import Cutout
16+
from .exceptions import InvalidInputError
1617

1718
FFI_TTLCACHE = TTLCache(maxsize=10, ttl=900) # Cache for FFI footprint files
1819

@@ -55,20 +56,6 @@ def __init__(self, coordinates: Union[SkyCoord, str],
5556
sequence = [sequence] # Convert to list
5657
self._sequence = sequence
5758

58-
# Populate these in child classes
59-
self._s3_footprint_cache = None # S3 URI to footprint cache file
60-
self._arcsec_per_px = None # Number of arcseconds per pixel in an image
61-
62-
@abstractmethod
63-
def _get_files_from_cone_results(self, cone_results: Table) -> dict:
64-
"""
65-
Converts a `~astropy.table.Table` of cone search results to a list of dictionaries containing
66-
metadata for each cloud file that intersects with the cutout.
67-
68-
This method is abstract and should be implemented in subclasses.
69-
"""
70-
raise NotImplementedError('Subclasses must implement this method.')
71-
7259
@abstractmethod
7360
def cutout(self):
7461
"""
@@ -314,7 +301,10 @@ def ra_dec_crossmatch(all_ffis: Table, coordinates: Union[SkyCoord, str], cutout
314301
"""
315302
# Convert coordinates to SkyCoord
316303
if not isinstance(coordinates, SkyCoord):
317-
coordinates = SkyCoord(coordinates, unit='deg')
304+
try:
305+
coordinates = SkyCoord(coordinates, unit='deg')
306+
except ValueError as e:
307+
raise InvalidInputError(f'Invalid coordinates input: {e}')
318308
ra, dec = coordinates.ra, coordinates.dec
319309

320310
px_size = np.zeros(2, dtype=object)

astrocut/tess_footprint_cutout.py

Lines changed: 158 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ class TessFootprintCutout(FootprintCutout):
6565
Write the cutouts as Target Pixel Files (TPFs) to the specified directory.
6666
"""
6767

68+
# Mission-specific defaults
69+
ARCSEC_PER_PX = 21 # Number of arcseconds per pixel in a TESS image
70+
S3_FOOTPRINT_CACHE = 's3://stpubdata/tess/public/footprints/tess_ffi_footprint_cache.json'
71+
S3_BASE_FILE_PATH = 's3://stpubdata/tess/public/mast/'
72+
73+
6874
@deprecated_renamed_argument('product', None, since='1.1.0', message='Astrocut no longer supports cutouts from '
6975
'TESS Image Calibrator (TICA) products. '
7076
'The `product` argument is deprecated and will be removed in a future version.')
@@ -78,101 +84,9 @@ def __init__(self, coordinates: Union[SkyCoord, str],
7884
if product.upper() != 'SPOC':
7985
raise InvalidInputError('Product for TESS cube cutouts must be "SPOC".')
8086
self._product = 'SPOC'
81-
self._arcsec_per_px = 21 # Number of arcseconds per pixel in a TESS image
82-
83-
# Set S3 URIs to footprint cache file and base file path
84-
self._s3_footprint_cache = 's3://stpubdata/tess/public/footprints/tess_ffi_footprint_cache.json'
85-
self._s3_base_file_path = 's3://stpubdata/tess/public/mast/'
8687

8788
# Make the cutouts upon initialization
8889
self.cutout()
89-
90-
def _extract_sequence_information(self, sector_name: str) -> dict:
91-
"""
92-
Extract the sector, camera, and ccd information from the sector name.
93-
94-
Parameters
95-
----------
96-
sector_name : str
97-
The name of the sector.
98-
99-
Returns
100-
-------
101-
dict
102-
A dictionary containing the sector name, sector number, camera number, and CCD number.
103-
"""
104-
# Example sector name format: "tess-s0001-4-4"
105-
pattern = re.compile(r"(tess-s)(?P<sector>\d{4})-(?P<camera>\d{1,4})-(?P<ccd>\d{1,4})")
106-
sector_match = re.match(pattern, sector_name)
107-
108-
if not sector_match:
109-
# Return an empty dictionary if the name does not match the product pattern
110-
return {}
111-
112-
# Extract the sector, camera, and ccd information
113-
sector = sector_match.group("sector")
114-
camera = sector_match.group("camera")
115-
ccd = sector_match.group("ccd")
116-
117-
return {"sectorName": sector_name, "sector": sector, "camera": camera, "ccd": ccd}
118-
119-
def _create_sequence_list(self, observations: Table) -> List[dict]:
120-
"""
121-
Extracts sequence information from a list of observations.
122-
123-
Parameters
124-
----------
125-
observations : `~astropy.table.Table`
126-
A table of FFI observations.
127-
128-
Returns
129-
-------
130-
list of dict
131-
A list of dictionaries, each containing the sector name, sector number, camera number, and CCD number.
132-
"""
133-
# Filter observations by target name to get only the FFI observations
134-
obs_filtered = [obs for obs in observations if obs["target_name"].upper() == "TESS FFI"]
135-
136-
sequence_results = []
137-
for row in obs_filtered:
138-
# Extract the sector information for each FFI observation
139-
sequence_extraction = self._extract_sequence_information(row["obs_id"])
140-
if sequence_extraction:
141-
sequence_results.append(sequence_extraction)
142-
143-
return sequence_results
144-
145-
def _get_files_from_cone_results(self, cone_results: Table) -> List[dict]:
146-
"""
147-
Converts a `~astropy.table.Table` of cone search results to a list of dictionaries containing
148-
information for each cloud cube file that intersects with the cutout.
149-
150-
Parameters
151-
----------
152-
cone_results : `~astropy.table.Table`
153-
A table containing observation results, including sector information.
154-
155-
Returns
156-
-------
157-
cube_files : list of dict
158-
A list of dictionaries, each containing:
159-
- "folder": The folder name corresponding to the sector, prefixed with 's' and zero-padded to 4 digits.
160-
- "cube": The expected filename for the cube FITS file in the format "{sectorName}-cube.fits".
161-
- "sectorName": The sector name.
162-
"""
163-
# Create a list of dictionaries containing the sector information
164-
seq_list = self._create_sequence_list(cone_results)
165-
166-
# Create a list of dictionaries containing the cube file information
167-
cube_files = [
168-
{
169-
"folder": "s" + sector["sector"].rjust(4, "0"),
170-
"cube": sector["sectorName"] + "-cube.fits",
171-
"sectorName": sector["sectorName"],
172-
}
173-
for sector in seq_list
174-
]
175-
return cube_files
17690

17791
def cutout(self):
17892
"""
@@ -185,7 +99,7 @@ def cutout(self):
18599
If the given coordinates are not found within the specified sequence(s).
186100
"""
187101
# Get footprints from the cloud
188-
all_ffis = get_ffis(self._s3_footprint_cache)
102+
all_ffis = get_ffis(self.S3_FOOTPRINT_CACHE)
189103
log.debug('Found %d footprint files.', len(all_ffis))
190104

191105
# Filter footprints by sequence
@@ -200,15 +114,15 @@ def cutout(self):
200114
', '.join(str(s) for s in self._sequence))
201115

202116
# Get sequence names and files that contain the cutout
203-
cone_results = ra_dec_crossmatch(all_ffis, self._coordinates, self._cutout_size, self._arcsec_per_px)
117+
cone_results = ra_dec_crossmatch(all_ffis, self._coordinates, self._cutout_size, self.ARCSEC_PER_PX)
204118
if not cone_results:
205119
raise InvalidQueryError('The given coordinates were not found within the specified sequence(s).')
206-
files_mapping = self._get_files_from_cone_results(cone_results)
120+
files_mapping = _get_files_from_cone_results(cone_results)
207121
log.debug('Found %d matching files.', len(files_mapping))
208122

209123
# Generate the cube cutouts
210124
log.debug('Generating cutouts...')
211-
input_files = [f"{self._s3_base_file_path}{file['cube']}" for file in files_mapping]
125+
input_files = [f"{self.S3_BASE_FILE_PATH}{file['cube']}" for file in files_mapping]
212126
tess_cube_cutout = TessCubeCutout(input_files, self._coordinates, self._cutout_size,
213127
self._fill_value, self._limit_rounding_method, threads=8,
214128
verbose=self._verbose)
@@ -237,6 +151,102 @@ def write_as_tpf(self, output_dir: Union[str, Path] = '.') -> List[str]:
237151
return self.tess_cube_cutout.write_as_tpf(output_dir)
238152

239153

154+
def _extract_sequence_information(sector_name: str) -> dict:
155+
"""
156+
Extract the sector, camera, and ccd information from the sector name.
157+
158+
This is a helper function and should be left private.
159+
160+
Parameters
161+
----------
162+
sector_name : str
163+
The name of the sector.
164+
165+
Returns
166+
-------
167+
dict
168+
A dictionary containing the sector name, sector number, camera number, and CCD number.
169+
"""
170+
# Example sector name format: "tess-s0001-4-4"
171+
pattern = re.compile(r"(tess-s)(?P<sector>\d{4})-(?P<camera>\d{1,4})-(?P<ccd>\d{1,4})")
172+
sector_match = re.match(pattern, sector_name)
173+
174+
if not sector_match:
175+
# Return an empty dictionary if the name does not match the product pattern
176+
return {}
177+
178+
# Extract the sector, camera, and ccd information
179+
sector = sector_match.group("sector")
180+
camera = sector_match.group("camera")
181+
ccd = sector_match.group("ccd")
182+
183+
return {"sectorName": sector_name, "sector": sector, "camera": camera, "ccd": ccd}
184+
185+
186+
def _create_sequence_list(observations: Table) -> List[dict]:
187+
"""
188+
Extracts sequence information from a list of observations.
189+
190+
This is a helper function and should be left private.
191+
192+
Parameters
193+
----------
194+
observations : `~astropy.table.Table`
195+
A table of FFI observations.
196+
197+
Returns
198+
-------
199+
list of dict
200+
A list of dictionaries, each containing the sector name, sector number, camera number, and CCD number.
201+
"""
202+
# Filter observations by target name to get only the FFI observations
203+
obs_filtered = [obs for obs in observations if obs["target_name"].upper() == "TESS FFI"]
204+
205+
sequence_results = []
206+
for row in obs_filtered:
207+
# Extract the sector information for each FFI observation
208+
sequence_extraction = _extract_sequence_information(row["obs_id"])
209+
if sequence_extraction:
210+
sequence_results.append(sequence_extraction)
211+
212+
return sequence_results
213+
214+
215+
def _get_files_from_cone_results(cone_results: Table) -> List[dict]:
216+
"""
217+
Converts a `~astropy.table.Table` of cone search results to a list of dictionaries containing
218+
information for each cloud cube file that intersects with the cutout.
219+
220+
This is a helper function and should be left private.
221+
222+
Parameters
223+
----------
224+
cone_results : `~astropy.table.Table`
225+
A table containing observation results, including sector information.
226+
227+
Returns
228+
-------
229+
cube_files : list of dict
230+
A list of dictionaries, each containing:
231+
- "folder": The folder name corresponding to the sector, prefixed with 's' and zero-padded to 4 digits.
232+
- "cube": The expected filename for the cube FITS file in the format "{sectorName}-cube.fits".
233+
- "sectorName": The sector name.
234+
"""
235+
# Create a list of dictionaries containing the sector information
236+
seq_list = _create_sequence_list(cone_results)
237+
238+
# Create a list of dictionaries containing the cube file information
239+
cube_files = [
240+
{
241+
"folder": "s" + sector["sector"].rjust(4, "0"),
242+
"cube": sector["sectorName"] + "-cube.fits",
243+
"sectorName": sector["sectorName"],
244+
}
245+
for sector in seq_list
246+
]
247+
return cube_files
248+
249+
240250
@deprecated_renamed_argument('product', None, since='1.1.0', message='Astrocut no longer supports cutouts from '
241251
'TESS Image Calibrator (TICA) products. '
242252
'The `product` argument is deprecated and will be removed in a future version.')
@@ -295,11 +305,62 @@ def cube_cut_from_footprint(coordinates: Union[str, SkyCoord], cutout_size,
295305
['./cutouts/tess-s0001-4-4/tess-s0001-4-4_83.406310_-62.489771_64x64_astrocut.fits',
296306
'./cutouts/tess-s0002-4-1/tess-s0002-4-1_83.406310_-62.489771_64x64_astrocut.fits']
297307
"""
298-
308+
# Create the TessFootprintCutout object
299309
cutouts = TessFootprintCutout(coordinates, cutout_size, sequence=sequence, product=product, verbose=verbose)
300310

311+
# Return cutouts as memory objects
301312
if memory_only:
302313
return cutouts.tpf_cutouts
303314

304315
# Write cutouts
305316
return cutouts.write_as_tpf(output_dir)
317+
318+
319+
def get_tess_sectors(coordinates: Union[str, SkyCoord],
320+
cutout_size: Union[int, u.Quantity, List[int], Tuple[int]]) -> Table:
321+
"""
322+
Return the TESS sectors (sequence, camera, CCD) whose FFI footprints overlap
323+
the given cutout defined by position and size.
324+
325+
Parameters
326+
----------
327+
coordinates : str or `astropy.coordinates.SkyCoord` object
328+
The position around which to cutout. It may be specified as a string ("ra dec" in degrees)
329+
or as the appropriate `~astropy.coordinates.SkyCoord` object.
330+
cutout_size : int, array-like, or `~astropy.units.Quantity`
331+
The size of the cutout array. If ``cutout_size``
332+
is a scalar number or a scalar `~astropy.units.Quantity`,
333+
then a square cutout of ``cutout_size`` will be used. If
334+
``cutout_size`` has two elements, they should be in ``(ny, nx)``
335+
order. Scalar numbers in ``cutout_size`` are assumed to be in
336+
units of pixels. `~astropy.units.Quantity` objects must be in pixel or
337+
angular units.
338+
339+
If a cutout size of zero is provided, the function will return sectors that contain
340+
the exact RA and Dec position. If a non-zero cutout size is provided, the function
341+
will return sectors whose footprints overlap with the cutout area.
342+
343+
Returns
344+
-------
345+
`~astropy.table.Table`
346+
A table containing the sector name, sector number, camera number, and CCD number
347+
for each sector that contains the specified coordinates within the cutout size.
348+
"""
349+
column_names = ['sectorName', 'sector', 'camera', 'ccd']
350+
column_dtypes = ['S20', 'i4', 'i4', 'i4']
351+
352+
# Get footprints from the cloud
353+
ffis = get_ffis(TessFootprintCutout.S3_FOOTPRINT_CACHE)
354+
355+
# Crossmatch to find matching FFIs
356+
matched_ffis = ra_dec_crossmatch(ffis, coordinates, cutout_size, TessFootprintCutout.ARCSEC_PER_PX)
357+
if len(matched_ffis) == 0: # Return empty table if no matches
358+
return Table(names=column_names, dtype=column_dtypes)
359+
360+
# Create a list of unique sector entries
361+
sector_list = _create_sequence_list(matched_ffis)
362+
363+
return Table(rows=[
364+
(entry['sectorName'], int(entry['sector']), int(entry['camera']), int(entry['ccd']))
365+
for entry in sector_list
366+
], names=column_names, dtype=column_dtypes)

0 commit comments

Comments
 (0)