Skip to content

Commit 6b3cd8d

Browse files
Fix #998: detect sunrise/moonrise even at 70°N
First, this makes the main search loop more precise by computing the actual rate at which the target’s HA is changing, instead of assuming one rotation per day — which worked okay for the Sun and planets, but less well for the Moon given its high 13°/day velocity. Second, this bolts on a final step which, a bit expensively, compares the final two positions computed in the loop to the actual horizon, and fits a parabola to the altitude and altitude-rate-of-change to estimate the exact moment at which the object reaches the horizon.
1 parent 89e71c2 commit 6b3cd8d

File tree

3 files changed

+145
-25
lines changed

3 files changed

+145
-25
lines changed

CHANGELOG.rst

+7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ v1.50 — Not yet released
2020
have to construct the position themselves: ``SSB.at(t)`` returns a
2121
position whose coordinates and velocity are both zero in the ICRS.
2222

23+
* The routines added last year, :func:`~skyfield.almanac.find_risings()`
24+
and :func:`~skyfield.almanac.find_settings()`, could occasionally miss
25+
a sunrise or moonrise at high latitudes like 70°N where the Sun or
26+
Moon barely crests the horizon. This has been fixed at a moderate
27+
cost to their runtime.
28+
`#998 <https://github.com/skyfielders/python-skyfield/issues/998>`_
29+
2330
* Skyfield no longer tries to protect users by raising an exception if,
2431
contrary to the usual custom in astronomy, they ask for ``ra.degrees``
2532
or ``dec.hours``. So users no longer need to add an underscore prefix

skyfield/almanac.py

+121-25
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@
44
from __future__ import division
55

66
import numpy as np
7-
from numpy import cos, sin, zeros_like
7+
from numpy import cos, sin, sqrt, zeros_like
88
from .constants import pi, tau
99
from .framelib import ecliptic_frame
1010
from .searchlib import find_discrete
1111
from .nutationlib import iau2000b_radians
1212
from .units import Angle
1313

14+
_SUN = 10
15+
_MOON = 301
16+
_MICROSECOND = 1 / 24.0 / 3600.0 / 1e6
17+
1418
# Not only to support historic code but also for future convenience, let
1519
# folks import the search routine alongside the almanac routines.
1620
find_discrete
@@ -330,29 +334,50 @@ def _rising_hour_angle(latitude, declination, altitude_radians):
330334
def _transit_ha(latitude, declination, altitude_radians):
331335
return 0.0
332336

337+
def _q(a, b, c, sign):
338+
discriminant = np.maximum(b*b - 4*a*c, 0.0) # avoid tiny negative results
339+
return - 2*c / (b + sign * sqrt(discriminant))
340+
341+
def _intersection(y0, y1, v0, v1):
342+
# Return x at which a curve reaches y=0, given its position and
343+
# velocity y0,v0 at x=0 and y1,v1 at x=1. For details, see
344+
# `design/intersect_function.py` in the Skyfield repository.
345+
sign = 1 - 2 * (y0 > y1)
346+
return _q(y1 - y0 - v0, v0, y0, sign)
347+
333348
# Per https://aa.usno.navy.mil/faq/RST_defs we estimate 34 arcminutes of
334349
# atmospheric refraction and 16 arcminutes for the radius of the Sun.
335350
_sun_horizon_radians = -50.0 / 21600.0 * tau
336351
_refraction_radians = -34.0 / 21600.0 * tau
337352
_moon_radius_m = 1.7374e6
353+
_clip_lower = -1.0
354+
_clip_upper = +2.0
355+
356+
def build_horizon_function(target):
357+
"""Build and return a horizon function `h()` for the given `target`.
358+
359+
The returned function takes a Distance argument giving the distance
360+
from the observer to the target, and returns a negative angle in
361+
radians giving the altitude which, when reached by the target's
362+
center, places it at the moment of rising.
363+
364+
"""
365+
target_id = getattr(target, 'target', None)
366+
if target_id == _SUN:
367+
def h(distance): return _sun_horizon_radians
368+
elif target_id == _MOON:
369+
def h(distance):
370+
return _refraction_radians - _moon_radius_m / distance.m
371+
else:
372+
def h(distance): return _refraction_radians
373+
return h
338374

339375
def _find(observer, target, start_time, end_time, horizon_degrees, f):
340-
# Build a function h() that returns the angle above or below the
341-
# horizon we are aiming for, in radians.
342376
if horizon_degrees is None:
343-
tt = getattr(target, 'target', None)
344-
if tt == 10:
345-
horizon_radians = _sun_horizon_radians
346-
h = lambda distance: horizon_radians
347-
elif tt == 301:
348-
horizon_radians = _refraction_radians
349-
h = lambda distance: horizon_radians - _moon_radius_m / distance.m
350-
else:
351-
horizon_radians = _refraction_radians
352-
h = lambda distance: horizon_radians
377+
h = build_horizon_function(target)
353378
else:
354379
horizon_radians = horizon_degrees / 360.0 * tau
355-
h = lambda distance: horizon_radians
380+
def h(distance): return horizon_radians
356381

357382
geo = observer.vector_functions[-1] # should we check observer.center?
358383
latitude = geo.latitude
@@ -384,6 +409,10 @@ def _find(observer, target, start_time, end_time, horizon_degrees, f):
384409
# throw the first few out and keep only the last one.
385410
i, = np.nonzero(np.diff(difference) > 0.0)
386411

412+
# Trim a few arrays down to just the matching elements.
413+
old_ha_radians = ha.radians[i]
414+
old_t = t[i]
415+
387416
# When might each rising have actually taken place? Let's
388417
# interpolate between the two times that bracket each rising.
389418
a = difference[i]
@@ -392,24 +421,92 @@ def _find(observer, target, start_time, end_time, horizon_degrees, f):
392421
interpolated_tt = (b * tt[i] + a * tt[i+1]) / (a + b)
393422
t = ts.tt_jd(interpolated_tt)
394423

395-
ha_per_day = tau # angle the celestrial sphere rotates in 1 day
424+
def normalize_zero_to_tau(radians):
425+
return radians % tau
396426

397-
# TODO: How many iterations do we need? And can we cut down on that
398-
# number if we use velocity intelligently? For now, we experiment
399-
# using the ./design/test_sunrise_moonrise.py script in the
400-
# repository, that checks both the old Skyfiled routines and this
401-
# new one against the USNO. It suggests that 3 iterations is enough
402-
# for the Moon, the fastest-moving Solar System object, to match.
427+
def normalize_plus_or_minus_pi(radians):
428+
return (radians + pi) % tau - pi
429+
430+
normalize = normalize_zero_to_tau
431+
432+
# How did we decide on this many iterations? We played with the
433+
# script ./design/test_sunrise_moonrise.py in the repository.
403434
for i in 0, 1, 2:
404435
_fastify(t)
405-
ha, dec, distance = observer.at(t).observe(target).apparent().hadec()
436+
437+
# Expensive: generate true ha/dec at `t`.
438+
apparent = observer.at(t).observe(target).apparent()
439+
ha, dec, distance = apparent.hadec()
440+
441+
# Estimate where the horizon-crossing is.
406442
desired_ha = f(latitude, dec, h(distance))
407443
ha_adjustment = desired_ha - ha.radians
408444
ha_adjustment = (ha_adjustment + pi) % tau - pi
445+
446+
# Figure out how fast the target's HA is changing.
447+
ha_diff = normalize(ha.radians - old_ha_radians)
448+
t_diff = t - old_t
449+
ha_per_day = ha_diff / t_diff
450+
451+
# Remember this iteration's HA and `t` for the next iteration.
452+
old_ha_radians = ha.radians
453+
old_t = t
454+
455+
# The big moment! Carefully adjust `t` towards intersection.
409456
timebump = ha_adjustment / ha_per_day
457+
timebump[timebump == 0.0] = _MICROSECOND # avoid divide-by-zero
458+
previous_t = t
410459
t = ts.tt_jd(t.whole, t.tt_fraction + timebump)
411460

412-
is_above_horizon = (desired_ha % pi != 0.0)
461+
# Different `normalize` for all but the first iteration.
462+
normalize = normalize_plus_or_minus_pi
463+
464+
if f is _transit_ha:
465+
return t
466+
467+
# In almost all cases, we are now happy. But for rising and setting
468+
# calculations at high latitudes where the target barely scrapes the
469+
# horizon, we might be stuck between two solutions, and need to
470+
# interpolate between them.
471+
472+
# Snag the observer's GeographicPosition and learn the target's
473+
# altitude vs the horizon and how fast it's moving vertically at
474+
# the second-to-last `t` we computed above.
475+
v = observer.vector_functions[-1]
476+
altitude0, _, distance0, rate0, _, _ = (
477+
apparent.frame_latlon_and_rates(v))
478+
479+
# Even faster than _fastify(t) is to just assume that nutation
480+
# doesn't have much time to move over this short interval.
481+
t.M = previous_t.M
482+
t._nutation_angles_radians = previous_t._nutation_angles_radians
483+
484+
# And again, this time with the very final `t` we computed.
485+
apparent = observer.at(t).observe(target).apparent()
486+
altitude1, _, distance1, rate1, _, _ = (
487+
apparent.frame_latlon_and_rates(v))
488+
489+
# Using the target's altitude and altitude-velocity at the final
490+
# two times we computed, compute where it crosses the horizon.
491+
tdiff = t - previous_t
492+
t_scaled_offset = _intersection(
493+
altitude0.radians - h(distance0),
494+
altitude1.radians - h(distance1),
495+
rate0.radians.per_day * tdiff,
496+
rate1.radians.per_day * tdiff,
497+
)
498+
499+
# In case the parabola for some reason goes crazy, don't let our
500+
# solution be thrown too far away from our final two times.
501+
t_scaled_offset = np.clip(t_scaled_offset, _clip_lower, _clip_upper)
502+
503+
t = previous_t + t_scaled_offset * tdiff
504+
505+
is_above_horizon = (
506+
(desired_ha % pi != 0.0)
507+
| ((t_scaled_offset > _clip_lower) & (t_scaled_offset < _clip_upper))
508+
)
509+
413510
return t, is_above_horizon
414511

415512
def find_risings(observer, target, start_time, end_time, horizon_degrees=None):
@@ -466,5 +563,4 @@ def find_transits(observer, target, start_time, end_time):
466563
.. versionadded:: 1.47
467564
468565
"""
469-
t, _ = _find(observer, target, start_time, end_time, 0.0, _transit_ha)
470-
return t
566+
return _find(observer, target, start_time, end_time, 0.0, _transit_ha)

skyfield/tests/test_almanac.py

+17
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,23 @@ def f(t0, t1, e, topos):
102102
return t, array([1, 0])
103103
_sunrise_sunset(f)
104104

105+
def test_new_moonrise_at_70_degrees_north():
106+
ts = api.load.timescale()
107+
t0 = ts.utc(2023, 2, 19)
108+
t1 = ts.utc(2023, 2, 20)
109+
e = api.load('de421.bsp')
110+
seventy = e['earth'] + api.wgs84.latlon(70, 0)
111+
112+
t, y = almanac.find_risings(seventy, e['moon'], t0, t1)
113+
strings = t.utc_strftime('%Y-%m-%d %H:%M')
114+
assert strings == ['2023-02-19 11:28']
115+
assert list(y) == [1]
116+
117+
t, y = almanac.find_settings(seventy, e['moon'], t0, t1)
118+
strings = t.utc_strftime('%Y-%m-%d %H:%M')
119+
assert strings == ['2023-02-19 12:05']
120+
assert list(y) == [1]
121+
105122
def test_dark_twilight_day():
106123
ts = api.load.timescale()
107124
t0 = ts.utc(2019, 11, 8, 4)

0 commit comments

Comments
 (0)