Files
2026-07-13 20:00:07 +02:00

586 lines
20 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Simple distance, velocity, and angle support for Skyfield.
"""
import numpy as np
from numpy import abs, copysign, isnan
from types import MethodType
from .constants import AU_KM, AU_M, C, DAY_S, tau
from .descriptorlib import reify
from .functions import _to_array, length_of
_dfmt = '{0}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'
_dsgn = '{0:+>1}{1:02}deg {2:02}\' {3:02}.{4:0{5}}"'
_hfmt = '{0}{1:02}h {2:02}m {3:02}.{4:0{5}}s'
class UnpackingError(Exception):
"""You cannot iterate directly over a Skyfield measurement object."""
class Unit(object):
"""A measurement that can be expressed in several choices of unit."""
def __getitem__(self, *args):
"""Tell users to ask for a specific unit before indexing or slicing."""
cls = self.__class__
name = cls.__name__
s = 'to use this {0}, ask for its value in a particular unit:\n\n{1}'
attrs = sorted(k for k, v in cls.__dict__.items()
if k[0].islower() and isinstance(v, (getset, reify)))
examples = [' {0}.{1}'.format(name.lower(), attr) for attr in attrs]
raise UnpackingError(s.format(name, '\n'.join(examples)))
__iter__ = __getitem__ # give advice about both foo[i] and "x,y,z = foo"
class getset(object):
"""Unit name that serves as both a class constructor and instance attribute.
This supports two use cases:
* When called as a class method like ``Distance.km(5.0)``, we build
and return an instance of ``Distance`` whose ``km`` has been set
to 5.0 and whose base unit ``m``, using the appropriate conversion
factor, has been set to 5000.0.
* When invoked like ``d.km`` on a particular ``Distance`` that
doesn't yet have a ``km`` attribute (which otherwise Python itself
would have returned), we apply the conversion factor to ``d.m``
and return the result.
"""
def __init__(self, name, docstring, conversion_factor=None, core_unit=None):
self.name = name
self.__doc__ = docstring
self.conversion_factor = conversion_factor
self.core_unit = core_unit
if conversion_factor is None:
def _constructor(cls, value):
value = _to_array(value)
obj = cls.__new__(cls)
setattr(obj, name, value)
return obj
else:
def _constructor(cls, value):
value = _to_array(value)
obj = cls.__new__(cls)
setattr(obj, name, value)
setattr(obj, core_unit, value / conversion_factor)
return obj
_constructor.__doc__ = self.__doc__
self._constructor = _constructor
def __get__(self, instance, objtype=None):
if instance is None: # the class itself has been asked for this name
return MethodType(self._constructor, objtype)
value = getattr(instance, self.core_unit) * self.conversion_factor
instance.__dict__[self.name] = value
return value
class Distance(Unit):
"""A distance, stored internally as au and available in other units.
You can initialize a ``Distance`` by providing a single float or a
float array as either an ``au=``, ``km=``, or ``m=`` parameter.
You can access the magnitude of the distance with its three
attributes ``.au``, ``.km``, and ``.m``. By default a distance
prints itself in astronomical units (au), but you can take control
of the formatting and choice of units yourself using standard Python
numeric formatting:
>>> d = Distance(au=1)
>>> print(d)
1.0 au
>>> print('{:.2f} km'.format(d.km))
149597870.70 km
"""
def __init__(self, au=None, km=None, m=None):
if au is not None:
self.au = _to_array(au)
elif km is not None:
self.km = km = _to_array(km)
self.au = km / AU_KM
elif m is not None:
self.m = m = _to_array(m)
self.au = m / AU_M
else:
raise ValueError('to construct a Distance provide au, km, or m')
@classmethod
def from_au(cls, au): # deprecated and no longer used internally
return cls.au(au)
au = getset('au', 'Astronomical units'
' (the Earth-Sun distance of 149,597,870,700 m).')
km = getset('km', 'Kilometers (1,000 meters).', AU_KM, 'au')
m = getset('m', 'Meters.', AU_M, 'au')
def __str__(self):
n = self.au
return ('{0} au' if getattr(n, 'shape', 0) else '{0:.6} au').format(n)
def __repr__(self):
return '<{0} {1}>'.format(type(self).__name__, self)
def length(self):
"""Compute the length when this is an |xyz| vector.
The Euclidean vector length of this vector is returned as a new
:class:`~skyfield.units.Distance` object.
>>> from skyfield.api import Distance
>>> d = Distance(au=[1, 1, 0])
>>> d.length()
<Distance 1.41421 au>
"""
return Distance(au=length_of(self.au))
def light_seconds(self):
"""Return the length of this vector in light seconds."""
return self.m / C
def to(self, unit):
"""Convert this distance to the given AstroPy unit."""
from astropy.units import au
return (self.au * au).to(unit)
class Velocity(Unit):
"""A velocity, stored internally as au/day and available in other units.
You can initialize a ``Velocity`` by providing a float or float
array to its ``au_per_d=`` parameter.
"""
# TODO: consider reworking this class to return a Rate object.
def __init__(self, au_per_d=None, km_per_s=None):
if km_per_s is not None:
self.km_per_s = km_per_s = _to_array(km_per_s)
self.au_per_d = km_per_s * DAY_S / AU_KM
elif au_per_d is not None:
self.au_per_d = _to_array(au_per_d)
else:
raise ValueError('to construct a Velocity provide'
' au_per_d or km_per_s')
au_per_d = getset('au_per_d', 'Astronomical units per day.')
km_per_s = getset('km_per_s', 'Kilometers per second.',
AU_KM / DAY_S, 'au_per_d')
m_per_s = getset('m_per_s', 'Meters per second.',
AU_M / DAY_S, 'au_per_d')
def __str__(self):
n = self.au_per_d
fmt = '{0} au/day' if getattr(n, 'shape', 0) else '{0:.6} au/day'
return fmt.format(n)
def __repr__(self):
return '<{0} {1}>'.format(type(self).__name__, self)
def to(self, unit):
"""Convert this velocity to the given AstroPy unit."""
from astropy.units import au, d
return (self.au_per_d * au / d).to(unit)
class AngleRate(object):
"""The rate at which an angle is changing."""
# TODO: design and implement public constructor.
@classmethod
def _from_radians_per_day(cls, radians_per_day):
ar = cls()
ar._radians_per_day = radians_per_day
return ar
@reify
def radians(self):
""":class:`Rate` of change in radians."""
return Rate._from_per_day(self._radians_per_day)
@reify
def degrees(self):
""":class:`Rate` of change in degrees."""
return Rate._from_per_day(self._radians_per_day / tau * 360.0)
@reify
def arcminutes(self):
""":class:`Rate` of change in arcminutes."""
return Rate._from_per_day(self._radians_per_day / tau * 21600.0)
@reify
def arcseconds(self):
""":class:`Rate` of change in arcseconds."""
return Rate._from_per_day(self._radians_per_day / tau * 1296000.0)
@reify
def mas(self):
""":class:`Rate` of change in milliarcseconds."""
return Rate._from_per_day(self._radians_per_day / tau * 1.296e9)
# TODO: str; repr; conversion to AstroPy units
class Rate(object):
"""Measurement whose denominator is time."""
# TODO: design and implement public constructor.
@classmethod
def _from_per_day(cls, per_day):
r = cls()
r._per_day = per_day
return r
@reify
def per_day(self):
"""Units per day of Terrestrial Time."""
return self._per_day
@reify
def per_hour(self):
"""Units per hour of Terrestrial Time."""
return self._per_day / 24.0
@reify
def per_minute(self):
"""Units per minute of Terrestrial Time."""
return self._per_day / 1440.0
@reify
def per_second(self):
"""Units per second of Terrestrial Time."""
return self._per_day / 86400.0
# Angle units.
_instantiation_instructions = """to instantiate an Angle, try one of:
Angle(angle=another_angle)
Angle(radians=value)
Angle(degrees=value)
Angle(hours=value)
where `value` can be either a Python float, a list of Python floats,
or a NumPy array of floats"""
class Angle(Unit):
def __init__(self, angle=None, radians=None, degrees=None, hours=None,
preference=None, signed=False):
if angle is not None:
if not isinstance(angle, Angle):
raise ValueError(_instantiation_instructions)
self.radians = angle.radians
elif radians is not None:
self.radians = _to_array(radians)
elif degrees is not None:
self.degrees = degrees = _to_array(_unsexagesimalize(degrees))
self.radians = degrees / 360.0 * tau
elif hours is not None:
self.hours = hours = _to_array(_unsexagesimalize(hours))
self.radians = hours / 24.0 * tau
self.preference = (preference if preference is not None
else 'hours' if hours is not None
else 'degrees')
self.signed = signed
@classmethod
def from_degrees(cls, degrees, signed=False):
degrees = _to_array(_unsexagesimalize(degrees))
self = cls.__new__(cls)
self.degrees = degrees
self.radians = degrees / 360.0 * tau
self.preference = 'degrees'
self.signed = signed
return self
radians = getset('radians', 'Radians (𝜏 = 2𝜋 in a circle).')
# Deprecated names, to support legacy code.
@reify
def _hours(self): return self.hours
@reify
def _degrees(self): return self.degrees
@reify
def hours(self):
r"""Hours (24\ |h| in a circle)."""
return self.radians * 24.0 / tau
@reify
def degrees(self):
"""Degrees (360° in a circle)."""
return self.radians * 360.0 / tau
def arcminutes(self):
"""Return the angle in arcminutes."""
return self.degrees * 60.0
def arcseconds(self):
"""Return the angle in arcseconds."""
return self.degrees * 3600.0
def mas(self):
"""Return the angle in milliarcseconds."""
return self.degrees * 3600000.0
def __str__(self):
size = self.radians.size
if size == 0:
return 'Angle []'
if self.preference == 'degrees':
v = self.degrees
fmt = _dsgn.format if self.signed else _dfmt.format
places = 1
else:
v = self.hours
fmt = _hfmt.format
places = 2
if size >= 2:
return '{0} values from {1} to {2}'.format(
len(v), _sfmt(fmt, v[0], places), _sfmt(fmt, v[-1], places))
return _sfmt(fmt, v, places)
def __repr__(self):
if self.radians.size == 0:
return '<{0} []>'.format(type(self).__name__)
else:
return '<{0} {1}>'.format(type(self).__name__, self)
def hms(self, warn=True):
"""Convert to a tuple (hours, minutes, seconds).
All three quantities will have the same sign as the angle itself.
"""
if warn and self.preference != 'hours':
raise WrongUnitError('hms')
sign, units, minutes, seconds = _sexagesimalize_to_float(self.hours)
return sign * units, sign * minutes, sign * seconds
def signed_hms(self, warn=True):
"""Convert to a tuple (sign, hours, minutes, seconds).
The ``sign`` will be either +1 or -1, and the other quantities
will all be positive.
"""
if warn and self.preference != 'hours':
raise WrongUnitError('signed_hms')
return _sexagesimalize_to_float(self.hours)
def hstr(self, places=2, warn=True, format=_hfmt):
"""Return a string like ``12h 07m 30.00s``; see `Formatting angles`.
.. versionadded:: 1.39
Added the ``format=`` parameter.
"""
if warn and self.preference != 'hours':
raise WrongUnitError('hstr')
hours = self.hours
shape = getattr(hours, 'shape', ())
fmt = format.format # `format()` method of `format` string
if shape:
return [_sfmt(fmt, h, places) for h in hours]
return _sfmt(fmt, hours, places)
def dms(self, warn=True):
"""Convert to a tuple (degrees, minutes, seconds).
All three quantities will have the same sign as the angle itself.
"""
if warn and self.preference != 'degrees':
raise WrongUnitError('dms')
sign, units, minutes, seconds = _sexagesimalize_to_float(self.degrees)
return sign * units, sign * minutes, sign * seconds
def signed_dms(self, warn=True):
"""Convert to a tuple (sign, degrees, minutes, seconds).
The ``sign`` will be either +1 or -1, and the other quantities
will all be positive.
"""
if warn and self.preference != 'degrees':
raise WrongUnitError('signed_dms')
return _sexagesimalize_to_float(self.degrees)
def dstr(self, places=1, warn=True, format=None):
"""Return a string like ``181deg 52' 30.0"``; see `Formatting angles`.
.. versionadded:: 1.39
Added the ``format=`` parameter.
"""
if warn and self.preference != 'degrees':
raise WrongUnitError('dstr')
degrees = self.degrees
signed = self.signed
if format is None:
format = _dsgn if signed else _dfmt
fmt = format.format # `format()` method of `format` string
shape = getattr(degrees, 'shape', ())
if shape:
return [_sfmt(fmt, d, places) for d in degrees]
return _sfmt(fmt, degrees, places)
def to(self, unit):
"""Convert this angle to the given AstroPy unit."""
from astropy.units import rad
return (self.radians * rad).to(unit)
# Or should this do:
from astropy.coordinates import Angle
from astropy.units import rad
return Angle(self.radians, rad).to(unit)
class WrongUnitError(ValueError):
def __init__(self, name):
unit = 'hours' if (name.startswith('h') or '_h' in name) else 'degrees'
usual = 'hours' if (unit == 'degrees') else 'degrees'
message = ('this angle is usually expressed in {0}, not {1};'
' if you want to use {1} anyway,'.format(usual, unit))
if name == unit:
message += ' then please use the attribute _{0}'.format(unit)
else:
message += ' then call {0}() with warn=False'.format(name)
self.args = (message,)
def _sexagesimalize_to_float(value):
"""Decompose `value` into units, minutes, and seconds.
Note that this routine is not appropriate for displaying a value,
because rounding to the smallest digit of display is necessary
before showing a value to the user. Use `_sexagesimalize_to_int()`
for data being displayed to the user.
This routine simply decomposes the floating point `value` into a
sign (+1.0 or -1.0), units, minutes, and seconds, returning the
result in a four-element tuple.
>>> _sexagesimalize_to_float(12.05125)
(1.0, 12.0, 3.0, 4.5)
>>> _sexagesimalize_to_float(-12.05125)
(-1.0, 12.0, 3.0, 4.5)
"""
sign = np.sign(value)
n = abs(value)
minutes, seconds = divmod(n * 3600.0, 60.0)
units, minutes = divmod(minutes, 60.0)
return sign, units, minutes, seconds
def _sexagesimalize_to_int(value, places=0):
"""Decompose `value` into units, minutes, seconds, and second fractions.
This routine prepares a value for sexagesimal display, with its
seconds fraction expressed as an integer with `places` digits. The
result is a tuple of five integers:
``(sign [either +1 or -1], units, minutes, seconds, second_fractions)``
The integers are properly rounded per astronomical convention so
that, for example, given ``places=3`` the result tuple ``(1, 11, 22,
33, 444)`` means that the input was closer to 11u 22' 33.444" than
to either 33.443" or 33.445" in its value.
"""
power = 10 ** places
n = int((power * 3600 * value + 0.5) // 1.0)
sign = np.sign(n)
n, fraction = divmod(abs(n), power)
n, seconds = divmod(n, 60)
n, minutes = divmod(n, 60)
return sign, n, minutes, seconds, fraction
def _sfmt(fmt, value, places):
"""Decompose floating point `value` into sexagesimal, and format."""
if isnan(value):
return 'nan'
sgn, h, m, s, fraction = _sexagesimalize_to_int(value, places)
sign = '-' if sgn < 0.0 else ''
return fmt(sign, h, m, s, fraction, places)
def wms(whole, minutes=0.0, seconds=0.0):
"""Return a quantity expressed with 1/60 minutes and 1/3600 seconds."""
return (whole
+ copysign(minutes, whole) / 60.0
+ copysign(seconds, whole) / 3600.0)
def _unsexagesimalize(value):
"""Return `value` after interpreting a (units, minutes, seconds) tuple.
When `value` is not a tuple, it is simply returned.
>>> _unsexagesimalize(3.25)
3.25
An input tuple is interpreted as units, minutes, and seconds. Note
that only the sign of `units` is significant! So all of the
following tuples convert into exactly the same value:
>>> '%f' % _unsexagesimalize((-1, 2, 3))
'-1.034167'
>>> '%f' % _unsexagesimalize((-1, -2, 3))
'-1.034167'
>>> '%f' % _unsexagesimalize((-1, -2, -3))
'-1.034167'
"""
if isinstance(value, tuple):
components = iter(value)
value = next(components)
factor = 1.0
for component in components:
factor *= 60.0
value += copysign(component, value) / factor
return value
def _interpret_angle(name, angle_object, angle_float, unit='degrees'):
"""Return an angle in radians from one of two arguments.
It is common for Skyfield routines to accept both an argument like
`alt` that takes an Angle object as well as an `alt_degrees` that
can be given a bare float or a sexagesimal tuple. A pair of such
arguments can be passed to this routine for interpretation.
"""
if angle_object is not None:
if isinstance(angle_object, Angle):
return angle_object.radians
elif angle_float is not None:
return _unsexagesimalize(angle_float) / 360.0 * tau
raise ValueError('you must either provide the {0}= parameter with'
' an Angle argument or supply the {0}_{1}= parameter'
' with a numeric argument'.format(name, unit))
def _ltude(value, name, psuffix, nsuffix):
# Support for old deprecated Topos argument interpretation.
if not isinstance(value, str):
return _unsexagesimalize(value)
value = value.strip().upper()
if value.endswith(psuffix):
sign = +1.0
elif value.endswith(nsuffix):
sign = -1.0
else:
raise ValueError('your {0} string {1!r} does not end with either {2!r}'
' or {3!r}'.format(name, value, psuffix, nsuffix))
try:
value = float(value[:-1])
except ValueError:
raise ValueError('your {0} string {1!r} cannot be parsed as a floating'
' point number'.format(name, value))
return sign * value