Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ matrix:
- os: osx
env: PYTHON_VERSION=2.7 SETUP_CMD='test'
CONDA_DEPENDENCIES=$CONDA_ALL_DEPENDENCIES
PIP_DEPENDENCIES='jplephem'

# Check for sphinx doc build warnings - we do this first because it
# runs for a long time. WCSAxes is a dependency for the docs.
- os: linux
env: PYTHON_VERSION=2.7 SETUP_CMD='build_sphinx -w'
CONDA_DEPENDENCIES=$CONDA_ALL_DEPENDENCIES
PIP_DEPENDENCIES='wcsaxes --no-deps'
PIP_DEPENDENCIES='wcsaxes --no-deps jplephem'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like this, @bsipocz? Thanks for the help, sorry to be learning by experimentation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this looks better :)


# Try all python versions with the latest numpy, but without the
# optional dependencies
Expand All @@ -68,12 +69,13 @@ matrix:
- os: linux
env: PYTHON_VERSION=2.7 SETUP_CMD='test --coverage'
CONDA_DEPENDENCIES=$CONDA_ALL_DEPENDENCIES
PIP_DEPENDENCIES='cpp-coveralls objgraph'
PIP_DEPENDENCIES='cpp-coveralls objgraph jplephem'
LC_CTYPE=C.ascii LC_ALL=C
CFLAGS='-ftest-coverage -fprofile-arcs -fno-inline-functions -O0'
- os: linux
env: PYTHON_VERSION=3.4 SETUP_CMD='test'
CONDA_DEPENDENCIES=$CONDA_ALL_DEPENDENCIES
PIP_DEPENDENCIES='jplephem'
LC_CTYPE=C.ascii LC_ALL=C

# Try older numpy versions without optional dependencies
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ New Features

- ``astropy.coordinates``

- Solar system and lunar ephemerides accessible via ``get_planet``
and ``get_moon`` functions. [#3219]

- ``astropy.cosmology``

- ``angular_diameter_distance_z1z2`` now supports the computation of
Expand Down
1 change: 1 addition & 0 deletions astropy/coordinates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .representation import *
from .sky_coordinate import *
from .funcs import *
from .solar_system import *

__doc__ += builtin_frames._transform_graph_docs + """

Expand Down
204 changes: 204 additions & 0 deletions astropy/coordinates/solar_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
This module contains convenience functions for retrieving solar system
ephemerides from jplephem.
"""

from __future__ import (absolute_import, division, print_function,
unicode_literals)

import numpy as np
from .sky_coordinate import SkyCoord
from ..utils.data import download_file
from .. import units as u
from ..constants import c as speed_of_light
from .representation import CartesianRepresentation
from .builtin_frames import GCRS
from .earth import EarthLocation


__all__ = ["get_planet", "get_moon"]

KERNEL = None


def _download_spk_file(show_progress=True):
"""
Get the Satellite Planet Kernel (SPK) file `de430.bsp` from NASA JPL.

Download the file from the JPL webpage once and subsequently access a
cached copy. This file is ~120 MB, and covers years ~1550-2650 CE [1]_.

.. [1] http://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/aareadme_de430-de431.txt
"""
de430_url = ('http://naif.jpl.nasa.gov/pub/naif/'
'generic_kernels/spk/planets/de430.bsp')
return download_file(de430_url, cache=True, show_progress=show_progress)


def _get_kernel(*args, **kwargs):
"""
Try importing jplephem, download/retrieve from cahce the Satellite Planet
Kernel.
"""
global KERNEL

try:
from jplephem.spk import SPK
except ImportError:
raise ImportError("Solar system ephemeris calculations depend on"
"jplephem")

if KERNEL is None:
KERNEL = SPK.open(_download_spk_file())
return KERNEL


def _get_absolute_planet_position(time, planet_index):
"""
Calculate the position of planet ``planet_index`` in cartesian coordinates.

Uses ``jplephem`` with the DE430 kernel.

Parameters
----------
time : `~astropy.time.Time`
Time of observation

planet_index : int
Index of the planet (1-9 for Mercury through Pluto)

Returns
-------
cartesian_position : `~astropy.units.Quantity`
GRCS position of the planet in cartesian coordinates

earth_distance : `~astropy.units.Quantity`
Distance between Earth and planet.

Notes
-----

"""
kernel = _get_kernel()

# Mercury and Venus have separate barycenter and planet vectors
if planet_index < 3:
barycenter_to_planet_ind = 100*planet_index + 99
cartesian_position_planet = (kernel[0, planet_index].compute(time.jd) +
kernel[planet_index,
barycenter_to_planet_ind].compute(time.jd))
# planet_index is 3 gets the moon
elif planet_index == 3:
barycenter_to_planet_ind = 301
cartesian_position_planet = (kernel[0, planet_index].compute(time.jd) +
kernel[planet_index,
barycenter_to_planet_ind].compute(time.jd))
else:
cartesian_position_planet = kernel[0, planet_index].compute(time.jd)

cartesian_position_earth = (kernel[0, 3].compute(time.jd) +
kernel[3, 399].compute(time.jd))

# Quadrature sum of the difference of the position vectors
earth_distance = np.sqrt(np.sum((np.array(cartesian_position_planet) -
np.array(cartesian_position_earth))**2))*u.km

earth_to_planet_vector = u.Quantity(cartesian_position_planet -
cartesian_position_earth,
unit=u.km)

return earth_to_planet_vector, earth_distance


def _get_apparent_planet_position(time, planet_index):
"""
Calculate the apparent position of planet ``planet_index`` in cartesian
coordinates, given the approximate light travel time to the object.

Uses ``jplephem`` with the DE430 kernel.

Parameters
----------
time : `~astropy.time.Time`
Time of observation

planet_index : int
Index of the planet (1-9 for Mercury through Pluto)

Returns
-------
cartesian_position : `~astropy.coordinates.CartesianRepresentation`
Position of the planet defined as a vector from the Earth.
"""
# Get distance of planet at `time`
earth_to_planet_vector, earth_distance = _get_absolute_planet_position(time, planet_index)

# The apparent position depends on the time that the light was emitted from
# the distant planet, so subtract off the light travel time
light_travel_time = earth_distance/speed_of_light
emitted_time = time - light_travel_time

# Calculate position given approximate light travel time.
# TODO: this should be solved iteratively to converge on precise positions
earth_to_planet_vector, earth_distance = _get_absolute_planet_position(emitted_time, planet_index)
x, y, z = earth_to_planet_vector

return CartesianRepresentation(x=x, y=y, z=z)


def get_planet(time, planet_index, location=None):
"""
Get a `~astropy.coordinates.SkyCoord` for a planet as observed from a
location on Earth.

Parameters
----------
time : `~astropy.time.Time`
Time of observation

planet_index : int

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Planet index seems a little obscure. Although most of us have the planet order memorized, what if someone finds Planet X between two other existing ones? Might be more descriptive to use explicit strings like 'mercury', 'earth', etc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I completely agree with this - Pluto has taught us the planet indices are not fixed! String name would be better. Or planet/dwarf planet unicode (☿, ♀, ♁, ♂, ♃, ♄, ♅, ♆, ♇) 😉

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I for once fall on @adrn's side against string parameters, and think it's faster and more convenient to type integer indices, especially for a problem like this where indices are rather obvious (planet 4 is the fourth planet). Would anyone be opposed to really simple parser that takes ints or strs, and maps strings to integers if they're given?

If Pluto doesn't sit well, we can limit the input arguments to integers 1-8 and make a separate function to expose Pluto, since you get it for free with jplephem.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a half-way compromise using module constants?

MERCURY = 1
VENUS = 2
...

This way, user can use solar_system.MERCURY as an input to the function.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just have

PLANETS = {'mercury': 1,
...
}

and then in the routine proper something like:

index = PLANETS.get(index, index)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My quick proposal was just that if index was in the dictionary, it would return the associated value, if not, it would just be kept as is. So, if you pass in an integer, it stays as is. Of course one could be more elaborate

if isinstance(index, six.string...):
    index = PLANETS[index]

And then catch the KeyError, etc.

Anyway, bottom line is that I don't see much of a problem with supporting both strings and integers (as long as the meaning of the integer is the same as it is in the ephemeris file).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mhvk, fine by me!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I definitely think we want to disallow 9 though. Also, +1 to supporting strings too (this is not going to dominate performance)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case it helps: note that there is already a standard set of integer indexes for planets, long maintained as part of the SPICE package from NASA:

https://github.com/mattbornski/spice/blob/master/src/zzidmap.c

The jplephem package makes these available in Python in its jplephem/names.py module, if you want to re-use them.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also +1 to allow strings. That's much more in line with what other pythonic tools do. But no harm in allowing ints or strings.

Index of the planet (1-9 for Mercury through Pluto), excluding the
special value 3, which corresponds to the Earth's Moon.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure 3 is the Moon? I haven't had time to look carefully, but I thought it usually encoded the earth-moon barycentre. In any case, all the more reasons to allow input using a string!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will be the first to admit that this interface is confusing. What's going on here is that it would be senseless to create a SkyCoord for Earth, so I want to prevent users from selecting planet_index=3 for the Earth. Then when I wrote the get_moon function, I didn't want to duplicate the code that accesses the DE430 kernel for both the planets and for the moon, so I wrote the underlying functions in get_planet to give the coordinates of the moon in the special case of planet_index=3. See the one-line get_moon implementation here.

This design choice isn't a weird relic from the weirdness of the DE430 kernel, it's me trying to write code that's not redundant. I'm open to alternatives to make this clear or more intuitive.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're using indices that match those used by the JPL kernel, 3 should have the same meaning as those have, otherwise it is just going to be too confusing in my opinion (especially since erfa.plan9 does the same thing!).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p.s. It is sensible to get a position of the Earth relative to the solar system barycentre, since this underlies any precise barycentric corrections.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand where you're coming from. I was only designing a function for the purposes of creating SkyCoord objects, since my selfish motivations are to use these functions in astropy/astroplan#35 and astropy/astroplan#111.

match those used by the JPL kernel, 3 should have the same meaning as those have

For the purposes of this simple get_planet as a SkyCoord function, I am in favor of avoiding using the jplephem lexicon for the integers that represent planet barycenters and planets because they are non-intuitive and unnecessary to know for the average user trying to get the position of a planet as a SkyCoord.

I think part of the reason why it took so long for a PR like this to arrive is because the JPL kernel indices are confusing and messy. Hiding that mess was my top priority in this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, for me, the reason I did only do a kludge myself is that I don't care where the planets are, but do care about very accurate barycentric corrections...

Anyway, I'm happy with unambiguous entries, but think it is only confusing if you use something that is almost but not exactly the same as the way the ephemeris files (and plan9) are organised.


location : `~astropy.coordinates.EarthLocation`
Location of observer on the Earth. If none is supplied, set to
Greenwich.

Returns
-------
skycoord : `~astropy.coordinates.SkyCoord`
Coordinate for the planet
"""
if location is None:
location = EarthLocation.of_site('greenwich')

cartrep = _get_apparent_planet_position(time, planet_index)

return SkyCoord(GCRS(cartrep, obstime=time,
obsgeoloc=u.Quantity(location.geocentric, copy=False)))


def get_moon(time, location=None):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just feel like it is a waste not to include the following in this function:

if some_condition:
    warnings.warn('That is no moon!', AstropyUserWarning)

"""
Get a `~astropy.coordinates.SkyCoord` for the Earth's moon as observed
from a location on Earth.

Parameters
----------
time : `~astropy.time.Time`
Time of observation

location : `~astropy.coordinates.EarthLocation`
Location of observer on the Earth. If none is supplied, set to
Greenwich.

planet_index : int
Index of the planet (1-9 for Mercury through Pluto)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, Pluto is not a planet anymore...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this and decided that it's still pretty clear what 9 represents. Does anyone think that pluto should be inaccessible from the get_planet function?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a tricky question. Because Pluto is still a planet in New Mexico.


Returns
-------
skycoord : `~astropy.coordinates.SkyCoord`
Coordinate for the planet
"""
return get_planet(time, planet_index=3, location=location)
117 changes: 117 additions & 0 deletions astropy/coordinates/tests/test_solar_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from __future__ import (absolute_import, division, print_function,
unicode_literals)

from ...time import Time
from ... import units as u
from ...constants import c
from ..builtin_frames import GCRS
from ..earth import EarthLocation
from ..sky_coordinate import SkyCoord
from ..solar_system import get_planet, get_moon
from ...tests.helper import pytest, assert_quantity_allclose, remote_data

try:
import jplephem # pylint: disable=W0611
except ImportError:
HAS_JPLEPHEM = False
else:
HAS_JPLEPHEM = True

separation_tolerance_planets = 1*u.arcmin
separation_tolerance_moon = 1*u.degree
distance_tolerance = 0.02


@remote_data
@pytest.mark.skipif(str('not HAS_JPLEPHEM'))
def test_positions_distances_kpno_1980():
"""
Test positions against those generated by JPL Horizons accessed on
2016-03-28, with refraction turned on.
"""
t = Time('1980-03-25 00:00')
kitt_peak = EarthLocation.from_geodetic(lon=-111.6*u.deg,
lat=31.963333333333342*u.deg,
height=2120*u.m)
frame = GCRS(obstime=t, obsgeoloc=u.Quantity(kitt_peak.geocentric,
copy=False))

moon_astropy_1980 = get_moon(t, kitt_peak)
mercury_astropy_1980 = get_planet(t, 1, kitt_peak)
jupiter_astropy_1980 = get_planet(t, 5, kitt_peak)

# Results returned by JPL Horizons web interface
mercury_horizons_1980 = SkyCoord(ra='22h42m51.03s', dec='-08d23m16.3s',
frame=frame)
moon_horizons_1980 = SkyCoord(ra='07h35m26.49s', dec='+18d14m43.2s',
frame=frame)
jupiter_horizons_1980 = SkyCoord(ra='10h18m15.71s', dec='+11d57m04.4s',
frame=frame)
d_mercury_1980 = c*(6.323068*u.min)
d_moon_1980 = c*(0.021651*u.min)
d_jupiter_1980 = c*(37.694477*u.min)

# Assert sky coordinates are close
assert (mercury_astropy_1980.separation(mercury_horizons_1980) <
separation_tolerance_planets)
assert (jupiter_astropy_1980.separation(jupiter_horizons_1980) <
separation_tolerance_planets)
assert (moon_astropy_1980.separation(moon_horizons_1980) <
separation_tolerance_moon)

# Assert distances are close
distances_astropy = [mercury_astropy_1980.distance,
moon_astropy_1980.distance,
jupiter_astropy_1980.distance]
distances_horizons = [d_mercury_1980, d_moon_1980, d_jupiter_1980]

assert_quantity_allclose(distances_astropy, distances_horizons,
rtol=distance_tolerance)


@remote_data
@pytest.mark.skipif(str('not HAS_JPLEPHEM'))
def test_positions_distances_ctio_2016():
"""
Test positions against those generated by JPL Horizons accessed on
2016-03-28, with refraction turned on.
"""
t = Time('2016-09-25 00:00')
ctio = EarthLocation.from_geodetic(lon=-70.815*u.deg,
lat=-30.165277777777778*u.deg,
height=2215*u.m)

frame = GCRS(obstime=t, obsgeoloc=u.Quantity(ctio.geocentric,
copy=False))

moon_astropy_2016 = get_moon(t, ctio)
mercury_astropy_2016 = get_planet(t, 1, ctio)
jupiter_astropy_2016 = get_planet(t, 5, ctio)

# Results returned by JPL Horizons web interface
mercury_horizons_2016 = SkyCoord(ra='11h06m01.23s', dec='+06d04m49.4s',
frame=frame)
moon_horizons_2016 = SkyCoord(ra='07h32m55.22s', dec='+17d49m10.7s',
frame=frame)
jupiter_horizons_2016 = SkyCoord(ra='12h13m09.58s', dec='-00d14m05.3s',
frame=frame)
d_mercury_1980 = c*(7.063664*u.min)
d_moon_1980 = c*(0.021536*u.min)
d_jupiter_1980 = c*(53.675250*u.min)

# Assert sky coordinates are close
assert (mercury_astropy_2016.separation(mercury_horizons_2016) <
separation_tolerance_planets)
assert (jupiter_astropy_2016.separation(jupiter_horizons_2016) <
separation_tolerance_planets)
assert (moon_astropy_2016.separation(moon_horizons_2016) <
separation_tolerance_moon)

# Assert distances are close
distances_astropy = [mercury_astropy_2016.distance,
moon_astropy_2016.distance,
jupiter_astropy_2016.distance]
distances_horizons = [d_mercury_1980, d_moon_1980, d_jupiter_1980]

assert_quantity_allclose(distances_astropy, distances_horizons,
rtol=distance_tolerance)