Files
skyview.astronomiemuseum.de/public/py/vendor_linux/skyfield/almanac.py
T
2026-07-13 20:00:07 +02:00

570 lines
20 KiB
Python
Raw 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.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Routines to solve for circumstances like sunrise, sunset, and moon phase."""
from __future__ import division
import numpy as np
from numpy import cos, sin, sqrt, zeros_like
from .constants import pi, tau
from .framelib import ecliptic_frame
from .searchlib import find_discrete
from .nutationlib import iau2000b_radians
from .units import Angle
_SUN = 10
_MOON = 301
_MICROSECOND = 1 / 24.0 / 3600.0 / 1e6
# Not only to support historic code but also for future convenience, let
# folks import the search routine alongside the almanac routines.
find_discrete
# Simple facts.
def phase_angle(ephemeris, body, t):
"""
.. deprecated:: 1.42
Use the :meth:`~skyfield.positionlib.ICRF.phase_angle()` position
method instead.
"""
p = ephemeris['earth'].at(t).observe(ephemeris[body])
return p.phase_angle(ephemeris['sun'])
def fraction_illuminated(ephemeris, body, t):
"""
.. deprecated:: 1.42
Use the :meth:`~skyfield.positionlib.ICRF.fraction_illuminated()`
position method instead.
"""
a = phase_angle(ephemeris, body, t).radians
return 0.5 * (1.0 + cos(a))
# Discrete circumstances to search.
SEASONS = [
'Spring',
'Summer',
'Autumn',
'Winter',
]
SEASON_EVENTS = [
'Vernal Equinox',
'Summer Solstice',
'Autumnal Equinox',
'Winter Solstice',
]
SEASON_EVENTS_NEUTRAL = [
'March Equinox',
'June Solstice',
'September Equinox',
'December Solstice',
]
def seasons(ephemeris):
"""Build a function of time that returns the quarter of the year.
The function that this returns will expect a single argument that is
a :class:`~skyfield.timelib.Time` and will return 0 through 3 for
the seasons Spring, Summer, Autumn, and Winter.
"""
earth = ephemeris['earth']
sun = ephemeris['sun']
def season_at(t):
"""Return season 0 (Spring) through 3 (Winter) at time `t`."""
t._nutation_angles_radians = iau2000b_radians(t)
e = earth.at(t)
_, slon, _ = e.observe(sun).apparent().frame_latlon(ecliptic_frame)
return (slon.radians // (tau / 4) % 4).astype(int)
season_at.step_days = 90.0
return season_at
MOON_PHASES = [
'New Moon',
'First Quarter',
'Full Moon',
'Last Quarter',
]
def moon_phase(ephemeris, t):
"""Return the Moon phase 0°–360° at time ``t``, where 180° is Full Moon.
More precisely: this returns an :class:`~skyfield.units.Angle`
giving the difference between the geocentric apparent ecliptic
longitudes of the Moon and Sun, constrained to the interval 0°–360°
(0–𝜏 radians) where 0° is New Moon and 180° is Full Moon.
"""
e = ephemeris['earth'].at(t)
moon, sun = ephemeris['moon'], ephemeris['sun']
_, mlon, _ = e.observe(moon).apparent().frame_latlon(ecliptic_frame)
_, slon, _ = e.observe(sun).apparent().frame_latlon(ecliptic_frame)
return Angle(radians=(mlon.radians - slon.radians) % tau)
def moon_phases(ephemeris):
"""Build a function of time that returns the moon phase 0 through 3.
The function that this returns will expect a single argument that is
a :class:`~skyfield.timelib.Time` and will return the phase of the
moon as an integer. See the accompanying array ``MOON_PHASES`` if
you want to give string names to each phase.
"""
earth = ephemeris['earth']
moon = ephemeris['moon']
sun = ephemeris['sun']
def moon_phase_at(t):
"""Return the phase of the moon 0 through 3 at time `t`."""
t._nutation_angles_radians = iau2000b_radians(t)
e = earth.at(t)
_, mlon, _ = e.observe(moon).apparent().frame_latlon(ecliptic_frame)
_, slon, _ = e.observe(sun).apparent().frame_latlon(ecliptic_frame)
return ((mlon.radians - slon.radians) // (tau / 4) % 4).astype(int)
moon_phase_at.step_days = 7.0 # one lunar phase per week
return moon_phase_at
MOON_NODES = [
'descending',
'ascending',
]
def moon_nodes(ephemeris):
"""Build a function of time that identifies lunar nodes.
This returns a function taking a :class:`~skyfield.timelib.Time` and
returning ``True`` if the Moon is above the ecliptic else ``False``.
See :ref:`lunar-nodes` for how to use this routine.
"""
earth = ephemeris['earth']
moon = ephemeris['moon']
def moon_node_at(t):
"""Return the phase of the moon 0 through 3 at time `t`."""
e = earth.at(t)
lat, _, _ = e.observe(moon).apparent().frame_latlon(ecliptic_frame)
return lat.radians > 0.0
moon_node_at.step_days = 12.0 # 2000-2050: closest nodes 12.38 days apart
return moon_node_at
CONJUNCTIONS = [
'conjunction',
'opposition',
]
def oppositions_conjunctions(ephemeris, target):
"""Build a function to find oppositions and conjunctions with the Sun.
See :ref:`oppositions-conjunctions` for how to call this routine and
interpret the results.
"""
earth_at = ephemeris['earth'].at
sun = ephemeris['sun']
def leading_or_trailing(t):
"""Return whether the target is east or west of the Sun."""
e = earth_at(t)
_, slon, _ = e.observe(sun).apparent().frame_latlon(ecliptic_frame)
_, tlon, _ = e.observe(target).apparent().frame_latlon(ecliptic_frame)
return ((slon.radians - tlon.radians) / pi % 2.0).astype('int8')
if target.target == 301:
leading_or_trailing.step_days = 14 # Moon
else:
leading_or_trailing.step_days = 40 # Mercury (the fastest planet)
return leading_or_trailing
MERIDIAN_TRANSITS = ['Antimeridian transit', 'Meridian transit']
def meridian_transits(ephemeris, target, topos):
"""Build a function of time for finding when a body transits the meridian.
The returned function accepts a :class:`~skyfield.timelib.Time`
argument and returns ``True`` if the ``target`` body is west of the
observer’s meridian at that time, and otherwise returns ``False.``
See :ref:`transits` for how to use this to search for a body’s
meridian transits and antimeridian transits.
"""
topos_at = (ephemeris['earth'] + topos).at
def west_of_meridian_at(t):
"""Return `True` if the target is west of the observer’s meridian."""
t._nutation_angles_radians = iau2000b_radians(t)
# TODO: should we work to avoid computing Topos position twice?
# We could grab its hidden GCRS vector and do the trig ourselves.
# Or there might be something clever we can do with the two raw
# vectors, skipping the cost of computing spherical coordinates.
ra1, _, _ = topos.at(t).radec(epoch='date')
ra2, _, _ = topos_at(t).observe(target).apparent().radec(epoch='date')
return (ra1.radians - ra2.radians) % tau < pi
west_of_meridian_at.step_days = 0.4 # twice a day
return west_of_meridian_at
def sunrise_sunset(ephemeris, topos):
"""Build a function of time that returns whether the Sun is up.
The function that is returned will expect a single argument that is
a :class:`~skyfield.timelib.Time`, and will return ``True`` if the
sun is up, else ``False``.
Skyfield uses the same definition as the United States Naval
Observatory: the Sun is up when its center is 0.8333 degrees below
the horizon, which accounts for both its apparent radius of around
16 arcminutes and also for the 34 arcminutes by which atmospheric
refraction on average lifts the image of the Sun.
If you need to provide a custom value for refraction, adjust the
estimate of the Sun’s radius, or account for a vantage point above
the Earth’s surface, see :ref:`risings-and-settings` to learn about
the more versatile :func:`~skyfield.almanac.risings_and_settings()`
routine.
"""
sun = ephemeris['sun']
topos_at = (ephemeris['earth'] + topos).at
def is_sun_up_at(t):
"""Return `True` if the sun has risen by time `t`.
The Sun has risen if its altitude above the horizon is greater
than -0.8333 degrees.
"""
t._nutation_angles_radians = iau2000b_radians(t)
return topos_at(t).observe(sun).apparent().altaz()[0].degrees >= -0.8333
is_sun_up_at.step_days = 0.04 # catch days at least an hour long
return is_sun_up_at
TWILIGHTS = {
0: 'Night',
1: 'Astronomical twilight',
2: 'Nautical twilight',
3: 'Civil twilight',
4: 'Day',
}
def dark_twilight_day(ephemeris, topos):
"""Build a function of time returning whether it is dark, twilight, or day.
The function that this returns will expect a single argument that is
a :class:`~skyfield.timelib.Time` and will return:
| 0 — Dark of night.
| 1 — Astronomical twilight.
| 2 — Nautical twilight.
| 3 — Civil twilight.
| 4 — Sun is up.
"""
sun = ephemeris['sun']
topos_at = (ephemeris['earth'] + topos).at
def is_it_dark_twilight_day_at(t):
"""Return whether the Sun is up, down, or whether there is twilight."""
t._nutation_angles_radians = iau2000b_radians(t)
degrees = topos_at(t).observe(sun).apparent().altaz()[0].degrees
r = zeros_like(degrees, int)
r[degrees >= -18.0] = 1
r[degrees >= -12.0] = 2
r[degrees >= -6.0] = 3
r[degrees >= -0.8333] = 4
return r
is_it_dark_twilight_day_at.step_days = 0.04 # catch days at least an hour long
return is_it_dark_twilight_day_at
def risings_and_settings(ephemeris, target, topos,
horizon_degrees=-34.0/60.0, radius_degrees=0): #?
"""Build a function of time that returns whether a body is up.
This returns a function taking a :class:`~skyfield.timelib.Time`
argument returning ``True`` if the body’s altazimuth altitude angle
plus ``radius_degrees`` is greater than ``horizon_degrees``, else
``False``. See :ref:`risings-and-settings` to learn about how to
search for risings and settings, and to see more about using the
parameters ``horizon_degrees`` and ``radius_degrees``.
"""
topos_at = (ephemeris['earth'] + topos).at
h = horizon_degrees - radius_degrees
def is_body_up_at(t):
"""Return `True` if the target has risen by time `t`."""
t._nutation_angles_radians = iau2000b_radians(t)
return topos_at(t).observe(target).apparent().altaz()[0].degrees > h
is_body_up_at.step_days = 0.25
return is_body_up_at
# Direct-search routines using geometry, that don't need find_discrete().
def _fastify(t):
t._nutation_angles_radians = iau2000b_radians(t)
def _setting_hour_angle(latitude, declination, altitude_radians):
"""Return the hour angle, in radians, when a body reaches the horizon.
Given the latitude of an observer, and the declination of a target,
return the positive hour angle at which the body will set below the
horizon, where the horizon is specified as `altitude_radians` above
(positive) or below (negative) the great circle of zero altitude.
"""
lat = latitude.radians
dec = declination.radians
numerator = sin(altitude_radians) - sin(lat) * sin(dec)
denominator = cos(lat) * cos(dec)
ha = np.arccos(np.clip(numerator / denominator, -1.0, 1.0))
return ha
def _rising_hour_angle(latitude, declination, altitude_radians):
return - _setting_hour_angle(latitude, declination, altitude_radians)
def _transit_ha(latitude, declination, altitude_radians):
return 0.0
def _q(a, b, c, sign):
discriminant = np.maximum(b*b - 4*a*c, 0.0) # avoid tiny negative results
return - 2*c / (b + sign * sqrt(discriminant))
def _intersection(y0, y1, v0, v1):
# Return x at which a curve reaches y=0, given its position and
# velocity y0,v0 at x=0 and y1,v1 at x=1. For details, see
# `design/intersect_function.py` in the Skyfield repository.
sign = 1 - 2 * (y0 > y1)
return _q(y1 - y0 - v0, v0, y0, sign)
# Per https://aa.usno.navy.mil/faq/RST_defs we estimate 34 arcminutes of
# atmospheric refraction and 16 arcminutes for the radius of the Sun.
_sun_horizon_radians = -50.0 / 21600.0 * tau
_refraction_radians = -34.0 / 21600.0 * tau
_moon_radius_m = 1.7374e6
_clip_lower = -1.0
_clip_upper = +2.0
def build_horizon_function(target):
"""Build and return a horizon function `h()` for the given `target`.
The returned function takes a Distance argument giving the distance
from the observer to the target, and returns a negative angle in
radians giving the altitude which, when reached by the target's
center, places it at the moment of rising.
"""
target_id = getattr(target, 'target', None)
if target_id == _SUN:
def h(distance): return _sun_horizon_radians
elif target_id == _MOON:
def h(distance):
return _refraction_radians - _moon_radius_m / distance.m
else:
def h(distance): return _refraction_radians
return h
def _find(observer, target, start_time, end_time, horizon_degrees, f):
if horizon_degrees is None:
h = build_horizon_function(target)
else:
horizon_radians = horizon_degrees / 360.0 * tau
def h(distance): return horizon_radians
geo = observer.vector_functions[-1] # should we check observer.center?
latitude = geo.latitude
# Build an array of times 0.8 days apart, in the hopes that nothing
# ever rises (or sets or transits) twice within a 0.8-day period.
ts = start_time.ts
tt0 = start_time.tt
tt1 = end_time.tt
sample_count = int(np.ceil((tt1 - tt0) / 0.8)) + 1
t = ts.tt_jd(np.linspace(tt0, tt1, sample_count))
# Determine the target's hour angle and declination at those times.
_fastify(t)
ha, dec, distance = observer.at(t).observe(target).apparent().hadec()
# Invoke our geometry formula: for each time `t`, predict the hour
# angle at which the target will next reach the horizon, if its
# declination were to remain constant.
desired_ha_radians = f(latitude, dec, h(distance))
# So at each time `t`, how many radians must the sky turn to bring
# the target to the horizon?
difference = desired_ha_radians - ha.radians
difference %= tau
# We want to return each rising exactly once, so where there are
# runs of several times `t` that all precede the same rising, let's
# throw the first few out and keep only the last one.
i, = np.nonzero(np.diff(difference) > 0.0)
# Trim a few arrays down to just the matching elements.
old_ha_radians = ha.radians[i]
old_t = t[i]
# When might each rising have actually taken place? Let's
# interpolate between the two times that bracket each rising.
a = difference[i]
b = tau - difference[i + 1]
tt = t.tt
interpolated_tt = (b * tt[i] + a * tt[i+1]) / (a + b)
t = ts.tt_jd(interpolated_tt)
def normalize_zero_to_tau(radians):
return radians % tau
def normalize_plus_or_minus_pi(radians):
return (radians + pi) % tau - pi
normalize = normalize_zero_to_tau
# How did we decide on this many iterations? We played with the
# script ./design/test_sunrise_moonrise.py in the repository.
for i in 0, 1, 2:
_fastify(t)
# Expensive: generate true ha/dec at `t`.
apparent = observer.at(t).observe(target).apparent()
ha, dec, distance = apparent.hadec()
# Estimate where the horizon-crossing is.
desired_ha = f(latitude, dec, h(distance))
ha_adjustment = desired_ha - ha.radians
ha_adjustment = (ha_adjustment + pi) % tau - pi
# Figure out how fast the target's HA is changing. After two
# iterations, just keep using the same value, in case t_diff
# gets so small that ha_diff drops to zero.
if i < 2:
ha_diff = normalize(ha.radians - old_ha_radians)
t_diff = t - old_t
ha_per_day = ha_diff / t_diff
# Remember this iteration's HA and `t` for the next iteration.
old_ha_radians = ha.radians
old_t = t
# The big moment! Carefully adjust `t` towards intersection.
timebump = ha_adjustment / ha_per_day
timebump[timebump == 0.0] = _MICROSECOND # avoid divide-by-zero
previous_t = t
t = ts.tt_jd(t.whole, t.tt_fraction + timebump)
# Different `normalize` for all but the first iteration.
normalize = normalize_plus_or_minus_pi
if f is _transit_ha:
return t
# In almost all cases, we are now happy. But for rising and setting
# calculations at high latitudes where the target barely scrapes the
# horizon, we might be stuck between two solutions, and need to
# interpolate between them.
# Snag the observer's GeographicPosition and learn the target's
# altitude vs the horizon and how fast it's moving vertically at
# the second-to-last `t` we computed above.
v = observer.vector_functions[-1]
altitude0, _, distance0, rate0, _, _ = (
apparent.frame_latlon_and_rates(v))
# Even faster than _fastify(t) is to just assume that nutation
# doesn't have much time to move over this short interval.
t.M = previous_t.M
t._nutation_angles_radians = previous_t._nutation_angles_radians
# And again, this time with the very final `t` we computed.
apparent = observer.at(t).observe(target).apparent()
altitude1, _, distance1, rate1, _, _ = (
apparent.frame_latlon_and_rates(v))
# Using the target's altitude and altitude-velocity at the final
# two times we computed, compute where it crosses the horizon.
tdiff = t - previous_t
t_scaled_offset = _intersection(
altitude0.radians - h(distance0),
altitude1.radians - h(distance1),
rate0.radians.per_day * tdiff,
rate1.radians.per_day * tdiff,
)
# In case the parabola for some reason goes crazy, don't let our
# solution be thrown too far away from our final two times.
t_scaled_offset = np.clip(t_scaled_offset, _clip_lower, _clip_upper)
t = previous_t + t_scaled_offset * tdiff
is_above_horizon = (
(desired_ha % pi != 0.0)
| ((t_scaled_offset > _clip_lower) & (t_scaled_offset < _clip_upper))
)
return t, is_above_horizon
def find_risings(observer, target, start_time, end_time, horizon_degrees=None):
"""Return the times at which a target rises above the eastern horizon.
Given an observer on the Earth’s surface, a target like the Sun or
Moon or a planet, and start and stop :class:`~skyfield.timelib.Time`
objects, this returns two arrays that have the same length. The
first is a :class:`~skyfield.timelib.Time` listing the moments at
which the target rises. The second array has ``True`` for each time
the target really crosses the horizon, and ``False`` when the target
merely transits without actually touching the horizon.
See `risings-and-settings` for examples, and `horizon_degrees` for
how to use the ``horizon_degrees`` argument.
.. versionadded:: 1.47
"""
return _find(observer, target, start_time, end_time, horizon_degrees,
_rising_hour_angle)
def find_settings(observer, target, start_time, end_time, horizon_degrees=None):
"""Return the times at which a target sets below the western horizon.
Given an observer on the Earth’s surface, a target like the Sun or
Moon or a planet, and start and stop :class:`~skyfield.timelib.Time`
objects, this returns two arrays that have the same length. The
first is a :class:`~skyfield.timelib.Time` listing the moments at
which the target sets. The second array has ``True`` for each time
the target really crosses the horizon, and ``False`` when the target
merely transits without actually touching the horizon.
See `risings-and-settings` for examples, and `horizon_degrees` for
how to use the ``horizon_degrees`` argument.
.. versionadded:: 1.47
"""
return _find(observer, target, start_time, end_time, horizon_degrees,
_setting_hour_angle)
def find_transits(observer, target, start_time, end_time):
"""Return the times at which a target transits across the meridian.
Given an observer on the Earth’s surface, a target like the Sun or
Moon or a planet, and start and stop :class:`~skyfield.timelib.Time`
objects, this returns a :class:`~skyfield.timelib.Time` array
listing the moments at which the target transits across the
meridian.
See `transits` for example code.
.. versionadded:: 1.47
"""
return _find(observer, target, start_time, end_time, 0.0, _transit_ha)