Skip to content

Commit cfcd623

Browse files
authored
Coordinate footprint crossmatch with a cutout size of zero (#166)
* Accept 0 for cutout size in ra_dec_crossmatch * docstring * changelog * Minor refactoring, mock functions in tests * fix return type on _crossmatch_point
1 parent 200b3e9 commit cfcd623

File tree

4 files changed

+164
-27
lines changed

4 files changed

+164
-27
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
Unreleased
22
----------
33

4+
- Added support in `ra_dec_crossmatch` for a cutout size of zero, enabling single-point matching to FFIs that contain
5+
the specified coordinates. [#166]
6+
47

58
1.1.0 (2025-09-15)
69
------------------

astrocut/cutout.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def __init__(self, input_files: List[Union[str, Path, S3Path]], coordinates: Uni
6161
log.debug('Coordinates: %s', self._coordinates)
6262

6363
# Turning the cutout size into an array of two values
64-
self._cutout_size = self._parse_size_input(cutout_size)
64+
self._cutout_size = self.parse_size_input(cutout_size)
6565
log.debug('Cutout size: %s', self._cutout_size)
6666

6767
# Assigning other attributes
@@ -150,7 +150,7 @@ def cutout(self):
150150
raise NotImplementedError('Subclasses must implement this method.')
151151

152152
@staticmethod
153-
def _parse_size_input(cutout_size):
153+
def parse_size_input(cutout_size, *, allow_zero: bool = False) -> np.ndarray:
154154
"""
155155
Makes the given cutout size into a length 2 array.
156156
@@ -162,6 +162,8 @@ def _parse_size_input(cutout_size):
162162
If ``cutout_size`` has two elements, they should be in ``(ny, nx)`` order. Scalar numbers
163163
in ``cutout_size`` are assumed to be in units of pixels. `~astropy.units.Quantity` objects
164164
must be in pixel or angular units.
165+
allow_zero : bool, optional
166+
If True, allows cutout dimensions to be zero. Default is False.
165167
166168
Returns
167169
-------
@@ -186,7 +188,7 @@ def _parse_size_input(cutout_size):
186188

187189
for dim in cutout_size:
188190
# Raise error if either dimension is not a positive number
189-
if dim <= 0:
191+
if dim < 0 or (not allow_zero and dim == 0):
190192
raise InvalidInputError('Cutout size dimensions must be greater than zero. '
191193
f'Provided size: ({cutout_size[0]}, {cutout_size[1]})')
192194

astrocut/footprint_cutout.py

Lines changed: 108 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from astropy.table import Table, Column
1111
from cachetools import TTLCache, cached
1212
from spherical_geometry.polygon import SphericalPolygon
13+
from spherical_geometry.vector import radec_to_vector
1314

1415
from .cutout import Cutout
1516

@@ -193,7 +194,91 @@ def get_ffis(s3_footprint_cache: str) -> Table:
193194
return ffis
194195

195196

196-
def ra_dec_crossmatch(all_ffis: Table, coordinates: SkyCoord, cutout_size, arcsec_per_px: int = 21) -> Table:
197+
def _crossmatch_point(ra: SkyCoord, dec: SkyCoord, all_ffis: Table) -> np.ndarray:
198+
"""
199+
Returns the indices of the Full Frame Images (FFIs) that contain the given RA and
200+
Dec coordinates by checking which FFI polygons contain the point.
201+
202+
Parameters
203+
----------
204+
ra : SkyCoord
205+
Right Ascension in degrees.
206+
dec : SkyCoord
207+
Declination in degrees.
208+
all_ffis : `~astropy.table.Table`
209+
Table of FFIs to crossmatch with the point.
210+
211+
Returns
212+
-------
213+
ffi_inds : `~numpy.ndarray`
214+
Indices of FFIs that contain the given RA and Dec coordinates.
215+
"""
216+
ffi_inds = []
217+
vector_coord = radec_to_vector(ra, dec)
218+
for sector in np.unique(all_ffis['sequence_number']):
219+
# Returns a 2-long array where the first element is indexes and the 2nd element is empty
220+
sector_ffi_inds = np.where(all_ffis['sequence_number'] == sector)[0]
221+
222+
for ind in sector_ffi_inds:
223+
if all_ffis[ind]["polygon"].contains_point(vector_coord):
224+
ffi_inds.append(ind)
225+
break # the ra/dec will only be on one ccd per sector
226+
return np.array(ffi_inds, dtype=int)
227+
228+
229+
def _crossmatch_polygon(ra: SkyCoord, dec: SkyCoord, all_ffis: Table, px_size: np.ndarray,
230+
arcsec_per_px: int = 21) -> np.ndarray:
231+
"""
232+
Returns the indices of the Full Frame Images (FFIs) that intersect with the given cutout footprint
233+
by checking which FFI polygons intersect with the cutout polygon.
234+
235+
Parameters
236+
----------
237+
ra : SkyCoord
238+
Right Ascension in degrees.
239+
dec : SkyCoord
240+
Declination in degrees.
241+
all_ffis : `~astropy.table.Table`
242+
Table of FFIs to crossmatch with the point.
243+
px_size : array-like
244+
Size of the cutout in pixels, in the form [ny, nx].
245+
arcsec_per_px : int, optional
246+
Default 21. The number of arcseconds per pixel in an image. Used to determine
247+
the footprint of the cutout. Default is the number of arcseconds per pixel in
248+
a TESS image.
249+
250+
Returns
251+
-------
252+
ffi_inds : `~numpy.ndarray`
253+
Boolean array indicating whether each FFI intersects with the cutout.
254+
"""
255+
# Create polygon for intersection
256+
# Convert dimensions from pixels to arcseconds and divide by 2 to get offset from center
257+
# If one of the dimensions is 0, use a very small value to avoid issues with SphericalPolygon
258+
min_offset = 0.1 # pixels
259+
ra_offset = ((max(px_size[0], min_offset) * arcsec_per_px) / 2) * u.arcsec
260+
dec_offset = ((max(px_size[1], min_offset) * arcsec_per_px) / 2) * u.arcsec
261+
262+
# Calculate RA and Dec boundaries
263+
ra_bounds = [ra - ra_offset, ra + ra_offset]
264+
dec_bounds = [dec - dec_offset, dec + dec_offset]
265+
266+
# Get RA and Dec for four corners of rectangle
267+
ras = [ra_bounds[0].value, ra_bounds[1].value, ra_bounds[1].value, ra_bounds[0].value]
268+
decs = [dec_bounds[0].value, dec_bounds[0].value, dec_bounds[1].value, dec_bounds[1].value]
269+
270+
# Create SphericalPolygon for comparison
271+
cutout_fp = SphericalPolygon.from_radec(ras, decs, center=(ra, dec))
272+
273+
# Find indices of FFIs that intersect with the cutout
274+
ffi_inds = np.vectorize(lambda ffi: ffi.intersects_poly(cutout_fp))(all_ffis['polygon'])
275+
ffi_inds = FootprintCutout._ffi_intersect(all_ffis, cutout_fp)
276+
277+
return ffi_inds
278+
279+
280+
def ra_dec_crossmatch(all_ffis: Table, coordinates: Union[SkyCoord, str], cutout_size,
281+
arcsec_per_px: int = 21) -> Table:
197282
"""
198283
Returns the Full Frame Images (FFIs) whose footprints overlap with a cutout of a given position and size.
199284
@@ -208,11 +293,15 @@ def ra_dec_crossmatch(all_ffis: Table, coordinates: SkyCoord, cutout_size, arcse
208293
cutout_size : int, array-like, `~astropy.units.Quantity`
209294
The size of the cutout array. If ``cutout_size``
210295
is a scalar number or a scalar `~astropy.units.Quantity`,
211-
then a square cutout of ``cutout_size`` will be created. If
296+
then a square cutout of ``cutout_size`` will be used. If
212297
``cutout_size`` has two elements, they should be in ``(ny, nx)``
213298
order. Scalar numbers in ``cutout_size`` are assumed to be in
214299
units of pixels. `~astropy.units.Quantity` objects must be in pixel or
215300
angular units.
301+
302+
If a cutout size of zero is provided, the function will return FFIs that contain
303+
the exact RA and Dec position. If a non-zero cutout size is provided, the function
304+
will return FFIs whose footprints overlap with the cutout area.
216305
arcsec_per_px : int, optional
217306
Default 21. The number of arcseconds per pixel in an image. Used to determine
218307
the footprint of the cutout. Default is the number of arcseconds per pixel in
@@ -228,27 +317,22 @@ def ra_dec_crossmatch(all_ffis: Table, coordinates: SkyCoord, cutout_size, arcse
228317
coordinates = SkyCoord(coordinates, unit='deg')
229318
ra, dec = coordinates.ra, coordinates.dec
230319

231-
# Parse cutout size
232-
cutout_size = Cutout._parse_size_input(cutout_size)
233-
234-
# Create polygon for intersection
235-
# Convert dimensions from pixels to arcseconds and divide by 2 to get offset from center
236-
ra_offset = ((cutout_size[0] * arcsec_per_px) / 2) * u.arcsec
237-
dec_offset = ((cutout_size[1] * arcsec_per_px) / 2) * u.arcsec
238-
239-
# Calculate RA and Dec boundaries
240-
ra_bounds = [ra - ra_offset, ra + ra_offset]
241-
dec_bounds = [dec - dec_offset, dec + dec_offset]
242-
243-
# Get RA and Dec for four corners of rectangle
244-
ras = [ra_bounds[0].value, ra_bounds[1].value, ra_bounds[1].value, ra_bounds[0].value]
245-
decs = [dec_bounds[0].value, dec_bounds[0].value, dec_bounds[1].value, dec_bounds[1].value]
246-
247-
# Create SphericalPolygon for comparison
248-
cutout_fp = SphericalPolygon.from_radec(ras, decs, center=(ra, dec))
249-
250-
# Find indices of FFIs that intersect with the cutout
251-
ffi_inds = np.vectorize(lambda ffi: ffi.intersects_poly(cutout_fp))(all_ffis['polygon'])
252-
ffi_inds = FootprintCutout._ffi_intersect(all_ffis, cutout_fp)
320+
px_size = np.zeros(2, dtype=object)
321+
for axis, size in enumerate(Cutout.parse_size_input(cutout_size, allow_zero=True)):
322+
if isinstance(size, u.Quantity): # If Quantity, convert to pixels
323+
if size.unit == u.pixel:
324+
px_size[axis] = size.value
325+
else: # Angular size
326+
# Convert angular size to pixels
327+
px_size[axis] = (size.to_value(u.arcsec)) / arcsec_per_px
328+
else: # Assume pixels
329+
px_size[axis] = size
330+
331+
if np.all(px_size == 0):
332+
# Cross match with point
333+
ffi_inds = _crossmatch_point(ra, dec, all_ffis)
334+
else:
335+
# Cross match with polygon
336+
ffi_inds = _crossmatch_polygon(ra, dec, all_ffis, px_size, arcsec_per_px)
253337

254338
return all_ffis[ffi_inds]

astrocut/tests/test_tess_footprint_cutout.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
from pathlib import Path
22
import pytest
33
import re
4+
from unittest.mock import MagicMock
45

6+
import astropy.units as u
7+
import numpy as np
58
from astropy.coordinates import SkyCoord
69
from astropy.io import fits
710
from astropy.table import Table
811
from spherical_geometry.polygon import SphericalPolygon
912

13+
from .. import footprint_cutout
1014
from ..cube_cutout import CubeCutout
1115
from ..exceptions import InvalidInputError, InvalidQueryError
1216
from ..footprint_cutout import get_ffis, ra_dec_crossmatch
@@ -26,6 +30,27 @@ def coordinates():
2630
return SkyCoord('350 -80', unit='deg')
2731

2832

33+
@pytest.fixture
34+
def all_ffis(scope='module'):
35+
"""Fixture to return the table of all FFIs"""
36+
return get_ffis('s3://stpubdata/tess/public/footprints/tess_ffi_footprint_cache.json')
37+
38+
39+
@pytest.fixture
40+
def crossmatch_spies(monkeypatch):
41+
# wrap real functions with MagicMocks that call the real implementation
42+
real_point = footprint_cutout._crossmatch_point
43+
real_poly = footprint_cutout._crossmatch_polygon
44+
45+
spy_point = MagicMock(side_effect=real_point)
46+
spy_poly = MagicMock(side_effect=real_poly)
47+
48+
monkeypatch.setattr(footprint_cutout, "_crossmatch_point", spy_point)
49+
monkeypatch.setattr(footprint_cutout, "_crossmatch_polygon", spy_poly)
50+
51+
yield spy_point, spy_poly
52+
53+
2954
def check_output_tpf(tpf, sequences=[], cutout_size=5):
3055
"""Helper function to check the validity of output cutout files"""
3156
tpf_table = tpf[1].data
@@ -77,6 +102,29 @@ def test_ffi_intersect(lon, lat, center, expected):
77102
assert intersection.value[0] == expected
78103

79104

105+
@pytest.mark.parametrize("cutout_size", [0, 0 * u.arcmin, [0, 0], (0, 0), (0*u.pix, 0*u.pix),
106+
[0*u.arcsec, 0*u.arcsec], np.array([0, 0])])
107+
def test_ra_dec_crossmatch_point(coordinates, all_ffis, cutout_size, crossmatch_spies):
108+
spy_point, spy_poly = crossmatch_spies
109+
110+
# Cutout size of 0 should do a point match
111+
results = ra_dec_crossmatch(all_ffis, coordinates, cutout_size)
112+
assert isinstance(results, Table)
113+
spy_point.assert_called_once()
114+
spy_poly.assert_not_called()
115+
116+
117+
@pytest.mark.parametrize("cutout_size", [5, 5 * u.arcmin, [5, 5], [5*u.arcsec, 5*u.arcsec], (5, 0), (5, 0)])
118+
def test_ra_dec_crossmatch_poly(all_ffis, cutout_size, crossmatch_spies):
119+
spy_point, spy_poly = crossmatch_spies
120+
121+
# Cutout size of 0 should do a point match
122+
results = ra_dec_crossmatch(all_ffis, '350 -80', cutout_size)
123+
assert isinstance(results, Table)
124+
spy_poly.assert_called_once()
125+
spy_point.assert_not_called()
126+
127+
80128
def test_tess_footprint_cutout(cutout_size, caplog):
81129
"""Test that a single data cube is created for a given sequence"""
82130
cutout = TessFootprintCutout('130 30', cutout_size, sequence=44, verbose=True)

0 commit comments

Comments
 (0)