Skip to content

Commit

Permalink
Merge pull request #7319 from ayshih/no_astroquery
Browse files Browse the repository at this point in the history
`get_horizons_coord()` no longer uses `astroquery`
  • Loading branch information
nabobalis authored Dec 7, 2023
2 parents fffb4c8 + 4eb77f1 commit fe5a0a3
Show file tree
Hide file tree
Showing 14 changed files with 134 additions and 67 deletions.
1 change: 1 addition & 0 deletions changelog/7319.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Long object names are no longer truncated in the logging output of :func:`~sunpy.coordinates.get_horizons_coord`.
1 change: 1 addition & 0 deletions changelog/7319.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:func:`~sunpy.coordinates.get_horizons_coord` now supports time arrays with up to 10,000 elements.
1 change: 1 addition & 0 deletions changelog/7319.trivial.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The function :func:`~sunpy.coordinates.get_horizons_coord` no longer calls the ``astroquery`` package, so ``astroquery`` is no longer a dependency.
2 changes: 2 additions & 0 deletions changelog/7319.trivial.2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
The ``requests`` package is a now formally a core dependency.
``requests`` was already commonly installed as an implied dependency of `sunpy.net` or for building documentation.
1 change: 0 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,6 @@
"aiapy": ("https://aiapy.readthedocs.io/en/stable/", None),
"asdf": ("https://asdf.readthedocs.io/en/stable/", None),
"astropy": ("https://docs.astropy.org/en/stable/", None),
"astroquery": ("https://astroquery.readthedocs.io/en/latest/", None),
"dask": ("https://docs.dask.org/en/stable/", None),
"drms": ("https://docs.sunpy.org/projects/drms/en/v0.6.4.post1/", None),
"hvpy": ("https://hvpy.readthedocs.io/en/latest/", None),
Expand Down
2 changes: 0 additions & 2 deletions examples/showcase/where_is_stereo.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
* :ref:`sphx_glr_generated_gallery_units_and_coordinates_planet_locations.py`
* :ref:`sphx_glr_generated_gallery_units_and_coordinates_ParkerSolarProbe_trajectory.py`
`astroquery <https://astroquery.readthedocs.io/>`__ needs to be installed.
"""
import matplotlib as mpl
import matplotlib.pyplot as plt
Expand Down
2 changes: 0 additions & 2 deletions examples/units_and_coordinates/ParkerSolarProbe_trajectory.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
JPL `Horizons <https://ssd.jpl.nasa.gov/horizons/>`__ can return the locations of
planets and minor bodies (e.g., asteroids) in the solar system, and it can also
return the location of a variety of major spacecraft.
You will need `astroquery <https://astroquery.readthedocs.io/>`__ installed.
"""

import matplotlib.pyplot as plt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,6 @@
How to get the correct location of SOHO using JPL HORIZONS
and update the header.
This requires the installation of the
`astroquery <https://astroquery.readthedocs.io/en/latest/>`__
package and an internet connection.
`astroquery <https://astroquery.readthedocs.io/en/latest/>`__ can be installed on-top of
the existing ``sunpy`` conda environment: ``conda install -c astropy astroquery``
"""
# sphinx_gallery_thumbnail_number = 2

Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ install_requires =
numpy>=1.21.0
packaging>=19.0
parfive[ftp]>=2.0.0
requests

[options.extras_require]
# The list of available extras is also in the installation docs, if you add or remove one please edit it there as well.
Expand Down
3 changes: 2 additions & 1 deletion sunpy-dev-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies:
- python_abi
- python-dateutil
- reproject
- requests
- scikit-image
- scipy
- setuptools
Expand All @@ -28,7 +29,6 @@ dependencies:
- zeep

# Optional
- astroquery
- dask
- glymur
- jplephem
Expand All @@ -53,6 +53,7 @@ dependencies:
- tox-conda

# Documentation
- astroquery
- graphviz
- ruamel.yaml
- sphinx
Expand Down
135 changes: 94 additions & 41 deletions sunpy/coordinates/ephemeris.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""
Ephemeris calculations using SunPy coordinate frames
"""
import re

import numpy as np
from packaging import version
import requests

import astropy.units as u
from astropy.constants import c as speed_of_light
Expand All @@ -18,6 +20,7 @@
CartesianRepresentation,
SphericalRepresentation,
)
from astropy.io import ascii
from astropy.time import Time

from sunpy import log
Expand Down Expand Up @@ -198,18 +201,19 @@ def get_horizons_coord(body, time='now', id_type=None, *, include_velocity=False
solar-system body at a specified time. This location is the instantaneous or "true" location,
and is not corrected for light travel time or observer motion.
.. note::
This function requires the Astroquery package to be installed and
requires an Internet connection.
Parameters
----------
body : `str`
The solar-system body for which to calculate positions. One can also use the search form
linked below to find valid names or ID numbers.
id_type : `None`, `str`
See the astroquery documentation for information on id_types: `astroquery.jplhorizons`.
If the installed astroquery version is less than 0.4.4, defaults to ``'majorbody'``.
Defaults to `None`, which searches major bodies first, and then searches
small bodies (comets and asteroids) if no major body is found. If
``'smallbody'``, the search is limited to only small bodies. If
``'designation'``, the search is limited to only small-body designations.
If ``'name'``, the search is limited to only small-body names. If
``'asteroid_name'`` or ``'comet_name'``, the search is limited to only
asteroid names or only comet names, respectively.
time : {parse_time_types}, `dict`
Time to use in a parse_time-compatible format.
Expand All @@ -219,8 +223,7 @@ def get_horizons_coord(body, time='now', id_type=None, *, include_velocity=False
``start_time`` and ``stop_time`` must be in a parse_time-compatible format,
and are interpreted as UTC time. ``step`` must be a string with either a
number and interval length (e.g. for every 10 seconds, ``'10s'``), or a
plain number for a number of evenly spaced intervals. For more information
see the docstring of `astroquery.jplhorizons.HorizonsClass`.
plain number for a number of evenly spaced intervals.
include_velocity : `bool`, optional
If True, include the body's velocity in the output coordinate. Defaults to False.
Expand All @@ -240,11 +243,12 @@ def get_horizons_coord(body, time='now', id_type=None, *, include_velocity=False
----------
* `JPL HORIZONS <https://ssd.jpl.nasa.gov/?horizons>`_
* `JPL HORIZONS form to search bodies <https://ssd.jpl.nasa.gov/horizons.cgi?s_target=1#top>`_
* `Astroquery <https://astroquery.readthedocs.io/en/latest/>`_
Examples
--------
>>> from sunpy.coordinates.ephemeris import get_horizons_coord
.. minigallery:: sunpy.coordinates.get_horizons_coord
>>> from sunpy.coordinates import get_horizons_coord
Query the location of Venus
Expand All @@ -256,7 +260,7 @@ def get_horizons_coord(body, time='now', id_type=None, *, include_velocity=False
Query the location of the SDO spacecraft
>>> get_horizons_coord('SDO', '2011-11-11 11:11:11') # doctest: +REMOTE_DATA
INFO: Obtained JPL HORIZONS location for Solar Dynamics Observatory (spac [sunpy.coordinates.ephemeris]
INFO: Obtained JPL HORIZONS location for Solar Dynamics Observatory (spacecraft) (-136395) [sunpy.coordinates.ephemeris]
<SkyCoord (HeliographicStonyhurst: obstime=2011-11-11T11:11:11.000, rsun=695700.0 km): (lon, lat, radius) in (deg, deg, AU)
(0.01019118, 3.29640728, 0.99011042)>
Expand All @@ -282,46 +286,95 @@ def get_horizons_coord(body, time='now', id_type=None, *, include_velocity=False
... time={{'start': '2020-12-01',
... 'stop': '2020-12-02',
... 'step': '12'}}) # doctest: +REMOTE_DATA
INFO: Obtained JPL HORIZONS location for Solar Orbiter (spacecraft) (-144 [sunpy.coordinates.ephemeris]
INFO: Obtained JPL HORIZONS location for Solar Orbiter (spacecraft) (-144) [sunpy.coordinates.ephemeris]
...
"""
# Import here so that astroquery is not a module-level dependency
import astroquery
from astroquery.jplhorizons import Horizons

if id_type is None and version.parse(astroquery.__version__) < version.parse('0.4.4'):
# For older versions of astroquery retain default behaviour of this function
# if id_type isn't manually specified.
id_type = 'majorbody'
# Reference plane defaults to the ecliptic (IAU 1976 definition)
args = {
'EPHEM_TYPE': 'VECTORS',
'OUT_UNITS': 'AU-D', # units of AU and days
'CENTER': '500@10', # origin is body center (500) of the Sun (10)
'VEC_TABLE': '2', # output the 6-element state vector
'CSV_FORMAT': 'YES',
}

if id_type in [None, 'smallbody', 'designation', 'name', 'asteroid_name', 'comet_name']:
prepend = {
None: "",
'smallbody': "",
'designation': "DES=",
'name': "NAME=",
'asteroid_name': "ASTNAM=",
'comet_name': "COMNAM=",
}
if id_type == 'smallbody' and body[-1] != ';':
body += ';'
args['COMMAND'] = f"'{prepend[id_type]}{body}'"
else:
raise ValueError("Invalid id_type")

if isinstance(time, dict):
if set(time.keys()) != set(['start', 'stop', 'step']):
raise ValueError('time dictionary must have the keys ["start", "stop", "step"]')
epochs = time
jpl_fmt = '%Y-%m-%d %H:%M:%S.%f'
epochs['start'] = parse_time(epochs['start']).tdb.strftime(jpl_fmt)
epochs['stop'] = parse_time(epochs['stop']).tdb.strftime(jpl_fmt)
jpl_fmt = "'%Y-%m-%d %H:%M:%S.%f'"
args['START_TIME'] = parse_time(time['start']).tdb.strftime(jpl_fmt)
args['STOP_TIME'] = parse_time(time['stop']).tdb.strftime(jpl_fmt)
args['STEP_SIZE'] = time['step']
else:
obstime = parse_time(time)
if obstime.size >= 10000:
raise ValueError("For more than 10,000 time values, use dictionary input.")
array_time = np.reshape(obstime, (-1,)) # Convert to an array, even if scalar
epochs = array_time.tdb.jd.tolist() # Time must be provided in JD TDB
args['TLIST'] = '\n'.join(str(epoch) for epoch in epochs)
args['TLIST_TYPE'] = 'JD' # needs to be explicitly set to avoid potential MJD confusion

contents = "!$$SOF\n" + '\n'.join(f"{k}={v}" for k, v in args.items())
log.debug(f"JPL HORIZONS query via POST request:\n{contents}")

output = requests.post('https://ssd.jpl.nasa.gov/api/horizons_file.api',
data={'format': 'text'}, files={'input': contents})

lines = output.text.splitlines()
start_index, stop_index = 0, len(lines)
success = False
error_message = ''
for index, line in enumerate(lines):
if line.startswith("Target body name:"):
target_name = re.search(r': (.*) {', line).group(1)
log.info(f"Obtained JPL HORIZONS location for {target_name}")

if "Multiple major-bodies match string" in line:
error_message = '\n'.join(lines[index:-1])
break

if "Matching small-bodies:" in line:
# Prepare the error message assuming there are multiple matches
error_message = '\n'.join(lines[index:-1])

# Change the error message if there are actually zero matches
if "No matches found." in error_message:
error_message = "No matches found."

break

if line.startswith("$$SOE"):
start_index = index + 1
if line.startswith("$$EOE"):
stop_index = index
success = True

if not success:
if error_message:
raise ValueError(error_message)
else:
raise RuntimeError(f"Unknown JPL HORIZONS error:\n{output.text}")

query = Horizons(id=body, id_type=id_type,
location='500@10', # Heliocentric (mean ecliptic)
epochs=epochs)
try:
result = query.vectors()
except Exception as e: # Catch and re-raise all exceptions, and also provide query URL if generated
if query.uri is not None:
log.error(f"See the raw output from the JPL HORIZONS query at {query.uri}")
raise e
finally:
query._session.close()
log.info(f"Obtained JPL HORIZONS location for {result[0]['targetname']}")
log.debug(f"See the raw output from the JPL HORIZONS query at {query.uri}")
column_names = [name.strip() for name in lines[start_index - 3].split(',')]
result = ascii.read(lines[start_index:stop_index], names=column_names)

if isinstance(time, dict):
obstime_tdb = parse_time(result['datetime_jd'], format='jd', scale='tdb')
obstime_tdb = parse_time(result['JDTDB'], format='jd', scale='tdb')
obstime = Time(obstime_tdb, format='isot', scale='utc')
else:
# JPL HORIZONS results are sorted by observation time, so this sorting needs to be undone.
Expand All @@ -332,9 +385,9 @@ def get_horizons_coord(body, time='now', id_type=None, *, include_velocity=False
unsorted_indices = obstime.argsort().argsort()
result = result[unsorted_indices]

vector = CartesianRepresentation(result['x'], result['y'], result['z'])
vector = CartesianRepresentation(result['X'], result['Y'], result['Z']) * u.AU
if include_velocity:
velocity = CartesianDifferential(result['vx'], result['vy'], result['vz'])
velocity = CartesianDifferential(result['VX'], result['VY'], result['VZ']) * u.AU / u.day
vector = vector.with_differentials(velocity)
coord = SkyCoord(vector, frame=HeliocentricEclipticIAU76, obstime=obstime)

Expand Down
42 changes: 30 additions & 12 deletions sunpy/coordinates/tests/test_ephemeris.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

import numpy as np
import pytest
from hypothesis import HealthCheck, given, settings
from numpy.testing import assert_array_equal
Expand Down Expand Up @@ -75,9 +76,6 @@ def test_get_earth():

@pytest.mark.remote_data
def test_get_horizons_coord():
# get_horizons_coord() depends on astroquery
pytest.importorskip("astroquery")

# Validate against published values from the Astronomical Almanac (2013)
e1 = get_horizons_coord('Geocenter', '2013-Jan-01')
assert_quantity_allclose(e1.lon, 0*u.deg, atol=5e-6*u.deg)
Expand All @@ -92,9 +90,6 @@ def test_get_horizons_coord():

@pytest.mark.remote_data
def test_get_horizons_coord_array_time():
# get_horizons_coord() depends on astroquery
pytest.importorskip("astroquery")

# Validate against published values from the Astronomical Almanac (2013, C8-C13)
array_time = Time(['2013-05-01', '2013-06-01', '2013-04-01', '2013-03-01'])
e = get_horizons_coord('Geocenter', array_time)
Expand All @@ -116,11 +111,14 @@ def test_get_horizons_coord_array_time():
assert_quantity_allclose(e[3].radius, 0.9908173*u.AU, atol=5e-7*u.AU)


def test_get_horizons_coord_array_time_too_large():
array_time = Time('2001-02-03') + np.arange(10001) * u.s
with pytest.raises(ValueError, match="For more than 10,000 time values"):
get_horizons_coord('Does not matter', array_time)


@pytest.mark.remote_data
def test_get_horizons_coord_dict_time():
# get_horizons_coord() depends on astroquery
pytest.importorskip("astroquery")

time_dict = {'start': '2013-03-01', 'stop': '2013-03-03', 'step': '1d'}
time_ref = Time(['2013-03-01', '2013-03-02', '2013-03-03'])

Expand All @@ -135,14 +133,34 @@ def test_get_horizons_coord_dict_time():
assert_quantity_allclose(e.radius, e_ref.radius)


def test_get_horizons_coord_bad_id_type():
with pytest.raises(ValueError, match="Invalid id_type"):
get_horizons_coord('Garbage', '2001-02-03', id_type="unknown")


@pytest.mark.remote_data
def test_get_horizons_coord_multiple_major_matches():
with pytest.raises(ValueError, match="Multiple major-bodies match string"):
get_horizons_coord('Neptune', '2001-02-03')


@pytest.mark.remote_data
def test_get_horizons_coord_multiple_minor_matches():
with pytest.raises(ValueError, match="Matching small-bodies:"):
get_horizons_coord('Halley', '2001-02-03')


@pytest.mark.remote_data
def test_get_horizons_coord_zero_matches():
with pytest.raises(ValueError, match="No matches found."):
get_horizons_coord('This will not match', '2001-02-03')


@pytest.mark.remote_data
@given(obstime=times(n=50))
@settings(deadline=5000, max_examples=1,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_consistency_with_horizons(use_DE440s, obstime):
# get_horizons_coord() depends on astroquery
pytest.importorskip("astroquery")

# Check that the high-accuracy Astropy ephemeris has been set
assert solar_system_ephemeris.get() == 'de440s'

Expand Down
3 changes: 2 additions & 1 deletion sunpy/util/tests/test_sysinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
def test_find_dependencies():
missing, installed = find_dependencies()
assert missing == {}
assert sorted(list(installed.keys())) == sorted(["astropy", "numpy", "packaging", "parfive"])
assert sorted(list(installed.keys())) == sorted(["astropy", "numpy", "packaging", "parfive", "requests"])

missing, installed = find_dependencies(package="sunpy", extras=["required", "all"])
assert missing == {}
Expand All @@ -22,6 +22,7 @@ def test_find_dependencies():
'numpy',
'parfive',
'packaging',
'requests',
'dask',
'sqlalchemy',
'scikit-image',
Expand Down
Loading

0 comments on commit fe5a0a3

Please sign in to comment.