# -*- coding: utf-8 -*-
# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
This module contains the fundamental classes used for representing
coordinates in astropy.
"""
from __future__ import (absolute_import, division, print_function,
unicode_literals)
import math
from collections import namedtuple
import numpy as np
from ..extern import six
from . import angle_utilities as util
from .. import units as u
from ..utils import deprecated, isiterable
__all__ = ['Angle', 'Latitude', 'Longitude']
TWOPI = math.pi * 2.0 # no need to calculate this all the time
# these are used by the `hms` and `dms` attributes
hms_tuple = namedtuple('hms_tuple', ('h', 'm', 's'))
dms_tuple = namedtuple('dms_tuple', ('d', 'm', 's'))
signed_dms_tuple = namedtuple('signed_dms_tuple', ('sign', 'd', 'm', 's'))
[docs]class Angle(u.Quantity):
"""
One or more angular value(s) with units equivalent to radians or degrees.
An angle can be specified either as an array, scalar, tuple (see
below), string, `~astropy.units.Quantity` or another
:class:`~astropy.coordinates.Angle`.
The input parser is flexible and supports a variety of formats::
Angle('10.2345d')
Angle(['10.2345d', '-20d'])
Angle('1:2:30.43 degrees')
Angle('1 2 0 hours')
Angle(np.arange(1, 8), unit=u.deg)
Angle(u'1°2′3″')
Angle('1d2m3.4s')
Angle('-1h2m3s')
Angle((-1, 2, 3), unit=u.deg) # (d, m, s)
Angle(10.2345 * u.deg)
Angle(Angle(10.2345 * u.deg))
Parameters
----------
angle : `~numpy.array`, scalar, `~astropy.units.Quantity`, :class:`~astropy.coordinates.Angle`
The angle value. If a tuple, will be interpreted as ``(h, m,
s)`` or ``(d, m, s)`` depending on ``unit``. If a string, it
will be interpreted following the rules described above.
If ``angle`` is a sequence or array of strings, the resulting
values will be in the given ``unit``, or if `None` is provided,
the unit will be taken from the first given value.
unit : `~astropy.units.UnitBase`, str, optional
The unit of the value specified for the angle. This may be
any string that `~astropy.units.Unit` understands, but it is
better to give an actual unit object. Must be an angular
unit.
dtype : `~numpy.dtype`, optional
See `~astropy.units.Quantity`.
copy : bool, optional
See `~astropy.units.Quantity`.
Raises
------
`~astropy.units.UnitsError`
If a unit is not provided or it is not an angular unit.
"""
_include_easy_conversion_members = True
def __new__(cls, angle, unit=None, dtype=None, copy=True):
unit = cls._convert_unit_to_angle_unit(unit)
if (unit is not None and not unit.is_equivalent(u.radian)):
raise u.UnitsError("Requested unit {0} is not convertible to an "
"angle".format(unit))
if isinstance(angle, u.Quantity):
# This includes Angle subclasses as well
if unit is not None:
angle = angle.to(unit).value
else:
unit = angle.unit
unit = cls._convert_unit_to_angle_unit(unit)
if not unit.is_equivalent(u.radian):
raise u.UnitsError(
"Given quantity {0} is not convertible to an "
"angle".format(angle))
angle = angle.value
else:
# this does nothing if it's not a tuple
angle = cls._tuple_to_float(angle, unit)
if isinstance(angle, six.string_types):
angle, new_unit = util.parse_angle(angle, unit)
if new_unit is not None and unit is None:
unit = new_unit
angle = cls._tuple_to_float(angle, unit)
if new_unit is not None and unit is not None and new_unit != unit:
angle = new_unit.to(unit, angle)
elif (isiterable(angle) and
not (isinstance(angle, np.ndarray) and
angle.dtype.kind not in 'SUVO')):
angle = [Angle(x, unit) for x in angle]
if unit is None:
unit = angle[0].unit
angle = [x.to(unit) for x in angle]
self = super(Angle, cls).__new__(cls, angle, unit, dtype=dtype,
copy=copy)
if self.unit is u.dimensionless_unscaled:
raise u.UnitsError("No unit was given - must be some kind of angle")
elif not self.unit.is_equivalent(u.radian):
raise u.UnitsError("Unit {0} is not an angle".format(self.unit))
if self.dtype.kind not in 'iuf':
raise TypeError("Unsupported dtype for "
"Angle:'{0}'".format(angle.dtype))
return self
@staticmethod
def _tuple_to_float(angle, unit):
"""
Converts an angle represented as a 3-tuple into a floating
point number in the given unit.
"""
if isinstance(angle, tuple):
# TODO: Numpy array of tuples?
if unit is u.hourangle:
util.check_hms_ranges(*angle)
angle = util.hms_to_hours(*angle)
elif unit is u.degree:
angle = util.dms_to_degrees(*angle)
else:
raise u.UnitsError(
"Can not parse '{0}' as unit '{1}'".format(
angle, unit))
return angle
@staticmethod
def _convert_unit_to_angle_unit(unit):
if unit is not None:
unit = u.Unit(unit)
if unit is u.hour:
unit = u.hourangle
return unit
def __quantity_subclass__(self, unit):
unit = self._convert_unit_to_angle_unit(unit)
if unit.is_equivalent(u.radian):
return Angle, True
else:
return super(Angle, self).__quantity_subclass__(unit)[0], False
@property
def hour(self):
"""
The angle's value in hours (read-only property).
"""
return self.hourangle
@property
def hms(self):
"""
The angle's value in hours, as a named tuple with ``(h, m, s)``
members. (This is a read-only property.)
"""
return hms_tuple(*util.hours_to_hms(self.hourangle))
@property
def dms(self):
"""
The angle's value in degrees, as a named tuple with ``(d, m, s)``
members. (This is a read-only property.)
"""
return dms_tuple(*util.degrees_to_dms(self.degree))
@property
def signed_dms(self):
"""
The angle's value in degrees, as a named tuple with ``(sign, d, m, s)``
members. The ``d``, ``m``, ``s`` are thus always positive, and the sign of
the angle is given by ``sign``. (This is a read-only property.)
This is primarily intented for use with `dms` to generate string
representations of coordinates that are correct for negative angles.
"""
return signed_dms_tuple(np.sign(self.degree),
*util.degrees_to_dms(np.abs(self.degree)))
[docs] def to_string(self, unit=None, decimal=False, sep='fromunit',
precision=None, alwayssign=False, pad=False,
fields=3, format=None):
""" A string representation of the angle.
Parameters
----------
unit : `~astropy.units.UnitBase`, optional
Specifies the unit. Must be an angular unit. If not
provided, the unit used to initialize the angle will be
used.
decimal : bool, optional
If `True`, a decimal respresentation will be used, otherwise
the returned string will be in sexagesimal form.
sep : str, optional
The separator between numbers in a sexagesimal
representation. E.g., if it is ':', the result is
``'12:41:11.1241'``. Also accepts 2 or 3 separators. E.g.,
``sep='hms'`` would give the result ``'12h41m11.1241s'``, or
sep='-:' would yield ``'11-21:17.124'``. Alternatively, the
special string 'fromunit' means 'dms' if the unit is
degrees, or 'hms' if the unit is hours.
precision : int, optional
The level of decimal precision. If ``decimal`` is `True`,
this is the raw precision, otherwise it gives the
precision of the last place of the sexagesimal
representation (seconds). If `None`, or not provided, the
number of decimal places is determined by the value, and
will be between 0-8 decimal places as required.
alwayssign : bool, optional
If `True`, include the sign no matter what. If `False`,
only include the sign if it is negative.
pad : bool, optional
If `True`, include leading zeros when needed to ensure a
fixed number of characters for sexagesimal representation.
fields : int, optional
Specifies the number of fields to display when outputting
sexagesimal notation. For example:
- fields == 1: ``'5d'``
- fields == 2: ``'5d45m'``
- fields == 3: ``'5d45m32.5s'``
By default, all fields are displayed.
format : str, optional
The format of the result. If not provided, an unadorned
string is returned. Supported values are:
- 'latex': Return a LaTeX-formatted string
- 'unicode': Return a string containing non-ASCII unicode
characters, such as the degree symbol
Returns
-------
strrepr : str
A string representation of the angle.
"""
if unit is None:
unit = self.unit
unit = self._convert_unit_to_angle_unit(unit)
separators = {
None: {
u.degree: 'dms',
u.hourangle: 'hms'},
'latex': {
u.degree: [r'^\circ', r'{}^\prime', r'{}^{\prime\prime}'],
u.hourangle: [r'^\mathrm{h}', r'^\mathrm{m}', r'^\mathrm{s}']},
'unicode': {
u.degree: '°′″',
u.hourangle: 'ʰᵐˢ'}
}
if sep == 'fromunit':
if format not in separators:
raise ValueError("Unknown format '{0}'".format(format))
seps = separators[format]
if unit in seps:
sep = seps[unit]
# Create an iterator so we can format each element of what
# might be an array.
if unit is u.degree:
if decimal:
values = self.degree
if precision is not None:
func = ("{0:0." + str(precision) + "f}").format
else:
func = '{0:g}'.format
else:
if sep == 'fromunit':
sep = 'dms'
values = self.degree
func = lambda x: util.degrees_to_string(
x, precision=precision, sep=sep, pad=pad,
fields=fields)
elif unit is u.hourangle:
if decimal:
values = self.hour
if precision is not None:
func = ("{0:0." + str(precision) + "f}").format
else:
func = '{0:g}'.format
else:
if sep == 'fromunit':
sep = 'hms'
values = self.hour
func = lambda x: util.hours_to_string(
x, precision=precision, sep=sep, pad=pad,
fields=fields)
elif unit.is_equivalent(u.radian):
if decimal:
values = self.to(unit).value
if precision is not None:
func = ("{0:1." + str(precision) + "f}").format
else:
func = "{0:g}".format
elif sep == 'fromunit':
values = self.to(unit).value
unit_string = unit.to_string(format=format)
if format == 'latex':
unit_string = unit_string[1:-1]
if precision is not None:
def plain_unit_format(val):
return ("{0:0." + str(precision) + "f}{1}").format(
val, unit_string)
func = plain_unit_format
else:
def plain_unit_format(val):
return "{0:g}{1}".format(val, unit_string)
func = plain_unit_format
else:
raise ValueError(
"'{0}' can not be represented in sexagesimal "
"notation".format(
unit.name))
else:
raise u.UnitsError(
"The unit value provided is not an angular unit.")
def do_format(val):
s = func(float(val))
if alwayssign and not s.startswith('-'):
s = '+' + s
if format == 'latex':
s = '${0}$'.format(s)
return s
format_ufunc = np.vectorize(do_format, otypes=[np.object])
result = format_ufunc(values)
if result.ndim == 0:
result = result[()]
return result
[docs] def wrap_at(self, wrap_angle, inplace=False):
"""
Wrap the `Angle` object at the given ``wrap_angle``.
This method forces all the angle values to be within a contiguous
360 degree range so that ``wrap_angle - 360d <= angle <
wrap_angle``. By default a new Angle object is returned, but if the
``inplace`` argument is `True` then the `Angle` object is wrapped in
place and nothing is returned.
For instance::
>>> from astropy.coordinates import Angle
>>> import astropy.units as u
>>> a = Angle([-20.0, 150.0, 350.0] * u.deg)
>>> a.wrap_at(360 * u.deg).degree # Wrap into range 0 to 360 degrees
array([ 340., 150., 350.])
>>> a.wrap_at('180d', inplace=True) # Wrap into range -180 to 180 degrees
>>> a.degree
array([ -20., 150., -10.])
Parameters
----------
wrap_angle : str, `Angle`, angular `~astropy.units.Quantity`
Specifies a single value for the wrap angle. This can be any
object that can initialize an `Angle` object, e.g. ``'180d'``,
``180 * u.deg``, or ``Angle(180, unit=u.deg)``.
inplace : bool
If `True` then wrap the object in place instead of returning
a new `Angle`
Returns
-------
out : Angle or `None`
If ``inplace is False`` (default), return new `Angle` object
with angles wrapped accordingly. Otherwise wrap in place and
return `None`.
"""
wrap_angle = Angle(wrap_angle) # Convert to an Angle
wrapped = np.mod(self - wrap_angle, 360.0 * u.deg) - (360.0 * u.deg - wrap_angle)
if inplace:
self[()] = wrapped
else:
return wrapped
[docs] def is_within_bounds(self, lower=None, upper=None):
"""
Check if all angle(s) satisfy ``lower <= angle < upper``
If ``lower`` is not specified (or `None`) then no lower bounds check is
performed. Likewise ``upper`` can be left unspecified. For example::
>>> from astropy.coordinates import Angle
>>> import astropy.units as u
>>> a = Angle([-20, 150, 350] * u.deg)
>>> a.is_within_bounds('0d', '360d')
False
>>> a.is_within_bounds(None, '360d')
True
>>> a.is_within_bounds(-30 * u.deg, None)
True
Parameters
----------
lower : str, `Angle`, angular `~astropy.units.Quantity`, `None`
Specifies lower bound for checking. This can be any object
that can initialize an `Angle` object, e.g. ``'180d'``,
``180 * u.deg``, or ``Angle(180, unit=u.deg)``.
upper : str, `Angle`, angular `~astropy.units.Quantity`, `None`
Specifies upper bound for checking. This can be any object
that can initialize an `Angle` object, e.g. ``'180d'``,
``180 * u.deg``, or ``Angle(180, unit=u.deg)``.
Returns
-------
is_within_bounds : bool
`True` if all angles satisfy ``lower <= angle < upper``
"""
ok = True
if lower is not None:
ok &= np.all(Angle(lower) <= self)
if ok and upper is not None:
ok &= np.all(self < Angle(upper))
return bool(ok)
@deprecated("0.3", name="format", alternative="to_string")
def __str__(self):
return str(self.to_string())
def _repr_latex_(self):
return str(self.to_string(format='latex'))
[docs]class Latitude(Angle):
"""
Latitude-like angle(s) which must be in the range -90 to +90 deg.
A Latitude object is distinguished from a pure
:class:`~astropy.coordinates.Angle` by virtue of being constrained
so that::
-90.0 * u.deg <= angle(s) <= +90.0 * u.deg
Any attempt to set a value outside that range will result in a
`~.exceptions.ValueError`.
The input angle(s) can be specified either as an array, list,
scalar, tuple (see below), string,
:class:`~astropy.units.Quantity` or another
:class:`~astropy.coordinates.Angle`.
The input parser is flexible and supports all of the input formats
supported by :class:`~astropy.coordinates.Angle`.
Parameters
----------
angle : array, list, scalar, `~astropy.units.Quantity`, `Angle`. The
angle value(s). If a tuple, will be interpreted as ``(h, m, s)`` or
``(d, m, s)`` depending on ``unit``. If a string, it will be
interpreted following the rules described for
:class:`~astropy.coordinates.Angle`.
If ``angle`` is a sequence or array of strings, the resulting
values will be in the given ``unit``, or if `None` is provided,
the unit will be taken from the first given value.
unit : :class:`~astropy.units.UnitBase`, str, optional
The unit of the value specified for the angle. This may be
any string that `~astropy.units.Unit` understands, but it is
better to give an actual unit object. Must be an angular
unit.
Raises
------
`~astropy.units.UnitsError`
If a unit is not provided or it is not an angular unit.
`TypeError`
If the angle parameter is an instance of :class:`~astropy.coordinates.Longitude`.
"""
def __new__(cls, angle, unit=None, **kwargs):
# Forbid creating a Lat from a Long.
if isinstance(angle, Longitude):
raise TypeError("A Latitude angle cannot be created from a Longitude angle")
self = super(Latitude, cls).__new__(cls, angle, unit=unit, **kwargs)
self._validate_angles()
return self
def _validate_angles(self, angles=None):
"""Check that angles are between -90 and 90 degrees.
If not given, the check is done on the object iself"""
# Convert the lower and upper bounds to the "native" unit of
# this angle. This limits multiplication to two values,
# rather than the N values in `self.value`. Also, the
# comparison is performed on raw arrays, rather than Quantity
# objects, for speed.
if angles is None:
angles = self
lower = u.degree.to(angles.unit, -90.0)
upper = u.degree.to(angles.unit, 90.0)
if np.any(angles.value < lower) or np.any(angles.value > upper):
raise ValueError('Latitude angle(s) must be within -90 deg <= angle <= 90 deg, '
'got {0}'.format(angles.to(u.degree)))
def __setitem__(self, item, value):
# Forbid assigning a Long to a Lat.
if isinstance(value, Longitude):
raise TypeError("A Longitude angle cannot be assigned to a Latitude angle")
# first check bounds
self._validate_angles(value)
super(Latitude, self).__setitem__(item, value)
# Any calculation should drop to Angle
def __array_wrap__(self, obj, context=None):
obj = super(Angle, self).__array_wrap__(obj, context=context)
if isinstance(obj, Angle):
return obj.view(Angle)
return obj
[docs]class Longitude(Angle):
"""
Longitude-like angle(s) which are wrapped within a contiguous 360 degree range.
A ``Longitude`` object is distinguished from a pure
:class:`~astropy.coordinates.Angle` by virtue of a ``wrap_angle``
property. The ``wrap_angle`` specifies that all angle values
represented by the object will be in the range::
wrap_angle - 360 * u.deg <= angle(s) < wrap_angle
The default ``wrap_angle`` is 360 deg. Setting ``wrap_angle=180 *
u.deg`` would instead result in values between -180 and +180 deg.
Setting the ``wrap_angle`` attribute of an existing ``Longitude``
object will result in re-wrapping the angle values in-place.
The input angle(s) can be specified either as an array, list,
scalar, tuple, string, :class:`~astropy.units.Quantity`
or another :class:`~astropy.coordinates.Angle`.
The input parser is flexible and supports all of the input formats
supported by :class:`~astropy.coordinates.Angle`.
Parameters
----------
angle : array, list, scalar, `~astropy.units.Quantity`,
:class:`~astropy.coordinates.Angle` The angle value(s). If a tuple,
will be interpreted as ``(h, m s)`` or ``(d, m, s)`` depending
on ``unit``. If a string, it will be interpreted following the
rules described for :class:`~astropy.coordinates.Angle`.
If ``angle`` is a sequence or array of strings, the resulting
values will be in the given ``unit``, or if `None` is provided,
the unit will be taken from the first given value.
unit : :class:`~astropy.units.UnitBase`, str, optional
The unit of the value specified for the angle. This may be
any string that `~astropy.units.Unit` understands, but it is
better to give an actual unit object. Must be an angular
unit.
wrap_angle : :class:`~astropy.coordinates.Angle` or equivalent, or None
Angle at which to wrap back to ``wrap_angle - 360 deg``.
If ``None`` (default), it will be taken to be 360 deg unless ``angle``
has a ``wrap_angle`` attribute already (i.e., is a ``Longitude``),
in which case it will be taken from there.
Raises
------
`~astropy.units.UnitsError`
If a unit is not provided or it is not an angular unit.
`TypeError`
If the angle parameter is an instance of :class:`~astropy.coordinates.Latitude`.
"""
_wrap_angle = None
def __new__(cls, angle, unit=None, wrap_angle=None, **kwargs):
# Forbid creating a Long from a Lat.
if isinstance(angle, Latitude):
raise TypeError("A Longitude angle cannot be created from a Latitude angle")
self = super(Longitude, cls).__new__(cls, angle, unit=unit, **kwargs)
self.wrap_angle = (wrap_angle if wrap_angle is not None
else getattr(angle, 'wrap_angle', 360 * u.deg))
return self
def __setitem__(self, item, value):
# Forbid assigning a Lat to a Long.
if isinstance(value, Latitude):
raise TypeError("A Latitude angle cannot be assigned to a Longitude angle")
super(Longitude, self).__setitem__(item, value)
self._wrap_internal()
def _wrap_internal(self):
"""
Wrap the internal values in the Longitude object. Using the
:meth:`~astropy.coordinates.Angle.wrap_at` method causes
recursion.
"""
# Convert the wrap angle and 360 degrees to the native unit of
# this Angle, then do all the math on raw Numpy arrays rather
# than Quantity objects for speed.
a360 = u.degree.to(self.unit, 360.0)
wrap_angle = self.wrap_angle.to(self.unit).value
wrap_angle_floor = wrap_angle - a360
self_angle = self.value
# Do the wrapping, but only if any angles need to be wrapped
if np.any(self_angle < wrap_angle_floor) or np.any(self_angle >= wrap_angle):
wrapped = np.mod(self_angle - wrap_angle, a360) + wrap_angle_floor
value = u.Quantity(wrapped, self.unit)
super(Longitude, self).__setitem__((), value)
@property
def wrap_angle(self):
return self._wrap_angle
@wrap_angle.setter
def wrap_angle(self, value):
self._wrap_angle = Angle(value)
self._wrap_internal()
def __array_finalize__(self, obj):
super(Longitude, self).__array_finalize__(obj)
self._wrap_angle = getattr(obj, '_wrap_angle', None)
# Any calculation should drop to Angle
def __array_wrap__(self, obj, context=None):
obj = super(Angle, self).__array_wrap__(obj, context=context)
if isinstance(obj, Angle):
return obj.view(Angle)
return obj
#<----------------------------------Rotations--------------------------------->
def rotation_matrix(angle, axis='z', unit=None):
"""
Generate a 3x3 cartesian rotation matrix in for rotation about
a particular axis.
Parameters
----------
angle : convertible to `Angle`
The amount of rotation this matrix should represent.
axis : str or 3-sequence
Either ``'x'``, ``'y'``, ``'z'``, or a (x,y,z) specifying an
axis to rotate about. If ``'x'``, ``'y'``, or ``'z'``, the
rotation sense is counterclockwise looking down the + axis
(e.g. positive rotations obey left-hand-rule).
unit : UnitBase, optional
If ``angle`` does not have associated units, they are in this
unit. If neither are provided, it is assumed to be degrees.
Returns
-------
rmat: `numpy.matrix`
A unitary rotation matrix.
"""
if unit is None:
unit = u.degree
angle = Angle(angle, unit=unit)
s = np.sin(angle)
c = np.cos(angle)
# use optimized implementations for x/y/z
if axis == 'z':
return np.matrix(((c, s, 0),
(-s, c, 0),
(0, 0, 1)))
elif axis == 'y':
return np.matrix(((c, 0, -s),
(0, 1, 0),
(s, 0, c)))
elif axis == 'x':
return np.matrix(((1, 0, 0),
(0, c, s),
(0, -s, c)))
else:
axis = np.asarray(axis)
axis = axis / np.sqrt((axis * axis).sum())
R = np.diag((c, c, c))
R += np.outer(axis, axis) * (1. - c)
axis *= s
R += np.array([[0., axis[2], -axis[1]],
[-axis[2], 0., axis[0]],
[axis[1], -axis[0], 0.]])
return R.view(np.matrix)
def angle_axis(matrix):
"""
Computes the angle of rotation and the rotation axis for a given rotation
matrix.
Parameters
----------
matrix : array-like
A 3 x 3 unitary rotation matrix.
Returns
-------
angle : `Angle`
The angle of rotation for this matrix.
axis : array (length 3)
The (normalized) axis of rotation for this matrix.
"""
m = np.asmatrix(matrix)
if m.shape != (3, 3):
raise ValueError('matrix is not 3x3')
axis = np.array((m[2, 1] - m[1, 2], m[0, 2] - m[2, 0], m[1, 0] - m[0, 1]))
r = np.sqrt((axis * axis).sum())
angle = np.arctan2(r, np.trace(m) - 1)
return Angle(angle, u.radian), -axis / r