14
14
from astropy .table import Table
15
15
from astropy .io .votable import parse
16
16
from astroquery import log
17
+ import numpy as np
17
18
18
19
# 3. local imports - use relative imports
19
20
# commonly required local imports shown below as example
@@ -58,7 +59,7 @@ def __init__(self, user=None, password=None):
58
59
# self._password = password
59
60
self ._auth = (user , password )
60
61
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 ,
62
63
get_query_payload = False , cache = True ):
63
64
"""
64
65
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,
67
68
----------
68
69
coordinates : str or `astropy.coordinates`.
69
70
coordinates around which to query
70
- radius : str or `astropy.units.Quantity`.
71
+ radius : str or `astropy.units.Quantity`, optional
71
72
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
75
74
the height for a box region
75
+ width : str or `astropy.units.Quantity`, optional
76
+ the width for a box region
76
77
get_query_payload : bool, optional
77
78
Just return the dict of HTTP request parameters.
78
79
cache: bool, optional
@@ -97,28 +98,71 @@ def query_region_async(self, coordinates, radius=None, height=None, width=None,
97
98
98
99
# Create the dict of HTTP request parameters by parsing the user
99
100
# entered values.
100
- def _args_to_payload (self , ** kwargs ):
101
+ def _args_to_payload (self , radius = 1 * u . arcmin , ** kwargs ):
101
102
request_payload = dict ()
102
103
103
104
# Convert the coordinates to FK5
104
105
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 ]} '
122
166
123
167
return request_payload
124
168
@@ -160,29 +204,7 @@ def filter_out_unreleased(self, table):
160
204
now = str (datetime .now (timezone .utc ).strftime ('%Y-%m-%dT%H:%M:%S.%f' ))
161
205
return table [(table ['obs_release_date' ] != '' ) & (table ['obs_release_date' ] < now )]
162
206
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 ):
186
208
# Use datalink to get authenticated access for each file
187
209
tokens = []
188
210
soda_url = None
@@ -192,7 +214,7 @@ def stage_data(self, table, verbose=False):
192
214
response = self ._request ('GET' , access_url , auth = self ._auth ,
193
215
timeout = self .TIMEOUT , cache = False )
194
216
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 )
196
218
if id_token :
197
219
tokens .append (id_token )
198
220
soda_url = service_url
@@ -206,6 +228,9 @@ def stage_data(self, table, verbose=False):
206
228
if verbose :
207
229
log .info ("Created data staging job " + job_url )
208
230
231
+ return job_url
232
+
233
+ def _complete_job (self , job_url , verbose ):
209
234
# Wait for job to be complete
210
235
final_status = self ._run_job (job_url , verbose , poll_interval = self .POLL_INTERVAL )
211
236
if final_status != 'COMPLETED' :
@@ -222,6 +247,89 @@ def stage_data(self, table, verbose=False):
222
247
223
248
return fileurls
224
249
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
+
225
333
def download_files (self , urls , savedir = '' ):
226
334
"""
227
335
Download a series of files
@@ -324,6 +432,25 @@ def _create_soda_job(self, authenticated_id_tokens, soda_url=None):
324
432
resp .raise_for_status ()
325
433
return resp .url
326
434
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
+
327
454
def _run_job (self , job_location , verbose , poll_interval = 20 ):
328
455
"""
329
456
Start an async job (e.g. TAP or SODA) and wait for it to be completed.
0 commit comments