Skip to content

Commit 536779d

Browse files
authored
Merge pull request #2746 from mkelley/jplhorizons-vectors-topo-queries-202305
Allow astropy Quantity in query locations
2 parents 88dd76d + 5b1435c commit 536779d

File tree

5 files changed

+173
-47
lines changed

5 files changed

+173
-47
lines changed

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ jplhorizons
156156

157157
- Assign units to ``"hour_angle"``, ``"solartime"``, and ``"siderealtime"`` columns. [#2794]
158158

159+
- Allow using units in locations specified as coordinates. [#2746]
160+
159161
jplsbdb
160162
^^^^^^^
161163

astroquery/jplhorizons/core.py

Lines changed: 81 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from astropy.table import Table, Column
1414
from astropy.io import ascii
1515
from astropy.time import Time
16+
from astropy import units as u
1617
from astropy.utils.exceptions import AstropyDeprecationWarning
1718
from astropy.utils.decorators import deprecated_renamed_argument, deprecated_attribute
1819

@@ -53,14 +54,14 @@ def __init__(self, id=None, *, location=None, epochs=None,
5354
5455
id : str or dict, required
5556
Name, number, or designation of target object. Uses the same codes
56-
as JPL Horizons. Arbitrary topocentric coordinates can be added
57-
in a dict. The dict has to be of the form
58-
{``'lon'``: longitude in deg (East positive, West
59-
negative), ``'lat'``: latitude in deg (North positive, South
60-
negative), ``'elevation'``: elevation in km above the reference
61-
ellipsoid, [``'body'``: Horizons body ID of the central body;
62-
optional; if this value is not provided it is assumed that this
63-
location is on Earth]}.
57+
as JPL Horizons. Arbitrary topocentric coordinates can be added in a
58+
dict. The dict has to be of the form {``'lon'``: longitude in deg
59+
(East positive, West negative), ``'lat'``: latitude in deg (North
60+
positive, South negative), ``'elevation'``: elevation in km above
61+
the reference ellipsoid, [``'body'``: Horizons body ID of the
62+
central body; optional; if this value is not provided it is assumed
63+
that this location is on Earth]}. Float values are assumed to have
64+
units of degrees and kilometers.
6465
6566
location : str or dict, optional
6667
Observer's location for ephemerides queries or center body name for
@@ -69,12 +70,13 @@ def __init__(self, id=None, *, location=None, epochs=None,
6970
ephemerides queries and the Sun's center for elements and vectors
7071
queries. Arbitrary topocentric coordinates for ephemerides queries
7172
can be provided in the format of a dictionary. The dictionary has to
72-
be of the form {``'lon'``: longitude in deg (East positive, West
73-
negative), ``'lat'``: latitude in deg (North positive, South
74-
negative), ``'elevation'``: elevation in km above the reference
75-
ellipsoid, [``'body'``: Horizons body ID of the central body;
76-
optional; if this value is not provided it is assumed that this
77-
location is on Earth]}.
73+
be of the form {``'lon'``: longitude (East positive, West negative),
74+
``'lat'``: latitude (North positive, South negative),
75+
``'elevation'``: elevation above the reference ellipsoid,
76+
[``'body'``: Horizons body ID of the central body; optional; if this
77+
value is not provided it is assumed that this location is on
78+
Earth]}. Float values are assumed to have units of degrees and
79+
kilometers.
7880
7981
epochs : scalar, list-like, or dictionary, optional
8082
Either a list of epochs in JD or MJD format or a dictionary defining
@@ -117,16 +119,10 @@ def __init__(self, id=None, *, location=None, epochs=None,
117119
"""
118120

119121
super().__init__()
120-
# check & format coordinate dictionaries for id and location; simply
121-
# treat other values as given
122-
if isinstance(id, Mapping):
123-
self.id = self._prep_loc_dict(dict(id), "id")
124-
else:
125-
self.id = id
126-
if isinstance(location, Mapping):
127-
self.location = self._prep_loc_dict(dict(location), "location")
128-
else:
129-
self.location = location
122+
123+
self.id = id
124+
self.location = location
125+
130126
# check for epochs to be dict or list-like; else: make it a list
131127
if epochs is not None:
132128
if isinstance(epochs, (list, tuple, ndarray)):
@@ -185,6 +181,32 @@ def __str__(self):
185181
str(self.epochs),
186182
str(self.id_type))
187183

184+
@property
185+
def id(self):
186+
return self._id
187+
188+
@id.setter
189+
def id(self, _id):
190+
# check & format coordinate dictionaries for id; simply treat other
191+
# values as given
192+
if isinstance(_id, Mapping):
193+
self._id = self._prep_loc_dict(dict(_id), "id")
194+
else:
195+
self._id = _id
196+
197+
@property
198+
def location(self):
199+
return self._location
200+
201+
@location.setter
202+
def location(self, _location):
203+
# check & format coordinate dictionaries for location; simply treat
204+
# other values as given
205+
if isinstance(_location, Mapping):
206+
self._location = self._prep_loc_dict(dict(_location), "location")
207+
else:
208+
self._location = _location
209+
188210
# ---------------------------------- query functions
189211

190212
@deprecated_renamed_argument("get_raw_response", None, since="0.4.7",
@@ -593,10 +615,7 @@ def ephemerides_async(self, *, airmass_lessthan=99,
593615
if self.id is None:
594616
raise ValueError("'id' parameter not set. Query aborted.")
595617
elif isinstance(self.id, dict):
596-
commandline = (
597-
f"g:{self.id['lon']},{self.id['lat']},"
598-
f"{self.id['elevation']}@{self.id['body']}"
599-
)
618+
commandline = self._format_id_coords(self.id)
600619
else:
601620
commandline = str(self.id)
602621
if self.location is None:
@@ -831,16 +850,20 @@ def elements_async(self, *, get_query_payload=False,
831850

832851
URL = conf.horizons_server
833852

834-
# check for required information
853+
# check for required information and assemble commandline stub
835854
if self.id is None:
836855
raise ValueError("'id' parameter not set. Query aborted.")
856+
elif isinstance(self.id, dict):
857+
commandline = self._format_id_coords(self.id)
858+
else:
859+
commandline = str(self.id)
860+
837861
if self.location is None:
838862
self.location = '500@10'
839863
if self.epochs is None:
840864
self.epochs = Time.now().jd
841865

842-
# assemble commandline based on self.id_type
843-
commandline = str(self.id)
866+
# expand commandline based on self.id_type
844867
if self.id_type in ['designation', 'name',
845868
'asteroid_name', 'comet_name']:
846869
commandline = ({'designation': 'DES=',
@@ -859,7 +882,7 @@ def elements_async(self, *, get_query_payload=False,
859882
commandline += ' NOFRAG;'
860883

861884
if isinstance(self.location, dict):
862-
raise ValueError(('cannot use topographic position in orbital'
885+
raise ValueError(('cannot use topographic position in orbital '
863886
'elements query'))
864887

865888
# configure request_payload for ephemerides query
@@ -1080,7 +1103,7 @@ def vectors_async(self, *, get_query_payload=False,
10801103
if self.id is None:
10811104
raise ValueError("'id' parameter not set. Query aborted.")
10821105
elif isinstance(self.id, dict):
1083-
commandline = "g:{lon},{lat},{elevation}@{body}".format(**self.id)
1106+
commandline = self._format_id_coords(self.id)
10841107
else:
10851108
commandline = str(self.id)
10861109
if self.location is None:
@@ -1183,20 +1206,38 @@ def _prep_loc_dict(loc_dict, attr_name):
11831206
)
11841207
if 'body' not in loc_dict:
11851208
loc_dict['body'] = 399
1209+
# assumed units are degrees and km
1210+
loc_dict["lat"] = u.Quantity(loc_dict["lat"], u.deg)
1211+
loc_dict["lon"] = u.Quantity(loc_dict["lon"], u.deg)
1212+
loc_dict["elevation"] = u.Quantity(loc_dict["elevation"], u.km)
11861213
return loc_dict
11871214

11881215
@staticmethod
11891216
def _location_to_params(loc_dict):
1190-
"""translate a 'location' dict to a dict of request parameters"""
1191-
loc_dict = {
1217+
"""translate a 'location' dict to request parameters"""
1218+
1219+
location = {
11921220
"CENTER": f"coord@{loc_dict['body']}",
11931221
"COORD_TYPE": "GEODETIC",
1194-
"SITE_COORD": ",".join(
1195-
str(float(loc_dict[k])) for k in ['lon', 'lat', 'elevation']
1196-
)
1222+
"SITE_COORD": "'{}'".format(str(HorizonsClass._format_site_coords(loc_dict)))
11971223
}
1198-
loc_dict["SITE_COORD"] = f"'{loc_dict['SITE_COORD']}'"
1199-
return loc_dict
1224+
return location
1225+
1226+
@staticmethod
1227+
def _format_coords(coords):
1228+
"""Dictionary to Horizons API formatted lon/lat/elevation coordinate triplet."""
1229+
return (f"{coords['lon'].to_value('deg')},{coords['lat'].to_value('deg')},"
1230+
f"{coords['elevation'].to_value('km')}")
1231+
1232+
@staticmethod
1233+
def _format_site_coords(coords):
1234+
"""`location` dictionary to SITE_COORDS parameter."""
1235+
return HorizonsClass._format_coords(coords)
1236+
1237+
@staticmethod
1238+
def _format_id_coords(coords):
1239+
"""`id` dictionary to COMMAND parameter's coordinate format."""
1240+
return f"g:{HorizonsClass._format_coords(coords)}@{coords['body']}"
12001241

12011242
def _parse_result(self, response, verbose=None):
12021243
"""

astroquery/jplhorizons/tests/test_jplhorizons.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from numpy.ma import is_masked
88
from astropy.tests.helper import assert_quantity_allclose
99
from astropy.utils.exceptions import AstropyDeprecationWarning
10+
from astropy import units as u
1011

1112
from astroquery.utils.mocks import MockResponse
1213
from ...query import AstroQuery
@@ -291,3 +292,61 @@ def test_id_type_deprecation():
291292

292293
with pytest.warns(AstropyDeprecationWarning):
293294
jplhorizons.Horizons(id='Ceres', id_type='majorbody')
295+
296+
297+
def test_id_geodetic_coords():
298+
"""Test target based on geodetic coordinates.
299+
300+
From the Horizons manual:
301+
302+
For example, while 301 specifies the target to be the center of the Moon,
303+
and Apollo-11 @ 301 specifies the Apollo-11 landing site as target, the
304+
following:
305+
306+
g: 348.8, -43.3, 0 @ 301
307+
308+
specifies an ephemeris for the crater Tycho on the Moon (body 301), at
309+
geodetic (planetodetic) coordinates 348.8 degrees east longitude, -43.3
310+
degrees latitude (south), and zero km altitude with respect to the Moon’s
311+
mean-Earth reference frame and ellipsoid surface.
312+
313+
"""
314+
315+
target = {
316+
"lon": 348.8 * u.deg,
317+
"lat": -43.3 * u.deg,
318+
"elevation": 0 * u.m,
319+
"body": 301
320+
}
321+
322+
q = jplhorizons.Horizons(id=target)
323+
for payload in (q.ephemerides(get_query_payload=True),
324+
q.vectors(get_query_payload=True),
325+
q.elements(get_query_payload=True)):
326+
assert payload["COMMAND"] == '"g:348.8,-43.3,0.0@301"'
327+
328+
329+
def test_location_topocentric_coords():
330+
"""Test location from topocentric coordinates.
331+
332+
Similar to `test_id_geodetic_coords`.
333+
334+
"""
335+
336+
location = {
337+
"lon": 348.8 * u.deg,
338+
"lat": -43.3 * u.deg,
339+
"elevation": 0 * u.m,
340+
"body": 301
341+
}
342+
343+
q = jplhorizons.Horizons(id=399, location=location)
344+
for payload in (q.ephemerides(get_query_payload=True),
345+
q.vectors(get_query_payload=True)):
346+
assert payload["CENTER"] == 'coord@301'
347+
assert payload["COORD_TYPE"] == "GEODETIC"
348+
assert payload["SITE_COORD"] == "'348.8,-43.3,0.0'"
349+
350+
# not allowed for elements
351+
with pytest.raises(ValueError):
352+
q.elements(get_query_payload=True)

astroquery/jplhorizons/tests/test_jplhorizons_remote.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,29 @@ def test_vectors_query_raw(self):
361361

362362
assert len(res) >= 6412
363363

364+
@pytest.mark.parametrize(
365+
"location",
366+
(
367+
{"lon": 244, "lat": 33, "elevation": 1.7},
368+
{"lon": (244 * u.deg).to(u.rad), "lat": (33 * u.deg).to(u.rad), "elevation": 1700 * u.m},
369+
)
370+
)
371+
def test_vectors_query_topocentric_coordinates(self, location):
372+
"Test vectors query specifying observer's longitude, latitude, and elevation"
373+
q = jplhorizons.Horizons(id='Ceres',
374+
location=location,
375+
id_type='smallbody',
376+
epochs=2451544.5)
377+
res = q.vectors_async()
378+
i = res.text.find("Center geodetic :")
379+
j = res.text.find("\n", i)
380+
parts = res.text[i:j].split()
381+
assert parts[3:6] == ['244.0,', '33.0,', '1.7']
382+
383+
start = res.text.find("$$SOE")
384+
end = res.text.find("$$EOE")
385+
assert res.text[start:end].find("2000-Jan-01") > 0
386+
364387
def test_unknownobject(self):
365388
with pytest.raises(ValueError):
366389
jplhorizons.Horizons(id='spamspamspameggsspam', location='500',

docs/jplhorizons/jplhorizons.rst

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ must still be entered in east-longitude, which means they must be negative; Hori
7878
will raise an error if given any positive longitude value for such bodies. Instead enter
7979
the west-longitude - 360. For instance, a site on Mars (id code 499) at 30 degrees
8080
longitude, 30 degrees latitude, 0 km elevation should be specified as
81-
``{'body': 499, 'elevation': 0, 'lon': -330, 'lat': 30}``.
81+
``{'body': 499, 'elevation': 0 * u.km, 'lon': -330 * u.deg, 'lat': 30 * u.deg}``.
8282
2. This does not apply to the Earth, Moon, and Sun. Although they are prograde,
8383
Horizons interprets east-longitude as positive and west-longitude as negative for these
8484
bodies.
@@ -96,14 +96,15 @@ as the observer's location, and Ceres as the target:
9696

9797
.. doctest-remote-data::
9898

99-
>>> statue_of_liberty = {'lon': -74.0466891,
100-
... 'lat': 40.6892534,
101-
... 'elevation': 0.093}
99+
>>> import astropy.units as u
100+
>>> statue_of_liberty = {'lon': -74.0466891 * u.deg,
101+
... 'lat': 40.6892534 * u.deg,
102+
... 'elevation': 0.093 * u.km}
102103
>>> obj = Horizons(id='Ceres',
103104
... location=statue_of_liberty,
104105
... epochs=2458133.33546)
105106
>>> print(obj)
106-
JPLHorizons instance "Ceres"; location={'lon': -74.0466891, 'lat': 40.6892534, 'elevation': 0.093, 'body': 399}, epochs=[2458133.33546], id_type=None
107+
JPLHorizons instance "Ceres"; location={'lon': <Quantity -74.0466891 deg>, 'lat': <Quantity 40.6892534 deg>, 'elevation': <Quantity 0.093 km>, 'body': 399}, epochs=[2458133.33546], id_type=None
107108

108109
2. Specifying topocentric coordinates for both location and observer is often
109110
useful when performing geometric calculations for artificial satellites without
@@ -116,8 +117,8 @@ at a particular point in time to the center of the crater Double:
116117

117118
.. doctest-remote-data::
118119

119-
>>> ce_2 = {'lon': 23.522, 'lat': 0.637, 'elevation': 181.2, 'body': 301}
120-
>>> double = {'lon': 23.47, 'lat': 0.67, 'elevation': 0, 'body': 301}
120+
>>> ce_2 = {'lon': 23.522 * u.deg, 'lat': 0.637 * u.deg, 'elevation': 181.2 * u.km, 'body': 301}
121+
>>> double = {'lon': 23.47 * u.deg, 'lat': 0.67 * u.deg, 'elevation': 0 * u.km, 'body': 301}
121122
>>> obj = Horizons(id=double, location=ce_2, epochs=2454483.84247)
122123
>>> vecs = obj.vectors()
123124
>>> distance_km = (vecs['x'] ** 2 + vecs['y'] ** 2 + vecs['z'] ** 2) ** 0.5 * 1.496e8

0 commit comments

Comments
 (0)