1# -*- coding: utf-8 -*-
2"""Simple distance, velocity, and angle support for Skyfield.
3
4"""
5import numpy as np
6from numpy import abs, copysign, isnan
7from .constants import AU_KM, AU_M, C, DAY_S, tau
8from .descriptorlib import reify
9from .functions import _to_array, length_of
10
11_dfmt = '{0}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'
12_dsgn = '{0:+>1}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'
13_hfmt = '{0}{1:02}h {2:02}m {3:02}.{4:0{5}}s'
14
15class UnpackingError(Exception):
16    """You cannot iterate directly over a Skyfield measurement object."""
17
18class Unit(object):
19    """A measurement that can be expressed in several choices of unit."""
20
21    def __getitem__(self, *args):
22        cls = self.__class__
23        name = cls.__name__
24        s = 'to use this {0}, ask for its value in a particular unit:\n\n{1}'
25        attrs = sorted(k for k, v in cls.__dict__.items()
26                       if k[0].islower() and isinstance(v, reify))
27        examples = ['    {0}.{1}'.format(name.lower(), attr) for attr in attrs]
28        raise UnpackingError(s.format(name, '\n'.join(examples)))
29
30    __iter__ = __getitem__   # give advice about both foo[i] and "x,y,z = foo"
31
32class Distance(Unit):
33    """A distance, stored internally as au and available in other units.
34
35    You can initialize a ``Distance`` by providing a single float or a
36    float array as either an ``au=``, ``km=``, or ``m=`` parameter.
37
38    You can access the magnitude of the distance with its three
39    attributes ``.au``, ``.km``, and ``.m``.  By default a distance
40    prints itself in astronomical units (au), but you can take control
41    of the formatting and choice of units yourself using standard Python
42    numeric formatting:
43
44    >>> d = Distance(au=1)
45    >>> print(d)
46    1.0 au
47    >>> print('{:.2f} km'.format(d.km))
48    149597870.70 km
49
50    """
51    _warned = False
52
53    def __init__(self, au=None, km=None, m=None):
54        if au is not None:
55            self.au = _to_array(au)
56            """Astronomical units."""
57        elif km is not None:
58            self.km = km = _to_array(km)
59            self.au = km / AU_KM
60        elif m is not None:
61            self.m = m = _to_array(m)
62            self.au = m / AU_M
63        else:
64            raise ValueError('to construct a Distance provide au, km, or m')
65
66    @classmethod
67    def from_au(cls, au):
68        self = cls.__new__(cls)
69        self.au = _to_array(au)
70        return self
71
72    @reify
73    def au(self):  # Empty property to provide Sphinx docstring.
74        """Astronomical units (the Earth-Sun distance of 149,597,870,700 m)."""
75
76    @reify
77    def km(self):
78        """Kilometers (1,000 meters)."""
79        return self.au * AU_KM
80
81    @reify
82    def m(self):
83        """Meters."""
84        return self.au * AU_M
85
86    def __str__(self):
87        n = self.au
88        return ('{0} au' if getattr(n, 'shape', 0) else '{0:.6} au').format(n)
89
90    def __repr__(self):
91        return '<{0} {1}>'.format(type(self).__name__, self)
92
93    def length(self):
94        """Compute the length when this is an |xyz| vector.
95
96        The Euclidean vector length of this vector is returned as a new
97        :class:`~skyfield.units.Distance` object.
98
99        >>> from skyfield.api import Distance
100        >>> d = Distance(au=[1, 1, 0])
101        >>> d.length()
102        <Distance 1.41421 au>
103
104        """
105        return Distance(au=length_of(self.au))
106
107    def light_seconds(self):
108        """Return the length of this vector in light seconds."""
109        return self.m / C
110
111    def to(self, unit):
112        """Convert this distance to the given AstroPy unit."""
113        from astropy.units import au
114        return (self.au * au).to(unit)
115
116class Velocity(Unit):
117    """A velocity, stored internally as au/day and available in other units.
118
119    You can initialize a ``Velocity`` by providing a float or float
120    array to its ``au_per_d=`` parameter.
121
122    """
123    _warned = False
124
125    # TODO: consider reworking this class to return a Rate object.
126
127    def __init__(self, au_per_d=None, km_per_s=None):
128        if km_per_s is not None:
129            self.km_per_s = km_per_s = _to_array(km_per_s)
130            self.au_per_d = km_per_s * DAY_S / AU_KM
131        elif au_per_d is not None:
132            self.au_per_d = _to_array(au_per_d)
133        else:
134            raise ValueError('to construct a Velocity provide'
135                             ' au_per_d or km_per_s')
136
137    @reify
138    def au_per_d(self):  # Empty property to provide Sphinx docstring.
139        """Astronomical units per day."""
140
141    @reify
142    def km_per_s(self):
143        """Kilometers per second."""
144        return self.au_per_d * AU_KM / DAY_S
145
146    @reify
147    def m_per_s(self):
148        """Meters per second."""
149        return self.au_per_d * AU_M / DAY_S
150
151    def __str__(self):
152        n = self.au_per_d
153        fmt = '{0} au/day' if getattr(n, 'shape', 0) else '{0:.6} au/day'
154        return fmt.format(n)
155
156    def __repr__(self):
157        return '<{0} {1}>'.format(type(self).__name__, self)
158
159    def to(self, unit):
160        """Convert this velocity to the given AstroPy unit."""
161        from astropy.units import au, d
162        return (self.au_per_d * au / d).to(unit)
163
164class AngleRate(object):
165    """The rate at which an angle is changing."""
166
167    # TODO: design and implement public constructor.
168
169    @classmethod
170    def _from_radians_per_day(cls, radians_per_day):
171        ar = cls()
172        ar._radians_per_day = radians_per_day
173        return ar
174
175    @reify
176    def radians(self):
177        """:class:`Rate` of change in radians."""
178        return Rate._from_per_day(self._radians_per_day)
179
180    @reify
181    def degrees(self):
182        """:class:`Rate` of change in degrees."""
183        return Rate._from_per_day(self._radians_per_day / tau * 360.0)
184
185    @reify
186    def arcminutes(self):
187        """:class:`Rate` of change in arcminutes."""
188        return Rate._from_per_day(self._radians_per_day / tau * 21600.0)
189
190    @reify
191    def arcseconds(self):
192        """:class:`Rate` of change in arcseconds."""
193        return Rate._from_per_day(self._radians_per_day / tau * 1296000.0)
194
195    @reify
196    def mas(self):
197        """:class:`Rate` of change in milliarcseconds."""
198        return Rate._from_per_day(self._radians_per_day / tau * 1.296e9)
199
200    # TODO: str; repr; conversion to AstroPy units
201
202class Rate(object):
203    """Measurement whose denominator is time."""
204
205    # TODO: design and implement public constructor.
206
207    @classmethod
208    def _from_per_day(cls, per_day):
209        r = cls()
210        r._per_day = per_day
211        return r
212
213    @reify
214    def per_day(self):
215        """Units per day of Terrestrial Time."""
216        return self._per_day
217
218    @reify
219    def per_hour(self):
220        """Units per hour of Terrestrial Time."""
221        return self._per_day / 24.0
222
223    @reify
224    def per_minute(self):
225        """Units per minute of Terrestrial Time."""
226        return self._per_day / 1440.0
227
228    @reify
229    def per_second(self):
230        """Units per second of Terrestrial Time."""
231        return self._per_day / 86400.0
232
233# Angle units.
234
235_instantiation_instructions = """to instantiate an Angle, try one of:
236
237Angle(angle=another_angle)
238Angle(radians=value)
239Angle(degrees=value)
240Angle(hours=value)
241
242where `value` can be either a Python float, a list of Python floats,
243or a NumPy array of floats"""
244
245class Angle(Unit):
246
247    def __init__(self, angle=None, radians=None, degrees=None, hours=None,
248                 preference=None, signed=False):
249
250        if angle is not None:
251            if not isinstance(angle, Angle):
252                raise ValueError(_instantiation_instructions)
253            self.radians = angle.radians
254        elif radians is not None:
255            self.radians = _to_array(radians)
256        elif degrees is not None:
257            self._degrees = degrees = _to_array(_unsexagesimalize(degrees))
258            self.radians = degrees / 360.0 * tau
259        elif hours is not None:
260            self._hours = hours = _to_array(_unsexagesimalize(hours))
261            self.radians = hours / 24.0 * tau
262
263        self.preference = (preference if preference is not None
264                           else 'hours' if hours is not None
265                           else 'degrees')
266        self.signed = signed
267
268    @classmethod
269    def from_degrees(cls, degrees, signed=False):
270        degrees = _to_array(_unsexagesimalize(degrees))
271        self = cls.__new__(cls)
272        self.degrees = degrees
273        self.radians = degrees / 360.0 * tau
274        self.preference = 'degrees'
275        self.signed = signed
276        return self
277
278    @reify
279    def radians(self):  # Empty property to provide Sphinx docstring.
280        """Radians (�� = 2�� in a circle)."""
281
282    @reify
283    def _hours(self):
284        return self.radians * 24.0 / tau
285
286    @reify
287    def _degrees(self):
288        return self.radians * 360.0 / tau
289
290    @reify
291    def hours(self):
292        """Hours (24\ |h| in a circle)."""
293        if self.preference != 'hours':
294            raise WrongUnitError('hours')
295        return self._hours
296
297    @reify
298    def degrees(self):
299        """Degrees (360° in a circle)."""
300        if self.preference != 'degrees':
301            raise WrongUnitError('degrees')
302        return self._degrees
303
304    def arcminutes(self):
305        """Return the angle in arcminutes."""
306        return self._degrees * 60.0
307
308    def arcseconds(self):
309        """Return the angle in arcseconds."""
310        return self._degrees * 3600.0
311
312    def mas(self):
313        """Return the angle in milliarcseconds."""
314        return self._degrees * 3600000.0
315
316    def __str__(self):
317        size = self.radians.size
318        if size == 0:
319            return 'Angle []'
320        if self.preference == 'degrees':
321            v = self._degrees
322            fmt = _dsgn.format if self.signed else _dfmt.format
323            places = 1
324        else:
325            v = self._hours
326            fmt = _hfmt.format
327            places = 2
328        if size >= 2:
329            return '{0} values from {1} to {2}'.format(
330                len(v), _sfmt(fmt, v[0], places), _sfmt(fmt, v[-1], places))
331        return _sfmt(fmt, v, places)
332
333    def __repr__(self):
334        if self.radians.size == 0:
335            return '<{0} []>'.format(type(self).__name__)
336        else:
337            return '<{0} {1}>'.format(type(self).__name__, self)
338
339    def hms(self, warn=True):
340        """Convert to a tuple (hours, minutes, seconds).
341
342        All three quantities will have the same sign as the angle itself.
343
344        """
345        if warn and self.preference != 'hours':
346            raise WrongUnitError('hms')
347        sign, units, minutes, seconds = _sexagesimalize_to_float(self._hours)
348        return sign * units, sign * minutes, sign * seconds
349
350    def signed_hms(self, warn=True):
351        """Convert to a tuple (sign, hours, minutes, seconds).
352
353        The ``sign`` will be either +1 or -1, and the other quantities
354        will all be positive.
355
356        """
357        if warn and self.preference != 'hours':
358            raise WrongUnitError('signed_hms')
359        return _sexagesimalize_to_float(self._hours)
360
361    def hstr(self, places=2, warn=True, format=_hfmt):
362        """Return a string like ``12h 07m 30.00s``; see `Formatting angles`.
363
364        .. versionadded:: 1.39
365
366           Added the ``format=`` parameter.
367
368        """
369        if warn and self.preference != 'hours':
370            raise WrongUnitError('hstr')
371        hours = self._hours
372        shape = getattr(hours, 'shape', ())
373        fmt = format.format  # `format()` method of `format` string
374        if shape:
375            return [_sfmt(fmt, h, places) for h in hours]
376        return _sfmt(fmt, hours, places)
377
378    def dms(self, warn=True):
379        """Convert to a tuple (degrees, minutes, seconds).
380
381        All three quantities will have the same sign as the angle itself.
382
383        """
384        if warn and self.preference != 'degrees':
385            raise WrongUnitError('dms')
386        sign, units, minutes, seconds = _sexagesimalize_to_float(self._degrees)
387        return sign * units, sign * minutes, sign * seconds
388
389    def signed_dms(self, warn=True):
390        """Convert to a tuple (sign, degrees, minutes, seconds).
391
392        The ``sign`` will be either +1 or -1, and the other quantities
393        will all be positive.
394
395        """
396        if warn and self.preference != 'degrees':
397            raise WrongUnitError('signed_dms')
398        return _sexagesimalize_to_float(self._degrees)
399
400    def dstr(self, places=1, warn=True, format=None):
401        """Return a string like ``181deg 52' 30.0"``; see `Formatting angles`.
402
403        .. versionadded:: 1.39
404
405           Added the ``format=`` parameter.
406
407        """
408        if warn and self.preference != 'degrees':
409            raise WrongUnitError('dstr')
410        degrees = self._degrees
411        signed = self.signed
412        if format is None:
413            format = _dsgn if signed else _dfmt
414        fmt = format.format  # `format()` method of `format` string
415        shape = getattr(degrees, 'shape', ())
416        if shape:
417            return [_sfmt(fmt, d, places) for d in degrees]
418        return _sfmt(fmt, degrees, places)
419
420    def to(self, unit):
421        """Convert this angle to the given AstroPy unit."""
422        from astropy.units import rad
423        return (self.radians * rad).to(unit)
424
425        # Or should this do:
426        from astropy.coordinates import Angle
427        from astropy.units import rad
428        return Angle(self.radians, rad).to(unit)
429
430class WrongUnitError(ValueError):
431
432    def __init__(self, name):
433        unit = 'hours' if (name.startswith('h') or '_h' in name) else 'degrees'
434        usual = 'hours' if (unit == 'degrees') else 'degrees'
435        message = ('this angle is usually expressed in {0}, not {1};'
436                   ' if you want to use {1} anyway,'.format(usual, unit))
437        if name == unit:
438            message += ' then please use the attribute _{0}'.format(unit)
439        else:
440            message += ' then call {0}() with warn=False'.format(name)
441        self.args = (message,)
442
443def _sexagesimalize_to_float(value):
444    """Decompose `value` into units, minutes, and seconds.
445
446    Note that this routine is not appropriate for displaying a value,
447    because rounding to the smallest digit of display is necessary
448    before showing a value to the user.  Use `_sexagesimalize_to_int()`
449    for data being displayed to the user.
450
451    This routine simply decomposes the floating point `value` into a
452    sign (+1.0 or -1.0), units, minutes, and seconds, returning the
453    result in a four-element tuple.
454
455    >>> _sexagesimalize_to_float(12.05125)
456    (1.0, 12.0, 3.0, 4.5)
457    >>> _sexagesimalize_to_float(-12.05125)
458    (-1.0, 12.0, 3.0, 4.5)
459
460    """
461    sign = np.sign(value)
462    n = abs(value)
463    minutes, seconds = divmod(n * 3600.0, 60.0)
464    units, minutes = divmod(minutes, 60.0)
465    return sign, units, minutes, seconds
466
467def _sexagesimalize_to_int(value, places=0):
468    """Decompose `value` into units, minutes, seconds, and second fractions.
469
470    This routine prepares a value for sexagesimal display, with its
471    seconds fraction expressed as an integer with `places` digits.  The
472    result is a tuple of five integers:
473
474    ``(sign [either +1 or -1], units, minutes, seconds, second_fractions)``
475
476    The integers are properly rounded per astronomical convention so
477    that, for example, given ``places=3`` the result tuple ``(1, 11, 22,
478    33, 444)`` means that the input was closer to 11u 22' 33.444" than
479    to either 33.443" or 33.445" in its value.
480
481    """
482    power = 10 ** places
483    n = int((power * 3600 * value + 0.5) // 1.0)
484    sign = np.sign(n)
485    n, fraction = divmod(abs(n), power)
486    n, seconds = divmod(n, 60)
487    n, minutes = divmod(n, 60)
488    return sign, n, minutes, seconds, fraction
489
490def _sfmt(fmt, value, places):
491    """Decompose floating point `value` into sexagesimal, and format."""
492    if isnan(value):
493        return 'nan'
494    sgn, h, m, s, fraction = _sexagesimalize_to_int(value, places)
495    sign = '-' if sgn < 0.0 else ''
496    return fmt(sign, h, m, s, fraction, places)
497
498def wms(whole, minutes=0.0, seconds=0.0):
499    """Return a quantity expressed with 1/60 minutes and 1/3600 seconds."""
500    return (whole
501            + copysign(minutes, whole) / 60.0
502            + copysign(seconds, whole) / 3600.0)
503
504def _unsexagesimalize(value):
505    """Return `value` after interpreting a (units, minutes, seconds) tuple.
506
507    When `value` is not a tuple, it is simply returned.
508
509    >>> _unsexagesimalize(3.25)
510    3.25
511
512    An input tuple is interpreted as units, minutes, and seconds.  Note
513    that only the sign of `units` is significant!  So all of the
514    following tuples convert into exactly the same value:
515
516    >>> '%f' % _unsexagesimalize((-1, 2, 3))
517    '-1.034167'
518    >>> '%f' % _unsexagesimalize((-1, -2, 3))
519    '-1.034167'
520    >>> '%f' % _unsexagesimalize((-1, -2, -3))
521    '-1.034167'
522
523    """
524    if isinstance(value, tuple):
525        components = iter(value)
526        value = next(components)
527        factor = 1.0
528        for component in components:
529            factor *= 60.0
530            value += copysign(component, value) / factor
531    return value
532
533def _interpret_angle(name, angle_object, angle_float, unit='degrees'):
534    """Return an angle in radians from one of two arguments.
535
536    It is common for Skyfield routines to accept both an argument like
537    `alt` that takes an Angle object as well as an `alt_degrees` that
538    can be given a bare float or a sexagesimal tuple.  A pair of such
539    arguments can be passed to this routine for interpretation.
540
541    """
542    if angle_object is not None:
543        if isinstance(angle_object, Angle):
544            return angle_object.radians
545    elif angle_float is not None:
546        return _unsexagesimalize(angle_float) / 360.0 * tau
547    raise ValueError('you must either provide the {0}= parameter with'
548                     ' an Angle argument or supply the {0}_{1}= parameter'
549                     ' with a numeric argument'.format(name, unit))
550
551def _ltude(value, name, psuffix, nsuffix):
552    # Support for old deprecated Topos argument interpretation.
553    if not isinstance(value, str):
554        return _unsexagesimalize(value)
555    value = value.strip().upper()
556    if value.endswith(psuffix):
557        sign = +1.0
558    elif value.endswith(nsuffix):
559        sign = -1.0
560    else:
561        raise ValueError('your {0} string {1!r} does not end with either {2!r}'
562                         ' or {3!r}'.format(name, value, psuffix, nsuffix))
563    try:
564        value = float(value[:-1])
565    except ValueError:
566        raise ValueError('your {0} string {1!r} cannot be parsed as a floating'
567                         ' point number'.format(name, value))
568    return sign * value
569