Skip to content

Commit 65846ed

Browse files
authored
Merge pull request #2366 from jd-au/CASDA-6552-Add-cutouts-for-CASDA
Add cutouts for casda
2 parents 5c1448d + aebbb34 commit 65846ed

File tree

6 files changed

+513
-55
lines changed

6 files changed

+513
-55
lines changed

CHANGES.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ hsa
1313
Service fixes and enhancements
1414
------------------------------
1515

16+
casda
17+
^^^^^
18+
19+
- Add the ability to produce 2D and 3D cutouts from ASKAP images and cubes. [#2366]
20+
1621

1722
Infrastructure, Utility and Other Changes and Additions
1823
-------------------------------------------------------

astroquery/casda/core.py

Lines changed: 174 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from astropy.table import Table
1515
from astropy.io.votable import parse
1616
from astroquery import log
17+
import numpy as np
1718

1819
# 3. local imports - use relative imports
1920
# commonly required local imports shown below as example
@@ -58,7 +59,7 @@ def __init__(self, user=None, password=None):
5859
# self._password = password
5960
self._auth = (user, password)
6061

61-
def query_region_async(self, coordinates, radius=None, height=None, width=None,
62+
def query_region_async(self, coordinates, radius=1*u.arcmin, height=None, width=None,
6263
get_query_payload=False, cache=True):
6364
"""
6465
Queries a region around the specified coordinates. Either a radius or both a height and a width must be provided.
@@ -67,12 +68,12 @@ def query_region_async(self, coordinates, radius=None, height=None, width=None,
6768
----------
6869
coordinates : str or `astropy.coordinates`.
6970
coordinates around which to query
70-
radius : str or `astropy.units.Quantity`.
71+
radius : str or `astropy.units.Quantity`, optional
7172
the radius of the cone search
72-
width : str or `astropy.units.Quantity`
73-
the width for a box region
74-
height : str or `astropy.units.Quantity`
73+
height : str or `astropy.units.Quantity`, optional
7574
the height for a box region
75+
width : str or `astropy.units.Quantity`, optional
76+
the width for a box region
7677
get_query_payload : bool, optional
7778
Just return the dict of HTTP request parameters.
7879
cache: bool, optional
@@ -97,28 +98,71 @@ def query_region_async(self, coordinates, radius=None, height=None, width=None,
9798

9899
# Create the dict of HTTP request parameters by parsing the user
99100
# entered values.
100-
def _args_to_payload(self, **kwargs):
101+
def _args_to_payload(self, radius=1*u.arcmin, **kwargs):
101102
request_payload = dict()
102103

103104
# Convert the coordinates to FK5
104105
coordinates = kwargs.get('coordinates')
105-
fk5_coords = commons.parse_coordinates(coordinates).transform_to(coord.FK5)
106-
107-
if kwargs['radius'] is not None:
108-
radius = u.Quantity(kwargs['radius']).to(u.deg)
109-
pos = 'CIRCLE {} {} {}'.format(fk5_coords.ra.degree, fk5_coords.dec.degree, radius.value)
110-
elif kwargs['width'] is not None and kwargs['height'] is not None:
111-
width = u.Quantity(kwargs['width']).to(u.deg).value
112-
height = u.Quantity(kwargs['height']).to(u.deg).value
113-
top = fk5_coords.dec.degree - (height/2)
114-
bottom = fk5_coords.dec.degree + (height/2)
115-
left = fk5_coords.ra.degree - (width/2)
116-
right = fk5_coords.ra.degree + (width/2)
117-
pos = 'RANGE {} {} {} {}'.format(left, right, top, bottom)
118-
else:
119-
raise ValueError("Either 'radius' or both 'height' and 'width' must be supplied.")
120-
121-
request_payload['POS'] = pos
106+
if coordinates is not None:
107+
fk5_coords = commons.parse_coordinates(coordinates).transform_to(coord.FK5)
108+
109+
if kwargs.get('width') is not None and kwargs.get('height') is not None:
110+
width = u.Quantity(kwargs['width']).to(u.deg).value
111+
height = u.Quantity(kwargs['height']).to(u.deg).value
112+
top = fk5_coords.dec.degree + (height/2)
113+
bottom = fk5_coords.dec.degree - (height/2)
114+
left = fk5_coords.ra.degree - (width/2)
115+
right = fk5_coords.ra.degree + (width/2)
116+
pos = f'RANGE {left} {right} {bottom} {top}'
117+
else:
118+
radius = u.Quantity(radius).to(u.deg)
119+
pos = f'CIRCLE {fk5_coords.ra.degree} {fk5_coords.dec.degree} {radius.value}'
120+
121+
request_payload['POS'] = pos
122+
123+
band = kwargs.get('band')
124+
channel = kwargs.get('channel')
125+
if band is not None:
126+
if channel is not None:
127+
raise ValueError("Either 'channel' or 'band' values may be provided but not both.")
128+
129+
if (not isinstance(band, (list, tuple, np.ndarray))) or len(band) != 2 or \
130+
(band[0] is not None and not isinstance(band[0], u.Quantity)) or \
131+
(band[1] is not None and not isinstance(band[1], u.Quantity)):
132+
raise ValueError("The 'band' value must be a list of 2 wavelength or frequency values.")
133+
134+
bandBoundedLow = band[0] is not None
135+
bandBoundedHigh = band[1] is not None
136+
if bandBoundedLow and bandBoundedHigh and band[0].unit.physical_type != band[1].unit.physical_type:
137+
raise ValueError("The 'band' values must have the same kind of units.")
138+
if bandBoundedLow or bandBoundedHigh:
139+
unit = band[0].unit if bandBoundedLow else band[1].unit
140+
if unit.physical_type == 'length':
141+
min_band = '-Inf' if not bandBoundedLow else band[0].to(u.m).value
142+
max_band = '+Inf' if not bandBoundedHigh else band[1].to(u.m).value
143+
elif unit.physical_type == 'frequency':
144+
# Swap the order when changing frequency to wavelength
145+
min_band = '-Inf' if not bandBoundedHigh else band[1].to(u.m, equivalencies=u.spectral()).value
146+
max_band = '+Inf' if not bandBoundedLow else band[0].to(u.m, equivalencies=u.spectral()).value
147+
else:
148+
raise ValueError("The 'band' values must be wavelengths or frequencies.")
149+
# If values were provided in the wrong order, swap them
150+
if bandBoundedLow and bandBoundedHigh and min_band > max_band:
151+
temp_val = min_band
152+
min_band = max_band
153+
max_band = temp_val
154+
155+
request_payload['BAND'] = f'{min_band} {max_band}'
156+
157+
if channel is not None:
158+
if not isinstance(channel, (list, tuple, np.ndarray)) or len(channel) != 2 or \
159+
not isinstance(channel[0], (int, np.integer)) or not isinstance(channel[1], (int, np.integer)):
160+
raise ValueError("The 'channel' value must be a list of 2 integer values.")
161+
if channel[0] <= channel[1]:
162+
request_payload['CHANNEL'] = f'{channel[0]} {channel[1]}'
163+
else:
164+
# If values were provided in the wrong order, swap them
165+
request_payload['CHANNEL'] = f'{channel[1]} {channel[0]}'
122166

123167
return request_payload
124168

@@ -160,29 +204,7 @@ def filter_out_unreleased(self, table):
160204
now = str(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f'))
161205
return table[(table['obs_release_date'] != '') & (table['obs_release_date'] < now)]
162206

163-
def stage_data(self, table, verbose=False):
164-
"""
165-
Request access to a set of data files. All requests for data must use authentication. If you have access to the
166-
data, the requested files will be brought online and a set of URLs to download the files will be returned.
167-
168-
Parameters
169-
----------
170-
table: `astropy.table.Table`
171-
A table describing the files to be staged, such as produced by query_region. It must include an
172-
access_url column.
173-
verbose: bool, optional
174-
Should status message be logged periodically, defaults to False
175-
176-
Returns
177-
-------
178-
A list of urls of both the requested files and the checksums for the files
179-
"""
180-
if not self._authenticated:
181-
raise ValueError("Credentials must be supplied to download CASDA image data")
182-
183-
if table is None or len(table) == 0:
184-
return []
185-
207+
def _create_job(self, table, service_name, verbose):
186208
# Use datalink to get authenticated access for each file
187209
tokens = []
188210
soda_url = None
@@ -192,7 +214,7 @@ def stage_data(self, table, verbose=False):
192214
response = self._request('GET', access_url, auth=self._auth,
193215
timeout=self.TIMEOUT, cache=False)
194216
response.raise_for_status()
195-
service_url, id_token = self._parse_datalink_for_service_and_id(response, 'async_service')
217+
service_url, id_token = self._parse_datalink_for_service_and_id(response, service_name)
196218
if id_token:
197219
tokens.append(id_token)
198220
soda_url = service_url
@@ -206,6 +228,9 @@ def stage_data(self, table, verbose=False):
206228
if verbose:
207229
log.info("Created data staging job " + job_url)
208230

231+
return job_url
232+
233+
def _complete_job(self, job_url, verbose):
209234
# Wait for job to be complete
210235
final_status = self._run_job(job_url, verbose, poll_interval=self.POLL_INTERVAL)
211236
if final_status != 'COMPLETED':
@@ -222,6 +247,89 @@ def stage_data(self, table, verbose=False):
222247

223248
return fileurls
224249

250+
def stage_data(self, table, verbose=False):
251+
"""
252+
Request access to a set of data files. All requests for data must use authentication. If you have access to the
253+
data, the requested files will be brought online and a set of URLs to download the files will be returned.
254+
255+
Parameters
256+
----------
257+
table: `astropy.table.Table`
258+
A table describing the files to be staged, such as produced by query_region. It must include an
259+
access_url column.
260+
verbose: bool, optional
261+
Should status message be logged periodically, defaults to False
262+
263+
Returns
264+
-------
265+
A list of urls of both the requested files/cutouts and the checksums for the files/cutouts
266+
"""
267+
if not self._authenticated:
268+
raise ValueError("Credentials must be supplied to download CASDA image data")
269+
270+
if table is None or len(table) == 0:
271+
return []
272+
273+
job_url = self._create_job(table, 'async_service', verbose)
274+
275+
return self._complete_job(job_url, verbose)
276+
277+
def cutout(self, table, *, coordinates=None, radius=None, height=None,
278+
width=None, band=None, channel=None, verbose=False):
279+
"""
280+
Produce a cutout from each selected file. All requests for data must use authentication. If you have access to
281+
the data, the requested files will be brought online, a cutout produced from each file and a set of URLs to
282+
download the cutouts will be returned.
283+
284+
If a set of coordinates is provided along with either a radius or a box height and width, then CASDA will
285+
produce a spatial cutout at that location from each data file specified in the table. If a band or channel pair
286+
is provided then CASDA will produce a spectral cutout of that range from each data file. These can be combined
287+
to produce subcubes with restrictions in both spectral and spatial axes.
288+
289+
Parameters
290+
----------
291+
table: `astropy.table.Table`
292+
A table describing the files to be staged, such as produced by query_region. It must include an
293+
access_url column.
294+
coordinates : str or `astropy.coordinates`, optional
295+
coordinates around which to produce a cutout, the radius will be 1 arcmin if no radius, height or width is
296+
provided.
297+
radius : str or `astropy.units.Quantity`, optional
298+
the radius of the cutout
299+
height : str or `astropy.units.Quantity`, optional
300+
the height for a box cutout
301+
width : str or `astropy.units.Quantity`, optional
302+
the width for a box cutout
303+
band : list of `astropy.units.Quantity` with two elements, optional
304+
the spectral range to be included, may be low and high wavelengths in metres or low and high frequencies in
305+
Hertz. Use None for an open bound.
306+
channel : list of int with two elements, optional
307+
the spectral range to be included, the low and high channels (i.e. planes of a cube) inclusive
308+
verbose: bool, optional
309+
Should status messages be logged periodically, defaults to False
310+
311+
Returns
312+
-------
313+
A list of urls of both the requested files/cutouts and the checksums for the files/cutouts
314+
"""
315+
if not self._authenticated:
316+
raise ValueError("Credentials must be supplied to download CASDA image data")
317+
318+
if table is None or len(table) == 0:
319+
return []
320+
321+
job_url = self._create_job(table, 'cutout_service', verbose)
322+
323+
cutout_spec = self._args_to_payload(radius=radius, coordinates=coordinates, height=height, width=width,
324+
band=band, channel=channel, verbose=verbose)
325+
326+
if not cutout_spec:
327+
raise ValueError("Please provide cutout parameters such as coordinates, band or channel.")
328+
329+
self._add_cutout_params(job_url, verbose, cutout_spec)
330+
331+
return self._complete_job(job_url, verbose)
332+
225333
def download_files(self, urls, savedir=''):
226334
"""
227335
Download a series of files
@@ -324,6 +432,25 @@ def _create_soda_job(self, authenticated_id_tokens, soda_url=None):
324432
resp.raise_for_status()
325433
return resp.url
326434

435+
def _add_cutout_params(self, job_location, verbose, cutout_spec):
436+
"""
437+
Add a cutout specification to an async SODA job. This will change the job
438+
from just retrieving the full file to producing a cutout from the target file.
439+
440+
Parameters
441+
----------
442+
job_location: str
443+
The url to query the job status and details
444+
verbose: bool
445+
Should progress be logged periodically
446+
cutout_spec: map
447+
The map containing the POS parameter defining the cutout.
448+
"""
449+
if verbose:
450+
log.info("Adding parameters: " + str(cutout_spec))
451+
resp = self._request('POST', job_location + '/parameters', data=cutout_spec, cache=False)
452+
resp.raise_for_status()
453+
327454
def _run_job(self, job_location, verbose, poll_interval=20):
328455
"""
329456
Start an async job (e.g. TAP or SODA) and wait for it to be completed.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<uws:job xmlns:uws="http://www.ivoa.net/xml/UWS/v1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
3+
<uws:jobId>
4+
<![CDATA[a7b53da7-1201-46ee-8718-0bf5b64dd5b8]]>
5+
</uws:jobId>
6+
<uws:runId xsi:nil="true" />
7+
<uws:ownerId xsi:nil="true" />
8+
<uws:phase>COMPLETED</uws:phase>
9+
<uws:quote xsi:nil="true" />
10+
<uws:startTime>2020-04-17T08:10:22.793+0800</uws:startTime>
11+
<uws:endTime xsi:nil="true" />
12+
<uws:executionDuration>0</uws:executionDuration>
13+
<uws:destruction xsi:nil="true" />
14+
<uws:parameters>
15+
<uws:parameter id="id">
16+
<![CDATA[ABCDEF]]>
17+
</uws:parameter>
18+
</uws:parameters>
19+
<uws:results>
20+
<uws:result id="cutout-1234-cube-244.checksum" xlink:type="simple" xlink:href="http%3A%2F%2Fcasda.csiro.au%2Fdownload%2Fweb%2F111-000-111-000%2Fcutout.fits.checksum" />
21+
<uws:result id="cutout-1234-cube-244" xlink:type="simple" xlink:href="http%3A%2F%2Fcasda.csiro.au%2Fdownload%2Fweb%2F111-000-111-000%2Fcutout.fits" />
22+
</uws:results>
23+
<uws:errorSummary xsi:nil="true" />
24+
</uws:job>

0 commit comments

Comments
 (0)