Skip to content

Commit 39638db

Browse files
authored
Merge pull request #2625 from MillionConcepts/jplh_planetodetic_coord
improve target coordinate specification in jplhorizons
2 parents 4a41858 + 5152eed commit 39638db

File tree

6 files changed

+213
-82
lines changed

6 files changed

+213
-82
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ jplhorizons
105105

106106
- Optional keyword arguments are now keyword only. [#1802]
107107

108+
- Topocentric coordinates can now be specified for both center and target in observer
109+
and vector queries. [#2625]
110+
108111
jplsbdb
109112
^^^^^^^
110113

astroquery/jplhorizons/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ class Conf(_config.ConfigNamespace):
5050
'k2': ('k2', '---'),
5151
'phasecoeff': ('phasecoeff', 'mag/deg'),
5252
'solar_presence': ('solar_presence', '---'),
53-
'flags': ('flags', '---'),
53+
'lunar_presence': ('lunar_presence', '---'),
54+
'interfering_body': ('interfering_body', '---'),
55+
'illumination_flag': ('illumination_flag', '---'),
56+
'nearside_flag': ('nearside_flag', '---'),
5457
'R.A._(ICRF)': ('RA', 'deg'),
5558
'DEC_(ICRF)': ('DEC', 'deg'),
5659
'R.A.___(ICRF)': ('RA', 'deg'),

astroquery/jplhorizons/core.py

Lines changed: 79 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# Licensed under a 3-clause BSD style license - see LICENSE.rst
22

3-
43
# 1. standard library imports
5-
from numpy import nan
6-
from numpy import isnan
7-
from numpy import ndarray
84
from collections import OrderedDict
5+
from typing import Mapping
96
import warnings
107

118
# 2. third party imports
129
from requests.exceptions import HTTPError
10+
from numpy import nan
11+
from numpy import isnan
12+
from numpy import ndarray
1313
from astropy.table import Table, Column
1414
from astropy.io import ascii
1515
from astropy.time import Time
@@ -51,8 +51,16 @@ def __init__(self, id=None, *, location=None, epochs=None,
5151
Parameters
5252
----------
5353
54-
id : str, required
55-
Name, number, or designation of the object to be queried.
54+
id : str or dict, required
55+
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]}.
5664
5765
location : str or dict, optional
5866
Observer's location for ephemerides queries or center body name for
@@ -108,9 +116,16 @@ def __init__(self, id=None, *, location=None, epochs=None,
108116
"""
109117

110118
super().__init__()
111-
self.id = id
112-
self.location = location
113-
119+
# check & format coordinate dictionaries for id and location; simply
120+
# treat other values as given
121+
if isinstance(id, Mapping):
122+
self.id = self._prep_loc_dict(dict(id), "id")
123+
else:
124+
self.id = id
125+
if isinstance(location, Mapping):
126+
self.location = self._prep_loc_dict(dict(location), "location")
127+
else:
128+
self.location = location
114129
# check for epochs to be dict or list-like; else: make it a list
115130
if epochs is not None:
116131
if isinstance(epochs, (list, tuple, ndarray)):
@@ -535,16 +550,22 @@ def ephemerides_async(self, *, airmass_lessthan=99,
535550

536551
URL = conf.horizons_server
537552

538-
# check for required information
553+
# check for required information and assemble commanddline stub
539554
if self.id is None:
540555
raise ValueError("'id' parameter not set. Query aborted.")
556+
elif isinstance(self.id, dict):
557+
commandline = (
558+
f"g:{self.id['lon']},{self.id['lat']},"
559+
f"{self.id['elevation']}@{self.id['body']}"
560+
)
561+
else:
562+
commandline = str(self.id)
541563
if self.location is None:
542564
self.location = '500@399'
543565
if self.epochs is None:
544566
self.epochs = Time.now().jd
567+
# expand commandline based on self.id_type
545568

546-
# assemble commandline based on self.id_type
547-
commandline = str(self.id)
548569
if self.id_type in ['designation', 'name',
549570
'asteroid_name', 'comet_name']:
550571
commandline = ({'designation': 'DES=',
@@ -580,19 +601,7 @@ def ephemerides_async(self, *, airmass_lessthan=99,
580601
('EXTRA_PREC', {True: 'YES', False: 'NO'}[extra_precision])])
581602

582603
if isinstance(self.location, dict):
583-
if ('lon' not in self.location or 'lat' not in self.location or
584-
'elevation' not in self.location):
585-
raise ValueError(("'location' must contain lon, lat, "
586-
"elevation"))
587-
588-
if 'body' not in self.location:
589-
self.location['body'] = '399'
590-
request_payload['CENTER'] = 'coord@{:s}'.format(
591-
str(self.location['body']))
592-
request_payload['COORD_TYPE'] = 'GEODETIC'
593-
request_payload['SITE_COORD'] = "'{:f},{:f},{:f}'".format(
594-
self.location['lon'], self.location['lat'],
595-
self.location['elevation'])
604+
request_payload = dict(**request_payload, **self._location_to_params(self.location))
596605
else:
597606
request_payload['CENTER'] = "'" + str(self.location) + "'"
598607

@@ -1032,17 +1041,18 @@ def vectors_async(self, *, get_query_payload=False,
10321041

10331042
URL = conf.horizons_server
10341043

1035-
# check for required information
1044+
# check for required information and assemble commandline stub
10361045
if self.id is None:
10371046
raise ValueError("'id' parameter not set. Query aborted.")
1047+
elif isinstance(self.id, dict):
1048+
commandline = "g:{lon},{lat},{elevation}@{body}".format(**self.id)
1049+
else:
1050+
commandline = str(self.id)
10381051
if self.location is None:
10391052
self.location = '500@10'
10401053
if self.epochs is None:
10411054
self.epochs = Time.now().jd
1042-
1043-
# assemble commandline based on self.id_type
1044-
commandline = str(self.id)
1045-
1055+
# expand commandline based on self.id_type
10461056
if self.id_type in ['designation', 'name',
10471057
'asteroid_name', 'comet_name']:
10481058
commandline = ({'designation': 'DES=',
@@ -1060,18 +1070,12 @@ def vectors_async(self, *, get_query_payload=False,
10601070
commandline += ' CAP{:s};'.format(closest_apparition)
10611071
if no_fragments:
10621072
commandline += ' NOFRAG;'
1063-
1064-
if isinstance(self.location, dict):
1065-
raise ValueError(('cannot use topographic position in state'
1066-
'vectors query'))
1067-
1068-
# configure request_payload for ephemerides query
1073+
# configure request_payload for vectors query
10691074
request_payload = OrderedDict([
10701075
('format', 'text'),
10711076
('EPHEM_TYPE', 'VECTORS'),
10721077
('OUT_UNITS', 'AU-D'),
10731078
('COMMAND', '"' + commandline + '"'),
1074-
('CENTER', ("'" + str(self.location) + "'")),
10751079
('CSV_FORMAT', ('"YES"')),
10761080
('REF_PLANE', {'ecliptic': 'ECLIPTIC',
10771081
'earth': 'FRAME',
@@ -1086,7 +1090,12 @@ def vectors_async(self, *, get_query_payload=False,
10861090
('VEC_DELTA_T', {True: 'YES', False: 'NO'}[delta_T]),
10871091
('OBJ_DATA', 'YES')]
10881092
)
1089-
1093+
if isinstance(self.location, dict):
1094+
request_payload = dict(
1095+
**request_payload, **self._location_to_params(self.location)
1096+
)
1097+
else:
1098+
request_payload['CENTER'] = "'" + str(self.location) + "'"
10901099
# parse self.epochs
10911100
if isinstance(self.epochs, (list, tuple, ndarray)):
10921101
request_payload['TLIST'] = "\n".join([str(epoch) for epoch in
@@ -1132,6 +1141,30 @@ def vectors_async(self, *, get_query_payload=False,
11321141
return response
11331142

11341143
# ---------------------------------- parser functions
1144+
@staticmethod
1145+
def _prep_loc_dict(loc_dict, attr_name):
1146+
"""prepare coord specification dict for 'location' or 'id'"""
1147+
if {'lat', 'lon', 'elevation'} - loc_dict.keys():
1148+
raise ValueError(
1149+
f"dict values for '{attr_name}' must contain 'lat', 'lon', "
1150+
"'elevation' (and optionally 'body')"
1151+
)
1152+
if 'body' not in loc_dict:
1153+
loc_dict['body'] = 399
1154+
return loc_dict
1155+
1156+
@staticmethod
1157+
def _location_to_params(loc_dict):
1158+
"""translate a 'location' dict to a dict of request parameters"""
1159+
loc_dict = {
1160+
"CENTER": f"coord@{loc_dict['body']}",
1161+
"COORD_TYPE": "GEODETIC",
1162+
"SITE_COORD": ",".join(
1163+
str(float(loc_dict[k])) for k in ['lon', 'lat', 'elevation']
1164+
)
1165+
}
1166+
loc_dict["SITE_COORD"] = f"'{loc_dict['SITE_COORD']}'"
1167+
return loc_dict
11351168

11361169
def _parse_result(self, response, verbose=None):
11371170
"""
@@ -1181,14 +1214,18 @@ def _parse_result(self, response, verbose=None):
11811214
H, G = nan, nan
11821215
M1, M2, k1, k2, phcof = nan, nan, nan, nan, nan
11831216
headerline = []
1217+
centername = ''
11841218
for idx, line in enumerate(src):
11851219
# read in ephemerides header line; replace some field names
11861220
if (self.query_type == 'ephemerides' and
11871221
"Date__(UT)__HR:MN" in line):
11881222
headerline = str(line).split(',')
11891223
headerline[2] = 'solar_presence'
1190-
headerline[3] = 'flags'
1224+
headerline[3] = "lunar_presence" if "Earth" in centername else "interfering_body"
11911225
headerline[-1] = '_dump'
1226+
if isinstance(self.id, dict) or str(self.id).startswith('g:'):
1227+
headerline[4] = 'nearside_flag'
1228+
headerline[5] = 'illumination_flag'
11921229
# read in elements header line
11931230
elif (self.query_type == 'elements' and
11941231
"JDTDB," in line):
@@ -1208,6 +1245,9 @@ def _parse_result(self, response, verbose=None):
12081245
# read in targetname
12091246
if "Target body name" in line:
12101247
targetname = line[18:50].strip()
1248+
# read in center body name
1249+
if "Center body name" in line:
1250+
centername = line[18:50].strip()
12111251
# read in H and G (if available)
12121252
if "rotational period in hours)" in line:
12131253
HGline = src[idx + 2].split('=')

astroquery/jplhorizons/tests/test_jplhorizons.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def test_ephemerides_query(patch_request):
8383
assert res['targetname'] == "1 Ceres (A801 AA)"
8484
assert res['datetime_str'] == "2000-Jan-01 00:00:00.000"
8585
assert res['solar_presence'] == ""
86-
assert res['flags'] == ""
86+
assert res['lunar_presence'] == ""
8787
assert res['elongFlag'] == '/L'
8888
assert res['airmass'] == 999
8989

@@ -256,22 +256,21 @@ def test_vectors_query_payload():
256256
res = jplhorizons.Horizons(id='Ceres', location='500@10',
257257
epochs=2451544.5).vectors(
258258
get_query_payload=True)
259-
260259
assert res == OrderedDict([
261-
('format', 'text'),
262-
('EPHEM_TYPE', 'VECTORS'),
263-
('OUT_UNITS', 'AU-D'),
264-
('COMMAND', '"Ceres"'),
265-
('CENTER', "'500@10'"),
266-
('CSV_FORMAT', '"YES"'),
267-
('REF_PLANE', 'ECLIPTIC'),
268-
('REF_SYSTEM', 'ICRF'),
269-
('TP_TYPE', 'ABSOLUTE'),
270-
('VEC_LABELS', 'YES'),
271-
('VEC_CORR', '"NONE"'),
272-
('VEC_DELTA_T', 'NO'),
273-
('OBJ_DATA', 'YES'),
274-
('TLIST', '2451544.5')])
260+
('format', 'text'),
261+
('EPHEM_TYPE', 'VECTORS'),
262+
('OUT_UNITS', 'AU-D'),
263+
('COMMAND', '"Ceres"'),
264+
('CSV_FORMAT', '"YES"'),
265+
('REF_PLANE', 'ECLIPTIC'),
266+
('REF_SYSTEM', 'ICRF'),
267+
('TP_TYPE', 'ABSOLUTE'),
268+
('VEC_LABELS', 'YES'),
269+
('VEC_CORR', '"NONE"'),
270+
('VEC_DELTA_T', 'NO'),
271+
('OBJ_DATA', 'YES'),
272+
('CENTER', "'500@10'"),
273+
('TLIST', '2451544.5')])
275274

276275

277276
def test_no_H(patch_request):

astroquery/jplhorizons/tests/test_jplhorizons_remote.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# Licensed under a 3-clause BSD style license - see LICENSE.rst
22

3-
4-
import pytest
53
from numpy.ma import is_masked
4+
import numpy as np
5+
import pytest
66

7+
from astropy.coordinates import spherical_to_cartesian
78
from astropy.tests.helper import assert_quantity_allclose
9+
import astropy.units as u
810
from astropy.utils.exceptions import AstropyDeprecationWarning
911

1012
from ... import jplhorizons
@@ -23,7 +25,7 @@ def test_ephemerides_query(self):
2325
assert res['targetname'] == "1 Ceres (A801 AA)"
2426
assert res['datetime_str'] == "2000-Jan-01 00:00:00.000"
2527
assert res['solar_presence'] == ""
26-
assert res['flags'] == ""
28+
assert res['lunar_presence'] == ""
2729
assert res['elongFlag'] == '/L'
2830
assert res['airmass'] == 999
2931

@@ -75,7 +77,7 @@ def test_ephemerides_query_two(self):
7577
assert res['targetname'] == "1P/Halley"
7678
assert res['datetime_str'] == "2080-Jan-11 09:00"
7779
assert res['solar_presence'] == ""
78-
assert res['flags'] == "m"
80+
assert res['lunar_presence'] == "m"
7981
assert res['elongFlag'] == '/L'
8082

8183
for value in ['H', 'G']:
@@ -98,7 +100,7 @@ def test_ephemerides_query_three(self):
98100
assert res['targetname'] == "73P/Schwassmann-Wachmann 3"
99101
assert res['datetime_str'] == "2080-Jan-01 00:00"
100102
assert res['solar_presence'] == "*"
101-
assert res['flags'] == "m"
103+
assert res['lunar_presence'] == "m"
102104
assert res['elongFlag'] == '/L'
103105

104106
for value in ['H', 'G']:
@@ -123,7 +125,7 @@ def test_ephemerides_query_four(self):
123125
assert res['targetname'] == "167P/CINEOS"
124126
assert res['datetime_str'] == "2080-Jan-01 00:00"
125127
assert res['solar_presence'] == "*"
126-
assert res['flags'] == "m"
128+
assert res['lunar_presence'] == "m"
127129
assert res['elongFlag'] == '/T'
128130

129131
for value in ['H', 'G', 'M1', 'k1']:
@@ -150,7 +152,7 @@ def test_ephemerides_query_five(self):
150152
assert res['targetname'] == "12P/Pons-Brooks"
151153
assert res['datetime_str'] == "2080-Jan-01 00:00"
152154
assert res['solar_presence'] == "*"
153-
assert res['flags'] == "m"
155+
assert res['lunar_presence'] == "m"
154156
assert res['elongFlag'] == '/L'
155157

156158
for value in ['H', 'G', 'phasecoeff']:
@@ -405,3 +407,35 @@ def test_ephemerides_extraprecision(self):
405407
vec_highprec = obj.ephemerides(extra_precision=True)
406408

407409
assert (vec_simple['RA'][0]-vec_highprec['RA'][0]) > 1e-7
410+
411+
def test_geodetic_queries(self):
412+
"""
413+
black-box test for observer and vectors queries with geodetic
414+
coordinates. checks spatial sensibility.
415+
"""
416+
phobos = {'body': 401, 'lon': -30, 'lat': -20, 'elevation': 0}
417+
deimos = {'body': 402, 'lon': -10, 'lat': -40, 'elevation': 0}
418+
deimos_phobos = jplhorizons.Horizons(phobos, location=deimos, epochs=2.4e6)
419+
phobos_deimos = jplhorizons.Horizons(deimos, location=phobos, epochs=2.4e6)
420+
pd_eph, dp_eph = phobos_deimos.ephemerides(), deimos_phobos.ephemerides()
421+
dp_xyz = spherical_to_cartesian(
422+
dp_eph['delta'], dp_eph['DEC'], dp_eph['RA']
423+
)
424+
pd_xyz = spherical_to_cartesian(
425+
pd_eph['delta'], pd_eph['DEC'], pd_eph['RA']
426+
)
427+
elementwise = [(dp_el + pd_el) for dp_el, pd_el in zip(dp_xyz, pd_xyz)]
428+
eph_offset = (sum([off ** 2 for off in elementwise]) ** 0.5).to(u.km)
429+
# horizons can do better than this, but we'd have to go to a little
430+
# more trouble than is necessary for a software test...
431+
assert np.isclose(eph_offset.value, 2.558895)
432+
# ...and vectors queries are really what you're meant to use for
433+
# this sort of thing.
434+
pd_vec, dp_vec = phobos_deimos.vectors(), deimos_phobos.vectors()
435+
vec_offset = np.sum(
436+
(
437+
pd_vec.as_array(names=('x', 'y', 'z')).view('f8')
438+
+ dp_vec.as_array(names=('x', 'y', 'z')).view('f8')
439+
) ** 2
440+
)
441+
assert np.isclose(vec_offset, 0)

0 commit comments

Comments
 (0)